Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
提交
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
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

# Cross-platform tools rewrite these files, so keep their output deterministic.
java/**/*.java text eol=lf
nodejs/**/*.ts text eol=lf
rust/**/*.rs text eol=lf

# Generated files — keep LF line endings so codegen output is deterministic across platforms.
nodejs/src/generated/* eol=lf linguist-generated=true
Expand All @@ -11,3 +13,4 @@ go/zsession_events.go eol=lf linguist-generated=true
go/zsession_encoding.go eol=lf linguist-generated=true
go/rpc/zrpc.go eol=lf linguist-generated=true
go/rpc/zrpc_encoding.go eol=lf linguist-generated=true
rust/src/generated/* eol=lf linguist-generated=true
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
77 changes: 69 additions & 8 deletions nodejs/src/generated/rpc.ts

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

Loading
Loading