Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions nodejs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,29 @@ Create a new conversation session.

Resume an existing session. Returns the session with `workspacePath` populated if infinite sessions were enabled.

##### `watchSharedSession(sessionId: string): Promise<SharedSessionWatch>`

Watch a session another user shared with the authenticated user. The handle is
passive: it exposes `sessionId`, `metadata`, `readOnly`, `on(...)`, and
`close()`, but no send, steer, permission, configuration, or cancellation APIs.
History is delivered first through the ordinary session event stream, followed
by live updates. Terminal connection loss is reported through the client's
existing `session.disconnected` lifecycle event.

```typescript
const disconnected = client.onLifecycle("session.disconnected", ({ sessionId }) => {
console.log(`Watch ${sessionId} disconnected`);
});
await using watch = await client.watchSharedSession(sharedSessionId);
watch.on((event) => {
console.log(event.type, event.data);
});
```

Authentication, viewer identity, lane credentials, channel derivation, and
reconnection remain internal to the runtime. Register the lifecycle handler
before opening the watch so an immediate terminal disconnect cannot be missed.

##### `ping(message?: string): Promise<{ message: string; timestamp: string }>`

Ping the server to check connectivity.
Expand Down
89 changes: 83 additions & 6 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import type {
SessionUpdateOptionsParams,
} from "./generated/rpc.js";
import { getSdkProtocolVersion } from "./sdkProtocolVersion.js";
import { CopilotSession } from "./session.js";
import { CopilotSession, SharedSessionWatch } from "./session.js";
import type { FfiRuntimeHost } from "./ffiRuntimeHost.js";
import { createSessionFsAdapter, type SessionFsProvider } from "./sessionFsProvider.js";
import { createCopilotRequestAdapter } from "./copilotRequestHandler.js";
Expand Down Expand Up @@ -483,6 +483,7 @@ export class CopilotClient {
private actualHost: string = "localhost";
private state: "disconnected" | "connecting" | "connected" | "error" = "disconnected";
private sessions: Map<string, CopilotSession> = new Map();
private sharedSessionWatches: Map<string, SharedSessionWatch> = new Map();
private stderrBuffer: string = ""; // Captures CLI stderr for error messages
/** Resolved connection mode chosen in the constructor. */
private connectionConfig: InternalRuntimeConnection;
Expand Down Expand Up @@ -966,6 +967,19 @@ export class CopilotClient {
async stop(): Promise<Error[]> {
const errors: Error[] = [];

const activeWatches = [...this.sharedSessionWatches.values()];
for (const watch of activeWatches) {
try {
await watch.close();
} catch (error) {
errors.push(
new Error(
`Failed to close shared-session watch ${watch.sessionId}: ${error instanceof Error ? error.message : String(error)}`
)
);
}
}

// Disconnect all active sessions with retry logic
const activeSessions = [...this.sessions.values()];
// TEMPORARY: over the in-process (FFI) transport the runtime shares this
Expand Down Expand Up @@ -1015,6 +1029,7 @@ export class CopilotClient {
session._markDisconnected();
}
this.sessions.clear();
this.sharedSessionWatches.clear();

// Ask SDK-owned runtimes to flush and clean up before we tear down
// their transport/process. External runtimes may be shared, so only
Expand Down Expand Up @@ -1197,6 +1212,7 @@ export class CopilotClient {
session._markDisconnected();
}
this.sessions.clear();
this.sharedSessionWatches.clear();

// Force close connection. Suppress writer failures first so teardown
// write rejections don't surface as unhandled rejections.
Expand Down Expand Up @@ -1682,6 +1698,54 @@ export class CopilotClient {
return session;
}

/**
* Watch a session shared with the authenticated user.
*
* The returned handle exposes canonical history and live events but no
* interactive session operations. Authentication and lane routing remain
* entirely inside the runtime. Register a `session.disconnected` lifecycle
* handler before calling this method if terminal connection loss must not
* be missed.
*
* @param sessionId - The owner's shared session ID.
*/
async watchSharedSession(sessionId: string): Promise<SharedSessionWatch> {
if (!this.connection) {
await this.start();
}

const result = await this.rpc.sessions.watch({ sessionId });
if (result.readOnly !== true) {
await this.rpc.sessions.close({ sessionId: result.sessionId });
throw new Error("Runtime returned an interactive shared-session watch");
}

const routedSession = new CopilotSession(
result.sessionId,
this.connection!,
undefined,
this.onGetTraceContext
);
const closeWatch = async (): Promise<void> => {
try {
await this.rpc.sessions.close({ sessionId: result.sessionId });
} finally {
routedSession._markDisconnected();
this.sessions.delete(result.sessionId);
this.sharedSessionWatches.delete(result.sessionId);
}
};
const watch = new SharedSessionWatch(
result.sessionId,
result.metadata,
routedSession,
closeWatch
);
this.sessions.set(result.sessionId, routedSession);
this.sharedSessionWatches.set(result.sessionId, watch);
return watch;
}

/**
* Resumes an existing conversation session by its ID.
*
Expand Down Expand Up @@ -2992,11 +3056,18 @@ export class CopilotClient {
};
}

const event = {
type: raw.type,
sessionId: raw.sessionId,
metadata,
} as SessionLifecycleEvent;
const event = (
raw.type === "session.disconnected"
? {
type: raw.type,
sessionId: raw.sessionId,
}
: {
type: raw.type,
sessionId: raw.sessionId,
metadata,
}
) as SessionLifecycleEvent;

// Dispatch to typed handlers for this specific event type
const typedHandlers = this.typedLifecycleHandlers.get(event.type);
Expand All @@ -3018,6 +3089,12 @@ export class CopilotClient {
// Ignore handler errors
}
}

if (event.type === "session.disconnected" && this.sharedSessionWatches.has(event.sessionId)) {
this.sessions.get(event.sessionId)?._markDisconnected();
this.sessions.delete(event.sessionId);
this.sharedSessionWatches.delete(event.sessionId);
}
}

private async handleUserInputRequest(params: {
Expand Down
40 changes: 40 additions & 0 deletions nodejs/src/generated/rpc.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion nodejs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@
export { CopilotClient } from "./client.js";
export { DisableBypassPermissionsModes, RuntimeConnection } from "./types.js";
export { BuiltInTools, ToolSet } from "./toolSet.js";
export { CopilotSession, type AssistantMessageEvent } from "./session.js";
export {
CopilotSession,
SharedSessionWatch,
type AssistantMessageEvent,
type SharedSessionMetadata,
} from "./session.js";
export { defineFactory, FactoryResumeError, isFactoryRunTerminal } from "./factory.js";
export {
Canvas,
Expand Down Expand Up @@ -152,6 +157,7 @@ export type {
SessionHooks,
SessionCreatedEvent,
SessionDeletedEvent,
SessionDisconnectedEvent,
SessionUpdatedEvent,
SessionForegroundEvent,
SessionBackgroundEvent,
Expand Down
84 changes: 84 additions & 0 deletions nodejs/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { createSessionRpc } from "./generated/rpc.js";
import type {
ClientSessionApiHandlers,
CanvasActionInvokeResult,
ConnectedRemoteSessionMetadata,
ConnectedRemoteSessionMetadataRepository,
CurrentToolMetadata,
McpOauthPendingRequestResponse,
FactoryLogLine,
Expand Down Expand Up @@ -81,6 +83,88 @@ import {
type FactoryStepOptions,
} from "./factory.js";

/** Immutable metadata describing a watched shared session. */
export type SharedSessionMetadata = Readonly<
Omit<ConnectedRemoteSessionMetadata, "repository"> & {
repository: Readonly<ConnectedRemoteSessionMetadataRepository>;
}
>;

/**
* Passive, read-only attachment to a session shared with the authenticated user.
*
* History and live updates are delivered through {@link on}. Interactive session
* operations are intentionally absent from this type.
*/
export class SharedSessionWatch {
/** Always `true`; watched sessions cannot be driven by this client. */
readonly readOnly = true as const;
/** Immutable metadata returned by the runtime when the watch is attached. */
readonly metadata: SharedSessionMetadata;

private readonly handlers = new Set<SessionEventHandler>();
private readonly pendingEvents: SessionEvent[] = [];
private closePromise: Promise<void> | undefined;

/** @internal */
constructor(
readonly sessionId: string,
metadata: ConnectedRemoteSessionMetadata,
session: CopilotSession,
private readonly closeWatch: () => Promise<void>
) {
this.metadata = Object.freeze({
...metadata,
repository: Object.freeze({ ...metadata.repository }),
});
session.on((event) => {
if (this.handlers.size === 0) {
this.pendingEvents.push(event);
return;
}
for (const handler of this.handlers) {
try {
handler(event);
} catch {
// A failing subscriber must not prevent delivery to others.
}
}
});
}

/**
* Subscribe to canonical replay and live session events.
*
* Events replayed before the first handler is attached are retained and
* delivered in order when that handler is registered.
*/
on(handler: SessionEventHandler): () => void {
this.handlers.add(handler);
if (this.pendingEvents.length > 0) {
const pending = this.pendingEvents.splice(0);
for (const event of pending) {
handler(event);
}
Comment on lines +145 to +147
}
return () => this.handlers.delete(handler);
}

/**
* Close the watch and release its local event routing.
*
* Repeated calls share the same close operation.
*/
close(): Promise<void> {
this.closePromise ??= this.closeWatch();
return this.closePromise;
}

/** Close the watch when used with `await using`. */
async [Symbol.asyncDispose](): Promise<void> {
await this.close();
}
}

function isFactoryResumeErrorCode(value: unknown): value is FactoryResumeErrorCode {
return (
value === "not_found" ||
Expand Down
16 changes: 12 additions & 4 deletions nodejs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3459,11 +3459,12 @@ export type SessionLifecycleEventType =
| "session.deleted"
| "session.updated"
| "session.foreground"
| "session.background";
| "session.background"
| "session.disconnected";

/**
* Metadata payload for session lifecycle events. Not present on
* `session.deleted` events.
* `session.deleted` or `session.disconnected` events.
*/
export interface SessionLifecycleEventMetadata {
/** Time the session was created. */
Expand All @@ -3478,7 +3479,7 @@ export interface SessionLifecycleEventMetadata {
interface SessionLifecycleEventBase {
/** ID of the session this event relates to. */
sessionId: string;
/** Session metadata (not included for `session.deleted`). */
/** Session metadata (not included for deleted or disconnected events). */
metadata?: SessionLifecycleEventMetadata;
}

Expand Down Expand Up @@ -3512,6 +3513,12 @@ export interface SessionBackgroundEvent extends SessionLifecycleEventBase {
metadata: SessionLifecycleEventMetadata;
}

/** Emitted when a session connection is terminally lost. */
export interface SessionDisconnectedEvent extends SessionLifecycleEventBase {
type: "session.disconnected";
metadata?: undefined;
}

/**
* Discriminated union of all session lifecycle events emitted in TUI+server mode.
* Switch on `type` to access the variant-specific metadata.
Expand All @@ -3521,7 +3528,8 @@ export type SessionLifecycleEvent =
| SessionDeletedEvent
| SessionUpdatedEvent
| SessionForegroundEvent
| SessionBackgroundEvent;
| SessionBackgroundEvent
| SessionDisconnectedEvent;

/**
* Handler for session lifecycle events.
Expand Down
Loading