From 851f3dd9655933500a209c356e748d3d347a344a Mon Sep 17 00:00:00 2001 From: Tal Zaccai Date: Mon, 18 May 2026 20:26:54 -0700 Subject: [PATCH 1/4] Onboarding scaffolder: emit port-registrar-compliant templates Updates the scaffolder so newly-generated websocket-bridge and view-ui agents follow the modern port-allocation pattern: OS-assigned port + context.registerPort(role, port), discoverable by external clients via discoverPort(name, role). - buildWebSocketBridgeTemplate (scaffoldPlugin): static start(port=0) factory, close(), connected getter, sendCommand helper. - buildWebSocketBridgeHandler (scaffoldAgent): full AppAgent lifecycle with refcounted shared server, registerPort, and backstop close. - buildViewUiHandler: full AppAgent with view server, registerPort, setLocalHostPort for shell integration, and ActivityContext-driven openLocalView in executeAction. - Both templates honor _BRIDGE_PORT / _VIEW_PORT env-var overrides for debugging. - Updated PLUGIN_TEMPLATES nextSteps for websocket-bridge to reflect the new await Bridge.start() usage. - agent-patterns.md sections 5 and 8 document the new port contract. The office-addin template is left unchanged in this PR. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ts/docs/architecture/agent-patterns.md | 32 +- .../src/scaffolder/scaffolderHandler.ts | 561 +++++++++++++++--- 2 files changed, 515 insertions(+), 78 deletions(-) diff --git a/ts/docs/architecture/agent-patterns.md b/ts/docs/architecture/agent-patterns.md index e813988442..8cce943d15 100644 --- a/ts/docs/architecture/agent-patterns.md +++ b/ts/docs/architecture/agent-patterns.md @@ -178,7 +178,18 @@ plugin/ (or extension/) ← connects to the bridge and calls host APIs ``` -**AppAgent lifecycle:** implements `closeAgentContext()` to stop the server. +**Port allocation:** the bridge binds on an OS-assigned ephemeral port +(`port: 0`) by default. The actual port is registered with the +dispatcher via `context.registerPort("default", port)` and is +discoverable by external clients through the agent-server's discovery +channel (`discoverPort("", "default")`). Set the +`_BRIDGE_PORT` environment variable to pin the bridge to a fixed +port when debugging. The server uses a refcounted shared-instance +pattern so multiple sessions reuse one listener. + +**AppAgent lifecycle:** `updateAgentContext` starts/stops the server +per session; `closeAgentContext` is the backstop that releases the +registration and closes the server if disable wasn't called. **Dependencies added:** `ws` @@ -240,21 +251,32 @@ system service that exposes no REST API. ### 8. `view-ui` — Web View Renderer A minimal action handler that opens a local HTTP server serving a `site/` -directory and signals the dispatcher to open the view via `openLocalView`. -The actual UX lives in the `site/` directory; the handler communicates with -it via display APIs and IPC types. +directory and signals the shell to load it. The actual UX lives in the +`site/` directory; the handler communicates with it via display APIs +and IPC types. **File layout** ``` src/ - ActionHandler.ts ← opens/closes view, handles actions + ActionHandler.ts ← opens/closes view server, handles actions ipcTypes.ts ← shared message types for handler ↔ view IPC site/ index.html ← web view entry point ... ``` +**Port allocation:** the view server binds on an OS-assigned ephemeral +port (`port: 0`) by default during `updateAgentContext(true)`. The +actual port is registered with the dispatcher via +`context.registerPort("view", port)` for out-of-process discovery +(`discoverPort("", "view")`) and also passed to +`context.setLocalHostPort(port)` so the embedding shell knows which URL +to load. Set the `_VIEW_PORT` environment variable to pin the +view to a fixed port when debugging. The view is surfaced in the shell +by returning an `ActivityContext` with `openLocalView: true` from +`executeAction`. + **Manifest flags:** `"localView": true` **When to choose:** agents that need a rich interactive UI beyond simple text diff --git a/ts/packages/agents/onboarding/src/scaffolder/scaffolderHandler.ts b/ts/packages/agents/onboarding/src/scaffolder/scaffolderHandler.ts index 76fcc3b12c..fa157ca1c5 100644 --- a/ts/packages/agents/onboarding/src/scaffolder/scaffolderHandler.ts +++ b/ts/packages/agents/onboarding/src/scaffolder/scaffolderHandler.ts @@ -874,7 +874,7 @@ const PLUGIN_TEMPLATES: Record< "WebSocket bridge (bidirectional RPC, used by Excel, VS Code agents)", defaultSubdir: "src", nextSteps: - "Start the bridge with `new WebSocketBridge(port).start()` and connect your plugin.", + 'Bind on an OS-assigned port via `await ${PascalName}Bridge.start()`, then publish the bound `.port` from your handler with `context.registerPort("default", bridge.port)` so external clients can discover it.', files: (name) => ({ [`${name}Bridge.ts`]: buildWebSocketBridgeTemplate(name), }), @@ -917,14 +917,24 @@ export class ${toPascalCase(name)}Bridge { } function buildWebSocketBridgeTemplate(name: string): string { + const pascalName = toPascalCase(name); return `// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. // WebSocket bridge for ${name}. // Manages a WebSocket connection to the host application plugin. // Pattern matches the Excel/VS Code agent bridge implementations. +// +// Port allocation: the bridge binds on an OS-assigned ephemeral port +// (port=0) by default. Read the actual bound port from \`.port\` after +// \`start()\` resolves and register it with the dispatcher via +// \`context.registerPort("default", bridge.port)\` from your handler so +// external clients can discover it through the agent-server's +// discovery channel. Pass a fixed port to \`start(port)\` when debugging +// or when a host plugin expects a known address. import { WebSocketServer, WebSocket } from "ws"; +import { AddressInfo } from "net"; type BridgeCommand = { id: string; @@ -939,34 +949,131 @@ type BridgeResponse = { error?: string; }; -export class ${toPascalCase(name)}Bridge { - private wss: WebSocketServer | undefined; - private client: WebSocket | undefined; +export class ${pascalName}Bridge { + private clients = new Map(); + private nextClientId = 0; private pending = new Map void>(); - constructor(private readonly port: number) {} - - start(): void { - this.wss = new WebSocketServer({ port: this.port }); - this.wss.on("connection", (ws) => { - this.client = ws; + // Construction is private — use {@link ${pascalName}Bridge.start} so + // callers always get a bridge that is guaranteed to be bound before + // they read {@link port} or pass it to the registrar. + private constructor( + private readonly server: WebSocketServer, + public readonly port: number, + ) { + this.server.on("connection", (ws) => { + const id = \`c-\${++this.nextClientId}\`; + this.clients.set(id, ws); ws.on("message", (data) => { - const response = JSON.parse(data.toString()) as BridgeResponse; - this.pending.get(response.id)?.(response); - this.pending.delete(response.id); + try { + const response = JSON.parse(data.toString()) as BridgeResponse; + const cb = this.pending.get(response.id); + if (cb) { + cb(response); + this.pending.delete(response.id); + } + } catch { + // Ignore malformed payloads. + } }); + ws.on("close", () => this.clients.delete(id)); + ws.on("error", () => this.clients.delete(id)); + }); + } + + /** + * Bind a new bridge on \`port\`. Pass 0 (default) to let the OS pick + * a free ephemeral port; read the actual bound port from + * {@link port} after the returned promise resolves. Rejects on bind + * failure (EADDRINUSE under a fixed-port override) so callers see + * the problem instead of having it swallowed by a late error + * handler. + */ + public static start(port: number = 0): Promise<${pascalName}Bridge> { + return new Promise((resolve, reject) => { + const server = new WebSocketServer({ port }); + let settled = false; + const onError = (e: Error) => { + if (settled) return; + settled = true; + server.removeListener("listening", onListening); + reject(e); + }; + const onListening = () => { + if (settled) return; + settled = true; + server.removeListener("error", onError); + const addr = server.address() as AddressInfo | null; + if (!addr || typeof addr === "string") { + server.close(); + reject( + new Error( + "ws server.address() did not return AddressInfo", + ), + ); + return; + } + // Re-attach a permanent error handler so post-listen + // errors are logged rather than crashing the process. + server.on("error", () => { + /* TODO: log */ + }); + resolve(new ${pascalName}Bridge(server, addr.port)); + }; + server.once("error", onError); + server.once("listening", onListening); }); } - async sendCommand(actionName: string, parameters: Record): Promise { - if (!this.client) throw new Error("No client connected"); + /** + * Close all client connections and the underlying server. Resolves + * when the server has fully released its port — important for a + * rapid restart cycle, where a synchronous return would race the + * new bind into EADDRINUSE. + */ + public close(): Promise { + for (const c of this.clients.values()) { + if (c.readyState === WebSocket.OPEN) c.close(); + } + this.clients.clear(); + return new Promise((resolve) => + this.server.close(() => resolve()), + ); + } + + public get connected(): boolean { + for (const c of this.clients.values()) { + if (c.readyState === WebSocket.OPEN) return true; + } + return false; + } + + public async sendCommand( + actionName: string, + parameters: Record, + ): Promise { + // Use the first OPEN client (single-plugin pattern). Adapt + // this selection if you need fan-out or per-session client + // targeting. + let target: WebSocket | undefined; + for (const c of this.clients.values()) { + if (c.readyState === WebSocket.OPEN) { + target = c; + break; + } + } + if (!target) { + throw new Error("No client connected to the ${name} bridge."); + } const id = \`cmd-\${Date.now()}-\${Math.random().toString(36).slice(2)}\`; return new Promise((resolve, reject) => { this.pending.set(id, (res) => { if (res.success) resolve(res.result); else reject(new Error(res.error)); }); - this.client!.send(JSON.stringify({ id, actionName, parameters } satisfies BridgeCommand)); + target!.send( + JSON.stringify({ id, actionName, parameters } satisfies BridgeCommand), + ); }); } } @@ -1255,12 +1362,28 @@ async function executeAction( } function buildWebSocketBridgeHandler(name: string, pascalName: string): string { + const portEnv = `${name.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_BRIDGE_PORT`; return `// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. // Pattern: websocket-bridge — bidirectional RPC to a host-side plugin. // The agent owns a WebSocketServer; the host plugin connects as the client. // Commands flow TypeAgent → WebSocket → plugin → response. +// +// Port allocation: the bridge binds on an OS-assigned ephemeral port +// (port=0) by default. The actual port is registered with the dispatcher +// via context.registerPort("default", port) so external clients can +// discover it through the agent-server's discovery channel +// (discoverPort("${name}", "default")). Set ${portEnv} to pin the +// bridge to a fixed port when debugging or when a host plugin expects +// a known address. +// +// Lifecycle: one bridge per process, refcounted across enabled sessions. +// Each enabled session registers the bridge under its own +// sessionContextId; lookup("${name}", "default") keeps returning the +// port as long as ≥1 session has the agent enabled. The dispatcher's +// closeSessionContext backstop releases stale per-session registrations +// if disable is skipped (e.g. crash). import { ActionContext, @@ -1271,9 +1394,15 @@ import { } from "@typeagent/agent-sdk"; import { createActionResultFromTextDisplay } from "@typeagent/agent-sdk/helpers/action"; import { WebSocketServer, WebSocket } from "ws"; +import { AddressInfo } from "net"; import { ${pascalName}Actions } from "./${name}Schema.js"; -const BRIDGE_PORT = 5678; // TODO: choose an unused port +function getBridgeBindPort(): number { + const v = process.env["${portEnv}"]; + if (!v) return 0; + const n = parseInt(v, 10); + return Number.isFinite(n) && n >= 0 ? n : 0; +} // ---- WebSocket bridge -------------------------------------------------- @@ -1281,86 +1410,234 @@ type BridgeRequest = { id: string; actionName: string; parameters: unknown }; type BridgeResponse = { id: string; success: boolean; result?: unknown; error?: string }; class ${pascalName}Bridge { - private wss: WebSocketServer | undefined; - private client: WebSocket | undefined; + private clients = new Map(); + private nextClientId = 0; private pending = new Map void>(); - start(): void { - this.wss = new WebSocketServer({ port: BRIDGE_PORT }); - this.wss.on("connection", (ws) => { - this.client = ws; + // Construction is private — use {@link ${pascalName}Bridge.start} so + // callers always get a bridge that is guaranteed to be bound before + // they read {@link port} or pass it to the registrar. + private constructor( + private readonly server: WebSocketServer, + public readonly port: number, + ) { + this.server.on("connection", (ws) => { + const id = \`c-\${++this.nextClientId}\`; + this.clients.set(id, ws); ws.on("message", (data) => { - const response = JSON.parse(data.toString()) as BridgeResponse; - this.pending.get(response.id)?.(response); - this.pending.delete(response.id); + try { + const response = JSON.parse(data.toString()) as BridgeResponse; + const cb = this.pending.get(response.id); + if (cb) { + cb(response); + this.pending.delete(response.id); + } + } catch { + // Ignore malformed payloads. + } }); - ws.on("close", () => { this.client = undefined; }); + ws.on("close", () => this.clients.delete(id)); + ws.on("error", () => this.clients.delete(id)); }); } - async stop(): Promise { - return new Promise((resolve) => this.wss?.close(() => resolve())); + /** + * Bind a new bridge on \`port\`. Pass 0 (default) to let the OS pick a + * free ephemeral port; read the actual bound port from {@link port} + * after the returned promise resolves. Rejects on bind failure + * (EADDRINUSE under a fixed-port override) so callers see the + * problem instead of having it swallowed by a late error handler. + */ + public static start(port: number = 0): Promise<${pascalName}Bridge> { + return new Promise((resolve, reject) => { + const server = new WebSocketServer({ port }); + let settled = false; + const onError = (e: Error) => { + if (settled) return; + settled = true; + server.removeListener("listening", onListening); + reject(e); + }; + const onListening = () => { + if (settled) return; + settled = true; + server.removeListener("error", onError); + const addr = server.address() as AddressInfo | null; + if (!addr || typeof addr === "string") { + server.close(); + reject(new Error("ws server.address() did not return AddressInfo")); + return; + } + // Re-attach a permanent error handler so post-listen errors + // are logged rather than crashing the process. + server.on("error", () => { /* TODO: log */ }); + resolve(new ${pascalName}Bridge(server, addr.port)); + }; + server.once("error", onError); + server.once("listening", onListening); + }); + } + + /** + * Close all client connections and the underlying server. Resolves + * when the server has fully released its port — important for a + * rapid disable→enable cycle under a fixed-port override + * (\`${portEnv}\`), where a synchronous return would race the new + * bind into EADDRINUSE. + */ + public close(): Promise { + for (const c of this.clients.values()) { + if (c.readyState === WebSocket.OPEN) c.close(); + } + this.clients.clear(); + return new Promise((resolve) => this.server.close(() => resolve())); + } + + public get connected(): boolean { + for (const c of this.clients.values()) { + if (c.readyState === WebSocket.OPEN) return true; + } + return false; } - async send(actionName: string, parameters: unknown): Promise { - if (!this.client) { - throw new Error("No host plugin connected on port " + BRIDGE_PORT); + public async send(actionName: string, parameters: unknown): Promise { + // Use the first OPEN client (single-plugin pattern). Adapt this + // selection if you need fan-out or per-session client targeting. + let target: WebSocket | undefined; + for (const c of this.clients.values()) { + if (c.readyState === WebSocket.OPEN) { target = c; break; } + } + if (!target) { + throw new Error("No host plugin connected to the ${name} bridge."); } const id = \`\${Date.now()}-\${Math.random().toString(36).slice(2)}\`; return new Promise((resolve, reject) => { this.pending.set(id, (res) => res.success ? resolve(res.result) : reject(new Error(res.error)), ); - this.client!.send( + target!.send( JSON.stringify({ id, actionName, parameters } satisfies BridgeRequest), ); }); } +} - get connected(): boolean { return this.client !== undefined; } +// ---- Shared module state ----------------------------------------------- +// +// Storing the bridge per-session would cause "no connection" errors when +// an action runs on a session different from the one that started the +// server, and would mask EADDRINUSE failures from a second bind under a +// fixed-port override. The shared-bridge + per-session-registration +// pattern matches the code and browser agents. + +let sharedBridge: ${pascalName}Bridge | undefined; +let sharedStartingPromise: Promise<${pascalName}Bridge> | undefined; +let sharedClosingPromise: Promise | undefined; +let sharedRefCount = 0; + +// Serialize concurrent starts; await any in-flight close before binding +// again so a rapid disable→enable doesn't race the port release. +async function ensureSharedBridge(): Promise<${pascalName}Bridge> { + if (sharedClosingPromise !== undefined) { + await sharedClosingPromise; + } + if (sharedBridge !== undefined) return sharedBridge; + if (sharedStartingPromise !== undefined) return sharedStartingPromise; + sharedStartingPromise = (async () => { + try { + sharedBridge = await ${pascalName}Bridge.start(getBridgeBindPort()); + return sharedBridge; + } finally { + sharedStartingPromise = undefined; + } + })(); + return sharedStartingPromise; } // ---- Agent lifecycle --------------------------------------------------- -type Context = { bridge: ${pascalName}Bridge }; +type ${pascalName}Context = { + enabledSchemas: Set; + portRegistration?: { release: () => void }; +}; export function instantiate(): AppAgent { return { initializeAgentContext, updateAgentContext, - closeAgentContext, executeAction, }; } -async function initializeAgentContext(): Promise { - const bridge = new ${pascalName}Bridge(); - bridge.start(); - return { bridge }; +async function initializeAgentContext(): Promise<${pascalName}Context> { + return { enabledSchemas: new Set() }; } async function updateAgentContext( - _enable: boolean, - _context: SessionContext, - _schemaName: string, -): Promise {} - -async function closeAgentContext(context: SessionContext): Promise { - await context.agentContext.bridge.stop(); + enable: boolean, + context: SessionContext<${pascalName}Context>, + schemaName: string, +): Promise { + const ctx = context.agentContext; + if (enable) { + if (ctx.enabledSchemas.has(schemaName)) return; + const isFirstForSession = ctx.enabledSchemas.size === 0; + ctx.enabledSchemas.add(schemaName); + try { + const bridge = await ensureSharedBridge(); + if (isFirstForSession) { + // Per-session registration: the registrar allows multiple + // entries for ("${name}", "default") across sessions and + // lookup returns the most recent, so each active session + // independently keeps the shared port discoverable. + ctx.portRegistration = context.registerPort( + "default", + bridge.port, + ); + sharedRefCount++; + } + } catch (e) { + // Roll back per-session bookkeeping so a subsequent retry sees + // a clean slate. Shared module state is untouched — the bind + // itself failed, so we never incremented the refcount or + // registered. + ctx.enabledSchemas.delete(schemaName); + throw e; + } + } else { + if (!ctx.enabledSchemas.has(schemaName)) return; + ctx.enabledSchemas.delete(schemaName); + if (ctx.enabledSchemas.size === 0) { + // Release this session's registration before potentially + // closing the server. Release is idempotent and a no-op if + // already released by the dispatcher's closeSessionContext + // backstop. + ctx.portRegistration?.release(); + delete ctx.portRegistration; + sharedRefCount = Math.max(0, sharedRefCount - 1); + if (sharedRefCount === 0 && sharedBridge) { + const bridge = sharedBridge; + sharedBridge = undefined; + sharedClosingPromise = bridge.close().finally(() => { + sharedClosingPromise = undefined; + }); + await sharedClosingPromise; + } + } + } } async function executeAction( action: TypeAgentAction<${pascalName}Actions>, - context: ActionContext, + _context: ActionContext<${pascalName}Context>, ): Promise { - const { bridge } = context.sessionContext.agentContext; - if (!bridge.connected) { + if (!sharedBridge?.connected) { return { - error: \`Host plugin not connected. Make sure the ${name} plugin is running on port \${BRIDGE_PORT}.\`, + error: "Host plugin not connected to the ${name} bridge. Start the plugin and ensure it is configured for the port reported by @system ports.", }; } try { - const result = await bridge.send(action.actionName, action.parameters); + const result = await sharedBridge.send(action.actionName, action.parameters); return createActionResultFromTextDisplay(JSON.stringify(result, null, 2)); } catch (err: any) { return { error: err?.message ?? String(err) }; @@ -1527,24 +1804,51 @@ async function executeCommand( } function buildViewUiHandler(name: string, pascalName: string): string { + const portEnv = `${name.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_VIEW_PORT`; return `// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. // Pattern: view-ui — web view renderer with IPC handler. -// Opens a local HTTP server serving site/ and communicates via display APIs. -// The actual UX lives in the site/ directory. +// Opens a local HTTP server serving site/ and surfaces it in the shell +// via an ActivityContext with openLocalView=true. +// +// Port allocation: the view server binds on an OS-assigned ephemeral +// port (port=0) by default. The actual port is registered with the +// dispatcher via context.registerPort("view", port) so external +// clients can discover it through the agent-server's discovery channel +// (discoverPort("${name}", "view")). context.setLocalHostPort(port) is +// also called so the embedding shell knows which port to load when an +// action returns openLocalView=true. Set ${portEnv} to pin the view +// to a fixed port when debugging. import { ActionContext, + ActionResult, + ActivityContext, AppAgent, SessionContext, TypeAgentAction, - ActionResult, } from "@typeagent/agent-sdk"; -import { createActionResultFromHtmlDisplay } from "@typeagent/agent-sdk/helpers/action"; +import { + createActionResult, + createActionResultFromHtmlDisplay, +} from "@typeagent/agent-sdk/helpers/action"; +import { createServer, Server } from "node:http"; +import { AddressInfo } from "node:net"; import { ${pascalName}Actions } from "./${name}Schema.js"; -const VIEW_PORT = 3456; // TODO: choose an unused port +type ${pascalName}AgentContext = { + server?: Server; + port?: number; + portRegistration?: { release: () => void }; +}; + +function getViewBindPort(): number { + const v = process.env["${portEnv}"]; + if (!v) return 0; + const n = parseInt(v, 10); + return Number.isFinite(n) && n >= 0 ? n : 0; +} export function instantiate(): AppAgent { return { @@ -1555,42 +1859,153 @@ export function instantiate(): AppAgent { }; } -async function initializeAgentContext(): Promise { - // TODO: start the local HTTP server that serves site/ +async function initializeAgentContext(): Promise<${pascalName}AgentContext> { return {}; } +/** + * Bind the view server on \`port\` (0 = OS-assigned). Returns the actual + * bound port so it can be registered and surfaced to the shell. + * Rejects on bind failure (EADDRINUSE under a fixed-port override) so + * callers see the problem instead of having it swallowed by a late + * error handler. + */ +function startViewServer(port: number): Promise<{ server: Server; port: number }> { + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + // TODO: serve static assets from ./site/, plus any + // JSON/IPC endpoints the view needs. For now, a placeholder. + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(\`

${pascalName} view

Path: \${req.url}

\`); + }); + let settled = false; + const onError = (e: Error) => { + if (settled) return; + settled = true; + server.removeListener("listening", onListening); + reject(e); + }; + const onListening = () => { + if (settled) return; + settled = true; + server.removeListener("error", onError); + const addr = server.address() as AddressInfo | null; + if (!addr || typeof addr === "string") { + server.close(); + reject(new Error("http server.address() did not return AddressInfo")); + return; + } + // Re-attach a permanent error handler so post-listen errors + // are logged rather than crashing the process. + server.on("error", () => { /* TODO: log */ }); + resolve({ server, port: addr.port }); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(port); + }); +} + async function updateAgentContext( enable: boolean, - context: SessionContext, + context: SessionContext<${pascalName}AgentContext>, _schemaName: string, ): Promise { + const agentContext = context.agentContext; if (enable) { - await context.agentIO.openLocalView( - context.requestId, - VIEW_PORT, - ); + if (agentContext.server !== undefined) { + // Already bound for this session. + return; + } + const { server, port } = await startViewServer(getViewBindPort()); + try { + agentContext.server = server; + agentContext.port = port; + agentContext.portRegistration = context.registerPort("view", port); + // Tell the embedding shell which port to load when an + // action returns openLocalView=true. Goes through the + // registrar with role="default", so the discovery-channel + // role "view" above keeps a stable contract for out-of- + // process clients regardless of this back-compat call. + context.setLocalHostPort(port); + } catch (e) { + // Roll back if registration/setLocalHostPort fails so a + // retry sees a clean slate. + agentContext.portRegistration?.release(); + await new Promise((resolve) => server.close(() => resolve())); + agentContext.server = undefined; + agentContext.port = undefined; + agentContext.portRegistration = undefined; + throw e; + } } else { - await context.agentIO.closeLocalView( - context.requestId, - VIEW_PORT, - ); + if (agentContext.server === undefined) return; + agentContext.portRegistration?.release(); + agentContext.portRegistration = undefined; + const server = agentContext.server; + agentContext.server = undefined; + agentContext.port = undefined; + // Resolve when the server has fully released its port — + // important for a rapid disable→enable cycle under a fixed- + // port override (\`${portEnv}\`), where a synchronous return + // would race the new bind into EADDRINUSE. + await new Promise((resolve) => server.close(() => resolve())); } } -async function closeAgentContext(_context: SessionContext): Promise { - // TODO: stop the local HTTP server +async function closeAgentContext( + context: SessionContext<${pascalName}AgentContext>, +): Promise { + // Backstop: if updateAgentContext(false) wasn't called (e.g. crash + // during shutdown), release the registration and close the server + // so the port doesn't leak. + const agentContext = context.agentContext; + agentContext.portRegistration?.release(); + agentContext.portRegistration = undefined; + if (agentContext.server) { + const server = agentContext.server; + agentContext.server = undefined; + agentContext.port = undefined; + await new Promise((resolve) => server.close(() => resolve())); + } } async function executeAction( action: TypeAgentAction<${pascalName}Actions>, - _context: ActionContext, + context: ActionContext<${pascalName}AgentContext>, ): Promise { - // Push state changes to the view via HTML display updates. - return createActionResultFromHtmlDisplay( + const port = context.sessionContext.agentContext.port; + // Returning an ActivityContext with openLocalView=true signals the + // shell to open the local view (it uses the port published via + // setLocalHostPort during enable). Drop the activityContext field + // if your action doesn't need to surface the view. + const activityContext: ActivityContext | undefined = + port !== undefined + ? { + appAgentName: "${name}", + activityName: action.actionName, + description: \`${pascalName}: \${action.actionName}\`, + state: {}, + openLocalView: true, + } + : undefined; + const result = createActionResultFromHtmlDisplay( \`

Executing \${action.actionName} — not yet implemented.

\`, ); + if (activityContext) { + // ActivityContext is attached so the shell can open the view. + // The shape comes from the SDK; cast through unknown to keep + // the template free of internal-only ActionResult fields. + (result as unknown as { activityContext: ActivityContext }).activityContext = + activityContext; + } + return result; } + +// Silence unused-import warning when the action handler is stripped +// down. \`createActionResult\` is provided alongside the HTML helper for +// callers that want a richer entity-bearing result. +void createActionResult; `; } From 56929a473333d26478a4fde23529fc17e87cc030 Mon Sep 17 00:00:00 2001 From: Tal Zaccai Date: Tue, 19 May 2026 15:39:56 -0700 Subject: [PATCH 2/4] fix: address PR #2368 review comments - websocket-bridge nextSteps: use placeholder instead of literal ${PascalName} interpolation - Both bridge templates: pending map now stores {resolve, reject}; close() rejects all pending so callers don't hang - Both bridge templates: post-listen errors are now console.error'd (was a TODO) - websocket-bridge template: add closeAgentContext backstop that releases the per-session port registration and decrements the shared refcount Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/scaffolder/scaffolderHandler.ts | 120 +++++++++++++----- 1 file changed, 90 insertions(+), 30 deletions(-) diff --git a/ts/packages/agents/onboarding/src/scaffolder/scaffolderHandler.ts b/ts/packages/agents/onboarding/src/scaffolder/scaffolderHandler.ts index fa157ca1c5..3dc2391ca2 100644 --- a/ts/packages/agents/onboarding/src/scaffolder/scaffolderHandler.ts +++ b/ts/packages/agents/onboarding/src/scaffolder/scaffolderHandler.ts @@ -874,7 +874,7 @@ const PLUGIN_TEMPLATES: Record< "WebSocket bridge (bidirectional RPC, used by Excel, VS Code agents)", defaultSubdir: "src", nextSteps: - 'Bind on an OS-assigned port via `await ${PascalName}Bridge.start()`, then publish the bound `.port` from your handler with `context.registerPort("default", bridge.port)` so external clients can discover it.', + 'Bind on an OS-assigned port via `await Bridge.start()` (replace `` with your agent name), then publish the bound `.port` from your handler with `context.registerPort("default", bridge.port)` so external clients can discover it.', files: (name) => ({ [`${name}Bridge.ts`]: buildWebSocketBridgeTemplate(name), }), @@ -952,7 +952,13 @@ type BridgeResponse = { export class ${pascalName}Bridge { private clients = new Map(); private nextClientId = 0; - private pending = new Map void>(); + private pending = new Map< + string, + { + resolve: (result: unknown) => void; + reject: (err: Error) => void; + } + >(); // Construction is private — use {@link ${pascalName}Bridge.start} so // callers always get a bridge that is guaranteed to be bound before @@ -967,10 +973,11 @@ export class ${pascalName}Bridge { ws.on("message", (data) => { try { const response = JSON.parse(data.toString()) as BridgeResponse; - const cb = this.pending.get(response.id); - if (cb) { - cb(response); + const entry = this.pending.get(response.id); + if (entry) { this.pending.delete(response.id); + if (response.success) entry.resolve(response.result); + else entry.reject(new Error(response.error)); } } catch { // Ignore malformed payloads. @@ -1014,9 +1021,11 @@ export class ${pascalName}Bridge { return; } // Re-attach a permanent error handler so post-listen - // errors are logged rather than crashing the process. - server.on("error", () => { - /* TODO: log */ + // errors are surfaced rather than crashing the process. + server.on("error", (err) => { + console.error( + \`[${name}Bridge] post-listen server error: \${err.message}\`, + ); }); resolve(new ${pascalName}Bridge(server, addr.port)); }; @@ -1026,12 +1035,20 @@ export class ${pascalName}Bridge { } /** - * Close all client connections and the underlying server. Resolves - * when the server has fully released its port — important for a - * rapid restart cycle, where a synchronous return would race the - * new bind into EADDRINUSE. + * Close all client connections and the underlying server. Pending + * \`sendCommand\` promises are rejected so callers never hang on a + * closed bridge. Resolves when the server has fully released its + * port — important for a rapid restart cycle, where a synchronous + * return would race the new bind into EADDRINUSE. */ public close(): Promise { + const closedError = new Error( + "${pascalName}Bridge closed before response was received.", + ); + for (const entry of this.pending.values()) { + entry.reject(closedError); + } + this.pending.clear(); for (const c of this.clients.values()) { if (c.readyState === WebSocket.OPEN) c.close(); } @@ -1067,10 +1084,7 @@ export class ${pascalName}Bridge { } const id = \`cmd-\${Date.now()}-\${Math.random().toString(36).slice(2)}\`; return new Promise((resolve, reject) => { - this.pending.set(id, (res) => { - if (res.success) resolve(res.result); - else reject(new Error(res.error)); - }); + this.pending.set(id, { resolve, reject }); target!.send( JSON.stringify({ id, actionName, parameters } satisfies BridgeCommand), ); @@ -1412,7 +1426,13 @@ type BridgeResponse = { id: string; success: boolean; result?: unknown; error?: class ${pascalName}Bridge { private clients = new Map(); private nextClientId = 0; - private pending = new Map void>(); + private pending = new Map< + string, + { + resolve: (result: unknown) => void; + reject: (err: Error) => void; + } + >(); // Construction is private — use {@link ${pascalName}Bridge.start} so // callers always get a bridge that is guaranteed to be bound before @@ -1427,10 +1447,11 @@ class ${pascalName}Bridge { ws.on("message", (data) => { try { const response = JSON.parse(data.toString()) as BridgeResponse; - const cb = this.pending.get(response.id); - if (cb) { - cb(response); + const entry = this.pending.get(response.id); + if (entry) { this.pending.delete(response.id); + if (response.success) entry.resolve(response.result); + else entry.reject(new Error(response.error)); } } catch { // Ignore malformed payloads. @@ -1469,8 +1490,12 @@ class ${pascalName}Bridge { return; } // Re-attach a permanent error handler so post-listen errors - // are logged rather than crashing the process. - server.on("error", () => { /* TODO: log */ }); + // are surfaced rather than crashing the process. + server.on("error", (err) => { + console.error( + \`[${name}Bridge] post-listen server error: \${err.message}\`, + ); + }); resolve(new ${pascalName}Bridge(server, addr.port)); }; server.once("error", onError); @@ -1479,13 +1504,21 @@ class ${pascalName}Bridge { } /** - * Close all client connections and the underlying server. Resolves - * when the server has fully released its port — important for a - * rapid disable→enable cycle under a fixed-port override - * (\`${portEnv}\`), where a synchronous return would race the new - * bind into EADDRINUSE. + * Close all client connections and the underlying server. Pending + * \`send\` promises are rejected so callers never hang on a closed + * bridge. Resolves when the server has fully released its port — + * important for a rapid disable→enable cycle under a fixed-port + * override (\`${portEnv}\`), where a synchronous return would race + * the new bind into EADDRINUSE. */ public close(): Promise { + const closedError = new Error( + "${pascalName}Bridge closed before response was received.", + ); + for (const entry of this.pending.values()) { + entry.reject(closedError); + } + this.pending.clear(); for (const c of this.clients.values()) { if (c.readyState === WebSocket.OPEN) c.close(); } @@ -1512,9 +1545,7 @@ class ${pascalName}Bridge { } const id = \`\${Date.now()}-\${Math.random().toString(36).slice(2)}\`; return new Promise((resolve, reject) => { - this.pending.set(id, (res) => - res.success ? resolve(res.result) : reject(new Error(res.error)), - ); + this.pending.set(id, { resolve, reject }); target!.send( JSON.stringify({ id, actionName, parameters } satisfies BridgeRequest), ); @@ -1565,6 +1596,7 @@ export function instantiate(): AppAgent { return { initializeAgentContext, updateAgentContext, + closeAgentContext, executeAction, }; } @@ -1573,6 +1605,34 @@ async function initializeAgentContext(): Promise<${pascalName}Context> { return { enabledSchemas: new Set() }; } +/** + * Backstop cleanup invoked by the dispatcher when a session closes + * without an explicit per-schema disable (crash, client disconnect, + * shell shutdown). Releases this session's port registration and + * decrements the shared refcount once, even if multiple schemas were + * enabled. Idempotent — a subsequent \`updateAgentContext(false, …)\` + * will see an empty \`enabledSchemas\` and no-op. + */ +async function closeAgentContext( + context: SessionContext<${pascalName}Context>, +): Promise { + const ctx = context.agentContext; + const wasActive = ctx.enabledSchemas.size > 0; + ctx.enabledSchemas.clear(); + ctx.portRegistration?.release(); + delete ctx.portRegistration; + if (!wasActive) return; + sharedRefCount = Math.max(0, sharedRefCount - 1); + if (sharedRefCount === 0 && sharedBridge) { + const bridge = sharedBridge; + sharedBridge = undefined; + sharedClosingPromise = bridge.close().finally(() => { + sharedClosingPromise = undefined; + }); + await sharedClosingPromise; + } +} + async function updateAgentContext( enable: boolean, context: SessionContext<${pascalName}Context>, From 2bbb64659485ad0c2db19ce5bd213e408a3c9f84 Mon Sep 17 00:00:00 2001 From: Tal Zaccai Date: Thu, 28 May 2026 13:21:39 -0700 Subject: [PATCH 3/4] onboarding: extract scaffolder code templates to .template files Per PR #2368 review feedback, 14 build* functions that emitted multi- hundred-line TypeScript blobs as template literals have been split into plain .template files loaded at runtime by a shared templateLoader. Reviewers can now read each emitted file as syntax-highlighted code in its own file instead of scrolling through escaped backticks inside scaffolderHandler.ts. The source file drops from ~2100 lines to ~970. Pattern matches the existing cliHandler.template precedent in the same directory: {{TOKEN}} placeholders (so they don't collide with the ${...} template literals that survive into the emitted code), loaded from src/ at runtime so no postbuild copy step is needed. The loader throws on unsubstituted placeholders to catch typos at scaffold time. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/scaffolder/scaffolderHandler.ts | 1228 +---------------- .../src/scaffolder/templateLoader.ts | 55 + .../templates/commandHandlerTemplate.template | 35 + .../templates/externalApiHandler.template | 69 + .../templates/llmStreamingHandler.template | 45 + .../templates/nativePlatformHandler.template | 66 + .../templates/officeAddinHtml.template | 13 + .../templates/officeAddinTs.template | 33 + .../templates/officeManifestXml.template | 18 + .../templates/restClientTemplate.template | 20 + .../templates/schemaGrammarHandler.template | 32 + .../templates/stateMachineHandler.template | 83 ++ .../subAgentOrchestratorHandler.template | 45 + .../templates/viewUiHandler.template | 201 +++ .../templates/websocketBridgeHandler.template | 326 +++++ .../websocketBridgeTemplate.template | 173 +++ 16 files changed, 1270 insertions(+), 1172 deletions(-) create mode 100644 ts/packages/agents/onboarding/src/scaffolder/templateLoader.ts create mode 100644 ts/packages/agents/onboarding/src/scaffolder/templates/commandHandlerTemplate.template create mode 100644 ts/packages/agents/onboarding/src/scaffolder/templates/externalApiHandler.template create mode 100644 ts/packages/agents/onboarding/src/scaffolder/templates/llmStreamingHandler.template create mode 100644 ts/packages/agents/onboarding/src/scaffolder/templates/nativePlatformHandler.template create mode 100644 ts/packages/agents/onboarding/src/scaffolder/templates/officeAddinHtml.template create mode 100644 ts/packages/agents/onboarding/src/scaffolder/templates/officeAddinTs.template create mode 100644 ts/packages/agents/onboarding/src/scaffolder/templates/officeManifestXml.template create mode 100644 ts/packages/agents/onboarding/src/scaffolder/templates/restClientTemplate.template create mode 100644 ts/packages/agents/onboarding/src/scaffolder/templates/schemaGrammarHandler.template create mode 100644 ts/packages/agents/onboarding/src/scaffolder/templates/stateMachineHandler.template create mode 100644 ts/packages/agents/onboarding/src/scaffolder/templates/subAgentOrchestratorHandler.template create mode 100644 ts/packages/agents/onboarding/src/scaffolder/templates/viewUiHandler.template create mode 100644 ts/packages/agents/onboarding/src/scaffolder/templates/websocketBridgeHandler.template create mode 100644 ts/packages/agents/onboarding/src/scaffolder/templates/websocketBridgeTemplate.template diff --git a/ts/packages/agents/onboarding/src/scaffolder/scaffolderHandler.ts b/ts/packages/agents/onboarding/src/scaffolder/scaffolderHandler.ts index 3dc2391ca2..a796d106c2 100644 --- a/ts/packages/agents/onboarding/src/scaffolder/scaffolderHandler.ts +++ b/ts/packages/agents/onboarding/src/scaffolder/scaffolderHandler.ts @@ -21,6 +21,7 @@ import { } from "../lib/workspace.js"; import type { ApiSurface } from "../discovery/discoveryHandler.js"; import { buildCliHandler } from "./cliHandlerTemplate.js"; +import { loadTemplate } from "./templateLoader.js"; import fs from "fs/promises"; import path from "path"; import { fileURLToPath } from "url"; @@ -690,39 +691,10 @@ async function buildHandler( } function buildSchemaGrammarHandler(name: string, pascalName: string): string { - return `// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { - ActionContext, - AppAgent, - TypeAgentAction, - ActionResult, -} from "@typeagent/agent-sdk"; -import { createActionResultFromTextDisplay } from "@typeagent/agent-sdk/helpers/action"; -import { ${pascalName}Actions } from "./${name}Schema.js"; - -export function instantiate(): AppAgent { - return { - initializeAgentContext, - executeAction, - }; -} - -async function initializeAgentContext(): Promise { - return {}; -} - -async function executeAction( - action: TypeAgentAction<${pascalName}Actions>, - context: ActionContext, -): Promise { - // TODO: implement action handlers - return createActionResultFromTextDisplay( - \`Executing \${action.actionName} — not yet implemented.\`, - ); -} -`; + return loadTemplate("schemaGrammarHandler.template", { + NAME: name, + PASCAL_NAME: pascalName, + }); } // Map of TypeAgent workspace packages to their location relative to the @@ -893,282 +865,39 @@ const PLUGIN_TEMPLATES: Record< }; function buildRestClientTemplate(name: string): string { - return `// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// REST client bridge for ${name}. -// Calls the target API and returns results to the TypeAgent handler. - -export class ${toPascalCase(name)}Bridge { - constructor(private readonly baseUrl: string, private readonly apiKey?: string) {} - - async executeCommand(actionName: string, parameters: Record): Promise { - // TODO: map actionName to HTTP endpoint and method - throw new Error(\`Not implemented: \${actionName}\`); - } - - private get headers(): Record { - const h: Record = { "Content-Type": "application/json" }; - if (this.apiKey) h["Authorization"] = \`Bearer \${this.apiKey}\`; - return h; - } -} -`; + const pascalName = toPascalCase(name); + return loadTemplate("restClientTemplate.template", { + NAME: name, + PASCAL_NAME: pascalName, + }); } function buildWebSocketBridgeTemplate(name: string): string { const pascalName = toPascalCase(name); - return `// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// WebSocket bridge for ${name}. -// Manages a WebSocket connection to the host application plugin. -// Pattern matches the Excel/VS Code agent bridge implementations. -// -// Port allocation: the bridge binds on an OS-assigned ephemeral port -// (port=0) by default. Read the actual bound port from \`.port\` after -// \`start()\` resolves and register it with the dispatcher via -// \`context.registerPort("default", bridge.port)\` from your handler so -// external clients can discover it through the agent-server's -// discovery channel. Pass a fixed port to \`start(port)\` when debugging -// or when a host plugin expects a known address. - -import { WebSocketServer, WebSocket } from "ws"; -import { AddressInfo } from "net"; - -type BridgeCommand = { - id: string; - actionName: string; - parameters: Record; -}; - -type BridgeResponse = { - id: string; - success: boolean; - result?: unknown; - error?: string; -}; - -export class ${pascalName}Bridge { - private clients = new Map(); - private nextClientId = 0; - private pending = new Map< - string, - { - resolve: (result: unknown) => void; - reject: (err: Error) => void; - } - >(); - - // Construction is private — use {@link ${pascalName}Bridge.start} so - // callers always get a bridge that is guaranteed to be bound before - // they read {@link port} or pass it to the registrar. - private constructor( - private readonly server: WebSocketServer, - public readonly port: number, - ) { - this.server.on("connection", (ws) => { - const id = \`c-\${++this.nextClientId}\`; - this.clients.set(id, ws); - ws.on("message", (data) => { - try { - const response = JSON.parse(data.toString()) as BridgeResponse; - const entry = this.pending.get(response.id); - if (entry) { - this.pending.delete(response.id); - if (response.success) entry.resolve(response.result); - else entry.reject(new Error(response.error)); - } - } catch { - // Ignore malformed payloads. - } - }); - ws.on("close", () => this.clients.delete(id)); - ws.on("error", () => this.clients.delete(id)); - }); - } - - /** - * Bind a new bridge on \`port\`. Pass 0 (default) to let the OS pick - * a free ephemeral port; read the actual bound port from - * {@link port} after the returned promise resolves. Rejects on bind - * failure (EADDRINUSE under a fixed-port override) so callers see - * the problem instead of having it swallowed by a late error - * handler. - */ - public static start(port: number = 0): Promise<${pascalName}Bridge> { - return new Promise((resolve, reject) => { - const server = new WebSocketServer({ port }); - let settled = false; - const onError = (e: Error) => { - if (settled) return; - settled = true; - server.removeListener("listening", onListening); - reject(e); - }; - const onListening = () => { - if (settled) return; - settled = true; - server.removeListener("error", onError); - const addr = server.address() as AddressInfo | null; - if (!addr || typeof addr === "string") { - server.close(); - reject( - new Error( - "ws server.address() did not return AddressInfo", - ), - ); - return; - } - // Re-attach a permanent error handler so post-listen - // errors are surfaced rather than crashing the process. - server.on("error", (err) => { - console.error( - \`[${name}Bridge] post-listen server error: \${err.message}\`, - ); - }); - resolve(new ${pascalName}Bridge(server, addr.port)); - }; - server.once("error", onError); - server.once("listening", onListening); - }); - } - - /** - * Close all client connections and the underlying server. Pending - * \`sendCommand\` promises are rejected so callers never hang on a - * closed bridge. Resolves when the server has fully released its - * port — important for a rapid restart cycle, where a synchronous - * return would race the new bind into EADDRINUSE. - */ - public close(): Promise { - const closedError = new Error( - "${pascalName}Bridge closed before response was received.", - ); - for (const entry of this.pending.values()) { - entry.reject(closedError); - } - this.pending.clear(); - for (const c of this.clients.values()) { - if (c.readyState === WebSocket.OPEN) c.close(); - } - this.clients.clear(); - return new Promise((resolve) => - this.server.close(() => resolve()), - ); - } - - public get connected(): boolean { - for (const c of this.clients.values()) { - if (c.readyState === WebSocket.OPEN) return true; - } - return false; - } - - public async sendCommand( - actionName: string, - parameters: Record, - ): Promise { - // Use the first OPEN client (single-plugin pattern). Adapt - // this selection if you need fan-out or per-session client - // targeting. - let target: WebSocket | undefined; - for (const c of this.clients.values()) { - if (c.readyState === WebSocket.OPEN) { - target = c; - break; - } - } - if (!target) { - throw new Error("No client connected to the ${name} bridge."); - } - const id = \`cmd-\${Date.now()}-\${Math.random().toString(36).slice(2)}\`; - return new Promise((resolve, reject) => { - this.pending.set(id, { resolve, reject }); - target!.send( - JSON.stringify({ id, actionName, parameters } satisfies BridgeCommand), - ); - }); - } -} -`; + return loadTemplate("websocketBridgeTemplate.template", { + NAME: name, + PASCAL_NAME: pascalName, + }); } function buildOfficeAddinHtml(name: string): string { - return ` - - - - ${toPascalCase(name)} TypeAgent Add-in - - - - -

${toPascalCase(name)} TypeAgent

-
Connecting...
- - -`; + const pascalName = toPascalCase(name); + return loadTemplate("officeAddinHtml.template", { + PASCAL_NAME: pascalName, + }); } function buildOfficeAddinTs(name: string): string { - return `// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Office.js task pane add-in for ${name} TypeAgent integration. -// Connects to the TypeAgent bridge via WebSocket and forwards commands -// to the Office.js API. - -const BRIDGE_PORT = 5678; - -Office.onReady(async () => { - document.getElementById("status")!.textContent = "Connecting to TypeAgent..."; - const ws = new WebSocket(\`ws://localhost:\${BRIDGE_PORT}\`); - - ws.onopen = () => { - document.getElementById("status")!.textContent = "Connected"; - ws.send(JSON.stringify({ type: "hello", addinName: "${name}" })); - }; - - ws.onmessage = async (event) => { - const command = JSON.parse(event.data); - try { - const result = await executeCommand(command.actionName, command.parameters); - ws.send(JSON.stringify({ id: command.id, success: true, result })); - } catch (err: any) { - ws.send(JSON.stringify({ id: command.id, success: false, error: err?.message ?? String(err) })); - } - }; -}); - -async function executeCommand(actionName: string, parameters: Record): Promise { - // TODO: map actionName to Office.js API calls - throw new Error(\`Not implemented: \${actionName}\`); -} -`; + return loadTemplate("officeAddinTs.template", { + NAME: name, + }); } function buildOfficeManifestXml(name: string): string { const pascal = toPascalCase(name); - return ` - - - 1.0.0.0 - Microsoft - en-US - - - - - - - - - ReadWriteDocument - -`; + return loadTemplate("officeManifestXml.template", { + PASCAL_NAME: pascal, + }); } async function writeFile(filePath: string, content: string): Promise { @@ -1202,908 +931,63 @@ async function handleListPatterns(): Promise { // ─── Pattern-specific handler builders ─────────────────────────────────────── function buildExternalApiHandler(name: string, pascalName: string): string { - return `// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Pattern: external-api — REST/OAuth cloud API bridge. -// Implement ${pascalName}Client with your API's authentication and endpoints. - -import { - ActionContext, - AppAgent, - SessionContext, - TypeAgentAction, - ActionResult, -} from "@typeagent/agent-sdk"; -import { createActionResultFromTextDisplay } from "@typeagent/agent-sdk/helpers/action"; -import { ${pascalName}Actions } from "./${name}Schema.js"; - -// ---- API client -------------------------------------------------------- - -class ${pascalName}Client { - private token: string | undefined; - - /** Authenticate and store the access token. */ - async authenticate(): Promise { - // TODO: implement OAuth flow or API key loading. - // Store token in: ~/.typeagent/profiles//${name}/token.json - throw new Error("authenticate() not yet implemented"); - } - - async callApi(endpoint: string, params: Record): Promise { - if (!this.token) await this.authenticate(); - // TODO: implement HTTP call using this.token - throw new Error(\`callApi(\${endpoint}) not yet implemented\`); - } -} - -// ---- Agent lifecycle --------------------------------------------------- - -type Context = { client: ${pascalName}Client }; - -export function instantiate(): AppAgent { - return { - initializeAgentContext, - updateAgentContext, - executeAction, - }; -} - -async function initializeAgentContext(): Promise { - return { client: new ${pascalName}Client() }; -} - -async function updateAgentContext( - _enable: boolean, - _context: SessionContext, - _schemaName: string, -): Promise { - // Optionally authenticate eagerly when the agent is enabled. -} - -async function executeAction( - action: TypeAgentAction<${pascalName}Actions>, - context: ActionContext, -): Promise { - const { client } = context.sessionContext.agentContext; - // TODO: map each action to a client.callApi() call. - return createActionResultFromTextDisplay( - \`Executing \${action.actionName} — not yet implemented.\`, - ); -} -`; + return loadTemplate("externalApiHandler.template", { + NAME: name, + PASCAL_NAME: pascalName, + }); } function buildLlmStreamingHandler(name: string, pascalName: string): string { - return `// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Pattern: llm-streaming — LLM-injected agent with streaming responses. -// Runs inside the dispatcher process (injected: true in manifest). -// Uses aiclient + typechat; streams partial results via streamingActionContext. - -import { - ActionContext, - AppAgent, - TypeAgentAction, - ActionResult, -} from "@typeagent/agent-sdk"; -import { createActionResultFromMarkdownDisplay } from "@typeagent/agent-sdk/helpers/action"; -import { ${pascalName}Actions } from "./${name}Schema.js"; - -export function instantiate(): AppAgent { - return { - initializeAgentContext, - executeAction, - }; -} - -async function initializeAgentContext(): Promise { - return {}; -} - -async function executeAction( - action: TypeAgentAction<${pascalName}Actions>, - context: ActionContext, -): Promise { - switch (action.actionName) { - case "generateResponse": { - // TODO: call your LLM and stream chunks via: - // context.streamingActionContext?.appendDisplay(chunk) - return createActionResultFromMarkdownDisplay( - "Streaming response not yet implemented.", - ); - } - default: - return createActionResultFromMarkdownDisplay( - \`Unknown action: \${(action as any).actionName}\`, - ); - } -} -`; + return loadTemplate("llmStreamingHandler.template", { + NAME: name, + PASCAL_NAME: pascalName, + }); } function buildSubAgentOrchestratorHandler( name: string, pascalName: string, ): string { - return `// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Pattern: sub-agent-orchestrator — root agent routing to N typed sub-schemas. -// Add one executeXxxAction() per sub-schema group defined in subActionManifests. -// The root executeAction routes by action name (each group owns disjoint names). - -import { - ActionContext, - AppAgent, - TypeAgentAction, - ActionResult, -} from "@typeagent/agent-sdk"; -import { createActionResultFromTextDisplay } from "@typeagent/agent-sdk/helpers/action"; -import { ${pascalName}Actions } from "./${name}Schema.js"; - -export function instantiate(): AppAgent { - return { - initializeAgentContext, - executeAction, - }; -} - -async function initializeAgentContext(): Promise { - return {}; -} - -async function executeAction( - action: TypeAgentAction<${pascalName}Actions>, - context: ActionContext, -): Promise { - // TODO: route to sub-schema handlers, e.g.: - // if (isGroupOneAction(action)) return executeGroupOneAction(action, context); - // if (isGroupTwoAction(action)) return executeGroupTwoAction(action, context); - return createActionResultFromTextDisplay( - \`Executing \${action.actionName} — not yet implemented.\`, - ); -} - -// ---- Sub-schema handlers (one per subActionManifests group) ------------ - -// async function executeGroupOneAction( -// action: TypeAgentAction, -// context: ActionContext, -// ): Promise { ... } -`; + return loadTemplate("subAgentOrchestratorHandler.template", { + NAME: name, + PASCAL_NAME: pascalName, + }); } function buildWebSocketBridgeHandler(name: string, pascalName: string): string { const portEnv = `${name.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_BRIDGE_PORT`; - return `// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Pattern: websocket-bridge — bidirectional RPC to a host-side plugin. -// The agent owns a WebSocketServer; the host plugin connects as the client. -// Commands flow TypeAgent → WebSocket → plugin → response. -// -// Port allocation: the bridge binds on an OS-assigned ephemeral port -// (port=0) by default. The actual port is registered with the dispatcher -// via context.registerPort("default", port) so external clients can -// discover it through the agent-server's discovery channel -// (discoverPort("${name}", "default")). Set ${portEnv} to pin the -// bridge to a fixed port when debugging or when a host plugin expects -// a known address. -// -// Lifecycle: one bridge per process, refcounted across enabled sessions. -// Each enabled session registers the bridge under its own -// sessionContextId; lookup("${name}", "default") keeps returning the -// port as long as ≥1 session has the agent enabled. The dispatcher's -// closeSessionContext backstop releases stale per-session registrations -// if disable is skipped (e.g. crash). - -import { - ActionContext, - AppAgent, - SessionContext, - TypeAgentAction, - ActionResult, -} from "@typeagent/agent-sdk"; -import { createActionResultFromTextDisplay } from "@typeagent/agent-sdk/helpers/action"; -import { WebSocketServer, WebSocket } from "ws"; -import { AddressInfo } from "net"; -import { ${pascalName}Actions } from "./${name}Schema.js"; - -function getBridgeBindPort(): number { - const v = process.env["${portEnv}"]; - if (!v) return 0; - const n = parseInt(v, 10); - return Number.isFinite(n) && n >= 0 ? n : 0; -} - -// ---- WebSocket bridge -------------------------------------------------- - -type BridgeRequest = { id: string; actionName: string; parameters: unknown }; -type BridgeResponse = { id: string; success: boolean; result?: unknown; error?: string }; - -class ${pascalName}Bridge { - private clients = new Map(); - private nextClientId = 0; - private pending = new Map< - string, - { - resolve: (result: unknown) => void; - reject: (err: Error) => void; - } - >(); - - // Construction is private — use {@link ${pascalName}Bridge.start} so - // callers always get a bridge that is guaranteed to be bound before - // they read {@link port} or pass it to the registrar. - private constructor( - private readonly server: WebSocketServer, - public readonly port: number, - ) { - this.server.on("connection", (ws) => { - const id = \`c-\${++this.nextClientId}\`; - this.clients.set(id, ws); - ws.on("message", (data) => { - try { - const response = JSON.parse(data.toString()) as BridgeResponse; - const entry = this.pending.get(response.id); - if (entry) { - this.pending.delete(response.id); - if (response.success) entry.resolve(response.result); - else entry.reject(new Error(response.error)); - } - } catch { - // Ignore malformed payloads. - } - }); - ws.on("close", () => this.clients.delete(id)); - ws.on("error", () => this.clients.delete(id)); - }); - } - - /** - * Bind a new bridge on \`port\`. Pass 0 (default) to let the OS pick a - * free ephemeral port; read the actual bound port from {@link port} - * after the returned promise resolves. Rejects on bind failure - * (EADDRINUSE under a fixed-port override) so callers see the - * problem instead of having it swallowed by a late error handler. - */ - public static start(port: number = 0): Promise<${pascalName}Bridge> { - return new Promise((resolve, reject) => { - const server = new WebSocketServer({ port }); - let settled = false; - const onError = (e: Error) => { - if (settled) return; - settled = true; - server.removeListener("listening", onListening); - reject(e); - }; - const onListening = () => { - if (settled) return; - settled = true; - server.removeListener("error", onError); - const addr = server.address() as AddressInfo | null; - if (!addr || typeof addr === "string") { - server.close(); - reject(new Error("ws server.address() did not return AddressInfo")); - return; - } - // Re-attach a permanent error handler so post-listen errors - // are surfaced rather than crashing the process. - server.on("error", (err) => { - console.error( - \`[${name}Bridge] post-listen server error: \${err.message}\`, - ); - }); - resolve(new ${pascalName}Bridge(server, addr.port)); - }; - server.once("error", onError); - server.once("listening", onListening); - }); - } - - /** - * Close all client connections and the underlying server. Pending - * \`send\` promises are rejected so callers never hang on a closed - * bridge. Resolves when the server has fully released its port — - * important for a rapid disable→enable cycle under a fixed-port - * override (\`${portEnv}\`), where a synchronous return would race - * the new bind into EADDRINUSE. - */ - public close(): Promise { - const closedError = new Error( - "${pascalName}Bridge closed before response was received.", - ); - for (const entry of this.pending.values()) { - entry.reject(closedError); - } - this.pending.clear(); - for (const c of this.clients.values()) { - if (c.readyState === WebSocket.OPEN) c.close(); - } - this.clients.clear(); - return new Promise((resolve) => this.server.close(() => resolve())); - } - - public get connected(): boolean { - for (const c of this.clients.values()) { - if (c.readyState === WebSocket.OPEN) return true; - } - return false; - } - - public async send(actionName: string, parameters: unknown): Promise { - // Use the first OPEN client (single-plugin pattern). Adapt this - // selection if you need fan-out or per-session client targeting. - let target: WebSocket | undefined; - for (const c of this.clients.values()) { - if (c.readyState === WebSocket.OPEN) { target = c; break; } - } - if (!target) { - throw new Error("No host plugin connected to the ${name} bridge."); - } - const id = \`\${Date.now()}-\${Math.random().toString(36).slice(2)}\`; - return new Promise((resolve, reject) => { - this.pending.set(id, { resolve, reject }); - target!.send( - JSON.stringify({ id, actionName, parameters } satisfies BridgeRequest), - ); - }); - } -} - -// ---- Shared module state ----------------------------------------------- -// -// Storing the bridge per-session would cause "no connection" errors when -// an action runs on a session different from the one that started the -// server, and would mask EADDRINUSE failures from a second bind under a -// fixed-port override. The shared-bridge + per-session-registration -// pattern matches the code and browser agents. - -let sharedBridge: ${pascalName}Bridge | undefined; -let sharedStartingPromise: Promise<${pascalName}Bridge> | undefined; -let sharedClosingPromise: Promise | undefined; -let sharedRefCount = 0; - -// Serialize concurrent starts; await any in-flight close before binding -// again so a rapid disable→enable doesn't race the port release. -async function ensureSharedBridge(): Promise<${pascalName}Bridge> { - if (sharedClosingPromise !== undefined) { - await sharedClosingPromise; - } - if (sharedBridge !== undefined) return sharedBridge; - if (sharedStartingPromise !== undefined) return sharedStartingPromise; - sharedStartingPromise = (async () => { - try { - sharedBridge = await ${pascalName}Bridge.start(getBridgeBindPort()); - return sharedBridge; - } finally { - sharedStartingPromise = undefined; - } - })(); - return sharedStartingPromise; -} - -// ---- Agent lifecycle --------------------------------------------------- - -type ${pascalName}Context = { - enabledSchemas: Set; - portRegistration?: { release: () => void }; -}; - -export function instantiate(): AppAgent { - return { - initializeAgentContext, - updateAgentContext, - closeAgentContext, - executeAction, - }; -} - -async function initializeAgentContext(): Promise<${pascalName}Context> { - return { enabledSchemas: new Set() }; -} - -/** - * Backstop cleanup invoked by the dispatcher when a session closes - * without an explicit per-schema disable (crash, client disconnect, - * shell shutdown). Releases this session's port registration and - * decrements the shared refcount once, even if multiple schemas were - * enabled. Idempotent — a subsequent \`updateAgentContext(false, …)\` - * will see an empty \`enabledSchemas\` and no-op. - */ -async function closeAgentContext( - context: SessionContext<${pascalName}Context>, -): Promise { - const ctx = context.agentContext; - const wasActive = ctx.enabledSchemas.size > 0; - ctx.enabledSchemas.clear(); - ctx.portRegistration?.release(); - delete ctx.portRegistration; - if (!wasActive) return; - sharedRefCount = Math.max(0, sharedRefCount - 1); - if (sharedRefCount === 0 && sharedBridge) { - const bridge = sharedBridge; - sharedBridge = undefined; - sharedClosingPromise = bridge.close().finally(() => { - sharedClosingPromise = undefined; - }); - await sharedClosingPromise; - } -} - -async function updateAgentContext( - enable: boolean, - context: SessionContext<${pascalName}Context>, - schemaName: string, -): Promise { - const ctx = context.agentContext; - if (enable) { - if (ctx.enabledSchemas.has(schemaName)) return; - const isFirstForSession = ctx.enabledSchemas.size === 0; - ctx.enabledSchemas.add(schemaName); - try { - const bridge = await ensureSharedBridge(); - if (isFirstForSession) { - // Per-session registration: the registrar allows multiple - // entries for ("${name}", "default") across sessions and - // lookup returns the most recent, so each active session - // independently keeps the shared port discoverable. - ctx.portRegistration = context.registerPort( - "default", - bridge.port, - ); - sharedRefCount++; - } - } catch (e) { - // Roll back per-session bookkeeping so a subsequent retry sees - // a clean slate. Shared module state is untouched — the bind - // itself failed, so we never incremented the refcount or - // registered. - ctx.enabledSchemas.delete(schemaName); - throw e; - } - } else { - if (!ctx.enabledSchemas.has(schemaName)) return; - ctx.enabledSchemas.delete(schemaName); - if (ctx.enabledSchemas.size === 0) { - // Release this session's registration before potentially - // closing the server. Release is idempotent and a no-op if - // already released by the dispatcher's closeSessionContext - // backstop. - ctx.portRegistration?.release(); - delete ctx.portRegistration; - sharedRefCount = Math.max(0, sharedRefCount - 1); - if (sharedRefCount === 0 && sharedBridge) { - const bridge = sharedBridge; - sharedBridge = undefined; - sharedClosingPromise = bridge.close().finally(() => { - sharedClosingPromise = undefined; - }); - await sharedClosingPromise; - } - } - } -} - -async function executeAction( - action: TypeAgentAction<${pascalName}Actions>, - _context: ActionContext<${pascalName}Context>, -): Promise { - if (!sharedBridge?.connected) { - return { - error: "Host plugin not connected to the ${name} bridge. Start the plugin and ensure it is configured for the port reported by @system ports.", - }; - } - try { - const result = await sharedBridge.send(action.actionName, action.parameters); - return createActionResultFromTextDisplay(JSON.stringify(result, null, 2)); - } catch (err: any) { - return { error: err?.message ?? String(err) }; - } -} -`; + return loadTemplate("websocketBridgeHandler.template", { + NAME: name, + PASCAL_NAME: pascalName, + PORT_ENV: portEnv, + }); } function buildStateMachineHandler(name: string, pascalName: string): string { - return `// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Pattern: state-machine — multi-phase disk-persisted workflow. -// State is stored in ~/.typeagent/${name}//state.json. -// Each phase must be approved before the next begins. - -import { - ActionContext, - AppAgent, - TypeAgentAction, - ActionResult, -} from "@typeagent/agent-sdk"; -import { createActionResultFromMarkdownDisplay } from "@typeagent/agent-sdk/helpers/action"; -import { ${pascalName}Actions } from "./${name}Schema.js"; -import fs from "fs/promises"; -import path from "path"; -import os from "os"; - -const STATE_ROOT = path.join(os.homedir(), ".typeagent", "${name}"); - -// ---- State types ------------------------------------------------------- - -type PhaseStatus = "pending" | "in-progress" | "approved"; - -type WorkflowState = { - workflowId: string; - currentPhase: string; - phases: Record; - config: Record; - createdAt: string; - updatedAt: string; -}; - -// ---- State I/O --------------------------------------------------------- - -async function loadState(workflowId: string): Promise { - const statePath = path.join(STATE_ROOT, workflowId, "state.json"); - try { - return JSON.parse(await fs.readFile(statePath, "utf-8")) as WorkflowState; - } catch { - return undefined; - } -} - -async function saveState(state: WorkflowState): Promise { - const stateDir = path.join(STATE_ROOT, state.workflowId); - await fs.mkdir(stateDir, { recursive: true }); - state.updatedAt = new Date().toISOString(); - await fs.writeFile( - path.join(stateDir, "state.json"), - JSON.stringify(state, null, 2), - "utf-8", - ); -} - -// ---- Agent lifecycle --------------------------------------------------- - -export function instantiate(): AppAgent { - return { - initializeAgentContext, - executeAction, - }; -} - -async function initializeAgentContext(): Promise { - await fs.mkdir(STATE_ROOT, { recursive: true }); - return {}; -} - -async function executeAction( - action: TypeAgentAction<${pascalName}Actions>, - _context: ActionContext, -): Promise { - // TODO: map actions to phase handlers, e.g.: - // case "startWorkflow": return handleStart(action.parameters.workflowId); - // case "runPhaseOne": return handlePhaseOne(action.parameters.workflowId); - // case "approvePhase": return handleApprove(action.parameters.workflowId, action.parameters.phase); - // case "getStatus": return handleStatus(action.parameters.workflowId); - return createActionResultFromMarkdownDisplay( - \`Executing \${action.actionName} — not yet implemented.\`, - ); -} -`; + return loadTemplate("stateMachineHandler.template", { + NAME: name, + PASCAL_NAME: pascalName, + }); } function buildNativePlatformHandler(name: string, pascalName: string): string { - return `// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Pattern: native-platform — OS/device APIs via child_process or SDK. -// No cloud dependency. Handle platform differences in executeCommand(). - -import { - ActionContext, - AppAgent, - TypeAgentAction, - ActionResult, -} from "@typeagent/agent-sdk"; -import { createActionResultFromTextDisplay } from "@typeagent/agent-sdk/helpers/action"; -import { exec } from "child_process"; -import { promisify } from "util"; -import { ${pascalName}Actions } from "./${name}Schema.js"; - -const execAsync = promisify(exec); -const platform = process.platform; // "win32" | "darwin" | "linux" - -export function instantiate(): AppAgent { - return { - initializeAgentContext, - executeAction, - }; -} - -async function initializeAgentContext(): Promise { - return {}; -} - -async function executeAction( - action: TypeAgentAction<${pascalName}Actions>, - _context: ActionContext, -): Promise { - try { - const output = await executeCommand( - action.actionName, - action.parameters as Record, - ); - return createActionResultFromTextDisplay(output ?? "Done."); - } catch (err: any) { - return { error: err?.message ?? String(err) }; - } -} - -/** - * Map a typed action to a platform-specific shell command or SDK call. - * Add one case per action defined in ${pascalName}Actions. - */ -async function executeCommand( - actionName: string, - parameters: Record, -): Promise { - switch (actionName) { - // TODO: add cases for each action. Example: - // case "openFile": { - // const cmd = platform === "win32" ? \`start "" "\${parameters.path}"\` - // : platform === "darwin" ? \`open "\${parameters.path}"\` - // : \`xdg-open "\${parameters.path}"\`; - // return (await execAsync(cmd)).stdout; - // } - default: - throw new Error(\`Not implemented: \${actionName}\`); - } -} -`; + return loadTemplate("nativePlatformHandler.template", { + NAME: name, + PASCAL_NAME: pascalName, + }); } function buildViewUiHandler(name: string, pascalName: string): string { const portEnv = `${name.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_VIEW_PORT`; - return `// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Pattern: view-ui — web view renderer with IPC handler. -// Opens a local HTTP server serving site/ and surfaces it in the shell -// via an ActivityContext with openLocalView=true. -// -// Port allocation: the view server binds on an OS-assigned ephemeral -// port (port=0) by default. The actual port is registered with the -// dispatcher via context.registerPort("view", port) so external -// clients can discover it through the agent-server's discovery channel -// (discoverPort("${name}", "view")). context.setLocalHostPort(port) is -// also called so the embedding shell knows which port to load when an -// action returns openLocalView=true. Set ${portEnv} to pin the view -// to a fixed port when debugging. - -import { - ActionContext, - ActionResult, - ActivityContext, - AppAgent, - SessionContext, - TypeAgentAction, -} from "@typeagent/agent-sdk"; -import { - createActionResult, - createActionResultFromHtmlDisplay, -} from "@typeagent/agent-sdk/helpers/action"; -import { createServer, Server } from "node:http"; -import { AddressInfo } from "node:net"; -import { ${pascalName}Actions } from "./${name}Schema.js"; - -type ${pascalName}AgentContext = { - server?: Server; - port?: number; - portRegistration?: { release: () => void }; -}; - -function getViewBindPort(): number { - const v = process.env["${portEnv}"]; - if (!v) return 0; - const n = parseInt(v, 10); - return Number.isFinite(n) && n >= 0 ? n : 0; -} - -export function instantiate(): AppAgent { - return { - initializeAgentContext, - updateAgentContext, - closeAgentContext, - executeAction, - }; -} - -async function initializeAgentContext(): Promise<${pascalName}AgentContext> { - return {}; -} - -/** - * Bind the view server on \`port\` (0 = OS-assigned). Returns the actual - * bound port so it can be registered and surfaced to the shell. - * Rejects on bind failure (EADDRINUSE under a fixed-port override) so - * callers see the problem instead of having it swallowed by a late - * error handler. - */ -function startViewServer(port: number): Promise<{ server: Server; port: number }> { - return new Promise((resolve, reject) => { - const server = createServer((req, res) => { - // TODO: serve static assets from ./site/, plus any - // JSON/IPC endpoints the view needs. For now, a placeholder. - res.writeHead(200, { "Content-Type": "text/html" }); - res.end(\`

${pascalName} view

Path: \${req.url}

\`); - }); - let settled = false; - const onError = (e: Error) => { - if (settled) return; - settled = true; - server.removeListener("listening", onListening); - reject(e); - }; - const onListening = () => { - if (settled) return; - settled = true; - server.removeListener("error", onError); - const addr = server.address() as AddressInfo | null; - if (!addr || typeof addr === "string") { - server.close(); - reject(new Error("http server.address() did not return AddressInfo")); - return; - } - // Re-attach a permanent error handler so post-listen errors - // are logged rather than crashing the process. - server.on("error", () => { /* TODO: log */ }); - resolve({ server, port: addr.port }); - }; - server.once("error", onError); - server.once("listening", onListening); - server.listen(port); + return loadTemplate("viewUiHandler.template", { + NAME: name, + PASCAL_NAME: pascalName, + PORT_ENV: portEnv, }); } -async function updateAgentContext( - enable: boolean, - context: SessionContext<${pascalName}AgentContext>, - _schemaName: string, -): Promise { - const agentContext = context.agentContext; - if (enable) { - if (agentContext.server !== undefined) { - // Already bound for this session. - return; - } - const { server, port } = await startViewServer(getViewBindPort()); - try { - agentContext.server = server; - agentContext.port = port; - agentContext.portRegistration = context.registerPort("view", port); - // Tell the embedding shell which port to load when an - // action returns openLocalView=true. Goes through the - // registrar with role="default", so the discovery-channel - // role "view" above keeps a stable contract for out-of- - // process clients regardless of this back-compat call. - context.setLocalHostPort(port); - } catch (e) { - // Roll back if registration/setLocalHostPort fails so a - // retry sees a clean slate. - agentContext.portRegistration?.release(); - await new Promise((resolve) => server.close(() => resolve())); - agentContext.server = undefined; - agentContext.port = undefined; - agentContext.portRegistration = undefined; - throw e; - } - } else { - if (agentContext.server === undefined) return; - agentContext.portRegistration?.release(); - agentContext.portRegistration = undefined; - const server = agentContext.server; - agentContext.server = undefined; - agentContext.port = undefined; - // Resolve when the server has fully released its port — - // important for a rapid disable→enable cycle under a fixed- - // port override (\`${portEnv}\`), where a synchronous return - // would race the new bind into EADDRINUSE. - await new Promise((resolve) => server.close(() => resolve())); - } -} - -async function closeAgentContext( - context: SessionContext<${pascalName}AgentContext>, -): Promise { - // Backstop: if updateAgentContext(false) wasn't called (e.g. crash - // during shutdown), release the registration and close the server - // so the port doesn't leak. - const agentContext = context.agentContext; - agentContext.portRegistration?.release(); - agentContext.portRegistration = undefined; - if (agentContext.server) { - const server = agentContext.server; - agentContext.server = undefined; - agentContext.port = undefined; - await new Promise((resolve) => server.close(() => resolve())); - } -} - -async function executeAction( - action: TypeAgentAction<${pascalName}Actions>, - context: ActionContext<${pascalName}AgentContext>, -): Promise { - const port = context.sessionContext.agentContext.port; - // Returning an ActivityContext with openLocalView=true signals the - // shell to open the local view (it uses the port published via - // setLocalHostPort during enable). Drop the activityContext field - // if your action doesn't need to surface the view. - const activityContext: ActivityContext | undefined = - port !== undefined - ? { - appAgentName: "${name}", - activityName: action.actionName, - description: \`${pascalName}: \${action.actionName}\`, - state: {}, - openLocalView: true, - } - : undefined; - const result = createActionResultFromHtmlDisplay( - \`

Executing \${action.actionName} — not yet implemented.

\`, - ); - if (activityContext) { - // ActivityContext is attached so the shell can open the view. - // The shape comes from the SDK; cast through unknown to keep - // the template free of internal-only ActionResult fields. - (result as unknown as { activityContext: ActivityContext }).activityContext = - activityContext; - } - return result; -} - -// Silence unused-import warning when the action handler is stripped -// down. \`createActionResult\` is provided alongside the HTML helper for -// callers that want a richer entity-bearing result. -void createActionResult; -`; -} - function buildCommandHandlerTemplate(name: string, pascalName: string): string { - return `// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Pattern: command-handler — direct dispatch via a handlers map. -// Suited for settings-style agents with a small number of well-known commands. - -import { AppAgent, ActionResult } from "@typeagent/agent-sdk"; -import { createActionResultFromTextDisplay } from "@typeagent/agent-sdk/helpers/action"; - -export function instantiate(): AppAgent { - return getCommandInterface(handlers); -} - -// ---- Handlers ---------------------------------------------------------- -// Add one entry per action name defined in ${pascalName}Actions. - -const handlers: Record Promise> = { - // exampleAction: async (params) => { - // return createActionResultFromTextDisplay("Done."); - // }, -}; - -function getCommandInterface( - handlerMap: Record Promise>, -): AppAgent { - return { - async executeAction(action: any): Promise { - const handler = handlerMap[action.actionName]; - if (!handler) { - return { error: \`Unknown action: \${action.actionName}\` }; - } - return handler(action.parameters); - }, - }; -} -`; + return loadTemplate("commandHandlerTemplate.template", { + PASCAL_NAME: pascalName, + }); } diff --git a/ts/packages/agents/onboarding/src/scaffolder/templateLoader.ts b/ts/packages/agents/onboarding/src/scaffolder/templateLoader.ts new file mode 100644 index 0000000000..4a763a7993 --- /dev/null +++ b/ts/packages/agents/onboarding/src/scaffolder/templateLoader.ts @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Shared loader for the scaffolder's emitted-code templates. +// +// Templates live in `src/scaffolder/templates/*.template` so a reviewer +// can read them as plain code (with syntax highlighting) instead of +// wading through 200-line template literals inside scaffolderHandler.ts. +// +// Placeholders use `{{TOKEN}}` syntax — chosen because the emitted code +// is itself TypeScript that contains `${...}` template literals, so +// reusing `${...}` for our own substitutions would collide. The same +// `{{...}}` convention is used by cliHandler.template. +// +// Templates are loaded at scaffold time (once per generated file) so the +// sync I/O cost is negligible and lets build* helpers stay synchronous — +// keeping their call sites unchanged. + +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// At runtime __dirname is `dist/scaffolder/`. Templates ship only in +// `src/` (this package isn't published to npm and the postbuild copies +// schema artifacts, not these). Resolve back to `src/scaffolder/templates`. +function templatePath(filename: string): string { + return path.resolve(__dirname, "../../src/scaffolder/templates", filename); +} + +/** + * Load `filename` from `src/scaffolder/templates/` and substitute every + * `{{KEY}}` with `vars[KEY]`. Throws if any `{{...}}` placeholder remains + * after substitution — that catches typos in either the template or the + * caller's `vars` map at scaffold time rather than emitting broken code. + */ +export function loadTemplate( + filename: string, + vars: Record, +): string { + let tpl = fs.readFileSync(templatePath(filename), "utf-8"); + for (const [key, value] of Object.entries(vars)) { + tpl = tpl.split(`{{${key}}}`).join(value); + } + const leftover = tpl.match(/\{\{[A-Z0-9_]+\}\}/g); + if (leftover && leftover.length > 0) { + const unique = Array.from(new Set(leftover)).join(", "); + throw new Error( + `Template ${filename} has unsubstituted placeholders: ${unique}`, + ); + } + return tpl; +} diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/commandHandlerTemplate.template b/ts/packages/agents/onboarding/src/scaffolder/templates/commandHandlerTemplate.template new file mode 100644 index 0000000000..41a44ae3ec --- /dev/null +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/commandHandlerTemplate.template @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Pattern: command-handler — direct dispatch via a handlers map. +// Suited for settings-style agents with a small number of well-known commands. + +import { AppAgent, ActionResult } from "@typeagent/agent-sdk"; +import { createActionResultFromTextDisplay } from "@typeagent/agent-sdk/helpers/action"; + +export function instantiate(): AppAgent { + return getCommandInterface(handlers); +} + +// ---- Handlers ---------------------------------------------------------- +// Add one entry per action name defined in {{PASCAL_NAME}}Actions. + +const handlers: Record Promise> = { + // exampleAction: async (params) => { + // return createActionResultFromTextDisplay("Done."); + // }, +}; + +function getCommandInterface( + handlerMap: Record Promise>, +): AppAgent { + return { + async executeAction(action: any): Promise { + const handler = handlerMap[action.actionName]; + if (!handler) { + return { error: `Unknown action: ${action.actionName}` }; + } + return handler(action.parameters); + }, + }; +} diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/externalApiHandler.template b/ts/packages/agents/onboarding/src/scaffolder/templates/externalApiHandler.template new file mode 100644 index 0000000000..0725a9a332 --- /dev/null +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/externalApiHandler.template @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Pattern: external-api — REST/OAuth cloud API bridge. +// Implement {{PASCAL_NAME}}Client with your API's authentication and endpoints. + +import { + ActionContext, + AppAgent, + SessionContext, + TypeAgentAction, + ActionResult, +} from "@typeagent/agent-sdk"; +import { createActionResultFromTextDisplay } from "@typeagent/agent-sdk/helpers/action"; +import { {{PASCAL_NAME}}Actions } from "./{{NAME}}Schema.js"; + +// ---- API client -------------------------------------------------------- + +class {{PASCAL_NAME}}Client { + private token: string | undefined; + + /** Authenticate and store the access token. */ + async authenticate(): Promise { + // TODO: implement OAuth flow or API key loading. + // Store token in: ~/.typeagent/profiles//{{NAME}}/token.json + throw new Error("authenticate() not yet implemented"); + } + + async callApi(endpoint: string, params: Record): Promise { + if (!this.token) await this.authenticate(); + // TODO: implement HTTP call using this.token + throw new Error(`callApi(${endpoint}) not yet implemented`); + } +} + +// ---- Agent lifecycle --------------------------------------------------- + +type Context = { client: {{PASCAL_NAME}}Client }; + +export function instantiate(): AppAgent { + return { + initializeAgentContext, + updateAgentContext, + executeAction, + }; +} + +async function initializeAgentContext(): Promise { + return { client: new {{PASCAL_NAME}}Client() }; +} + +async function updateAgentContext( + _enable: boolean, + _context: SessionContext, + _schemaName: string, +): Promise { + // Optionally authenticate eagerly when the agent is enabled. +} + +async function executeAction( + action: TypeAgentAction<{{PASCAL_NAME}}Actions>, + context: ActionContext, +): Promise { + const { client } = context.sessionContext.agentContext; + // TODO: map each action to a client.callApi() call. + return createActionResultFromTextDisplay( + `Executing ${action.actionName} — not yet implemented.`, + ); +} diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/llmStreamingHandler.template b/ts/packages/agents/onboarding/src/scaffolder/templates/llmStreamingHandler.template new file mode 100644 index 0000000000..f1c2a7315a --- /dev/null +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/llmStreamingHandler.template @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Pattern: llm-streaming — LLM-injected agent with streaming responses. +// Runs inside the dispatcher process (injected: true in manifest). +// Uses aiclient + typechat; streams partial results via streamingActionContext. + +import { + ActionContext, + AppAgent, + TypeAgentAction, + ActionResult, +} from "@typeagent/agent-sdk"; +import { createActionResultFromMarkdownDisplay } from "@typeagent/agent-sdk/helpers/action"; +import { {{PASCAL_NAME}}Actions } from "./{{NAME}}Schema.js"; + +export function instantiate(): AppAgent { + return { + initializeAgentContext, + executeAction, + }; +} + +async function initializeAgentContext(): Promise { + return {}; +} + +async function executeAction( + action: TypeAgentAction<{{PASCAL_NAME}}Actions>, + context: ActionContext, +): Promise { + switch (action.actionName) { + case "generateResponse": { + // TODO: call your LLM and stream chunks via: + // context.streamingActionContext?.appendDisplay(chunk) + return createActionResultFromMarkdownDisplay( + "Streaming response not yet implemented.", + ); + } + default: + return createActionResultFromMarkdownDisplay( + `Unknown action: ${(action as any).actionName}`, + ); + } +} diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/nativePlatformHandler.template b/ts/packages/agents/onboarding/src/scaffolder/templates/nativePlatformHandler.template new file mode 100644 index 0000000000..da37db85f1 --- /dev/null +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/nativePlatformHandler.template @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Pattern: native-platform — OS/device APIs via child_process or SDK. +// No cloud dependency. Handle platform differences in executeCommand(). + +import { + ActionContext, + AppAgent, + TypeAgentAction, + ActionResult, +} from "@typeagent/agent-sdk"; +import { createActionResultFromTextDisplay } from "@typeagent/agent-sdk/helpers/action"; +import { exec } from "child_process"; +import { promisify } from "util"; +import { {{PASCAL_NAME}}Actions } from "./{{NAME}}Schema.js"; + +const execAsync = promisify(exec); +const platform = process.platform; // "win32" | "darwin" | "linux" + +export function instantiate(): AppAgent { + return { + initializeAgentContext, + executeAction, + }; +} + +async function initializeAgentContext(): Promise { + return {}; +} + +async function executeAction( + action: TypeAgentAction<{{PASCAL_NAME}}Actions>, + _context: ActionContext, +): Promise { + try { + const output = await executeCommand( + action.actionName, + action.parameters as Record, + ); + return createActionResultFromTextDisplay(output ?? "Done."); + } catch (err: any) { + return { error: err?.message ?? String(err) }; + } +} + +/** + * Map a typed action to a platform-specific shell command or SDK call. + * Add one case per action defined in {{PASCAL_NAME}}Actions. + */ +async function executeCommand( + actionName: string, + parameters: Record, +): Promise { + switch (actionName) { + // TODO: add cases for each action. Example: + // case "openFile": { + // const cmd = platform === "win32" ? `start "" "${parameters.path}"` + // : platform === "darwin" ? `open "${parameters.path}"` + // : `xdg-open "${parameters.path}"`; + // return (await execAsync(cmd)).stdout; + // } + default: + throw new Error(`Not implemented: ${actionName}`); + } +} diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/officeAddinHtml.template b/ts/packages/agents/onboarding/src/scaffolder/templates/officeAddinHtml.template new file mode 100644 index 0000000000..e28802d848 --- /dev/null +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/officeAddinHtml.template @@ -0,0 +1,13 @@ + + + + + {{PASCAL_NAME}} TypeAgent Add-in + + + + +

{{PASCAL_NAME}} TypeAgent

+
Connecting...
+ + diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/officeAddinTs.template b/ts/packages/agents/onboarding/src/scaffolder/templates/officeAddinTs.template new file mode 100644 index 0000000000..600c5a5629 --- /dev/null +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/officeAddinTs.template @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Office.js task pane add-in for {{NAME}} TypeAgent integration. +// Connects to the TypeAgent bridge via WebSocket and forwards commands +// to the Office.js API. + +const BRIDGE_PORT = 5678; + +Office.onReady(async () => { + document.getElementById("status")!.textContent = "Connecting to TypeAgent..."; + const ws = new WebSocket(`ws://localhost:${BRIDGE_PORT}`); + + ws.onopen = () => { + document.getElementById("status")!.textContent = "Connected"; + ws.send(JSON.stringify({ type: "hello", addinName: "{{NAME}}" })); + }; + + ws.onmessage = async (event) => { + const command = JSON.parse(event.data); + try { + const result = await executeCommand(command.actionName, command.parameters); + ws.send(JSON.stringify({ id: command.id, success: true, result })); + } catch (err: any) { + ws.send(JSON.stringify({ id: command.id, success: false, error: err?.message ?? String(err) })); + } + }; +}); + +async function executeCommand(actionName: string, parameters: Record): Promise { + // TODO: map actionName to Office.js API calls + throw new Error(`Not implemented: ${actionName}`); +} diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/officeManifestXml.template b/ts/packages/agents/onboarding/src/scaffolder/templates/officeManifestXml.template new file mode 100644 index 0000000000..5ff40b7dcc --- /dev/null +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/officeManifestXml.template @@ -0,0 +1,18 @@ + + + + 1.0.0.0 + Microsoft + en-US + + + + + + + + + ReadWriteDocument + diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/restClientTemplate.template b/ts/packages/agents/onboarding/src/scaffolder/templates/restClientTemplate.template new file mode 100644 index 0000000000..e23a277c52 --- /dev/null +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/restClientTemplate.template @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// REST client bridge for {{NAME}}. +// Calls the target API and returns results to the TypeAgent handler. + +export class {{PASCAL_NAME}}Bridge { + constructor(private readonly baseUrl: string, private readonly apiKey?: string) {} + + async executeCommand(actionName: string, parameters: Record): Promise { + // TODO: map actionName to HTTP endpoint and method + throw new Error(`Not implemented: ${actionName}`); + } + + private get headers(): Record { + const h: Record = { "Content-Type": "application/json" }; + if (this.apiKey) h["Authorization"] = `Bearer ${this.apiKey}`; + return h; + } +} diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/schemaGrammarHandler.template b/ts/packages/agents/onboarding/src/scaffolder/templates/schemaGrammarHandler.template new file mode 100644 index 0000000000..7d867a0506 --- /dev/null +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/schemaGrammarHandler.template @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + ActionContext, + AppAgent, + TypeAgentAction, + ActionResult, +} from "@typeagent/agent-sdk"; +import { createActionResultFromTextDisplay } from "@typeagent/agent-sdk/helpers/action"; +import { {{PASCAL_NAME}}Actions } from "./{{NAME}}Schema.js"; + +export function instantiate(): AppAgent { + return { + initializeAgentContext, + executeAction, + }; +} + +async function initializeAgentContext(): Promise { + return {}; +} + +async function executeAction( + action: TypeAgentAction<{{PASCAL_NAME}}Actions>, + context: ActionContext, +): Promise { + // TODO: implement action handlers + return createActionResultFromTextDisplay( + `Executing ${action.actionName} — not yet implemented.`, + ); +} diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/stateMachineHandler.template b/ts/packages/agents/onboarding/src/scaffolder/templates/stateMachineHandler.template new file mode 100644 index 0000000000..54c926645c --- /dev/null +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/stateMachineHandler.template @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Pattern: state-machine — multi-phase disk-persisted workflow. +// State is stored in ~/.typeagent/{{NAME}}//state.json. +// Each phase must be approved before the next begins. + +import { + ActionContext, + AppAgent, + TypeAgentAction, + ActionResult, +} from "@typeagent/agent-sdk"; +import { createActionResultFromMarkdownDisplay } from "@typeagent/agent-sdk/helpers/action"; +import { {{PASCAL_NAME}}Actions } from "./{{NAME}}Schema.js"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; + +const STATE_ROOT = path.join(os.homedir(), ".typeagent", "{{NAME}}"); + +// ---- State types ------------------------------------------------------- + +type PhaseStatus = "pending" | "in-progress" | "approved"; + +type WorkflowState = { + workflowId: string; + currentPhase: string; + phases: Record; + config: Record; + createdAt: string; + updatedAt: string; +}; + +// ---- State I/O --------------------------------------------------------- + +async function loadState(workflowId: string): Promise { + const statePath = path.join(STATE_ROOT, workflowId, "state.json"); + try { + return JSON.parse(await fs.readFile(statePath, "utf-8")) as WorkflowState; + } catch { + return undefined; + } +} + +async function saveState(state: WorkflowState): Promise { + const stateDir = path.join(STATE_ROOT, state.workflowId); + await fs.mkdir(stateDir, { recursive: true }); + state.updatedAt = new Date().toISOString(); + await fs.writeFile( + path.join(stateDir, "state.json"), + JSON.stringify(state, null, 2), + "utf-8", + ); +} + +// ---- Agent lifecycle --------------------------------------------------- + +export function instantiate(): AppAgent { + return { + initializeAgentContext, + executeAction, + }; +} + +async function initializeAgentContext(): Promise { + await fs.mkdir(STATE_ROOT, { recursive: true }); + return {}; +} + +async function executeAction( + action: TypeAgentAction<{{PASCAL_NAME}}Actions>, + _context: ActionContext, +): Promise { + // TODO: map actions to phase handlers, e.g.: + // case "startWorkflow": return handleStart(action.parameters.workflowId); + // case "runPhaseOne": return handlePhaseOne(action.parameters.workflowId); + // case "approvePhase": return handleApprove(action.parameters.workflowId, action.parameters.phase); + // case "getStatus": return handleStatus(action.parameters.workflowId); + return createActionResultFromMarkdownDisplay( + `Executing ${action.actionName} — not yet implemented.`, + ); +} diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/subAgentOrchestratorHandler.template b/ts/packages/agents/onboarding/src/scaffolder/templates/subAgentOrchestratorHandler.template new file mode 100644 index 0000000000..2df7c83874 --- /dev/null +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/subAgentOrchestratorHandler.template @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Pattern: sub-agent-orchestrator — root agent routing to N typed sub-schemas. +// Add one executeXxxAction() per sub-schema group defined in subActionManifests. +// The root executeAction routes by action name (each group owns disjoint names). + +import { + ActionContext, + AppAgent, + TypeAgentAction, + ActionResult, +} from "@typeagent/agent-sdk"; +import { createActionResultFromTextDisplay } from "@typeagent/agent-sdk/helpers/action"; +import { {{PASCAL_NAME}}Actions } from "./{{NAME}}Schema.js"; + +export function instantiate(): AppAgent { + return { + initializeAgentContext, + executeAction, + }; +} + +async function initializeAgentContext(): Promise { + return {}; +} + +async function executeAction( + action: TypeAgentAction<{{PASCAL_NAME}}Actions>, + context: ActionContext, +): Promise { + // TODO: route to sub-schema handlers, e.g.: + // if (isGroupOneAction(action)) return executeGroupOneAction(action, context); + // if (isGroupTwoAction(action)) return executeGroupTwoAction(action, context); + return createActionResultFromTextDisplay( + `Executing ${action.actionName} — not yet implemented.`, + ); +} + +// ---- Sub-schema handlers (one per subActionManifests group) ------------ + +// async function executeGroupOneAction( +// action: TypeAgentAction, +// context: ActionContext, +// ): Promise { ... } diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/viewUiHandler.template b/ts/packages/agents/onboarding/src/scaffolder/templates/viewUiHandler.template new file mode 100644 index 0000000000..5631267bb2 --- /dev/null +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/viewUiHandler.template @@ -0,0 +1,201 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Pattern: view-ui — web view renderer with IPC handler. +// Opens a local HTTP server serving site/ and surfaces it in the shell +// via an ActivityContext with openLocalView=true. +// +// Port allocation: the view server binds on an OS-assigned ephemeral +// port (port=0) by default. The actual port is registered with the +// dispatcher via context.registerPort("view", port) so external +// clients can discover it through the agent-server's discovery channel +// (discoverPort("{{NAME}}", "view")). context.setLocalHostPort(port) is +// also called so the embedding shell knows which port to load when an +// action returns openLocalView=true. Set {{PORT_ENV}} to pin the view +// to a fixed port when debugging. + +import { + ActionContext, + ActionResult, + ActivityContext, + AppAgent, + SessionContext, + TypeAgentAction, +} from "@typeagent/agent-sdk"; +import { + createActionResult, + createActionResultFromHtmlDisplay, +} from "@typeagent/agent-sdk/helpers/action"; +import { createServer, Server } from "node:http"; +import { AddressInfo } from "node:net"; +import { {{PASCAL_NAME}}Actions } from "./{{NAME}}Schema.js"; + +type {{PASCAL_NAME}}AgentContext = { + server?: Server; + port?: number; + portRegistration?: { release: () => void }; +}; + +function getViewBindPort(): number { + const v = process.env["{{PORT_ENV}}"]; + if (!v) return 0; + const n = parseInt(v, 10); + return Number.isFinite(n) && n >= 0 ? n : 0; +} + +export function instantiate(): AppAgent { + return { + initializeAgentContext, + updateAgentContext, + closeAgentContext, + executeAction, + }; +} + +async function initializeAgentContext(): Promise<{{PASCAL_NAME}}AgentContext> { + return {}; +} + +/** + * Bind the view server on `port` (0 = OS-assigned). Returns the actual + * bound port so it can be registered and surfaced to the shell. + * Rejects on bind failure (EADDRINUSE under a fixed-port override) so + * callers see the problem instead of having it swallowed by a late + * error handler. + */ +function startViewServer(port: number): Promise<{ server: Server; port: number }> { + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + // TODO: serve static assets from ./site/, plus any + // JSON/IPC endpoints the view needs. For now, a placeholder. + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(`

{{PASCAL_NAME}} view

Path: ${req.url}

`); + }); + let settled = false; + const onError = (e: Error) => { + if (settled) return; + settled = true; + server.removeListener("listening", onListening); + reject(e); + }; + const onListening = () => { + if (settled) return; + settled = true; + server.removeListener("error", onError); + const addr = server.address() as AddressInfo | null; + if (!addr || typeof addr === "string") { + server.close(); + reject(new Error("http server.address() did not return AddressInfo")); + return; + } + // Re-attach a permanent error handler so post-listen errors + // are logged rather than crashing the process. + server.on("error", () => { /* TODO: log */ }); + resolve({ server, port: addr.port }); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(port); + }); +} + +async function updateAgentContext( + enable: boolean, + context: SessionContext<{{PASCAL_NAME}}AgentContext>, + _schemaName: string, +): Promise { + const agentContext = context.agentContext; + if (enable) { + if (agentContext.server !== undefined) { + // Already bound for this session. + return; + } + const { server, port } = await startViewServer(getViewBindPort()); + try { + agentContext.server = server; + agentContext.port = port; + agentContext.portRegistration = context.registerPort("view", port); + // Tell the embedding shell which port to load when an + // action returns openLocalView=true. Goes through the + // registrar with role="default", so the discovery-channel + // role "view" above keeps a stable contract for out-of- + // process clients regardless of this back-compat call. + context.setLocalHostPort(port); + } catch (e) { + // Roll back if registration/setLocalHostPort fails so a + // retry sees a clean slate. + agentContext.portRegistration?.release(); + await new Promise((resolve) => server.close(() => resolve())); + agentContext.server = undefined; + agentContext.port = undefined; + agentContext.portRegistration = undefined; + throw e; + } + } else { + if (agentContext.server === undefined) return; + agentContext.portRegistration?.release(); + agentContext.portRegistration = undefined; + const server = agentContext.server; + agentContext.server = undefined; + agentContext.port = undefined; + // Resolve when the server has fully released its port — + // important for a rapid disable→enable cycle under a fixed- + // port override (`{{PORT_ENV}}`), where a synchronous return + // would race the new bind into EADDRINUSE. + await new Promise((resolve) => server.close(() => resolve())); + } +} + +async function closeAgentContext( + context: SessionContext<{{PASCAL_NAME}}AgentContext>, +): Promise { + // Backstop: if updateAgentContext(false) wasn't called (e.g. crash + // during shutdown), release the registration and close the server + // so the port doesn't leak. + const agentContext = context.agentContext; + agentContext.portRegistration?.release(); + agentContext.portRegistration = undefined; + if (agentContext.server) { + const server = agentContext.server; + agentContext.server = undefined; + agentContext.port = undefined; + await new Promise((resolve) => server.close(() => resolve())); + } +} + +async function executeAction( + action: TypeAgentAction<{{PASCAL_NAME}}Actions>, + context: ActionContext<{{PASCAL_NAME}}AgentContext>, +): Promise { + const port = context.sessionContext.agentContext.port; + // Returning an ActivityContext with openLocalView=true signals the + // shell to open the local view (it uses the port published via + // setLocalHostPort during enable). Drop the activityContext field + // if your action doesn't need to surface the view. + const activityContext: ActivityContext | undefined = + port !== undefined + ? { + appAgentName: "{{NAME}}", + activityName: action.actionName, + description: `{{PASCAL_NAME}}: ${action.actionName}`, + state: {}, + openLocalView: true, + } + : undefined; + const result = createActionResultFromHtmlDisplay( + `

Executing ${action.actionName} — not yet implemented.

`, + ); + if (activityContext) { + // ActivityContext is attached so the shell can open the view. + // The shape comes from the SDK; cast through unknown to keep + // the template free of internal-only ActionResult fields. + (result as unknown as { activityContext: ActivityContext }).activityContext = + activityContext; + } + return result; +} + +// Silence unused-import warning when the action handler is stripped +// down. `createActionResult` is provided alongside the HTML helper for +// callers that want a richer entity-bearing result. +void createActionResult; diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/websocketBridgeHandler.template b/ts/packages/agents/onboarding/src/scaffolder/templates/websocketBridgeHandler.template new file mode 100644 index 0000000000..83b88467a7 --- /dev/null +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/websocketBridgeHandler.template @@ -0,0 +1,326 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Pattern: websocket-bridge — bidirectional RPC to a host-side plugin. +// The agent owns a WebSocketServer; the host plugin connects as the client. +// Commands flow TypeAgent → WebSocket → plugin → response. +// +// Port allocation: the bridge binds on an OS-assigned ephemeral port +// (port=0) by default. The actual port is registered with the dispatcher +// via context.registerPort("default", port) so external clients can +// discover it through the agent-server's discovery channel +// (discoverPort("{{NAME}}", "default")). Set {{PORT_ENV}} to pin the +// bridge to a fixed port when debugging or when a host plugin expects +// a known address. +// +// Lifecycle: one bridge per process, refcounted across enabled sessions. +// Each enabled session registers the bridge under its own +// sessionContextId; lookup("{{NAME}}", "default") keeps returning the +// port as long as ≥1 session has the agent enabled. The dispatcher's +// closeSessionContext backstop releases stale per-session registrations +// if disable is skipped (e.g. crash). + +import { + ActionContext, + AppAgent, + SessionContext, + TypeAgentAction, + ActionResult, +} from "@typeagent/agent-sdk"; +import { createActionResultFromTextDisplay } from "@typeagent/agent-sdk/helpers/action"; +import { WebSocketServer, WebSocket } from "ws"; +import { AddressInfo } from "net"; +import { {{PASCAL_NAME}}Actions } from "./{{NAME}}Schema.js"; + +function getBridgeBindPort(): number { + const v = process.env["{{PORT_ENV}}"]; + if (!v) return 0; + const n = parseInt(v, 10); + return Number.isFinite(n) && n >= 0 ? n : 0; +} + +// ---- WebSocket bridge -------------------------------------------------- + +type BridgeRequest = { id: string; actionName: string; parameters: unknown }; +type BridgeResponse = { id: string; success: boolean; result?: unknown; error?: string }; + +class {{PASCAL_NAME}}Bridge { + private clients = new Map(); + private nextClientId = 0; + private pending = new Map< + string, + { + resolve: (result: unknown) => void; + reject: (err: Error) => void; + } + >(); + + // Construction is private — use {@link {{PASCAL_NAME}}Bridge.start} so + // callers always get a bridge that is guaranteed to be bound before + // they read {@link port} or pass it to the registrar. + private constructor( + private readonly server: WebSocketServer, + public readonly port: number, + ) { + this.server.on("connection", (ws) => { + const id = `c-${++this.nextClientId}`; + this.clients.set(id, ws); + ws.on("message", (data) => { + try { + const response = JSON.parse(data.toString()) as BridgeResponse; + const entry = this.pending.get(response.id); + if (entry) { + this.pending.delete(response.id); + if (response.success) entry.resolve(response.result); + else entry.reject(new Error(response.error)); + } + } catch { + // Ignore malformed payloads. + } + }); + ws.on("close", () => this.clients.delete(id)); + ws.on("error", () => this.clients.delete(id)); + }); + } + + /** + * Bind a new bridge on `port`. Pass 0 (default) to let the OS pick a + * free ephemeral port; read the actual bound port from {@link port} + * after the returned promise resolves. Rejects on bind failure + * (EADDRINUSE under a fixed-port override) so callers see the + * problem instead of having it swallowed by a late error handler. + */ + public static start(port: number = 0): Promise<{{PASCAL_NAME}}Bridge> { + return new Promise((resolve, reject) => { + const server = new WebSocketServer({ port }); + let settled = false; + const onError = (e: Error) => { + if (settled) return; + settled = true; + server.removeListener("listening", onListening); + reject(e); + }; + const onListening = () => { + if (settled) return; + settled = true; + server.removeListener("error", onError); + const addr = server.address() as AddressInfo | null; + if (!addr || typeof addr === "string") { + server.close(); + reject(new Error("ws server.address() did not return AddressInfo")); + return; + } + // Re-attach a permanent error handler so post-listen errors + // are surfaced rather than crashing the process. + server.on("error", (err) => { + console.error( + `[{{NAME}}Bridge] post-listen server error: ${err.message}`, + ); + }); + resolve(new {{PASCAL_NAME}}Bridge(server, addr.port)); + }; + server.once("error", onError); + server.once("listening", onListening); + }); + } + + /** + * Close all client connections and the underlying server. Pending + * `send` promises are rejected so callers never hang on a closed + * bridge. Resolves when the server has fully released its port — + * important for a rapid disable→enable cycle under a fixed-port + * override (`{{PORT_ENV}}`), where a synchronous return would race + * the new bind into EADDRINUSE. + */ + public close(): Promise { + const closedError = new Error( + "{{PASCAL_NAME}}Bridge closed before response was received.", + ); + for (const entry of this.pending.values()) { + entry.reject(closedError); + } + this.pending.clear(); + for (const c of this.clients.values()) { + if (c.readyState === WebSocket.OPEN) c.close(); + } + this.clients.clear(); + return new Promise((resolve) => this.server.close(() => resolve())); + } + + public get connected(): boolean { + for (const c of this.clients.values()) { + if (c.readyState === WebSocket.OPEN) return true; + } + return false; + } + + public async send(actionName: string, parameters: unknown): Promise { + // Use the first OPEN client (single-plugin pattern). Adapt this + // selection if you need fan-out or per-session client targeting. + let target: WebSocket | undefined; + for (const c of this.clients.values()) { + if (c.readyState === WebSocket.OPEN) { target = c; break; } + } + if (!target) { + throw new Error("No host plugin connected to the {{NAME}} bridge."); + } + const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + target!.send( + JSON.stringify({ id, actionName, parameters } satisfies BridgeRequest), + ); + }); + } +} + +// ---- Shared module state ----------------------------------------------- +// +// Storing the bridge per-session would cause "no connection" errors when +// an action runs on a session different from the one that started the +// server, and would mask EADDRINUSE failures from a second bind under a +// fixed-port override. The shared-bridge + per-session-registration +// pattern matches the code and browser agents. + +let sharedBridge: {{PASCAL_NAME}}Bridge | undefined; +let sharedStartingPromise: Promise<{{PASCAL_NAME}}Bridge> | undefined; +let sharedClosingPromise: Promise | undefined; +let sharedRefCount = 0; + +// Serialize concurrent starts; await any in-flight close before binding +// again so a rapid disable→enable doesn't race the port release. +async function ensureSharedBridge(): Promise<{{PASCAL_NAME}}Bridge> { + if (sharedClosingPromise !== undefined) { + await sharedClosingPromise; + } + if (sharedBridge !== undefined) return sharedBridge; + if (sharedStartingPromise !== undefined) return sharedStartingPromise; + sharedStartingPromise = (async () => { + try { + sharedBridge = await {{PASCAL_NAME}}Bridge.start(getBridgeBindPort()); + return sharedBridge; + } finally { + sharedStartingPromise = undefined; + } + })(); + return sharedStartingPromise; +} + +// ---- Agent lifecycle --------------------------------------------------- + +type {{PASCAL_NAME}}Context = { + enabledSchemas: Set; + portRegistration?: { release: () => void }; +}; + +export function instantiate(): AppAgent { + return { + initializeAgentContext, + updateAgentContext, + closeAgentContext, + executeAction, + }; +} + +async function initializeAgentContext(): Promise<{{PASCAL_NAME}}Context> { + return { enabledSchemas: new Set() }; +} + +/** + * Backstop cleanup invoked by the dispatcher when a session closes + * without an explicit per-schema disable (crash, client disconnect, + * shell shutdown). Releases this session's port registration and + * decrements the shared refcount once, even if multiple schemas were + * enabled. Idempotent — a subsequent `updateAgentContext(false, …)` + * will see an empty `enabledSchemas` and no-op. + */ +async function closeAgentContext( + context: SessionContext<{{PASCAL_NAME}}Context>, +): Promise { + const ctx = context.agentContext; + const wasActive = ctx.enabledSchemas.size > 0; + ctx.enabledSchemas.clear(); + ctx.portRegistration?.release(); + delete ctx.portRegistration; + if (!wasActive) return; + sharedRefCount = Math.max(0, sharedRefCount - 1); + if (sharedRefCount === 0 && sharedBridge) { + const bridge = sharedBridge; + sharedBridge = undefined; + sharedClosingPromise = bridge.close().finally(() => { + sharedClosingPromise = undefined; + }); + await sharedClosingPromise; + } +} + +async function updateAgentContext( + enable: boolean, + context: SessionContext<{{PASCAL_NAME}}Context>, + schemaName: string, +): Promise { + const ctx = context.agentContext; + if (enable) { + if (ctx.enabledSchemas.has(schemaName)) return; + const isFirstForSession = ctx.enabledSchemas.size === 0; + ctx.enabledSchemas.add(schemaName); + try { + const bridge = await ensureSharedBridge(); + if (isFirstForSession) { + // Per-session registration: the registrar allows multiple + // entries for ("{{NAME}}", "default") across sessions and + // lookup returns the most recent, so each active session + // independently keeps the shared port discoverable. + ctx.portRegistration = context.registerPort( + "default", + bridge.port, + ); + sharedRefCount++; + } + } catch (e) { + // Roll back per-session bookkeeping so a subsequent retry sees + // a clean slate. Shared module state is untouched — the bind + // itself failed, so we never incremented the refcount or + // registered. + ctx.enabledSchemas.delete(schemaName); + throw e; + } + } else { + if (!ctx.enabledSchemas.has(schemaName)) return; + ctx.enabledSchemas.delete(schemaName); + if (ctx.enabledSchemas.size === 0) { + // Release this session's registration before potentially + // closing the server. Release is idempotent and a no-op if + // already released by the dispatcher's closeSessionContext + // backstop. + ctx.portRegistration?.release(); + delete ctx.portRegistration; + sharedRefCount = Math.max(0, sharedRefCount - 1); + if (sharedRefCount === 0 && sharedBridge) { + const bridge = sharedBridge; + sharedBridge = undefined; + sharedClosingPromise = bridge.close().finally(() => { + sharedClosingPromise = undefined; + }); + await sharedClosingPromise; + } + } + } +} + +async function executeAction( + action: TypeAgentAction<{{PASCAL_NAME}}Actions>, + _context: ActionContext<{{PASCAL_NAME}}Context>, +): Promise { + if (!sharedBridge?.connected) { + return { + error: "Host plugin not connected to the {{NAME}} bridge. Start the plugin and ensure it is configured for the port reported by @system ports.", + }; + } + try { + const result = await sharedBridge.send(action.actionName, action.parameters); + return createActionResultFromTextDisplay(JSON.stringify(result, null, 2)); + } catch (err: any) { + return { error: err?.message ?? String(err) }; + } +} diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/websocketBridgeTemplate.template b/ts/packages/agents/onboarding/src/scaffolder/templates/websocketBridgeTemplate.template new file mode 100644 index 0000000000..b92b61ebbc --- /dev/null +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/websocketBridgeTemplate.template @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// WebSocket bridge for {{NAME}}. +// Manages a WebSocket connection to the host application plugin. +// Pattern matches the Excel/VS Code agent bridge implementations. +// +// Port allocation: the bridge binds on an OS-assigned ephemeral port +// (port=0) by default. Read the actual bound port from `.port` after +// `start()` resolves and register it with the dispatcher via +// `context.registerPort("default", bridge.port)` from your handler so +// external clients can discover it through the agent-server's +// discovery channel. Pass a fixed port to `start(port)` when debugging +// or when a host plugin expects a known address. + +import { WebSocketServer, WebSocket } from "ws"; +import { AddressInfo } from "net"; + +type BridgeCommand = { + id: string; + actionName: string; + parameters: Record; +}; + +type BridgeResponse = { + id: string; + success: boolean; + result?: unknown; + error?: string; +}; + +export class {{PASCAL_NAME}}Bridge { + private clients = new Map(); + private nextClientId = 0; + private pending = new Map< + string, + { + resolve: (result: unknown) => void; + reject: (err: Error) => void; + } + >(); + + // Construction is private — use {@link {{PASCAL_NAME}}Bridge.start} so + // callers always get a bridge that is guaranteed to be bound before + // they read {@link port} or pass it to the registrar. + private constructor( + private readonly server: WebSocketServer, + public readonly port: number, + ) { + this.server.on("connection", (ws) => { + const id = `c-${++this.nextClientId}`; + this.clients.set(id, ws); + ws.on("message", (data) => { + try { + const response = JSON.parse(data.toString()) as BridgeResponse; + const entry = this.pending.get(response.id); + if (entry) { + this.pending.delete(response.id); + if (response.success) entry.resolve(response.result); + else entry.reject(new Error(response.error)); + } + } catch { + // Ignore malformed payloads. + } + }); + ws.on("close", () => this.clients.delete(id)); + ws.on("error", () => this.clients.delete(id)); + }); + } + + /** + * Bind a new bridge on `port`. Pass 0 (default) to let the OS pick + * a free ephemeral port; read the actual bound port from + * {@link port} after the returned promise resolves. Rejects on bind + * failure (EADDRINUSE under a fixed-port override) so callers see + * the problem instead of having it swallowed by a late error + * handler. + */ + public static start(port: number = 0): Promise<{{PASCAL_NAME}}Bridge> { + return new Promise((resolve, reject) => { + const server = new WebSocketServer({ port }); + let settled = false; + const onError = (e: Error) => { + if (settled) return; + settled = true; + server.removeListener("listening", onListening); + reject(e); + }; + const onListening = () => { + if (settled) return; + settled = true; + server.removeListener("error", onError); + const addr = server.address() as AddressInfo | null; + if (!addr || typeof addr === "string") { + server.close(); + reject( + new Error( + "ws server.address() did not return AddressInfo", + ), + ); + return; + } + // Re-attach a permanent error handler so post-listen + // errors are surfaced rather than crashing the process. + server.on("error", (err) => { + console.error( + `[{{NAME}}Bridge] post-listen server error: ${err.message}`, + ); + }); + resolve(new {{PASCAL_NAME}}Bridge(server, addr.port)); + }; + server.once("error", onError); + server.once("listening", onListening); + }); + } + + /** + * Close all client connections and the underlying server. Pending + * `sendCommand` promises are rejected so callers never hang on a + * closed bridge. Resolves when the server has fully released its + * port — important for a rapid restart cycle, where a synchronous + * return would race the new bind into EADDRINUSE. + */ + public close(): Promise { + const closedError = new Error( + "{{PASCAL_NAME}}Bridge closed before response was received.", + ); + for (const entry of this.pending.values()) { + entry.reject(closedError); + } + this.pending.clear(); + for (const c of this.clients.values()) { + if (c.readyState === WebSocket.OPEN) c.close(); + } + this.clients.clear(); + return new Promise((resolve) => + this.server.close(() => resolve()), + ); + } + + public get connected(): boolean { + for (const c of this.clients.values()) { + if (c.readyState === WebSocket.OPEN) return true; + } + return false; + } + + public async sendCommand( + actionName: string, + parameters: Record, + ): Promise { + // Use the first OPEN client (single-plugin pattern). Adapt + // this selection if you need fan-out or per-session client + // targeting. + let target: WebSocket | undefined; + for (const c of this.clients.values()) { + if (c.readyState === WebSocket.OPEN) { + target = c; + break; + } + } + if (!target) { + throw new Error("No client connected to the {{NAME}} bridge."); + } + const id = `cmd-${Date.now()}-${Math.random().toString(36).slice(2)}`; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + target!.send( + JSON.stringify({ id, actionName, parameters } satisfies BridgeCommand), + ); + }); + } +} From 1949da938a8a0104b8c85818fa9e89ce03d1efeb Mon Sep 17 00:00:00 2001 From: Tal Zaccai Date: Thu, 28 May 2026 13:37:26 -0700 Subject: [PATCH 4/4] onboarding: harden templateLoader against placeholder re-substitution The previous sequential split/join loop would re-process a substituted value that happened to contain `{{KEY}}` text. Not exploitable today -- all callers pass values derived from toPascalCase(name) etc. -- but a future caller passing a var derived from user input could see surprising behavior. Switch to a single-pass regex replace that treats each substituted value as opaque. Bonus: an evil `{{KEY}}` value now correctly surfaces via the leftover-placeholder check rather than silently re-expanding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../onboarding/src/scaffolder/templateLoader.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/ts/packages/agents/onboarding/src/scaffolder/templateLoader.ts b/ts/packages/agents/onboarding/src/scaffolder/templateLoader.ts index 4a763a7993..b96b785854 100644 --- a/ts/packages/agents/onboarding/src/scaffolder/templateLoader.ts +++ b/ts/packages/agents/onboarding/src/scaffolder/templateLoader.ts @@ -40,16 +40,21 @@ export function loadTemplate( filename: string, vars: Record, ): string { - let tpl = fs.readFileSync(templatePath(filename), "utf-8"); - for (const [key, value] of Object.entries(vars)) { - tpl = tpl.split(`{{${key}}}`).join(value); - } - const leftover = tpl.match(/\{\{[A-Z0-9_]+\}\}/g); + const tpl = fs.readFileSync(templatePath(filename), "utf-8"); + // Single-pass regex replacement so a substituted value that happens to + // contain `{{KEY}}` text is NOT re-processed by a later iteration -- + // important if a future caller ever passes a var derived from user + // input. Unknown placeholders are left in place and surfaced by the + // leftover check below. + const out = tpl.replace(/\{\{([A-Z0-9_]+)\}\}/g, (match, key) => + key in vars ? vars[key] : match, + ); + const leftover = out.match(/\{\{[A-Z0-9_]+\}\}/g); if (leftover && leftover.length > 0) { const unique = Array.from(new Set(leftover)).join(", "); throw new Error( `Template ${filename} has unsubstituted placeholders: ${unique}`, ); } - return tpl; + return out; }