From 53bada01bdd5c175a1edd5d622f76823172a4fcc Mon Sep 17 00:00:00 2001 From: Curtis Man Date: Tue, 31 Mar 2026 16:03:16 -0700 Subject: [PATCH 01/11] Session --- .../client/src/agentServerClient.ts | 185 +++++++-- ts/packages/agentServer/client/src/index.ts | 12 +- ts/packages/agentServer/protocol/src/index.ts | 4 + .../agentServer/protocol/src/protocol.ts | 34 +- ts/packages/agentServer/server/src/server.ts | 164 ++++++-- .../agentServer/server/src/sessionManager.ts | 370 ++++++++++++++++++ .../server/src/sharedDispatcher.ts | 37 +- .../serviceWorker/dispatcherConnection.ts | 35 +- 8 files changed, 761 insertions(+), 80 deletions(-) create mode 100644 ts/packages/agentServer/server/src/sessionManager.ts diff --git a/ts/packages/agentServer/client/src/agentServerClient.ts b/ts/packages/agentServer/client/src/agentServerClient.ts index 853450583c..48c0e1888e 100644 --- a/ts/packages/agentServer/client/src/agentServerClient.ts +++ b/ts/packages/agentServer/client/src/agentServerClient.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import { createChannelProviderAdapter } from "@typeagent/agent-rpc/channel"; +import type { ChannelProviderAdapter } from "@typeagent/agent-rpc/channel"; import { createRpc } from "@typeagent/agent-rpc/rpc"; import { createClientIORpcServer } from "@typeagent/dispatcher-rpc/clientio/server"; import { createDispatcherRpcClient } from "@typeagent/dispatcher-rpc/dispatcher/client"; @@ -13,24 +14,47 @@ import { AgentServerInvokeFunctions, ChannelName, DispatcherConnectOptions, + SessionInfo, + JoinSessionResult, + getDispatcherChannelName, + getClientIOChannelName, } from "@typeagent/agent-server-protocol"; const debug = registerDebug("typeagent:agent-server-client"); const debugErr = registerDebug("typeagent:agent-server-client:error"); -export async function connectDispatcher( - clientIO: ClientIO, +export type SessionDispatcher = { + dispatcher: Dispatcher; + sessionId: string; +}; + +export type AgentServerConnection = { + joinSession( + clientIO: ClientIO, + options?: DispatcherConnectOptions, + ): Promise; + leaveSession(sessionId: string): Promise; + createSession(name: string): Promise; + listSessions(): Promise; + renameSession(sessionId: string, newName: string): Promise; + deleteSession(sessionId: string): Promise; + close(): Promise; +}; + +/** + * Connect to an agent server and return a connection object that supports + * multiple sessions over a single WebSocket. + */ +export async function connectAgentServer( url: string | URL, - options?: DispatcherConnectOptions, onDisconnect?: () => void, -): Promise { +): Promise { return new Promise((resolve, reject: (e: Error) => void) => { const ws = new WebSocket(url); - const channel = createChannelProviderAdapter( + const channel: ChannelProviderAdapter = createChannelProviderAdapter( "agent-server:client", (message: any) => { debug("Sending message to server:", message); - // Server assume data are JSON strings ws.send(JSON.stringify(message)); }, ); @@ -40,49 +64,142 @@ export async function connectDispatcher( channel.createChannel(ChannelName.AgentServer), ); - let resolved = false; - createClientIORpcServer( - clientIO, - channel.createChannel(ChannelName.ClientIO), - ); + // Track joined sessions for cleanup on close + const joinedSessions = new Map< + string, + { dispatcher: Dispatcher; connectionId: string } + >(); + + let closed = false; + + const connection: AgentServerConnection = { + async joinSession( + clientIO: ClientIO, + options?: DispatcherConnectOptions, + ): Promise { + const result: JoinSessionResult = await rpc.invoke( + "joinSession", + options, + ); + + const sessionId = result.sessionId; + + // Create session-namespaced channels + createClientIORpcServer( + clientIO, + channel.createChannel(getClientIOChannelName(sessionId)), + ); + + const dispatcher = createDispatcherRpcClient( + channel.createChannel(getDispatcherChannelName(sessionId)), + result.connectionId, + ); + + // Override close to leave the session rather than close the WebSocket + dispatcher.close = async () => { + await connection.leaveSession(sessionId); + }; + + joinedSessions.set(sessionId, { + dispatcher, + connectionId: result.connectionId, + }); + + return { dispatcher, sessionId }; + }, + + async leaveSession(sessionId: string): Promise { + const entry = joinedSessions.get(sessionId); + if (entry === undefined) { + return; + } + joinedSessions.delete(sessionId); + channel.deleteChannel(getDispatcherChannelName(sessionId)); + channel.deleteChannel(getClientIOChannelName(sessionId)); + await rpc.invoke("leaveSession", sessionId); + }, + + async createSession(name: string): Promise { + return rpc.invoke("createSession", name); + }, + + async listSessions(): Promise { + return rpc.invoke("listSessions"); + }, + + async renameSession( + sessionId: string, + newName: string, + ): Promise { + return rpc.invoke("renameSession", sessionId, newName); + }, + + async deleteSession(sessionId: string): Promise { + // Clean up local channels if we're in this session + const entry = joinedSessions.get(sessionId); + if (entry !== undefined) { + joinedSessions.delete(sessionId); + channel.deleteChannel(getDispatcherChannelName(sessionId)); + channel.deleteChannel(getClientIOChannelName(sessionId)); + } + return rpc.invoke("deleteSession", sessionId); + }, + + async close(): Promise { + if (closed) { + return; + } + closed = true; + debug("Closing agent server connection"); + ws.close(); + }, + }; + ws.onopen = () => { debug("WebSocket connection established", ws.readyState); - rpc.invoke("join", options) - .then((connectionId) => { - debug("Connected to dispatcher"); - resolved = true; - const dispatcher = createDispatcherRpcClient( - channel.createChannel(ChannelName.Dispatcher), - connectionId, - ); - // Override the close method to close the WebSocket connection - dispatcher.close = async () => { - debug("Closing WebSocket connection"); - ws.close(); - }; - resolve(dispatcher); - }) - .catch((err: any) => { - debugErr("Failed to join dispatcher:", err); - reject(err); - }); + resolve(connection); }; ws.onmessage = (event: WebSocket.MessageEvent) => { debug("Received message from server:", event.data); - channel.notifyMessage(JSON.parse(event.data.toString())); }; ws.onclose = (event: WebSocket.CloseEvent) => { debug("WebSocket connection closed", event.code, event.reason); channel.notifyDisconnected(); - if (resolved) { + joinedSessions.clear(); + if (!closed) { + closed = true; onDisconnect?.(); - } else { - reject(new Error(`Failed to connect to dispatcher at ${url}`)); } }; ws.onerror = (error: WebSocket.ErrorEvent) => { debugErr("WebSocket error:", error); + if (!closed) { + reject( + new Error(`Failed to connect to agent server at ${url}`), + ); + } }; }); } + +/** + * Convenience wrapper: connect to an agent server and immediately join a + * session. Returns a single Dispatcher (backward compatible with old API). + * + * @deprecated Use `connectAgentServer()` for full multi-session support. + */ +export async function connectDispatcher( + clientIO: ClientIO, + url: string | URL, + options?: DispatcherConnectOptions, + onDisconnect?: () => void, +): Promise { + const connection = await connectAgentServer(url, onDisconnect); + const { dispatcher } = await connection.joinSession(clientIO, options); + // Override close to also close the WebSocket (old behavior) + dispatcher.close = async () => { + await connection.close(); + }; + return dispatcher; +} diff --git a/ts/packages/agentServer/client/src/index.ts b/ts/packages/agentServer/client/src/index.ts index a1a566404c..7145cb5ea4 100644 --- a/ts/packages/agentServer/client/src/index.ts +++ b/ts/packages/agentServer/client/src/index.ts @@ -1,5 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -export { connectDispatcher } from "./agentServerClient.js"; +export { + connectAgentServer, + connectDispatcher, + AgentServerConnection, + SessionDispatcher, +} from "./agentServerClient.js"; export type * from "@typeagent/dispatcher-rpc/types"; +export type { + SessionInfo, + JoinSessionResult, + DispatcherConnectOptions, +} from "@typeagent/agent-server-protocol"; diff --git a/ts/packages/agentServer/protocol/src/index.ts b/ts/packages/agentServer/protocol/src/index.ts index 7c26eeb566..118294a287 100644 --- a/ts/packages/agentServer/protocol/src/index.ts +++ b/ts/packages/agentServer/protocol/src/index.ts @@ -5,6 +5,10 @@ export { DispatcherConnectOptions, AgentServerInvokeFunctions, ChannelName, + SessionInfo, + JoinSessionResult, + getDispatcherChannelName, + getClientIOChannelName, registerClientType, getClientType, unregisterClient, diff --git a/ts/packages/agentServer/protocol/src/protocol.ts b/ts/packages/agentServer/protocol/src/protocol.ts index 531313b817..460f3970ea 100644 --- a/ts/packages/agentServer/protocol/src/protocol.ts +++ b/ts/packages/agentServer/protocol/src/protocol.ts @@ -4,16 +4,44 @@ export type DispatcherConnectOptions = { filter?: boolean; // filter to message for own request. Default is false (no filtering) clientType?: "shell" | "extension"; // identifies the connecting client type + sessionId?: string; // join a specific session by UUID. If omitted, joins the most recently active session. +}; + +export type SessionInfo = { + sessionId: string; + name: string; + clientCount: number; + createdAt: string; // ISO 8601 +}; + +export type JoinSessionResult = { + connectionId: string; + sessionId: string; }; export type AgentServerInvokeFunctions = { - join: (options?: DispatcherConnectOptions) => Promise; + joinSession: ( + options?: DispatcherConnectOptions, + ) => Promise; + leaveSession: (sessionId: string) => Promise; + createSession: (name: string) => Promise; + listSessions: () => Promise; + renameSession: (sessionId: string, newName: string) => Promise; + deleteSession: (sessionId: string) => Promise; }; export const enum ChannelName { AgentServer = "agent-server", - Dispatcher = "dispatcher", - ClientIO = "clientio", +} + +/** Build the dispatcher channel name for a given session. */ +export function getDispatcherChannelName(sessionId: string): string { + return `dispatcher:${sessionId}`; +} + +/** Build the clientIO channel name for a given session. */ +export function getClientIOChannelName(sessionId: string): string { + return `clientio:${sessionId}`; } // ============================================= diff --git a/ts/packages/agentServer/server/src/server.ts b/ts/packages/agentServer/server/src/server.ts index 840318a41a..cac81446a5 100644 --- a/ts/packages/agentServer/server/src/server.ts +++ b/ts/packages/agentServer/server/src/server.ts @@ -3,7 +3,7 @@ import { createWebSocketChannelServer } from "websocket-channel-server"; import { createDispatcherRpcServer } from "@typeagent/dispatcher-rpc/dispatcher/server"; -import { createSharedDispatcher } from "./sharedDispatcher.js"; +import { createSessionManager, SessionManager } from "./sessionManager.js"; import { getInstanceDir, getTraceId } from "agent-dispatcher/helpers/data"; import { getDefaultAppAgentProviders, @@ -17,7 +17,11 @@ import { AgentServerInvokeFunctions, ChannelName, DispatcherConnectOptions, + getDispatcherChannelName, + getClientIOChannelName, } from "@typeagent/agent-server-protocol"; +import type { ChannelProvider } from "@typeagent/agent-rpc/channel"; +import type { Dispatcher } from "agent-dispatcher"; import dotenv from "dotenv"; const envPath = new URL("../../../../.env", import.meta.url); dotenv.config({ path: envPath }); @@ -30,54 +34,152 @@ async function main() { const configName = configIdx !== -1 ? process.argv[configIdx + 1] : undefined; - // Create single shared dispatcher with routing ClientIO - const sharedDispatcher = await createSharedDispatcher("agent server", { - appAgentProviders: getDefaultAppAgentProviders(instanceDir, configName), - persistSession: true, - persistDir: instanceDir, - storageProvider: getFsStorageProvider(), - metrics: true, - dblogging: false, - traceId: getTraceId(), - indexingServiceRegistry: await getIndexingServiceRegistry( - instanceDir, - configName, - ), - constructionProvider: getDefaultConstructionProvider(), - conversationMemorySettings: { - requestKnowledgeExtraction: false, - actionResultKnowledgeExtraction: false, + const sessionManager: SessionManager = await createSessionManager( + "agent server", + { + appAgentProviders: getDefaultAppAgentProviders( + instanceDir, + configName, + ), + persistSession: true, + storageProvider: getFsStorageProvider(), + metrics: true, + dblogging: false, + traceId: getTraceId(), + indexingServiceRegistry: await getIndexingServiceRegistry( + instanceDir, + configName, + ), + constructionProvider: getDefaultConstructionProvider(), + conversationMemorySettings: { + requestKnowledgeExtraction: false, + actionResultKnowledgeExtraction: false, + }, + collectCommandResult: true, }, - collectCommandResult: true, - }); + instanceDir, + ); await createWebSocketChannelServer( { port: 8999 }, - (channelProvider, closeFn) => { + (channelProvider: ChannelProvider, closeFn: () => void) => { + // Track which sessions this WebSocket connection has joined + // sessionId → { dispatcher, connectionId } + const joinedSessions = new Map< + string, + { dispatcher: Dispatcher; connectionId: string } + >(); + const invokeFunctions: AgentServerInvokeFunctions = { - join: async (options?: DispatcherConnectOptions) => { - const dispatcherChannel = channelProvider.createChannel( - ChannelName.Dispatcher, + joinSession: async (options?: DispatcherConnectOptions) => { + // Resolve session ID first (may auto-create default) + const sessionId = await sessionManager.resolveSessionId( + options?.sessionId, ); + + // Create session-namespaced channels const clientIOChannel = channelProvider.createChannel( - ChannelName.ClientIO, + getClientIOChannelName(sessionId), ); const clientIORpcClient = createClientIORpcClient(clientIOChannel); - const dispatcher = sharedDispatcher.join( + const result = await sessionManager.joinSession( + sessionId, clientIORpcClient, - closeFn, + () => { + channelProvider.deleteChannel( + getDispatcherChannelName(sessionId), + ); + channelProvider.deleteChannel( + getClientIOChannelName(sessionId), + ); + joinedSessions.delete(sessionId); + }, options, ); - channelProvider.on("disconnect", () => { - dispatcher.close(); + + const dispatcherChannel = channelProvider.createChannel( + getDispatcherChannelName(sessionId), + ); + createDispatcherRpcServer( + result.dispatcher, + dispatcherChannel, + ); + + joinedSessions.set(sessionId, { + dispatcher: result.dispatcher, + connectionId: result.connectionId, }); - createDispatcherRpcServer(dispatcher, dispatcherChannel); - return dispatcher.connectionId!; + + return { + connectionId: result.connectionId, + sessionId, + }; + }, + + leaveSession: async (sessionId: string) => { + const entry = joinedSessions.get(sessionId); + if (entry === undefined) { + throw new Error(`Not joined to session: ${sessionId}`); + } + channelProvider.deleteChannel( + getDispatcherChannelName(sessionId), + ); + channelProvider.deleteChannel( + getClientIOChannelName(sessionId), + ); + joinedSessions.delete(sessionId); + await sessionManager.leaveSession( + sessionId, + entry.connectionId, + ); + }, + + createSession: async (name: string) => { + return sessionManager.createSession(name); + }, + + listSessions: async () => { + return sessionManager.listSessions(); + }, + + renameSession: async (sessionId: string, newName: string) => { + return sessionManager.renameSession(sessionId, newName); + }, + + deleteSession: async (sessionId: string) => { + // If this client is in the session being deleted, + // clean up local channels first + const entry = joinedSessions.get(sessionId); + if (entry !== undefined) { + channelProvider.deleteChannel( + getDispatcherChannelName(sessionId), + ); + channelProvider.deleteChannel( + getClientIOChannelName(sessionId), + ); + joinedSessions.delete(sessionId); + } + return sessionManager.deleteSession(sessionId); }, }; + // Clean up all sessions on WebSocket disconnect + channelProvider.on("disconnect", () => { + for (const [ + sessionId, + { connectionId }, + ] of joinedSessions.entries()) { + sessionManager + .leaveSession(sessionId, connectionId) + .catch(() => { + // Best effort on disconnect + }); + } + joinedSessions.clear(); + }); + createRpc( "agent-server", channelProvider.createChannel(ChannelName.AgentServer), diff --git a/ts/packages/agentServer/server/src/sessionManager.ts b/ts/packages/agentServer/server/src/sessionManager.ts new file mode 100644 index 0000000000..ba54207b76 --- /dev/null +++ b/ts/packages/agentServer/server/src/sessionManager.ts @@ -0,0 +1,370 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { randomUUID } from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { + DispatcherConnectOptions, + SessionInfo, +} from "@typeagent/agent-server-protocol"; +import { ClientIO, Dispatcher, DispatcherOptions } from "agent-dispatcher"; +import { + createSharedDispatcher, + SharedDispatcher, +} from "./sharedDispatcher.js"; + +import registerDebug from "debug"; +const debugSession = registerDebug("agent-server:session"); + +const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes +const SESSIONS_DIR = "server-sessions"; +const METADATA_FILE = "sessions.json"; + +type SessionMetadata = { + sessionId: string; + name: string; + createdAt: string; +}; + +type SessionRecord = { + sessionId: string; + name: string; + createdAt: string; + lastActiveAt: number; + sharedDispatcher: SharedDispatcher | undefined; // undefined = not yet restored + idleTimer: ReturnType | undefined; +}; + +type PersistedMetadata = { + sessions: SessionMetadata[]; + lastActiveSessionId: string | undefined; +}; + +export type SessionManager = { + createSession(name: string): Promise; + /** + * Resolve a session ID. If undefined, returns the most recently active + * session, creating a default one if none exist. + */ + resolveSessionId(sessionId: string | undefined): Promise; + joinSession( + sessionId: string, + clientIO: ClientIO, + closeFn: () => void, + options?: DispatcherConnectOptions, + ): Promise<{ dispatcher: Dispatcher; connectionId: string }>; + leaveSession(sessionId: string, connectionId: string): Promise; + listSessions(): SessionInfo[]; + renameSession(sessionId: string, newName: string): Promise; + deleteSession(sessionId: string): Promise; + close(): Promise; +}; + +export async function createSessionManager( + hostName: string, + baseOptions: DispatcherOptions, + baseDir: string, + idleTimeoutMs: number = DEFAULT_IDLE_TIMEOUT_MS, +): Promise { + const sessionsDir = path.join(baseDir, SESSIONS_DIR); + await fs.promises.mkdir(sessionsDir, { recursive: true }); + + const sessions = new Map(); + let lastActiveSessionId: string | undefined; + + // Load persisted metadata + await loadMetadata(); + + async function loadMetadata(): Promise { + const metadataPath = path.join(sessionsDir, METADATA_FILE); + try { + const data = await fs.promises.readFile(metadataPath, "utf-8"); + const persisted: PersistedMetadata = JSON.parse(data); + for (const entry of persisted.sessions) { + sessions.set(entry.sessionId, { + sessionId: entry.sessionId, + name: entry.name, + createdAt: entry.createdAt, + lastActiveAt: 0, + sharedDispatcher: undefined, // lazy restore + idleTimer: undefined, + }); + } + lastActiveSessionId = persisted.lastActiveSessionId; + debugSession(`Loaded ${sessions.size} session(s) from metadata`); + } catch { + // No metadata file yet — first run + debugSession("No session metadata found, starting fresh"); + } + } + + async function saveMetadata(): Promise { + const metadataPath = path.join(sessionsDir, METADATA_FILE); + const entries: SessionMetadata[] = []; + for (const record of sessions.values()) { + entries.push({ + sessionId: record.sessionId, + name: record.name, + createdAt: record.createdAt, + }); + } + const persisted: PersistedMetadata = { + sessions: entries, + lastActiveSessionId, + }; + await fs.promises.writeFile( + metadataPath, + JSON.stringify(persisted, undefined, 2), + ); + } + + function getSessionPersistDir(sessionId: string): string { + return path.join(sessionsDir, sessionId); + } + + async function ensureDispatcher( + record: SessionRecord, + ): Promise { + if (record.sharedDispatcher === undefined) { + const persistDir = getSessionPersistDir(record.sessionId); + await fs.promises.mkdir(persistDir, { recursive: true }); + record.sharedDispatcher = await createSharedDispatcher(hostName, { + ...baseOptions, + persistDir, + persistSession: true, + }); + debugSession( + `Dispatcher initialized for session "${record.name}" (${record.sessionId})`, + ); + } + return record.sharedDispatcher; + } + + function cancelIdleTimer(record: SessionRecord): void { + if (record.idleTimer !== undefined) { + clearTimeout(record.idleTimer); + record.idleTimer = undefined; + debugSession( + `Idle timer cancelled for session "${record.name}" (${record.sessionId})`, + ); + } + } + + function startIdleTimer(record: SessionRecord): void { + if (idleTimeoutMs <= 0) { + return; + } + cancelIdleTimer(record); + record.idleTimer = setTimeout(async () => { + record.idleTimer = undefined; + if ( + record.sharedDispatcher !== undefined && + record.sharedDispatcher.clientCount === 0 + ) { + debugSession( + `Idle timeout: closing dispatcher for session "${record.name}" (${record.sessionId})`, + ); + await record.sharedDispatcher.close(); + record.sharedDispatcher = undefined; + } + }, idleTimeoutMs); + } + + function touchSession(sessionId: string): void { + const record = sessions.get(sessionId); + if (record) { + record.lastActiveAt = Date.now(); + lastActiveSessionId = sessionId; + } + } + + function getMostRecentSessionId(): string | undefined { + // Prefer explicitly tracked last active session + if (lastActiveSessionId && sessions.has(lastActiveSessionId)) { + return lastActiveSessionId; + } + // Fall back to any existing session + for (const id of sessions.keys()) { + return id; + } + return undefined; + } + + function validateSessionName(name: string): void { + if (name.length === 0 || name.length > 256) { + throw new Error( + "Session name must be between 1 and 256 characters", + ); + } + } + + const manager: SessionManager = { + async createSession(name: string): Promise { + validateSessionName(name); + const sessionId = randomUUID(); + const createdAt = new Date().toISOString(); + const record: SessionRecord = { + sessionId, + name, + createdAt, + lastActiveAt: Date.now(), + sharedDispatcher: undefined, + idleTimer: undefined, + }; + sessions.set(sessionId, record); + lastActiveSessionId = sessionId; + await saveMetadata(); + debugSession(`Session created: "${name}" (${sessionId})`); + return { + sessionId, + name, + clientCount: 0, + createdAt, + }; + }, + + async resolveSessionId(sessionId: string | undefined): Promise { + if (sessionId !== undefined) { + if (!sessions.has(sessionId)) { + throw new Error(`Session not found: ${sessionId}`); + } + return sessionId; + } + const resolved = getMostRecentSessionId(); + if (resolved !== undefined) { + return resolved; + } + // No sessions exist — auto-create a default + const info = await manager.createSession("default"); + return info.sessionId; + }, + + async joinSession( + sessionId: string, + clientIO: ClientIO, + closeFn: () => void, + options?: DispatcherConnectOptions, + ): Promise<{ dispatcher: Dispatcher; connectionId: string }> { + const record = sessions.get(sessionId); + if (record === undefined) { + throw new Error(`Session not found: ${sessionId}`); + } + + cancelIdleTimer(record); + const sharedDispatcher = await ensureDispatcher(record); + const dispatcher = sharedDispatcher.join( + clientIO, + closeFn, + options, + ); + touchSession(sessionId); + await saveMetadata(); + + debugSession( + `Client joined session "${record.name}" (${sessionId}), clients: ${sharedDispatcher.clientCount}`, + ); + + return { + dispatcher, + connectionId: dispatcher.connectionId!, + }; + }, + + async leaveSession( + sessionId: string, + connectionId: string, + ): Promise { + const record = sessions.get(sessionId); + if (record === undefined) { + throw new Error(`Session not found: ${sessionId}`); + } + if (record.sharedDispatcher === undefined) { + return; // Session not active + } + await record.sharedDispatcher.leave(connectionId); + debugSession( + `Client ${connectionId} left session "${record.name}" (${sessionId}), clients: ${record.sharedDispatcher.clientCount}`, + ); + + if (record.sharedDispatcher.clientCount === 0) { + startIdleTimer(record); + } + }, + + listSessions(): SessionInfo[] { + const result: SessionInfo[] = []; + for (const record of sessions.values()) { + result.push({ + sessionId: record.sessionId, + name: record.name, + clientCount: record.sharedDispatcher?.clientCount ?? 0, + createdAt: record.createdAt, + }); + } + return result; + }, + + async renameSession(sessionId: string, newName: string): Promise { + validateSessionName(newName); + const record = sessions.get(sessionId); + if (record === undefined) { + throw new Error(`Session not found: ${sessionId}`); + } + record.name = newName; + await saveMetadata(); + debugSession(`Session renamed: "${newName}" (${sessionId})`); + }, + + async deleteSession(sessionId: string): Promise { + const record = sessions.get(sessionId); + if (record === undefined) { + throw new Error(`Session not found: ${sessionId}`); + } + + cancelIdleTimer(record); + + // Close all clients and the dispatcher + if (record.sharedDispatcher !== undefined) { + await record.sharedDispatcher.close(); + record.sharedDispatcher = undefined; + } + + sessions.delete(sessionId); + + // Update last active if we just deleted it + if (lastActiveSessionId === sessionId) { + lastActiveSessionId = getMostRecentSessionId(); + } + + // Remove persist directory + const persistDir = getSessionPersistDir(sessionId); + try { + await fs.promises.rm(persistDir, { + recursive: true, + force: true, + }); + } catch { + // Best effort — dir may not exist + } + + await saveMetadata(); + debugSession(`Session deleted: "${record.name}" (${sessionId})`); + }, + + async close(): Promise { + const promises: Promise[] = []; + for (const record of sessions.values()) { + cancelIdleTimer(record); + if (record.sharedDispatcher !== undefined) { + promises.push(record.sharedDispatcher.close()); + } + } + await Promise.all(promises); + await saveMetadata(); + debugSession("SessionManager closed"); + }, + }; + + return manager; +} diff --git a/ts/packages/agentServer/server/src/sharedDispatcher.ts b/ts/packages/agentServer/server/src/sharedDispatcher.ts index 259bd0a313..0e4525ea74 100644 --- a/ts/packages/agentServer/server/src/sharedDispatcher.ts +++ b/ts/packages/agentServer/server/src/sharedDispatcher.ts @@ -25,6 +25,7 @@ const debugClientIOError = registerDebug("agent-server:clientIO:error"); type ClientRecord = { clientIO: ClientIO; filter: boolean; + closeFn: () => void; }; export async function createSharedDispatcher( @@ -161,7 +162,11 @@ export async function createSharedDispatcher( ...options, clientIO, }); - return { + const dispatchers = new Map(); + const shared: SharedDispatcher = { + get clientCount() { + return clients.size; + }, join( clientIO: ClientIO, closeFn: () => void, @@ -171,6 +176,7 @@ export async function createSharedDispatcher( clients.set(connectionId, { clientIO, filter: options?.filter ?? false, + closeFn, }); // Register client type for per-request routing if (options?.clientType) { @@ -181,6 +187,7 @@ export async function createSharedDispatcher( connectionId, async () => { clients.delete(connectionId); + dispatchers.delete(connectionId); unregisterClient(connectionId); closeFn(); debugConnect( @@ -188,13 +195,41 @@ export async function createSharedDispatcher( ); }, ); + dispatchers.set(connectionId, dispatcher); debugConnect( `Client connected: ${connectionId} (total clients: ${clients.size})`, ); return dispatcher; }, + async leave(connectionId: string) { + const dispatcher = dispatchers.get(connectionId); + if (dispatcher) { + await dispatcher.close(); + } + }, + async closeAllClients() { + const promises: Promise[] = []; + for (const dispatcher of dispatchers.values()) { + promises.push(dispatcher.close()); + } + await Promise.all(promises); + }, async close() { + await this.closeAllClients(); await closeCommandHandlerContext(context); }, }; + return shared; } + +export type SharedDispatcher = { + readonly clientCount: number; + join( + clientIO: ClientIO, + closeFn: () => void, + options?: DispatcherConnectOptions, + ): Dispatcher; + leave(connectionId: string): Promise; + closeAllClients(): Promise; + close(): Promise; +}; diff --git a/ts/packages/agents/browser/src/extension/serviceWorker/dispatcherConnection.ts b/ts/packages/agents/browser/src/extension/serviceWorker/dispatcherConnection.ts index dff4cb6f78..82c2661dc4 100644 --- a/ts/packages/agents/browser/src/extension/serviceWorker/dispatcherConnection.ts +++ b/ts/packages/agents/browser/src/extension/serviceWorker/dispatcherConnection.ts @@ -20,6 +20,11 @@ import type { ClientIO, Dispatcher } from "@typeagent/dispatcher-rpc/types"; import type { AgentServerInvokeFunctions, DispatcherConnectOptions, + JoinSessionResult, +} from "@typeagent/agent-server-protocol"; +import { + getDispatcherChannelName, + getClientIOChannelName, } from "@typeagent/agent-server-protocol"; import registerDebug from "debug"; @@ -168,14 +173,10 @@ async function doConnect(): Promise { const rpc = createRpc( "agent-server:extension", - channel.createChannel("agent-server" as any), + channel.createChannel("agent-server"), ); const clientIO = createChatPanelClientIO(); - createClientIORpcServer( - clientIO, - channel.createChannel("clientio" as any), - ); let resolved = false; @@ -185,13 +186,27 @@ async function doConnect(): Promise { filter: true, clientType: "extension", }; - rpc.invoke("join", options) - .then((connectionId) => { - debug("Joined dispatcher, connectionId=%s", connectionId); + rpc.invoke("joinSession", options) + .then((result: JoinSessionResult) => { + debug( + "Joined session=%s, connectionId=%s", + result.sessionId, + result.connectionId, + ); resolved = true; + + createClientIORpcServer( + clientIO, + channel.createChannel( + getClientIOChannelName(result.sessionId), + ), + ); + const d = createDispatcherRpcClient( - channel.createChannel("dispatcher" as any), - connectionId, + channel.createChannel( + getDispatcherChannelName(result.sessionId), + ), + result.connectionId, ); // Override close to close our WebSocket d.close = async () => { From 0cc05ea5778b387b7c1ce5c508e80f10834c075b Mon Sep 17 00:00:00 2001 From: George Ng Date: Fri, 3 Apr 2026 16:18:59 -0700 Subject: [PATCH 02/11] Change server shutdown to use sessionManager.close() instead of relying on sharedDispatcher --- ts/packages/agentServer/server/src/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ts/packages/agentServer/server/src/server.ts b/ts/packages/agentServer/server/src/server.ts index bacc169912..7827ec9933 100644 --- a/ts/packages/agentServer/server/src/server.ts +++ b/ts/packages/agentServer/server/src/server.ts @@ -166,7 +166,7 @@ async function main() { shutdown: async () => { console.log("Shutdown requested, stopping agent server..."); wss.close(); - await sharedDispatcher.close(); + await sessionManager.close(); process.exit(0); }, }; From da10a9289f1613ea9d82edb51f74b3c4d5fbe5a8 Mon Sep 17 00:00:00 2001 From: George Ng Date: Fri, 3 Apr 2026 16:45:37 -0700 Subject: [PATCH 03/11] Add optional name param to listSessions() to allow simple substring search on names --- .../agentServer/client/src/agentServerClient.ts | 6 +++--- ts/packages/agentServer/protocol/src/protocol.ts | 2 +- ts/packages/agentServer/server/src/server.ts | 4 ++-- ts/packages/agentServer/server/src/sessionManager.ts | 10 ++++++++-- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/ts/packages/agentServer/client/src/agentServerClient.ts b/ts/packages/agentServer/client/src/agentServerClient.ts index 1e07e19b7b..df732ebd77 100644 --- a/ts/packages/agentServer/client/src/agentServerClient.ts +++ b/ts/packages/agentServer/client/src/agentServerClient.ts @@ -39,7 +39,7 @@ export type AgentServerConnection = { ): Promise; leaveSession(sessionId: string): Promise; createSession(name: string): Promise; - listSessions(): Promise; + listSessions(name?: string): Promise; renameSession(sessionId: string, newName: string): Promise; deleteSession(sessionId: string): Promise; close(): Promise; @@ -127,8 +127,8 @@ export async function connectAgentServer( return rpc.invoke("createSession", name); }, - async listSessions(): Promise { - return rpc.invoke("listSessions"); + async listSessions(name?: string): Promise { + return rpc.invoke("listSessions", name); }, async renameSession( diff --git a/ts/packages/agentServer/protocol/src/protocol.ts b/ts/packages/agentServer/protocol/src/protocol.ts index 2b1c8cb743..c31a067e83 100644 --- a/ts/packages/agentServer/protocol/src/protocol.ts +++ b/ts/packages/agentServer/protocol/src/protocol.ts @@ -25,7 +25,7 @@ export type AgentServerInvokeFunctions = { ) => Promise; leaveSession: (sessionId: string) => Promise; createSession: (name: string) => Promise; - listSessions: () => Promise; + listSessions: (name?: string) => Promise; renameSession: (sessionId: string, newName: string) => Promise; deleteSession: (sessionId: string) => Promise; shutdown: () => Promise; diff --git a/ts/packages/agentServer/server/src/server.ts b/ts/packages/agentServer/server/src/server.ts index 7827ec9933..95a14a2443 100644 --- a/ts/packages/agentServer/server/src/server.ts +++ b/ts/packages/agentServer/server/src/server.ts @@ -140,8 +140,8 @@ async function main() { return sessionManager.createSession(name); }, - listSessions: async () => { - return sessionManager.listSessions(); + listSessions: async (name?: string) => { + return sessionManager.listSessions(name); }, renameSession: async (sessionId: string, newName: string) => { diff --git a/ts/packages/agentServer/server/src/sessionManager.ts b/ts/packages/agentServer/server/src/sessionManager.ts index ba54207b76..3cd2784c84 100644 --- a/ts/packages/agentServer/server/src/sessionManager.ts +++ b/ts/packages/agentServer/server/src/sessionManager.ts @@ -55,7 +55,7 @@ export type SessionManager = { options?: DispatcherConnectOptions, ): Promise<{ dispatcher: Dispatcher; connectionId: string }>; leaveSession(sessionId: string, connectionId: string): Promise; - listSessions(): SessionInfo[]; + listSessions(name?: string): SessionInfo[]; renameSession(sessionId: string, newName: string): Promise; deleteSession(sessionId: string): Promise; close(): Promise; @@ -292,9 +292,15 @@ export async function createSessionManager( } }, - listSessions(): SessionInfo[] { + listSessions(name?: string): SessionInfo[] { const result: SessionInfo[] = []; for (const record of sessions.values()) { + if ( + name !== undefined && + !record.name.toLowerCase().includes(name.toLowerCase()) + ) { + continue; + } result.push({ sessionId: record.sessionId, name: record.name, From 10310cce41b892829184ccbdace088f887eac84b Mon Sep 17 00:00:00 2001 From: George Ng Date: Fri, 3 Apr 2026 17:04:35 -0700 Subject: [PATCH 04/11] Update agentServer readme files along with usage instructions --- ts/packages/agentServer/README.md | 149 ++++++++++++++------- ts/packages/agentServer/client/README.md | 73 ++++++---- ts/packages/agentServer/protocol/README.md | 55 ++++++-- ts/packages/agentServer/server/README.md | 70 +++++++--- 4 files changed, 238 insertions(+), 109 deletions(-) diff --git a/ts/packages/agentServer/README.md b/ts/packages/agentServer/README.md index 462edecc98..3f080b9ab1 100644 --- a/ts/packages/agentServer/README.md +++ b/ts/packages/agentServer/README.md @@ -1,12 +1,12 @@ # agentServer -The agentServer hosts a **shared TypeAgent dispatcher** over WebSocket, allowing multiple clients (Shell, CLI, extensions) to share a single running dispatcher instance. It is split into three sub-packages: +The agentServer hosts a **TypeAgent dispatcher over WebSocket**, allowing multiple clients (Shell, CLI, extensions) to share a single running dispatcher instance with full session management. It is split into three sub-packages: -| Package | npm name | Purpose | -| ----------- | ----------------------- | ------------------------------------------------------------ | -| `protocol/` | `agent-server-protocol` | RPC channel names, join/shutdown types, client-type registry | -| `client/` | `agent-server-client` | Client library: connect, auto-spawn, stop | -| `server/` | `agent-server` | Long-running WebSocket server with shared dispatcher | +| Package | npm name | Purpose | +| ------------ | ----------------------- | ---------------------------------------------------------------------------- | +| `protocol/` | `agent-server-protocol` | RPC channel names, session types, client-type registry | +| `client/` | `agent-server-client` | Client library: connect, session management, auto-spawn, stop | +| `server/` | `agent-server` | Long-running WebSocket server with `SessionManager` and per-session dispatch | --- @@ -21,37 +21,85 @@ Shell (Electron) CLI (Node.js) ┌────────▼────────┐ │ agentServer │ │ │ - │ ┌───────────┐ │ - │ │ Routing │ │ routes ClientIO callbacks - │ │ ClientIO │ │ back to correct client - │ └─────┬─────┘ │ by connectionId - │ │ │ - │ ┌─────▼─────┐ │ - │ │ Shared │ │ one instance shared - │ │Dispatcher │ │ by all connected clients - │ └───────────┘ │ + │ SessionManager │ + │ ┌────────────┐ │ + │ │ Session A │ │ ← clients 0, 1 + │ │ Dispatcher │ │ + │ ├────────────┤ │ + │ │ Session B │ │ ← client 2 + │ │ Dispatcher │ │ + │ └────────────┘ │ └─────────────────┘ ``` -### Three RPC channels per connection +Each session has its own `SharedDispatcher` instance with isolated chat history, conversation memory, display log, and persist directory. Clients connected to the same session share one dispatcher; clients in different sessions are fully isolated. -Each WebSocket connection multiplexes three independent JSON-RPC channels: +### RPC channels per connection -| Channel | Direction | Purpose | -| ------------- | --------------- | ----------------------------------------------------------------- | -| `AgentServer` | client → server | Lifecycle: `join()`, `shutdown()` | -| `Dispatcher` | client → server | Commands: `processCommand()`, `getCommandCompletion()`, etc. | -| `ClientIO` | server → client | Display/interaction callbacks: `setDisplay()`, `askYesNo()`, etc. | +Each WebSocket connection multiplexes independent JSON-RPC channels: -### Shared dispatcher + routing ClientIO +| Channel | Direction | Purpose | +| ------------------------ | --------------- | ------------------------------------------------------------- | +| `agent-server` | client → server | Session lifecycle: `joinSession`, `leaveSession`, CRUD, `shutdown` | +| `dispatcher:` | client → server | Commands: `processCommand`, `getCommandCompletion`, etc. | +| `clientio:` | server → client | Display/interaction callbacks: `setDisplay`, `askYesNo`, etc. | -A single `Dispatcher` instance is created at server startup and shared by all connected clients. Each `processCommand()` call carries a `ClientRequestId = { connectionId, requestId }`. When the dispatcher (or an agent) calls a `ClientIO` method, the **routing ClientIO** layer uses `connectionId` to forward the callback to the correct client's WebSocket. +The dispatcher and clientIO channels are namespaced by `sessionId`, allowing a single WebSocket connection to participate in multiple sessions simultaneously. -This means: +--- + +## Starting and stopping the server + +### With pnpm (recommended) + +From the `ts/` directory: + +```bash +# Build (if not already built) +pnpm run build agentServer + +# Start +pnpm --filter agent-server start + +# Start with a named config (e.g. loads config.test.json) +pnpm --filter agent-server start -- --config test -- Agents are loaded once and shared across clients. -- Per-client state (session, cache) is isolated by `connectionId`. -- Agents are unaware that multiple clients are connected. +# Stop (sends shutdown via RPC) +pnpm --filter agent-server stop +``` + +### With node directly + +```bash +# From the repo root +node --disable-warning=DEP0190 ts/packages/agentServer/server/dist/server.js + +# With optional config name +node --disable-warning=DEP0190 ts/packages/agentServer/server/dist/server.js --config test +``` + +The server listens on `ws://localhost:8999` and logs `Agent server started at ws://localhost:8999` when ready. + +--- + +## Session lifecycle + +``` +Client calls joinSession({ sessionId?, clientType, filter }) + │ + ├─ sessionId provided? + │ ├─ Yes → look up sessions.json + │ │ ├─ Found → load SharedDispatcher (lazy init if not in memory) + │ │ └─ Not found → error: "Session not found" + │ └─ No → resume most recently active session + │ └─ No sessions exist → auto-create session named "default" + │ + ├─ Register client in session's SharedDispatcher routing table + ├─ Update lastActiveSessionId in sessions.json + └─ Return { connectionId, sessionId } +``` + +Session dispatchers are automatically evicted from memory after 5 minutes with no connected clients. --- @@ -60,19 +108,18 @@ This means: ``` Client calls ensureAndConnectDispatcher(clientIO, port) │ - ├─ Check: is server already listening on ws://localhost:? + ├─ Is server already listening on ws://localhost:? │ └─ No → spawnAgentServer() — detached child process, survives parent exit - │ └─ Yes → continue │ - ├─ Open WebSocket → create 3 RPC channels + ├─ Open WebSocket → create RPC channels │ - ├─ Send join({ clientType, filter }) on AgentServer channel - │ └─ Server assigns connectionId, registers client type + ├─ Send joinSession({ clientType, filter }) on agent-server channel + │ └─ Server assigns connectionId, returns { connectionId, sessionId } │ └─ Return Dispatcher RPC proxy to caller ``` -On disconnect, the server removes the client from its routing table and cleans up the connection. +On disconnect, the server removes all of that connection's sessions from its routing table. --- @@ -80,25 +127,23 @@ On disconnect, the server removes the client from its routing table and cleans u [`packages/shell/src/main/instance.ts`](../shell/src/main/instance.ts) supports two modes: -**Standalone (default)** — dispatcher runs in-process inside the Electron main process. No WebSocket overhead, fastest for single-user desktop use. +**Standalone (default)** — dispatcher runs in-process inside the Electron main process. ``` Chat UI (renderer) ↔ IPC ↔ Main process ↔ in-process Dispatcher ``` -**Connected (`--connect `)** — connects to a running agentServer. Enables sharing a dispatcher across multiple Shell windows or CLI sessions. +**Connected (`--connect `)** — connects to a running agentServer. ``` Chat UI (renderer) ↔ IPC ↔ Main process ↔ WebSocket ↔ agentServer ``` -The Shell also registers its own `AppAgentProvider` ([`agent.ts`](../shell/src/main/agent.ts)) for shell-specific commands (themes, voice mode, etc.). - --- ## CLI integration -The CLI ([`packages/cli/src/commands/connect.ts`](../cli/src/commands/connect.ts)) always uses remote connection. It calls `ensureAndConnectDispatcher()`, which auto-spawns the server if it is not already running, then enters an interactive readline loop (or processes a single `--request`). +The CLI ([`packages/cli/src/commands/connect.ts`](../cli/src/commands/connect.ts)) always uses remote connection. It calls `ensureAndConnectDispatcher()`, which auto-spawns the server if not already running, then enters an interactive readline loop. ``` Terminal ↔ EnhancedConsoleClientIO ↔ WebSocket ↔ agentServer @@ -114,37 +159,43 @@ Terminal ↔ EnhancedConsoleClientIO ↔ WebSocket ↔ agentServer Shell launches → createDispatcher() in-process → no server involved ``` -**Shell or CLI with running server** +**Shell or CLI — server already running** ``` Client → ensureAndConnectDispatcher(port=8999) - → server already running → connect → join() → get Dispatcher proxy + → server already running → connect → joinSession() → Dispatcher proxy ``` -**Server not yet running** +**Shell or CLI — server not yet running** ``` Client → ensureAndConnectDispatcher(port=8999) → server not found → spawnAgentServer() → poll until ready (60 s timeout) - → connect → join() → get Dispatcher proxy + → connect → joinSession() → Dispatcher proxy ``` -**Headless server only** +**Headless server** ``` -node packages/agentServer/server/dist/server.js +pnpm --filter agent-server start → listens on ws://localhost:8999 -→ any number of Shell/CLI clients can connect and share the dispatcher +→ any number of Shell/CLI clients can connect and share sessions ``` --- +## Session persistence + +Session metadata is stored at `~/.typeagent/server-sessions/sessions.json`. Each session's data (chat history, conversation memory, display log) lives under `~/.typeagent/server-sessions//`. + +--- + ## Sub-package details -- [protocol/README.md](protocol/README.md) — channel names, RPC types, client-type registry -- [client/README.md](client/README.md) — `connectDispatcher`, `ensureAndConnectDispatcher`, `stopAgentServer` -- [server/README.md](server/README.md) — server entry point, `createSharedDispatcher`, routing ClientIO +- [protocol/README.md](protocol/README.md) — channel names, RPC types, session types, client-type registry +- [client/README.md](client/README.md) — `connectAgentServer`, `ensureAndConnectDispatcher`, `stopAgentServer` +- [server/README.md](server/README.md) — server entry point, `SessionManager`, `SharedDispatcher`, routing ClientIO --- diff --git a/ts/packages/agentServer/client/README.md b/ts/packages/agentServer/client/README.md index 0edd7f762a..a8d5392d28 100644 --- a/ts/packages/agentServer/client/README.md +++ b/ts/packages/agentServer/client/README.md @@ -4,49 +4,70 @@ Client library for connecting to a running agentServer, used by the Shell and CL ## API -### `connectDispatcher(clientIO, url, options?, onDisconnect?)` +### `connectAgentServer(url, onDisconnect?)` -Opens a WebSocket to an already-running agentServer at `url`, sets up the three RPC channels, calls `join()`, and returns a `Dispatcher` RPC proxy. +Opens a WebSocket to an already-running agentServer and returns an `AgentServerConnection` with full session management support. + +```typescript +const connection = await connectAgentServer("ws://localhost:8999"); + +// Join a session +const { dispatcher, sessionId } = await connection.joinSession(clientIO, { + clientType: "shell", +}); + +// Session management +await connection.createSession("my session"); +await connection.listSessions(); // all sessions +await connection.listSessions("workout"); // filter by name substring +await connection.renameSession(sessionId, "new name"); +await connection.deleteSession(sessionId); + +// Leave and close +await connection.leaveSession(sessionId); +await connection.close(); +``` + +**`AgentServerConnection`** methods: + +| Method | Description | +| --------------------------------------- | ------------------------------------------------------------------ | +| `joinSession(clientIO, options?)` | Join a session; returns `{ dispatcher, sessionId }` | +| `leaveSession(sessionId)` | Leave a session and clean up its channels | +| `createSession(name)` | Create a new named session | +| `listSessions(name?)` | List sessions, optionally filtered by name substring | +| `renameSession(sessionId, newName)` | Rename a session | +| `deleteSession(sessionId)` | Delete a session and its persisted data | +| `close()` | Close the WebSocket connection | ### `ensureAndConnectDispatcher(clientIO, port?, options?, onDisconnect?)` -Higher-level convenience wrapper: +Convenience wrapper that auto-spawns the server if needed and joins a session, returning a `Dispatcher` directly. Used by Shell and CLI. 1. Checks whether a server is already listening on `ws://localhost:` (default 8999). 2. If not, calls `spawnAgentServer()` to start it as a detached child process. 3. Polls until the server is ready (500 ms interval, 60 s timeout). 4. Calls `connectDispatcher()` and returns the `Dispatcher` proxy. -This is the function both the Shell and CLI call — they do not need to know whether the server was already running. - -### `stopAgentServer(port?)` - -Connects to the running server on the given port and sends a `shutdown()` RPC. - -### `spawnAgentServer(serverPath)` - -Spawns `packages/agentServer/server/dist/server.js` as a detached child process (so it survives parent exit). Cross-platform: uses `windowsHide` on Windows. - ---- - -## Usage - ```typescript -import { ensureAndConnectDispatcher } from "@typeagent/agent-server-client"; - const dispatcher = await ensureAndConnectDispatcher( - clientIO, // your ClientIO implementation - 8999, // port (optional, defaults to 8999) - undefined, // DispatcherConnectOptions (optional) - () => { - console.error("Disconnected"); - process.exit(1); - }, + clientIO, + 8999, + { clientType: "shell" }, + () => { console.error("Disconnected"); process.exit(1); }, ); await dispatcher.processCommand("help"); ``` +### `stopAgentServer(port?)` + +Connects to the running server on the given port and sends a `shutdown()` RPC. + +### `connectDispatcher(clientIO, url, options?, onDisconnect?)` *(deprecated)* + +Backward-compatible wrapper: connects and immediately joins a session, returning a `Dispatcher`. Use `connectAgentServer()` for full multi-session support. + --- ## Trademarks diff --git a/ts/packages/agentServer/protocol/README.md b/ts/packages/agentServer/protocol/README.md index 6693b25eab..13f7ea9e98 100644 --- a/ts/packages/agentServer/protocol/README.md +++ b/ts/packages/agentServer/protocol/README.md @@ -6,33 +6,64 @@ Defines the WebSocket RPC contract between agentServer clients and the server. ```typescript enum ChannelName { - AgentServer = "AgentServer", // lifecycle: join / shutdown - Dispatcher = "Dispatcher", // command dispatch - ClientIO = "ClientIO", // display / interaction callbacks + AgentServer = "agent-server", // session lifecycle and management } + +// Session-namespaced channels (one pair per joined session): +// dispatcher: — command dispatch +// clientio: — display / interaction callbacks +``` + +Helper functions to construct the namespaced channel names: + +```typescript +getDispatcherChannelName(sessionId: string): string // "dispatcher:" +getClientIOChannelName(sessionId: string): string // "clientio:" ``` -Each WebSocket connection uses all three channels independently. +## Session types -## RPC types +**`SessionInfo`** — describes a session: -**`AgentServerInvokeFunctions`** — methods exposed on the `AgentServer` channel: +| Field | Type | Description | +| ------------- | -------- | -------------------------------------------------------- | +| `sessionId` | `string` | UUIDv4 stable identifier | +| `name` | `string` | Human-readable label (1–256 chars) | +| `clientCount` | `number` | Number of clients currently connected (runtime-only, never persisted) | +| `createdAt` | `string` | ISO 8601 creation timestamp | -| Method | Description | -| ---------------- | ------------------------------------------------------- | -| `join(options?)` | Register this connection; returns `connectionId` string | -| `shutdown()` | Request graceful server shutdown | +**`JoinSessionResult`** — returned by `joinSession`: -**`DispatcherConnectOptions`** — options passed to `join()`: +| Field | Type | Description | +| ------------- | -------- | ------------------------------------ | +| `connectionId`| `string` | Unique identifier for this connection | +| `sessionId` | `string` | The session that was joined or auto-created | + +**`DispatcherConnectOptions`** — options passed to `joinSession`: | Field | Type | Description | | ------------ | --------- | ---------------------------------------------------------------------- | +| `sessionId` | `string` | Join a specific session by UUID. Omit to resume the most recently active session. | | `clientType` | `string` | Identifies the client (`"shell"`, `"extension"`, etc.) | | `filter` | `boolean` | If true, only receive ClientIO messages for this connection's requests | +## RPC methods + +**`AgentServerInvokeFunctions`** — methods exposed on the `agent-server` channel: + +| Method | Description | +| ------------------------------------------- | --------------------------------------------------------------- | +| `joinSession(options?)` | Join or auto-create a session; returns `JoinSessionResult` | +| `leaveSession(sessionId)` | Leave a session and clean up its channels | +| `createSession(name)` | Create a new named session; returns `SessionInfo` | +| `listSessions(name?)` | List all sessions, optionally filtered by name substring (case-insensitive) | +| `renameSession(sessionId, newName)` | Rename a session | +| `deleteSession(sessionId)` | Delete a session and all its persisted data | +| `shutdown()` | Request graceful server shutdown | + ## Client-type registry -A module-level registry maps `connectionId → clientType`, populated when a client calls `join()`. Agents and the dispatcher can call `getClientType(connectionId)` to adapt behavior per client. +A module-level registry maps `connectionId → clientType`, populated when a client calls `joinSession()`. Agents and the dispatcher can call `getClientType(connectionId)` to adapt behavior per client. ```typescript registerClientType(connectionId: string, clientType: string): void diff --git a/ts/packages/agentServer/server/README.md b/ts/packages/agentServer/server/README.md index 76f7ee1594..55f2363cb2 100644 --- a/ts/packages/agentServer/server/README.md +++ b/ts/packages/agentServer/server/README.md @@ -1,49 +1,75 @@ # agent-server -Long-running WebSocket server that hosts a shared TypeAgent dispatcher. +Long-running WebSocket server that hosts TypeAgent dispatchers with full session management. -## Entry point +## Starting the server -``` -packages/agentServer/server/dist/server.js +### With pnpm (from the `ts/` directory) + +```bash +# Start +pnpm --filter agent-server start + +# Start with a named config (e.g. loads config.test.json) +pnpm --filter agent-server start -- --config test + +# Stop (sends shutdown via RPC) +pnpm --filter agent-server stop ``` -Starts automatically when needed (via `spawnAgentServer()` in the client library), or can be started manually: +### With node directly ```bash -node packages/agentServer/server/dist/server.js -# Listening on ws://localhost:8999 +node --disable-warning=DEP0190 packages/agentServer/server/dist/server.js + +# With optional config name +node --disable-warning=DEP0190 packages/agentServer/server/dist/server.js --config test ``` +Listens on `ws://localhost:8999`. The server also starts automatically when clients call `ensureAndConnectDispatcher()`. + +--- + ## Key components ### `server.ts` — WebSocket listener -1. Calls `createSharedDispatcher()` once at startup to initialize agents, grammar, and state. +1. Creates a `SessionManager` at startup with agent providers and storage options. 2. Calls `createWebSocketChannelServer(8999)` to accept connections. -3. For each connection: - - Sets up an `AgentServerInvokeFunctions` handler with `join()` and `shutdown()`. - - On `join()`: calls `sharedDispatcher.join()`, receives a per-connection `Dispatcher`, and wires up `Dispatcher` and `ClientIO` RPC servers for that connection. +3. For each connection, exposes `AgentServerInvokeFunctions` over the `agent-server` RPC channel: + - `joinSession` / `leaveSession` — join or leave a named session + - `createSession` / `listSessions` / `renameSession` / `deleteSession` — session CRUD + - `shutdown` — graceful server shutdown via `sessionManager.close()` + +### `sessionManager.ts` — Session pool + +Maintains a pool of per-session `SharedDispatcher` instances. Key behaviors: + +- **Persistence:** session metadata stored in `~/.typeagent/server-sessions/sessions.json`; each session's data in `~/.typeagent/server-sessions//` +- **Lazy init:** each session's `SharedDispatcher` is created on first `joinSession()` and torn down after 5 minutes of inactivity +- **Auto-create:** if no session exists and no `sessionId` is provided, a `"default"` session is created automatically +- **Last active tracking:** `lastActiveSessionId` is updated on each join so the most recently used session is resumed by default ### `sharedDispatcher.ts` — Routing layer -`createSharedDispatcher()` returns a `SharedDispatcher` that wraps a single underlying `Dispatcher` and manages multiple client connections. +`createSharedDispatcher()` wraps a single underlying dispatcher context and manages multiple client connections within one session. **On `join(clientIO, closeFn, options)`:** -- Assigns a `connectionId` (auto-incrementing integer, as string). -- Stores the client's `ClientIO` in a `clients` map. -- Registers the client type in the protocol registry. -- Returns a per-connection `Dispatcher` whose commands are tagged with `connectionId`. +- Assigns a `connectionId` (auto-incrementing integer, as string) +- Stores the client's `ClientIO` in a routing table +- Registers the client type in the protocol registry +- Returns a per-connection `Dispatcher` whose commands are tagged with `connectionId` **Routing ClientIO:** -When the dispatcher or an agent calls a `ClientIO` method, the routing layer inspects `requestId.connectionId` to look up the correct entry in the `clients` map and forwards the call there. This isolates each client's display output even though they share one dispatcher. -| Method type | Routing | -| ------------------------------------------------------------------------ | ----------------------------------------------------------------- | -| Display (`setDisplay`, `appendDisplay`, `notify`, `setUserRequest`) | Forwarded to the client matching `connectionId` | -| Interactive (`askYesNo`, `proposeAction`, `requestChoice`, `takeAction`) | Forwarded to the originating client; awaits response | -| Broadcast | Can optionally be sent to all clients (filter flag controls this) | +When the dispatcher or an agent calls a `ClientIO` method, the routing layer uses `requestId.connectionId` to forward the call to the correct client. This isolates each client's display output even though they share one dispatcher and session context. + +| Method type | Routing | +| ------------------------------------------------------------------- | ------------------------------------------------------------ | +| Display (`setDisplay`, `appendDisplay`, `notify`, `setUserRequest`) | Forwarded to the client matching `connectionId` | +| Interactive (`askYesNo`, `proposeAction`, `requestChoice`) | Forwarded to the originating client; awaits response | +| Broadcast | Sent to all clients (filter flag controls per-client opt-in) | --- From 4731abecc90a0d3f070ff0956599c2c471d28006 Mon Sep 17 00:00:00 2001 From: George Ng Date: Fri, 3 Apr 2026 17:22:28 -0700 Subject: [PATCH 05/11] Run prettier --- ts/docs/architecture/agentServerSessions.md | 60 +++++++++++---------- ts/packages/agentServer/README.md | 18 +++---- ts/packages/agentServer/client/README.md | 37 +++++++------ ts/packages/agentServer/protocol/README.md | 46 ++++++++-------- 4 files changed, 83 insertions(+), 78 deletions(-) diff --git a/ts/docs/architecture/agentServerSessions.md b/ts/docs/architecture/agentServerSessions.md index 34b6dff02e..439674991b 100644 --- a/ts/docs/architecture/agentServerSessions.md +++ b/ts/docs/architecture/agentServerSessions.md @@ -1,7 +1,7 @@ -# AgentServer Sessions Architecture +# AgentServer Sessions Architecture **Author:** George Ng -**Status:** Review +**Status:** Review **Last Updated:** 2026-04-03 --- @@ -57,8 +57,8 @@ The `join()` call today accepts only: ```typescript type DispatcherConnectOptions = { - filter?: boolean; - clientType?: "shell" | "extension"; + filter?: boolean; + clientType?: "shell" | "extension"; }; ``` @@ -72,12 +72,12 @@ There is no way for a client to specify which session to use, or to perform sess Each session is identified by: -| Field | Type | Description | -|---|---|---| -| `sessionId` | `string` (UUIDv4) | Stable, globally unique identifier | -| `name` | `string` | Human-readable label (1–256 chars), set by the caller at `createSession()` time. Not enforced unique. | -| `createdAt` | `string` (ISO 8601) | When the session was first created | -| `clientCount` | `number` | Number of clients currently connected to this session (runtime-derived; `0` if the session is not loaded in memory). **Never persisted** — see Section 2. | +| Field | Type | Description | +| ------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `sessionId` | `string` (UUIDv4) | Stable, globally unique identifier | +| `name` | `string` | Human-readable label (1–256 chars), set by the caller at `createSession()` time. Not enforced unique. | +| `createdAt` | `string` (ISO 8601) | When the session was first created | +| `clientCount` | `number` | Number of clients currently connected to this session (runtime-derived; `0` if the session is not loaded in memory). **Never persisted** — see Section 2. | ### 2. Session Metadata @@ -108,11 +108,11 @@ Each session's full data (chat history, conversation memory, display log) is sto ```typescript type DispatcherConnectOptions = { - filter?: boolean; - clientType?: "shell" | "extension"; + filter?: boolean; + clientType?: "shell" | "extension"; - // Session management (new) - sessionId?: string; // Join a specific session by UUID. If omitted → resumes most recently active session. + // Session management (new) + sessionId?: string; // Join a specific session by UUID. If omitted → resumes most recently active session. }; ``` @@ -122,15 +122,17 @@ The existing `join` RPC is replaced by `joinSession`. A `leaveSession` call is a ```typescript type AgentServerInvokeFunctions = { - // Replaces the old `join` - joinSession: (options?: DispatcherConnectOptions) => Promise; - leaveSession: (sessionId: string) => Promise; - - // Session CRUD - createSession: (name: string) => Promise; - listSessions: (name?: string) => Promise; - renameSession: (sessionId: string, newName: string) => Promise; - deleteSession: (sessionId: string) => Promise; + // Replaces the old `join` + joinSession: ( + options?: DispatcherConnectOptions, + ) => Promise; + leaveSession: (sessionId: string) => Promise; + + // Session CRUD + createSession: (name: string) => Promise; + listSessions: (name?: string) => Promise; + renameSession: (sessionId: string, newName: string) => Promise; + deleteSession: (sessionId: string) => Promise; }; ``` @@ -138,8 +140,8 @@ type AgentServerInvokeFunctions = { ```typescript type JoinSessionResult = { - connectionId: string; - sessionId: string; // The session that was joined or auto-created + connectionId: string; + sessionId: string; // The session that was joined or auto-created }; ``` @@ -149,10 +151,10 @@ type JoinSessionResult = { ```typescript type SessionInfo = { - sessionId: string; - name: string; - clientCount: number; - createdAt: string; + sessionId: string; + name: string; + clientCount: number; + createdAt: string; }; ``` diff --git a/ts/packages/agentServer/README.md b/ts/packages/agentServer/README.md index 3f080b9ab1..f3b99c58ee 100644 --- a/ts/packages/agentServer/README.md +++ b/ts/packages/agentServer/README.md @@ -2,11 +2,11 @@ The agentServer hosts a **TypeAgent dispatcher over WebSocket**, allowing multiple clients (Shell, CLI, extensions) to share a single running dispatcher instance with full session management. It is split into three sub-packages: -| Package | npm name | Purpose | -| ------------ | ----------------------- | ---------------------------------------------------------------------------- | -| `protocol/` | `agent-server-protocol` | RPC channel names, session types, client-type registry | -| `client/` | `agent-server-client` | Client library: connect, session management, auto-spawn, stop | -| `server/` | `agent-server` | Long-running WebSocket server with `SessionManager` and per-session dispatch | +| Package | npm name | Purpose | +| ----------- | ----------------------- | ---------------------------------------------------------------------------- | +| `protocol/` | `agent-server-protocol` | RPC channel names, session types, client-type registry | +| `client/` | `agent-server-client` | Client library: connect, session management, auto-spawn, stop | +| `server/` | `agent-server` | Long-running WebSocket server with `SessionManager` and per-session dispatch | --- @@ -38,11 +38,11 @@ Each session has its own `SharedDispatcher` instance with isolated chat history, Each WebSocket connection multiplexes independent JSON-RPC channels: -| Channel | Direction | Purpose | -| ------------------------ | --------------- | ------------------------------------------------------------- | +| Channel | Direction | Purpose | +| ------------------------ | --------------- | ------------------------------------------------------------------ | | `agent-server` | client → server | Session lifecycle: `joinSession`, `leaveSession`, CRUD, `shutdown` | -| `dispatcher:` | client → server | Commands: `processCommand`, `getCommandCompletion`, etc. | -| `clientio:` | server → client | Display/interaction callbacks: `setDisplay`, `askYesNo`, etc. | +| `dispatcher:` | client → server | Commands: `processCommand`, `getCommandCompletion`, etc. | +| `clientio:` | server → client | Display/interaction callbacks: `setDisplay`, `askYesNo`, etc. | The dispatcher and clientIO channels are namespaced by `sessionId`, allowing a single WebSocket connection to participate in multiple sessions simultaneously. diff --git a/ts/packages/agentServer/client/README.md b/ts/packages/agentServer/client/README.md index a8d5392d28..9245a6a89d 100644 --- a/ts/packages/agentServer/client/README.md +++ b/ts/packages/agentServer/client/README.md @@ -13,13 +13,13 @@ const connection = await connectAgentServer("ws://localhost:8999"); // Join a session const { dispatcher, sessionId } = await connection.joinSession(clientIO, { - clientType: "shell", + clientType: "shell", }); // Session management await connection.createSession("my session"); -await connection.listSessions(); // all sessions -await connection.listSessions("workout"); // filter by name substring +await connection.listSessions(); // all sessions +await connection.listSessions("workout"); // filter by name substring await connection.renameSession(sessionId, "new name"); await connection.deleteSession(sessionId); @@ -30,15 +30,15 @@ await connection.close(); **`AgentServerConnection`** methods: -| Method | Description | -| --------------------------------------- | ------------------------------------------------------------------ | -| `joinSession(clientIO, options?)` | Join a session; returns `{ dispatcher, sessionId }` | -| `leaveSession(sessionId)` | Leave a session and clean up its channels | -| `createSession(name)` | Create a new named session | -| `listSessions(name?)` | List sessions, optionally filtered by name substring | -| `renameSession(sessionId, newName)` | Rename a session | -| `deleteSession(sessionId)` | Delete a session and its persisted data | -| `close()` | Close the WebSocket connection | +| Method | Description | +| ----------------------------------- | ---------------------------------------------------- | +| `joinSession(clientIO, options?)` | Join a session; returns `{ dispatcher, sessionId }` | +| `leaveSession(sessionId)` | Leave a session and clean up its channels | +| `createSession(name)` | Create a new named session | +| `listSessions(name?)` | List sessions, optionally filtered by name substring | +| `renameSession(sessionId, newName)` | Rename a session | +| `deleteSession(sessionId)` | Delete a session and its persisted data | +| `close()` | Close the WebSocket connection | ### `ensureAndConnectDispatcher(clientIO, port?, options?, onDisconnect?)` @@ -51,10 +51,13 @@ Convenience wrapper that auto-spawns the server if needed and joins a session, r ```typescript const dispatcher = await ensureAndConnectDispatcher( - clientIO, - 8999, - { clientType: "shell" }, - () => { console.error("Disconnected"); process.exit(1); }, + clientIO, + 8999, + { clientType: "shell" }, + () => { + console.error("Disconnected"); + process.exit(1); + }, ); await dispatcher.processCommand("help"); @@ -64,7 +67,7 @@ await dispatcher.processCommand("help"); Connects to the running server on the given port and sends a `shutdown()` RPC. -### `connectDispatcher(clientIO, url, options?, onDisconnect?)` *(deprecated)* +### `connectDispatcher(clientIO, url, options?, onDisconnect?)` _(deprecated)_ Backward-compatible wrapper: connects and immediately joins a session, returning a `Dispatcher`. Use `connectAgentServer()` for full multi-session support. diff --git a/ts/packages/agentServer/protocol/README.md b/ts/packages/agentServer/protocol/README.md index 13f7ea9e98..61cc5c44fb 100644 --- a/ts/packages/agentServer/protocol/README.md +++ b/ts/packages/agentServer/protocol/README.md @@ -6,7 +6,7 @@ Defines the WebSocket RPC contract between agentServer clients and the server. ```typescript enum ChannelName { - AgentServer = "agent-server", // session lifecycle and management + AgentServer = "agent-server", // session lifecycle and management } // Session-namespaced channels (one pair per joined session): @@ -25,41 +25,41 @@ getClientIOChannelName(sessionId: string): string // "clientio:" **`SessionInfo`** — describes a session: -| Field | Type | Description | -| ------------- | -------- | -------------------------------------------------------- | -| `sessionId` | `string` | UUIDv4 stable identifier | -| `name` | `string` | Human-readable label (1–256 chars) | +| Field | Type | Description | +| ------------- | -------- | --------------------------------------------------------------------- | +| `sessionId` | `string` | UUIDv4 stable identifier | +| `name` | `string` | Human-readable label (1–256 chars) | | `clientCount` | `number` | Number of clients currently connected (runtime-only, never persisted) | -| `createdAt` | `string` | ISO 8601 creation timestamp | +| `createdAt` | `string` | ISO 8601 creation timestamp | **`JoinSessionResult`** — returned by `joinSession`: -| Field | Type | Description | -| ------------- | -------- | ------------------------------------ | -| `connectionId`| `string` | Unique identifier for this connection | -| `sessionId` | `string` | The session that was joined or auto-created | +| Field | Type | Description | +| -------------- | -------- | ------------------------------------------- | +| `connectionId` | `string` | Unique identifier for this connection | +| `sessionId` | `string` | The session that was joined or auto-created | **`DispatcherConnectOptions`** — options passed to `joinSession`: -| Field | Type | Description | -| ------------ | --------- | ---------------------------------------------------------------------- | +| Field | Type | Description | +| ------------ | --------- | --------------------------------------------------------------------------------- | | `sessionId` | `string` | Join a specific session by UUID. Omit to resume the most recently active session. | -| `clientType` | `string` | Identifies the client (`"shell"`, `"extension"`, etc.) | -| `filter` | `boolean` | If true, only receive ClientIO messages for this connection's requests | +| `clientType` | `string` | Identifies the client (`"shell"`, `"extension"`, etc.) | +| `filter` | `boolean` | If true, only receive ClientIO messages for this connection's requests | ## RPC methods **`AgentServerInvokeFunctions`** — methods exposed on the `agent-server` channel: -| Method | Description | -| ------------------------------------------- | --------------------------------------------------------------- | -| `joinSession(options?)` | Join or auto-create a session; returns `JoinSessionResult` | -| `leaveSession(sessionId)` | Leave a session and clean up its channels | -| `createSession(name)` | Create a new named session; returns `SessionInfo` | -| `listSessions(name?)` | List all sessions, optionally filtered by name substring (case-insensitive) | -| `renameSession(sessionId, newName)` | Rename a session | -| `deleteSession(sessionId)` | Delete a session and all its persisted data | -| `shutdown()` | Request graceful server shutdown | +| Method | Description | +| ----------------------------------- | --------------------------------------------------------------------------- | +| `joinSession(options?)` | Join or auto-create a session; returns `JoinSessionResult` | +| `leaveSession(sessionId)` | Leave a session and clean up its channels | +| `createSession(name)` | Create a new named session; returns `SessionInfo` | +| `listSessions(name?)` | List all sessions, optionally filtered by name substring (case-insensitive) | +| `renameSession(sessionId, newName)` | Rename a session | +| `deleteSession(sessionId)` | Delete a session and all its persisted data | +| `shutdown()` | Request graceful server shutdown | ## Client-type registry From c26fa9487ab88df7563f7bbd90a6041f1391fcdb Mon Sep 17 00:00:00 2001 From: George Ng Date: Fri, 3 Apr 2026 17:54:34 -0700 Subject: [PATCH 06/11] Remove unnecessary enum for ChannelType --- .../agentServer/client/src/agentServerClient.ts | 6 +++--- ts/packages/agentServer/protocol/README.md | 16 ++++++---------- ts/packages/agentServer/protocol/src/index.ts | 2 +- ts/packages/agentServer/protocol/src/protocol.ts | 4 +--- ts/packages/agentServer/server/src/server.ts | 4 ++-- 5 files changed, 13 insertions(+), 19 deletions(-) diff --git a/ts/packages/agentServer/client/src/agentServerClient.ts b/ts/packages/agentServer/client/src/agentServerClient.ts index df732ebd77..edeb6964ad 100644 --- a/ts/packages/agentServer/client/src/agentServerClient.ts +++ b/ts/packages/agentServer/client/src/agentServerClient.ts @@ -16,7 +16,7 @@ import { fileURLToPath } from "url"; import registerDebug from "debug"; import { AgentServerInvokeFunctions, - ChannelName, + AgentServerChannelName, DispatcherConnectOptions, SessionInfo, JoinSessionResult, @@ -65,7 +65,7 @@ export async function connectAgentServer( const rpc = createRpc( "agent-server:client", - channel.createChannel(ChannelName.AgentServer), + channel.createChannel(AgentServerChannelName), ); // Track joined sessions for cleanup on close @@ -284,7 +284,7 @@ export async function stopAgentServer(port: number = 8999): Promise { ); const rpc = createRpc( "agent-server:stop", - channel.createChannel(ChannelName.AgentServer), + channel.createChannel(AgentServerChannelName), ); ws.onopen = () => { diff --git a/ts/packages/agentServer/protocol/README.md b/ts/packages/agentServer/protocol/README.md index 61cc5c44fb..bae1cf38cf 100644 --- a/ts/packages/agentServer/protocol/README.md +++ b/ts/packages/agentServer/protocol/README.md @@ -4,21 +4,17 @@ Defines the WebSocket RPC contract between agentServer clients and the server. ## Channel names -```typescript -enum ChannelName { - AgentServer = "agent-server", // session lifecycle and management -} +The fixed channel name for session lifecycle RPC is exported as `AgentServerChannelName`: -// Session-namespaced channels (one pair per joined session): -// dispatcher: — command dispatch -// clientio: — display / interaction callbacks +```typescript +export const AgentServerChannelName = "agent-server"; ``` -Helper functions to construct the namespaced channel names: +Session-namespaced channels (one pair per joined session) are constructed via helper functions: ```typescript -getDispatcherChannelName(sessionId: string): string // "dispatcher:" -getClientIOChannelName(sessionId: string): string // "clientio:" +getDispatcherChannelName(sessionId: string): string // "dispatcher:" +getClientIOChannelName(sessionId: string): string // "clientio:" ``` ## Session types diff --git a/ts/packages/agentServer/protocol/src/index.ts b/ts/packages/agentServer/protocol/src/index.ts index 118294a287..8eb10f556b 100644 --- a/ts/packages/agentServer/protocol/src/index.ts +++ b/ts/packages/agentServer/protocol/src/index.ts @@ -4,7 +4,7 @@ export { DispatcherConnectOptions, AgentServerInvokeFunctions, - ChannelName, + AgentServerChannelName, SessionInfo, JoinSessionResult, getDispatcherChannelName, diff --git a/ts/packages/agentServer/protocol/src/protocol.ts b/ts/packages/agentServer/protocol/src/protocol.ts index c31a067e83..ec2a7efff2 100644 --- a/ts/packages/agentServer/protocol/src/protocol.ts +++ b/ts/packages/agentServer/protocol/src/protocol.ts @@ -31,9 +31,7 @@ export type AgentServerInvokeFunctions = { shutdown: () => Promise; }; -export const enum ChannelName { - AgentServer = "agent-server", -} +export const AgentServerChannelName = "agent-server"; /** Build the dispatcher channel name for a given session. */ export function getDispatcherChannelName(sessionId: string): string { diff --git a/ts/packages/agentServer/server/src/server.ts b/ts/packages/agentServer/server/src/server.ts index 95a14a2443..56925720cb 100644 --- a/ts/packages/agentServer/server/src/server.ts +++ b/ts/packages/agentServer/server/src/server.ts @@ -15,7 +15,7 @@ import { createClientIORpcClient } from "@typeagent/dispatcher-rpc/clientio/clie import { createRpc } from "@typeagent/agent-rpc/rpc"; import { AgentServerInvokeFunctions, - ChannelName, + AgentServerChannelName, DispatcherConnectOptions, getDispatcherChannelName, getClientIOChannelName, @@ -188,7 +188,7 @@ async function main() { createRpc( "agent-server", - channelProvider.createChannel(ChannelName.AgentServer), + channelProvider.createChannel(AgentServerChannelName), invokeFunctions, ); }, From a5c59c91b68eb7f4ee3af1068deb2eb5c13634cc Mon Sep 17 00:00:00 2001 From: George Ng Date: Fri, 3 Apr 2026 19:10:43 -0700 Subject: [PATCH 07/11] Improve error handling for double calls of joinSession --- .../agentServer/client/src/agentServerClient.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ts/packages/agentServer/client/src/agentServerClient.ts b/ts/packages/agentServer/client/src/agentServerClient.ts index edeb6964ad..bb78bafa65 100644 --- a/ts/packages/agentServer/client/src/agentServerClient.ts +++ b/ts/packages/agentServer/client/src/agentServerClient.ts @@ -81,6 +81,16 @@ export async function connectAgentServer( clientIO: ClientIO, options?: DispatcherConnectOptions, ): Promise { + const requestedSessionId = options?.sessionId; + if ( + requestedSessionId !== undefined && + joinedSessions.has(requestedSessionId) + ) { + throw new Error( + `Already joined session '${requestedSessionId}'. Call leaveSession() before joining again.`, + ); + } + const result: JoinSessionResult = await rpc.invoke( "joinSession", options, From ace5a9a43e63ddc5fa786d234d891927244ac728 Mon Sep 17 00:00:00 2001 From: George Ng Date: Fri, 3 Apr 2026 19:21:56 -0700 Subject: [PATCH 08/11] Address concurrency concerns in the server and improve error handling --- ts/packages/agentServer/server/src/server.ts | 6 ++++++ .../agentServer/server/src/sessionManager.ts | 19 +++++++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/ts/packages/agentServer/server/src/server.ts b/ts/packages/agentServer/server/src/server.ts index 56925720cb..1242fec249 100644 --- a/ts/packages/agentServer/server/src/server.ts +++ b/ts/packages/agentServer/server/src/server.ts @@ -77,6 +77,12 @@ async function main() { options?.sessionId, ); + if (joinedSessions.has(sessionId)) { + throw new Error( + `Already joined session '${sessionId}'. Call leaveSession() before joining again.`, + ); + } + // Create session-namespaced channels const clientIOChannel = channelProvider.createChannel( getClientIOChannelName(sessionId), diff --git a/ts/packages/agentServer/server/src/sessionManager.ts b/ts/packages/agentServer/server/src/sessionManager.ts index 3cd2784c84..3038944216 100644 --- a/ts/packages/agentServer/server/src/sessionManager.ts +++ b/ts/packages/agentServer/server/src/sessionManager.ts @@ -16,6 +16,7 @@ import { import registerDebug from "debug"; const debugSession = registerDebug("agent-server:session"); +const debugSessionErr = registerDebug("agent-server:session:error"); const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes const SESSIONS_DIR = "server-sessions"; @@ -93,14 +94,23 @@ export async function createSessionManager( } lastActiveSessionId = persisted.lastActiveSessionId; debugSession(`Loaded ${sessions.size} session(s) from metadata`); - } catch { - // No metadata file yet — first run - debugSession("No session metadata found, starting fresh"); + } catch (e: any) { + if (e?.code === "ENOENT") { + // No metadata file yet — first run + debugSession("No session metadata found, starting fresh"); + } else { + // File exists but is unreadable or malformed — log and start fresh + debugSessionErr( + "Failed to load session metadata, starting fresh:", + e, + ); + } } } async function saveMetadata(): Promise { const metadataPath = path.join(sessionsDir, METADATA_FILE); + const tmpPath = `${metadataPath}.tmp`; const entries: SessionMetadata[] = []; for (const record of sessions.values()) { entries.push({ @@ -114,9 +124,10 @@ export async function createSessionManager( lastActiveSessionId, }; await fs.promises.writeFile( - metadataPath, + tmpPath, JSON.stringify(persisted, undefined, 2), ); + await fs.promises.rename(tmpPath, metadataPath); } function getSessionPersistDir(sessionId: string): string { From 8708ad5f212dd85046c16c9a7b22c681e9b00512 Mon Sep 17 00:00:00 2001 From: George Ng Date: Fri, 3 Apr 2026 19:26:08 -0700 Subject: [PATCH 09/11] Address remaining cleanup comments --- ts/packages/agentServer/server/src/sessionManager.ts | 11 +++++++++-- .../agentServer/server/src/sharedDispatcher.ts | 2 -- .../extension/serviceWorker/dispatcherConnection.ts | 3 ++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/ts/packages/agentServer/server/src/sessionManager.ts b/ts/packages/agentServer/server/src/sessionManager.ts index 3038944216..99369390d4 100644 --- a/ts/packages/agentServer/server/src/sessionManager.ts +++ b/ts/packages/agentServer/server/src/sessionManager.ts @@ -176,8 +176,15 @@ export async function createSessionManager( debugSession( `Idle timeout: closing dispatcher for session "${record.name}" (${record.sessionId})`, ); - await record.sharedDispatcher.close(); - record.sharedDispatcher = undefined; + try { + await record.sharedDispatcher.close(); + record.sharedDispatcher = undefined; + } catch (e) { + debugSessionErr( + `Failed to close idle dispatcher for session "${record.name}" (${record.sessionId}):`, + e, + ); + } } }, idleTimeoutMs); } diff --git a/ts/packages/agentServer/server/src/sharedDispatcher.ts b/ts/packages/agentServer/server/src/sharedDispatcher.ts index 0e4525ea74..8c11bc3e8b 100644 --- a/ts/packages/agentServer/server/src/sharedDispatcher.ts +++ b/ts/packages/agentServer/server/src/sharedDispatcher.ts @@ -25,7 +25,6 @@ const debugClientIOError = registerDebug("agent-server:clientIO:error"); type ClientRecord = { clientIO: ClientIO; filter: boolean; - closeFn: () => void; }; export async function createSharedDispatcher( @@ -176,7 +175,6 @@ export async function createSharedDispatcher( clients.set(connectionId, { clientIO, filter: options?.filter ?? false, - closeFn, }); // Register client type for per-request routing if (options?.clientType) { diff --git a/ts/packages/agents/browser/src/extension/serviceWorker/dispatcherConnection.ts b/ts/packages/agents/browser/src/extension/serviceWorker/dispatcherConnection.ts index 5dc60be750..06834b216f 100644 --- a/ts/packages/agents/browser/src/extension/serviceWorker/dispatcherConnection.ts +++ b/ts/packages/agents/browser/src/extension/serviceWorker/dispatcherConnection.ts @@ -25,6 +25,7 @@ import type { import { getDispatcherChannelName, getClientIOChannelName, + AgentServerChannelName, } from "@typeagent/agent-server-protocol"; import registerDebug from "debug"; @@ -202,7 +203,7 @@ async function doConnect(): Promise { const rpc = createRpc( "agent-server:extension", - channel.createChannel("agent-server"), + channel.createChannel(AgentServerChannelName), ); const clientIO = createChatPanelClientIO(); From e49f97d6109e5e4ec266c720fab895a65beb1ac3 Mon Sep 17 00:00:00 2001 From: George Ng Date: Fri, 3 Apr 2026 23:53:22 -0700 Subject: [PATCH 10/11] Address additional comments regarding concurrency, error handling, and channel cleanup --- .../client/src/agentServerClient.ts | 23 ++++-- ts/packages/agentServer/server/src/server.ts | 72 +++++++++++-------- .../agentServer/server/src/sessionManager.ts | 11 ++- 3 files changed, 72 insertions(+), 34 deletions(-) diff --git a/ts/packages/agentServer/client/src/agentServerClient.ts b/ts/packages/agentServer/client/src/agentServerClient.ts index bb78bafa65..02fd4d5455 100644 --- a/ts/packages/agentServer/client/src/agentServerClient.ts +++ b/ts/packages/agentServer/client/src/agentServerClient.ts @@ -74,6 +74,8 @@ export async function connectAgentServer( { dispatcher: Dispatcher; connectionId: string } >(); + let opened = false; + let resolved = false; let closed = false; const connection: AgentServerConnection = { @@ -171,6 +173,8 @@ export async function connectAgentServer( ws.onopen = () => { debug("WebSocket connection established", ws.readyState); + opened = true; + resolved = true; resolve(connection); }; ws.onmessage = (event: WebSocket.MessageEvent) => { @@ -181,6 +185,18 @@ export async function connectAgentServer( debug("WebSocket connection closed", event.code, event.reason); channel.notifyDisconnected(); joinedSessions.clear(); + if (!opened) { + // Closed before onopen fired — reject the pending promise. + if (!resolved) { + resolved = true; + reject( + new Error( + `Failed to connect to agent server at ${url}`, + ), + ); + } + return; + } if (!closed) { closed = true; onDisconnect?.(); @@ -188,10 +204,9 @@ export async function connectAgentServer( }; ws.onerror = (error: WebSocket.ErrorEvent) => { debugErr("WebSocket error:", error); - if (!closed) { - reject( - new Error(`Failed to connect to agent server at ${url}`), - ); + if (!opened && !resolved) { + resolved = true; + reject(new Error(`Failed to connect to agent server at ${url}`)); } }; }); diff --git a/ts/packages/agentServer/server/src/server.ts b/ts/packages/agentServer/server/src/server.ts index 1242fec249..b372852cc9 100644 --- a/ts/packages/agentServer/server/src/server.ts +++ b/ts/packages/agentServer/server/src/server.ts @@ -87,41 +87,55 @@ async function main() { const clientIOChannel = channelProvider.createChannel( getClientIOChannelName(sessionId), ); - const clientIORpcClient = - createClientIORpcClient(clientIOChannel); + try { + const clientIORpcClient = + createClientIORpcClient(clientIOChannel); + + const result = await sessionManager.joinSession( + sessionId, + clientIORpcClient, + () => { + channelProvider.deleteChannel( + getDispatcherChannelName(sessionId), + ); + channelProvider.deleteChannel( + getClientIOChannelName(sessionId), + ); + joinedSessions.delete(sessionId); + }, + options, + ); - const result = await sessionManager.joinSession( - sessionId, - clientIORpcClient, - () => { - channelProvider.deleteChannel( - getDispatcherChannelName(sessionId), + const dispatcherChannel = channelProvider.createChannel( + getDispatcherChannelName(sessionId), + ); + try { + createDispatcherRpcServer( + result.dispatcher, + dispatcherChannel, ); + } catch (e) { channelProvider.deleteChannel( - getClientIOChannelName(sessionId), + getDispatcherChannelName(sessionId), ); - joinedSessions.delete(sessionId); - }, - options, - ); - - const dispatcherChannel = channelProvider.createChannel( - getDispatcherChannelName(sessionId), - ); - createDispatcherRpcServer( - result.dispatcher, - dispatcherChannel, - ); + throw e; + } - joinedSessions.set(sessionId, { - dispatcher: result.dispatcher, - connectionId: result.connectionId, - }); + joinedSessions.set(sessionId, { + dispatcher: result.dispatcher, + connectionId: result.connectionId, + }); - return { - connectionId: result.connectionId, - sessionId, - }; + return { + connectionId: result.connectionId, + sessionId, + }; + } catch (e) { + channelProvider.deleteChannel( + getClientIOChannelName(sessionId), + ); + throw e; + } }, leaveSession: async (sessionId: string) => { diff --git a/ts/packages/agentServer/server/src/sessionManager.ts b/ts/packages/agentServer/server/src/sessionManager.ts index 99369390d4..0cb5523805 100644 --- a/ts/packages/agentServer/server/src/sessionManager.ts +++ b/ts/packages/agentServer/server/src/sessionManager.ts @@ -108,7 +108,16 @@ export async function createSessionManager( } } - async function saveMetadata(): Promise { + // Serialize metadata writes: each call chains onto the previous one so + // concurrent async callers never interleave writeFile/rename operations. + let saveQueue: Promise = Promise.resolve(); + + function saveMetadata(): Promise { + saveQueue = saveQueue.then(doSaveMetadata); + return saveQueue; + } + + async function doSaveMetadata(): Promise { const metadataPath = path.join(sessionsDir, METADATA_FILE); const tmpPath = `${metadataPath}.tmp`; const entries: SessionMetadata[] = []; From 31a8ec1a8d1b31bfd7b3e7808aab7e0f889965b8 Mon Sep 17 00:00:00 2001 From: George Ng Date: Fri, 3 Apr 2026 23:59:21 -0700 Subject: [PATCH 11/11] Run prettier --- ts/packages/agentServer/client/src/agentServerClient.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ts/packages/agentServer/client/src/agentServerClient.ts b/ts/packages/agentServer/client/src/agentServerClient.ts index 02fd4d5455..3e8a8fed6e 100644 --- a/ts/packages/agentServer/client/src/agentServerClient.ts +++ b/ts/packages/agentServer/client/src/agentServerClient.ts @@ -206,7 +206,9 @@ export async function connectAgentServer( debugErr("WebSocket error:", error); if (!opened && !resolved) { resolved = true; - reject(new Error(`Failed to connect to agent server at ${url}`)); + reject( + new Error(`Failed to connect to agent server at ${url}`), + ); } }; });