From 851f3dd9655933500a209c356e748d3d347a344a Mon Sep 17 00:00:00 2001 From: Tal Zaccai Date: Mon, 18 May 2026 20:26:54 -0700 Subject: [PATCH 1/8] 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/8] 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/8] 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/8] 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; } From f65015328fc77e843afcfefe069e0fad76439680 Mon Sep 17 00:00:00 2001 From: Tal Zaccai Date: Tue, 2 Jun 2026 18:45:31 -0700 Subject: [PATCH 5/8] =?UTF-8?q?scaffolder:=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20convert=20templates=20to=20.ts;=20add=20BRIDGE=5FPO?= =?UTF-8?q?RT=20placeholder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the three remaining open comments on PR #2368 from @robgruen: 1. commandHandlerTemplate / externalApiHandler / other handler templates: "Should we make this file a .ts so it compiles and we can verify it has no compilation issues?" Renamed 11 of the 14 scaffolder templates from `.template` to `.ts` so `tsc` validates each one as standalone TypeScript at build time. Changes: - Renamed placeholder convention from `{{TOKEN}}` to sentinel identifiers `__token__` / `__Token__` / `__TOKEN__` so the templates remain valid TypeScript when read by the compiler (`class __AgentName__Bridge {}`, `process.env["__PORT_ENV__"]`, `import { __AgentName__Actions } from "./__agentName__Schema.js"`). - The substitution regex requires *paired* double-underscores, so Node's `__filename` / `__dirname` are left untouched. - Added `templates/__agentName__Schema.ts` — a stub schema module so the import paths in the templates resolve. - Added `templates/tsconfig.json` — composite sub-project that extends `tsconfig.base.json` and disables `noUnusedLocals` / `noUnusedParameters` (templates intentionally pre-import helpers for the user's TODOs). Wired in via project reference from `src/tsconfig.json`. - `templateLoader` regex updated to match the new `__TOKEN__` convention; reserved-name guard added so the stub schema / placeholders d.ts can never be `loadTemplate`'d at scaffold time. - Added `ws`, `@types/ws`, `@types/node` to devDependencies so the websocket-bridge templates type-check standalone. - `viewUiHandler.ts` switched from `agentContext.x = undefined` to `delete agentContext.x` to satisfy `exactOptionalPropertyTypes`, matching the convention already in `websocketBridgeHandler`. The three browser/markup templates (officeAddinHtml, officeManifestXml, officeAddinTs) stay as `.template` — they're not Node TypeScript and would need a separate DOM/Office.js lib configuration. They use the same `__TOKEN__` placeholder syntax for consistency. 2. officeAddinTs.template:8 (`const BRIDGE_PORT = 5678;`): "Should this be a replaceable template value?" Yes — the addin's bridge port must match whatever port the bridge agent is bound to. Made it a `__BRIDGE_PORT__` placeholder with a `5678` default (the same pinned-sideload default the bridge handler uses when `_BRIDGE_PORT` is unset). The scaffolder substitutes the value at scaffold time via `buildOfficeAddinTs`. Verification: - `pnpm --filter onboarding-agent build` clean. - Migration smoke test (verifyTemplateMigration.mjs) renders each template with a canned (NAME, PASCAL_NAME, PORT_ENV) tuple and diffs against the pre-migration output. 13 of 14 templates are byte-identical; the one diff is `viewUiHandler.ts` switching to `delete` (intentional, as above). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ts/packages/agents/onboarding/package.json | 5 +- .../src/scaffolder/scaffolderHandler.ts | 71 ++++----- .../src/scaffolder/templateLoader.ts | 65 +++++--- .../templates/__agentName__Schema.ts | 16 ++ ...ate.template => commandHandlerTemplate.ts} | 2 +- ...Handler.template => externalApiHandler.ts} | 19 ++- ...andler.template => llmStreamingHandler.ts} | 4 +- ...dler.template => nativePlatformHandler.ts} | 6 +- .../templates/officeAddinHtml.template | 4 +- .../templates/officeAddinTs.template | 6 +- .../templates/officeManifestXml.template | 4 +- ...emplate.template => restClientTemplate.ts} | 18 ++- ...ndler.template => schemaGrammarHandler.ts} | 4 +- ...andler.template => stateMachineHandler.ts} | 16 +- ...emplate => subAgentOrchestratorHandler.ts} | 4 +- .../src/scaffolder/templates/tsconfig.json | 11 ++ ...iewUiHandler.template => viewUiHandler.ts} | 65 ++++---- ...ler.template => websocketBridgeHandler.ts} | 92 ++++++++---- ...te.template => websocketBridgeTemplate.ts} | 30 ++-- .../agents/onboarding/src/tsconfig.json | 2 + ts/pnpm-lock.yaml | 140 ++++++++---------- 21 files changed, 347 insertions(+), 237 deletions(-) create mode 100644 ts/packages/agents/onboarding/src/scaffolder/templates/__agentName__Schema.ts rename ts/packages/agents/onboarding/src/scaffolder/templates/{commandHandlerTemplate.template => commandHandlerTemplate.ts} (94%) rename ts/packages/agents/onboarding/src/scaffolder/templates/{externalApiHandler.template => externalApiHandler.ts} (76%) rename ts/packages/agents/onboarding/src/scaffolder/templates/{llmStreamingHandler.template => llmStreamingHandler.ts} (91%) rename ts/packages/agents/onboarding/src/scaffolder/templates/{nativePlatformHandler.template => nativePlatformHandler.ts} (91%) rename ts/packages/agents/onboarding/src/scaffolder/templates/{restClientTemplate.template => restClientTemplate.ts} (50%) rename ts/packages/agents/onboarding/src/scaffolder/templates/{schemaGrammarHandler.template => schemaGrammarHandler.ts} (86%) rename ts/packages/agents/onboarding/src/scaffolder/templates/{stateMachineHandler.template => stateMachineHandler.ts} (83%) rename ts/packages/agents/onboarding/src/scaffolder/templates/{subAgentOrchestratorHandler.template => subAgentOrchestratorHandler.ts} (92%) create mode 100644 ts/packages/agents/onboarding/src/scaffolder/templates/tsconfig.json rename ts/packages/agents/onboarding/src/scaffolder/templates/{viewUiHandler.template => viewUiHandler.ts} (79%) rename ts/packages/agents/onboarding/src/scaffolder/templates/{websocketBridgeHandler.template => websocketBridgeHandler.ts} (79%) rename ts/packages/agents/onboarding/src/scaffolder/templates/{websocketBridgeTemplate.template => websocketBridgeTemplate.ts} (86%) diff --git a/ts/packages/agents/onboarding/package.json b/ts/packages/agents/onboarding/package.json index bb35f29be5..37a6ce105c 100644 --- a/ts/packages/agents/onboarding/package.json +++ b/ts/packages/agents/onboarding/package.json @@ -54,12 +54,15 @@ "devDependencies": { "@typeagent/action-schema-compiler": "workspace:*", "@types/debug": "^4.1.12", + "@types/node": "^22.0.0", + "@types/ws": "^8.5.10", "action-grammar-compiler": "workspace:*", "concurrently": "^9.1.2", "copyfiles": "^2.4.1", "prettier": "^3.5.3", "rimraf": "^6.0.1", - "typescript": "~5.4.5" + "typescript": "~5.4.5", + "ws": "^8.18.0" }, "engines": { "node": ">=22" diff --git a/ts/packages/agents/onboarding/src/scaffolder/scaffolderHandler.ts b/ts/packages/agents/onboarding/src/scaffolder/scaffolderHandler.ts index a796d106c2..fc9685dc92 100644 --- a/ts/packages/agents/onboarding/src/scaffolder/scaffolderHandler.ts +++ b/ts/packages/agents/onboarding/src/scaffolder/scaffolderHandler.ts @@ -691,9 +691,9 @@ async function buildHandler( } function buildSchemaGrammarHandler(name: string, pascalName: string): string { - return loadTemplate("schemaGrammarHandler.template", { - NAME: name, - PASCAL_NAME: pascalName, + return loadTemplate("schemaGrammarHandler.ts", { + agentName: name, + AgentName: pascalName, }); } @@ -866,37 +866,38 @@ const PLUGIN_TEMPLATES: Record< function buildRestClientTemplate(name: string): string { const pascalName = toPascalCase(name); - return loadTemplate("restClientTemplate.template", { - NAME: name, - PASCAL_NAME: pascalName, + return loadTemplate("restClientTemplate.ts", { + agentName: name, + AgentName: pascalName, }); } function buildWebSocketBridgeTemplate(name: string): string { const pascalName = toPascalCase(name); - return loadTemplate("websocketBridgeTemplate.template", { - NAME: name, - PASCAL_NAME: pascalName, + return loadTemplate("websocketBridgeTemplate.ts", { + agentName: name, + AgentName: pascalName, }); } function buildOfficeAddinHtml(name: string): string { const pascalName = toPascalCase(name); return loadTemplate("officeAddinHtml.template", { - PASCAL_NAME: pascalName, + AgentName: pascalName, }); } function buildOfficeAddinTs(name: string): string { return loadTemplate("officeAddinTs.template", { - NAME: name, + agentName: name, + BRIDGE_PORT: "5678", }); } function buildOfficeManifestXml(name: string): string { const pascal = toPascalCase(name); return loadTemplate("officeManifestXml.template", { - PASCAL_NAME: pascal, + AgentName: pascal, }); } @@ -931,16 +932,16 @@ async function handleListPatterns(): Promise { // ─── Pattern-specific handler builders ─────────────────────────────────────── function buildExternalApiHandler(name: string, pascalName: string): string { - return loadTemplate("externalApiHandler.template", { - NAME: name, - PASCAL_NAME: pascalName, + return loadTemplate("externalApiHandler.ts", { + agentName: name, + AgentName: pascalName, }); } function buildLlmStreamingHandler(name: string, pascalName: string): string { - return loadTemplate("llmStreamingHandler.template", { - NAME: name, - PASCAL_NAME: pascalName, + return loadTemplate("llmStreamingHandler.ts", { + agentName: name, + AgentName: pascalName, }); } @@ -948,46 +949,46 @@ function buildSubAgentOrchestratorHandler( name: string, pascalName: string, ): string { - return loadTemplate("subAgentOrchestratorHandler.template", { - NAME: name, - PASCAL_NAME: pascalName, + return loadTemplate("subAgentOrchestratorHandler.ts", { + agentName: name, + AgentName: pascalName, }); } function buildWebSocketBridgeHandler(name: string, pascalName: string): string { const portEnv = `${name.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_BRIDGE_PORT`; - return loadTemplate("websocketBridgeHandler.template", { - NAME: name, - PASCAL_NAME: pascalName, + return loadTemplate("websocketBridgeHandler.ts", { + agentName: name, + AgentName: pascalName, PORT_ENV: portEnv, }); } function buildStateMachineHandler(name: string, pascalName: string): string { - return loadTemplate("stateMachineHandler.template", { - NAME: name, - PASCAL_NAME: pascalName, + return loadTemplate("stateMachineHandler.ts", { + agentName: name, + AgentName: pascalName, }); } function buildNativePlatformHandler(name: string, pascalName: string): string { - return loadTemplate("nativePlatformHandler.template", { - NAME: name, - PASCAL_NAME: pascalName, + return loadTemplate("nativePlatformHandler.ts", { + agentName: name, + AgentName: pascalName, }); } function buildViewUiHandler(name: string, pascalName: string): string { const portEnv = `${name.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_VIEW_PORT`; - return loadTemplate("viewUiHandler.template", { - NAME: name, - PASCAL_NAME: pascalName, + return loadTemplate("viewUiHandler.ts", { + agentName: name, + AgentName: pascalName, PORT_ENV: portEnv, }); } function buildCommandHandlerTemplate(name: string, pascalName: string): string { - return loadTemplate("commandHandlerTemplate.template", { - PASCAL_NAME: pascalName, + return loadTemplate("commandHandlerTemplate.ts", { + AgentName: pascalName, }); } diff --git a/ts/packages/agents/onboarding/src/scaffolder/templateLoader.ts b/ts/packages/agents/onboarding/src/scaffolder/templateLoader.ts index b96b785854..a23cad310c 100644 --- a/ts/packages/agents/onboarding/src/scaffolder/templateLoader.ts +++ b/ts/packages/agents/onboarding/src/scaffolder/templateLoader.ts @@ -3,14 +3,18 @@ // 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. +// Templates live in `src/scaffolder/templates/*.ts` so a reviewer can +// read them as plain TypeScript (with syntax highlighting and +// `tsc`-level verification — see `__placeholders__.d.ts` and +// `__agentName__Schema.ts` for the type-check stubs) 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. +// Placeholders use the `__TOKEN__` convention — chosen so the templates +// remain valid TypeScript identifiers (`class __AgentName__Bridge {}`, +// `process.env["__PORT_ENV__"]`, etc.). The substitution regex only +// matches identifiers wrapped in *paired* double-underscores, so +// Node's `__filename` / `__dirname` (single trailing underscore is +// absent) are left untouched. // // Templates are loaded at scaffold time (once per generated file) so the // sync I/O cost is negligible and lets build* helpers stay synchronous — @@ -30,26 +34,53 @@ function templatePath(filename: string): string { return path.resolve(__dirname, "../../src/scaffolder/templates", filename); } +// Files in the templates directory that are type-check scaffolding only +// (stubs for placeholder identifiers) and must never be loaded as a +// template at scaffold time. +const RESERVED_TEMPLATE_NAMES = new Set([ + "__agentName__Schema.ts", + "__placeholders__.d.ts", +]); + /** * 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. + * `__TOKEN__` with `vars[TOKEN]`. Throws if any `__TOKEN__` 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. + * + * For TypeScript templates the recommended `vars` keys are: + * - `agentName` (camelCase agent name) + * - `AgentName` (PascalCase agent name) + * - `PORT_ENV` (uppercase env-var name) + * - `BRIDGE_PORT` (numeric default port literal) + * + * Non-TS templates (`.template` extension) use the same `__TOKEN__` + * convention as well. */ export function loadTemplate( filename: string, vars: Record, ): string { + if (RESERVED_TEMPLATE_NAMES.has(filename)) { + throw new Error( + `Template ${filename} is a type-check stub and cannot be loaded.`, + ); + } 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) => + // Single-pass regex replacement so a substituted value that happens + // to contain a `__KEY__` token 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. + // + // Requires *paired* leading and trailing double-underscores: matches + // `__agentName__` but leaves Node's `__filename` (no trailing `__`) + // and `____` (empty body) alone. + const out = tpl.replace(/__([A-Za-z_][A-Za-z0-9_]*)__/g, (match, key) => key in vars ? vars[key] : match, ); - const leftover = out.match(/\{\{[A-Z0-9_]+\}\}/g); + const leftover = out.match(/__[A-Za-z_][A-Za-z0-9_]*__/g); if (leftover && leftover.length > 0) { const unique = Array.from(new Set(leftover)).join(", "); throw new Error( diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/__agentName__Schema.ts b/ts/packages/agents/onboarding/src/scaffolder/templates/__agentName__Schema.ts new file mode 100644 index 0000000000..5b7da84d2d --- /dev/null +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/__agentName__Schema.ts @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// SCAFFOLDER TEMPLATE STUB — DO NOT IMPORT AT RUNTIME. +// +// This file is a placeholder schema module so the sibling `*.ts` scaffolder +// templates in this directory can be type-checked standalone by `tsc`. The +// real generated agent ships its own `Schema.ts` next to its handler; +// at scaffold time `templateLoader` rewrites the import path so this stub +// is never referenced by emitted code. + +// eslint-disable-next-line @typescript-eslint/naming-convention +export type __AgentName__Actions = { + actionName: string; + parameters?: Record; +}; diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/commandHandlerTemplate.template b/ts/packages/agents/onboarding/src/scaffolder/templates/commandHandlerTemplate.ts similarity index 94% rename from ts/packages/agents/onboarding/src/scaffolder/templates/commandHandlerTemplate.template rename to ts/packages/agents/onboarding/src/scaffolder/templates/commandHandlerTemplate.ts index 41a44ae3ec..dc7dd75a5b 100644 --- a/ts/packages/agents/onboarding/src/scaffolder/templates/commandHandlerTemplate.template +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/commandHandlerTemplate.ts @@ -12,7 +12,7 @@ export function instantiate(): AppAgent { } // ---- Handlers ---------------------------------------------------------- -// Add one entry per action name defined in {{PASCAL_NAME}}Actions. +// Add one entry per action name defined in __AgentName__Actions. const handlers: Record Promise> = { // exampleAction: async (params) => { diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/externalApiHandler.template b/ts/packages/agents/onboarding/src/scaffolder/templates/externalApiHandler.ts similarity index 76% rename from ts/packages/agents/onboarding/src/scaffolder/templates/externalApiHandler.template rename to ts/packages/agents/onboarding/src/scaffolder/templates/externalApiHandler.ts index 0725a9a332..62df2a7e45 100644 --- a/ts/packages/agents/onboarding/src/scaffolder/templates/externalApiHandler.template +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/externalApiHandler.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. // Pattern: external-api — REST/OAuth cloud API bridge. -// Implement {{PASCAL_NAME}}Client with your API's authentication and endpoints. +// Implement __AgentName__Client with your API's authentication and endpoints. import { ActionContext, @@ -12,21 +12,24 @@ import { ActionResult, } from "@typeagent/agent-sdk"; import { createActionResultFromTextDisplay } from "@typeagent/agent-sdk/helpers/action"; -import { {{PASCAL_NAME}}Actions } from "./{{NAME}}Schema.js"; +import { __AgentName__Actions } from "./__agentName__Schema.js"; // ---- API client -------------------------------------------------------- -class {{PASCAL_NAME}}Client { +class __AgentName__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 + // Store token in: ~/.typeagent/profiles//__agentName__/token.json throw new Error("authenticate() not yet implemented"); } - async callApi(endpoint: string, params: Record): Promise { + 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`); @@ -35,7 +38,7 @@ class {{PASCAL_NAME}}Client { // ---- Agent lifecycle --------------------------------------------------- -type Context = { client: {{PASCAL_NAME}}Client }; +type Context = { client: __AgentName__Client }; export function instantiate(): AppAgent { return { @@ -46,7 +49,7 @@ export function instantiate(): AppAgent { } async function initializeAgentContext(): Promise { - return { client: new {{PASCAL_NAME}}Client() }; + return { client: new __AgentName__Client() }; } async function updateAgentContext( @@ -58,7 +61,7 @@ async function updateAgentContext( } async function executeAction( - action: TypeAgentAction<{{PASCAL_NAME}}Actions>, + action: TypeAgentAction<__AgentName__Actions>, context: ActionContext, ): Promise { const { client } = context.sessionContext.agentContext; diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/llmStreamingHandler.template b/ts/packages/agents/onboarding/src/scaffolder/templates/llmStreamingHandler.ts similarity index 91% rename from ts/packages/agents/onboarding/src/scaffolder/templates/llmStreamingHandler.template rename to ts/packages/agents/onboarding/src/scaffolder/templates/llmStreamingHandler.ts index f1c2a7315a..66c41454b4 100644 --- a/ts/packages/agents/onboarding/src/scaffolder/templates/llmStreamingHandler.template +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/llmStreamingHandler.ts @@ -12,7 +12,7 @@ import { ActionResult, } from "@typeagent/agent-sdk"; import { createActionResultFromMarkdownDisplay } from "@typeagent/agent-sdk/helpers/action"; -import { {{PASCAL_NAME}}Actions } from "./{{NAME}}Schema.js"; +import { __AgentName__Actions } from "./__agentName__Schema.js"; export function instantiate(): AppAgent { return { @@ -26,7 +26,7 @@ async function initializeAgentContext(): Promise { } async function executeAction( - action: TypeAgentAction<{{PASCAL_NAME}}Actions>, + action: TypeAgentAction<__AgentName__Actions>, context: ActionContext, ): Promise { switch (action.actionName) { diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/nativePlatformHandler.template b/ts/packages/agents/onboarding/src/scaffolder/templates/nativePlatformHandler.ts similarity index 91% rename from ts/packages/agents/onboarding/src/scaffolder/templates/nativePlatformHandler.template rename to ts/packages/agents/onboarding/src/scaffolder/templates/nativePlatformHandler.ts index da37db85f1..531cccad07 100644 --- a/ts/packages/agents/onboarding/src/scaffolder/templates/nativePlatformHandler.template +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/nativePlatformHandler.ts @@ -13,7 +13,7 @@ import { 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"; +import { __AgentName__Actions } from "./__agentName__Schema.js"; const execAsync = promisify(exec); const platform = process.platform; // "win32" | "darwin" | "linux" @@ -30,7 +30,7 @@ async function initializeAgentContext(): Promise { } async function executeAction( - action: TypeAgentAction<{{PASCAL_NAME}}Actions>, + action: TypeAgentAction<__AgentName__Actions>, _context: ActionContext, ): Promise { try { @@ -46,7 +46,7 @@ async function executeAction( /** * Map a typed action to a platform-specific shell command or SDK call. - * Add one case per action defined in {{PASCAL_NAME}}Actions. + * Add one case per action defined in __AgentName__Actions. */ async function executeCommand( actionName: string, diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/officeAddinHtml.template b/ts/packages/agents/onboarding/src/scaffolder/templates/officeAddinHtml.template index e28802d848..d0a92d4d6b 100644 --- a/ts/packages/agents/onboarding/src/scaffolder/templates/officeAddinHtml.template +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/officeAddinHtml.template @@ -2,12 +2,12 @@ - {{PASCAL_NAME}} TypeAgent Add-in + __AgentName__ TypeAgent Add-in -

{{PASCAL_NAME}} TypeAgent

+

__AgentName__ TypeAgent

Connecting...
diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/officeAddinTs.template b/ts/packages/agents/onboarding/src/scaffolder/templates/officeAddinTs.template index 600c5a5629..6c6fdc8e64 100644 --- a/ts/packages/agents/onboarding/src/scaffolder/templates/officeAddinTs.template +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/officeAddinTs.template @@ -1,11 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -// Office.js task pane add-in for {{NAME}} TypeAgent integration. +// Office.js task pane add-in for __agentName__ TypeAgent integration. // Connects to the TypeAgent bridge via WebSocket and forwards commands // to the Office.js API. -const BRIDGE_PORT = 5678; +const BRIDGE_PORT = __BRIDGE_PORT__; Office.onReady(async () => { document.getElementById("status")!.textContent = "Connecting to TypeAgent..."; @@ -13,7 +13,7 @@ Office.onReady(async () => { ws.onopen = () => { document.getElementById("status")!.textContent = "Connected"; - ws.send(JSON.stringify({ type: "hello", addinName: "{{NAME}}" })); + ws.send(JSON.stringify({ type: "hello", addinName: "__agentName__" })); }; ws.onmessage = async (event) => { diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/officeManifestXml.template b/ts/packages/agents/onboarding/src/scaffolder/templates/officeManifestXml.template index 5ff40b7dcc..b9ace78701 100644 --- a/ts/packages/agents/onboarding/src/scaffolder/templates/officeManifestXml.template +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/officeManifestXml.template @@ -6,8 +6,8 @@ 1.0.0.0 Microsoft en-US - - + + diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/restClientTemplate.template b/ts/packages/agents/onboarding/src/scaffolder/templates/restClientTemplate.ts similarity index 50% rename from ts/packages/agents/onboarding/src/scaffolder/templates/restClientTemplate.template rename to ts/packages/agents/onboarding/src/scaffolder/templates/restClientTemplate.ts index e23a277c52..8a640cdb6c 100644 --- a/ts/packages/agents/onboarding/src/scaffolder/templates/restClientTemplate.template +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/restClientTemplate.ts @@ -1,19 +1,27 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -// REST client bridge for {{NAME}}. +// REST client bridge for __agentName__. // 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) {} +export class __AgentName__Bridge { + constructor( + private readonly baseUrl: string, + private readonly apiKey?: string, + ) {} - async executeCommand(actionName: string, parameters: Record): Promise { + 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" }; + 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.ts similarity index 86% rename from ts/packages/agents/onboarding/src/scaffolder/templates/schemaGrammarHandler.template rename to ts/packages/agents/onboarding/src/scaffolder/templates/schemaGrammarHandler.ts index 7d867a0506..941eff211d 100644 --- a/ts/packages/agents/onboarding/src/scaffolder/templates/schemaGrammarHandler.template +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/schemaGrammarHandler.ts @@ -8,7 +8,7 @@ import { ActionResult, } from "@typeagent/agent-sdk"; import { createActionResultFromTextDisplay } from "@typeagent/agent-sdk/helpers/action"; -import { {{PASCAL_NAME}}Actions } from "./{{NAME}}Schema.js"; +import { __AgentName__Actions } from "./__agentName__Schema.js"; export function instantiate(): AppAgent { return { @@ -22,7 +22,7 @@ async function initializeAgentContext(): Promise { } async function executeAction( - action: TypeAgentAction<{{PASCAL_NAME}}Actions>, + action: TypeAgentAction<__AgentName__Actions>, context: ActionContext, ): Promise { // TODO: implement action handlers diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/stateMachineHandler.template b/ts/packages/agents/onboarding/src/scaffolder/templates/stateMachineHandler.ts similarity index 83% rename from ts/packages/agents/onboarding/src/scaffolder/templates/stateMachineHandler.template rename to ts/packages/agents/onboarding/src/scaffolder/templates/stateMachineHandler.ts index 54c926645c..101184390e 100644 --- a/ts/packages/agents/onboarding/src/scaffolder/templates/stateMachineHandler.template +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/stateMachineHandler.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. // Pattern: state-machine — multi-phase disk-persisted workflow. -// State is stored in ~/.typeagent/{{NAME}}//state.json. +// State is stored in ~/.typeagent/__agentName__//state.json. // Each phase must be approved before the next begins. import { @@ -12,12 +12,12 @@ import { ActionResult, } from "@typeagent/agent-sdk"; import { createActionResultFromMarkdownDisplay } from "@typeagent/agent-sdk/helpers/action"; -import { {{PASCAL_NAME}}Actions } from "./{{NAME}}Schema.js"; +import { __AgentName__Actions } from "./__agentName__Schema.js"; import fs from "fs/promises"; import path from "path"; import os from "os"; -const STATE_ROOT = path.join(os.homedir(), ".typeagent", "{{NAME}}"); +const STATE_ROOT = path.join(os.homedir(), ".typeagent", "__agentName__"); // ---- State types ------------------------------------------------------- @@ -34,10 +34,14 @@ type WorkflowState = { // ---- State I/O --------------------------------------------------------- -async function loadState(workflowId: string): Promise { +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; + return JSON.parse( + await fs.readFile(statePath, "utf-8"), + ) as WorkflowState; } catch { return undefined; } @@ -69,7 +73,7 @@ async function initializeAgentContext(): Promise { } async function executeAction( - action: TypeAgentAction<{{PASCAL_NAME}}Actions>, + action: TypeAgentAction<__AgentName__Actions>, _context: ActionContext, ): Promise { // TODO: map actions to phase handlers, e.g.: diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/subAgentOrchestratorHandler.template b/ts/packages/agents/onboarding/src/scaffolder/templates/subAgentOrchestratorHandler.ts similarity index 92% rename from ts/packages/agents/onboarding/src/scaffolder/templates/subAgentOrchestratorHandler.template rename to ts/packages/agents/onboarding/src/scaffolder/templates/subAgentOrchestratorHandler.ts index 2df7c83874..d62aa44ce5 100644 --- a/ts/packages/agents/onboarding/src/scaffolder/templates/subAgentOrchestratorHandler.template +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/subAgentOrchestratorHandler.ts @@ -12,7 +12,7 @@ import { ActionResult, } from "@typeagent/agent-sdk"; import { createActionResultFromTextDisplay } from "@typeagent/agent-sdk/helpers/action"; -import { {{PASCAL_NAME}}Actions } from "./{{NAME}}Schema.js"; +import { __AgentName__Actions } from "./__agentName__Schema.js"; export function instantiate(): AppAgent { return { @@ -26,7 +26,7 @@ async function initializeAgentContext(): Promise { } async function executeAction( - action: TypeAgentAction<{{PASCAL_NAME}}Actions>, + action: TypeAgentAction<__AgentName__Actions>, context: ActionContext, ): Promise { // TODO: route to sub-schema handlers, e.g.: diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/tsconfig.json b/ts/packages/agents/onboarding/src/scaffolder/templates/tsconfig.json new file mode 100644 index 0000000000..463b9c12f2 --- /dev/null +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": ".", + "outDir": "../../../dist/scaffolder/templates", + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["./**/*"] +} diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/viewUiHandler.template b/ts/packages/agents/onboarding/src/scaffolder/templates/viewUiHandler.ts similarity index 79% rename from ts/packages/agents/onboarding/src/scaffolder/templates/viewUiHandler.template rename to ts/packages/agents/onboarding/src/scaffolder/templates/viewUiHandler.ts index 5631267bb2..52b30123ad 100644 --- a/ts/packages/agents/onboarding/src/scaffolder/templates/viewUiHandler.template +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/viewUiHandler.ts @@ -9,9 +9,9 @@ // 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 +// (discoverPort("__agentName__", "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 +// action returns openLocalView=true. Set __PORT_ENV__ to pin the view // to a fixed port when debugging. import { @@ -28,16 +28,16 @@ import { } 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"; +import { __AgentName__Actions } from "./__agentName__Schema.js"; -type {{PASCAL_NAME}}AgentContext = { +type __AgentName__AgentContext = { server?: Server; port?: number; portRegistration?: { release: () => void }; }; function getViewBindPort(): number { - const v = process.env["{{PORT_ENV}}"]; + const v = process.env["__PORT_ENV__"]; if (!v) return 0; const n = parseInt(v, 10); return Number.isFinite(n) && n >= 0 ? n : 0; @@ -52,7 +52,7 @@ export function instantiate(): AppAgent { }; } -async function initializeAgentContext(): Promise<{{PASCAL_NAME}}AgentContext> { +async function initializeAgentContext(): Promise<__AgentName__AgentContext> { return {}; } @@ -63,13 +63,15 @@ async function initializeAgentContext(): Promise<{{PASCAL_NAME}}AgentContext> { * callers see the problem instead of having it swallowed by a late * error handler. */ -function startViewServer(port: number): Promise<{ server: Server; port: number }> { +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}

`); + res.end(`

__AgentName__ view

Path: ${req.url}

`); }); let settled = false; const onError = (e: Error) => { @@ -85,12 +87,18 @@ function startViewServer(port: number): Promise<{ server: Server; port: number } const addr = server.address() as AddressInfo | null; if (!addr || typeof addr === "string") { server.close(); - reject(new Error("http server.address() did not return AddressInfo")); + 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 */ }); + server.on("error", () => { + /* TODO: log */ + }); resolve({ server, port: addr.port }); }; server.once("error", onError); @@ -101,7 +109,7 @@ function startViewServer(port: number): Promise<{ server: Server; port: number } async function updateAgentContext( enable: boolean, - context: SessionContext<{{PASCAL_NAME}}AgentContext>, + context: SessionContext<__AgentName__AgentContext>, _schemaName: string, ): Promise { const agentContext = context.agentContext; @@ -126,46 +134,46 @@ async function updateAgentContext( // retry sees a clean slate. agentContext.portRegistration?.release(); await new Promise((resolve) => server.close(() => resolve())); - agentContext.server = undefined; - agentContext.port = undefined; - agentContext.portRegistration = undefined; + delete agentContext.server; + delete agentContext.port; + delete agentContext.portRegistration; throw e; } } else { if (agentContext.server === undefined) return; agentContext.portRegistration?.release(); - agentContext.portRegistration = undefined; + delete agentContext.portRegistration; const server = agentContext.server; - agentContext.server = undefined; - agentContext.port = undefined; + delete agentContext.server; + delete agentContext.port; // 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 + // 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>, + context: SessionContext<__AgentName__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; + delete agentContext.portRegistration; if (agentContext.server) { const server = agentContext.server; - agentContext.server = undefined; - agentContext.port = undefined; + delete agentContext.server; + delete agentContext.port; await new Promise((resolve) => server.close(() => resolve())); } } async function executeAction( - action: TypeAgentAction<{{PASCAL_NAME}}Actions>, - context: ActionContext<{{PASCAL_NAME}}AgentContext>, + action: TypeAgentAction<__AgentName__Actions>, + context: ActionContext<__AgentName__AgentContext>, ): Promise { const port = context.sessionContext.agentContext.port; // Returning an ActivityContext with openLocalView=true signals the @@ -175,9 +183,9 @@ async function executeAction( const activityContext: ActivityContext | undefined = port !== undefined ? { - appAgentName: "{{NAME}}", + appAgentName: "__agentName__", activityName: action.actionName, - description: `{{PASCAL_NAME}}: ${action.actionName}`, + description: `__AgentName__: ${action.actionName}`, state: {}, openLocalView: true, } @@ -189,8 +197,9 @@ async function executeAction( // 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; + ( + result as unknown as { activityContext: ActivityContext } + ).activityContext = activityContext; } return result; } diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/websocketBridgeHandler.template b/ts/packages/agents/onboarding/src/scaffolder/templates/websocketBridgeHandler.ts similarity index 79% rename from ts/packages/agents/onboarding/src/scaffolder/templates/websocketBridgeHandler.template rename to ts/packages/agents/onboarding/src/scaffolder/templates/websocketBridgeHandler.ts index 83b88467a7..b040f39395 100644 --- a/ts/packages/agents/onboarding/src/scaffolder/templates/websocketBridgeHandler.template +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/websocketBridgeHandler.ts @@ -9,13 +9,13 @@ // (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 +// (discoverPort("__agentName__", "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 +// sessionContextId; lookup("__agentName__", "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). @@ -30,10 +30,10 @@ import { 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"; +import { __AgentName__Actions } from "./__agentName__Schema.js"; function getBridgeBindPort(): number { - const v = process.env["{{PORT_ENV}}"]; + const v = process.env["__PORT_ENV__"]; if (!v) return 0; const n = parseInt(v, 10); return Number.isFinite(n) && n >= 0 ? n : 0; @@ -42,9 +42,14 @@ function getBridgeBindPort(): number { // ---- WebSocket bridge -------------------------------------------------- type BridgeRequest = { id: string; actionName: string; parameters: unknown }; -type BridgeResponse = { id: string; success: boolean; result?: unknown; error?: string }; +type BridgeResponse = { + id: string; + success: boolean; + result?: unknown; + error?: string; +}; -class {{PASCAL_NAME}}Bridge { +class __AgentName__Bridge { private clients = new Map(); private nextClientId = 0; private pending = new Map< @@ -55,7 +60,7 @@ class {{PASCAL_NAME}}Bridge { } >(); - // Construction is private — use {@link {{PASCAL_NAME}}Bridge.start} so + // Construction is private — use {@link __AgentName__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( @@ -67,7 +72,9 @@ class {{PASCAL_NAME}}Bridge { this.clients.set(id, ws); ws.on("message", (data) => { try { - const response = JSON.parse(data.toString()) as BridgeResponse; + const response = JSON.parse( + data.toString(), + ) as BridgeResponse; const entry = this.pending.get(response.id); if (entry) { this.pending.delete(response.id); @@ -90,7 +97,7 @@ class {{PASCAL_NAME}}Bridge { * (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> { + public static start(port: number = 0): Promise<__AgentName__Bridge> { return new Promise((resolve, reject) => { const server = new WebSocketServer({ port }); let settled = false; @@ -107,17 +114,21 @@ class {{PASCAL_NAME}}Bridge { const addr = server.address() as AddressInfo | null; if (!addr || typeof addr === "string") { server.close(); - reject(new Error("ws server.address() did not return AddressInfo")); + 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}`, + `[__agentName__Bridge] post-listen server error: ${err.message}`, ); }); - resolve(new {{PASCAL_NAME}}Bridge(server, addr.port)); + resolve(new __AgentName__Bridge(server, addr.port)); }; server.once("error", onError); server.once("listening", onListening); @@ -129,12 +140,12 @@ class {{PASCAL_NAME}}Bridge { * `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 + * 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.", + "__AgentName__Bridge closed before response was received.", ); for (const entry of this.pending.values()) { entry.reject(closedError); @@ -154,21 +165,33 @@ class {{PASCAL_NAME}}Bridge { return false; } - public async send(actionName: string, parameters: unknown): Promise { + 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 (c.readyState === WebSocket.OPEN) { + target = c; + break; + } } if (!target) { - throw new Error("No host plugin connected to the {{NAME}} bridge."); + throw new Error( + "No host plugin connected to the __agentName__ 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), + JSON.stringify({ + id, + actionName, + parameters, + } satisfies BridgeRequest), ); }); } @@ -182,14 +205,14 @@ class {{PASCAL_NAME}}Bridge { // 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 sharedBridge: __AgentName__Bridge | undefined; +let sharedStartingPromise: Promise<__AgentName__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> { +async function ensureSharedBridge(): Promise<__AgentName__Bridge> { if (sharedClosingPromise !== undefined) { await sharedClosingPromise; } @@ -197,7 +220,7 @@ async function ensureSharedBridge(): Promise<{{PASCAL_NAME}}Bridge> { if (sharedStartingPromise !== undefined) return sharedStartingPromise; sharedStartingPromise = (async () => { try { - sharedBridge = await {{PASCAL_NAME}}Bridge.start(getBridgeBindPort()); + sharedBridge = await __AgentName__Bridge.start(getBridgeBindPort()); return sharedBridge; } finally { sharedStartingPromise = undefined; @@ -208,7 +231,7 @@ async function ensureSharedBridge(): Promise<{{PASCAL_NAME}}Bridge> { // ---- Agent lifecycle --------------------------------------------------- -type {{PASCAL_NAME}}Context = { +type __AgentName__Context = { enabledSchemas: Set; portRegistration?: { release: () => void }; }; @@ -222,7 +245,7 @@ export function instantiate(): AppAgent { }; } -async function initializeAgentContext(): Promise<{{PASCAL_NAME}}Context> { +async function initializeAgentContext(): Promise<__AgentName__Context> { return { enabledSchemas: new Set() }; } @@ -235,7 +258,7 @@ async function initializeAgentContext(): Promise<{{PASCAL_NAME}}Context> { * will see an empty `enabledSchemas` and no-op. */ async function closeAgentContext( - context: SessionContext<{{PASCAL_NAME}}Context>, + context: SessionContext<__AgentName__Context>, ): Promise { const ctx = context.agentContext; const wasActive = ctx.enabledSchemas.size > 0; @@ -256,7 +279,7 @@ async function closeAgentContext( async function updateAgentContext( enable: boolean, - context: SessionContext<{{PASCAL_NAME}}Context>, + context: SessionContext<__AgentName__Context>, schemaName: string, ): Promise { const ctx = context.agentContext; @@ -268,7 +291,7 @@ async function updateAgentContext( const bridge = await ensureSharedBridge(); if (isFirstForSession) { // Per-session registration: the registrar allows multiple - // entries for ("{{NAME}}", "default") across sessions and + // entries for ("__agentName__", "default") across sessions and // lookup returns the most recent, so each active session // independently keeps the shared port discoverable. ctx.portRegistration = context.registerPort( @@ -309,17 +332,22 @@ async function updateAgentContext( } async function executeAction( - action: TypeAgentAction<{{PASCAL_NAME}}Actions>, - _context: ActionContext<{{PASCAL_NAME}}Context>, + action: TypeAgentAction<__AgentName__Actions>, + _context: ActionContext<__AgentName__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.", + error: "Host plugin not connected to the __agentName__ 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)); + 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.ts similarity index 86% rename from ts/packages/agents/onboarding/src/scaffolder/templates/websocketBridgeTemplate.template rename to ts/packages/agents/onboarding/src/scaffolder/templates/websocketBridgeTemplate.ts index b92b61ebbc..d6b6c47794 100644 --- a/ts/packages/agents/onboarding/src/scaffolder/templates/websocketBridgeTemplate.template +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/websocketBridgeTemplate.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -// WebSocket bridge for {{NAME}}. +// WebSocket bridge for __agentName__. // Manages a WebSocket connection to the host application plugin. // Pattern matches the Excel/VS Code agent bridge implementations. // @@ -29,7 +29,7 @@ type BridgeResponse = { error?: string; }; -export class {{PASCAL_NAME}}Bridge { +export class __AgentName__Bridge { private clients = new Map(); private nextClientId = 0; private pending = new Map< @@ -40,7 +40,7 @@ export class {{PASCAL_NAME}}Bridge { } >(); - // Construction is private — use {@link {{PASCAL_NAME}}Bridge.start} so + // Construction is private — use {@link __AgentName__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( @@ -52,7 +52,9 @@ export class {{PASCAL_NAME}}Bridge { this.clients.set(id, ws); ws.on("message", (data) => { try { - const response = JSON.parse(data.toString()) as BridgeResponse; + const response = JSON.parse( + data.toString(), + ) as BridgeResponse; const entry = this.pending.get(response.id); if (entry) { this.pending.delete(response.id); @@ -76,7 +78,7 @@ export class {{PASCAL_NAME}}Bridge { * the problem instead of having it swallowed by a late error * handler. */ - public static start(port: number = 0): Promise<{{PASCAL_NAME}}Bridge> { + public static start(port: number = 0): Promise<__AgentName__Bridge> { return new Promise((resolve, reject) => { const server = new WebSocketServer({ port }); let settled = false; @@ -104,10 +106,10 @@ export class {{PASCAL_NAME}}Bridge { // errors are surfaced rather than crashing the process. server.on("error", (err) => { console.error( - `[{{NAME}}Bridge] post-listen server error: ${err.message}`, + `[__agentName__Bridge] post-listen server error: ${err.message}`, ); }); - resolve(new {{PASCAL_NAME}}Bridge(server, addr.port)); + resolve(new __AgentName__Bridge(server, addr.port)); }; server.once("error", onError); server.once("listening", onListening); @@ -123,7 +125,7 @@ export class {{PASCAL_NAME}}Bridge { */ public close(): Promise { const closedError = new Error( - "{{PASCAL_NAME}}Bridge closed before response was received.", + "__AgentName__Bridge closed before response was received.", ); for (const entry of this.pending.values()) { entry.reject(closedError); @@ -133,9 +135,7 @@ export class {{PASCAL_NAME}}Bridge { if (c.readyState === WebSocket.OPEN) c.close(); } this.clients.clear(); - return new Promise((resolve) => - this.server.close(() => resolve()), - ); + return new Promise((resolve) => this.server.close(() => resolve())); } public get connected(): boolean { @@ -160,13 +160,17 @@ export class {{PASCAL_NAME}}Bridge { } } if (!target) { - throw new Error("No client connected to the {{NAME}} bridge."); + throw new Error("No client connected to the __agentName__ 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), + JSON.stringify({ + id, + actionName, + parameters, + } satisfies BridgeCommand), ); }); } diff --git a/ts/packages/agents/onboarding/src/tsconfig.json b/ts/packages/agents/onboarding/src/tsconfig.json index 85efcd566d..add5e087d7 100644 --- a/ts/packages/agents/onboarding/src/tsconfig.json +++ b/ts/packages/agents/onboarding/src/tsconfig.json @@ -6,6 +6,8 @@ "outDir": "../dist" }, "include": ["./**/*"], + "exclude": ["./scaffolder/templates/**/*"], + "references": [{ "path": "./scaffolder/templates" }], "ts-node": { "esm": true } diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index 732f7ef20e..d80cb566b6 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -155,7 +155,7 @@ importers: version: 8.18.1 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@22.15.18)(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.4.5)) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -577,7 +577,7 @@ importers: version: 24.37.5(typescript@5.4.5) ts-node: specifier: ^10.9.1 - version: 10.9.2(@types/node@22.19.19)(typescript@5.4.5) + version: 10.9.2(@types/node@25.9.1)(typescript@5.4.5) xml2js: specifier: ^0.6.2 version: 0.6.2 @@ -906,7 +906,7 @@ importers: version: 12.0.2(webpack@5.105.0) jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.4.5)) + version: 29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.4.5)) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -2642,6 +2642,12 @@ importers: '@types/debug': specifier: ^4.1.12 version: 4.1.12 + '@types/node': + specifier: ^22.0.0 + version: 22.19.19 + '@types/ws': + specifier: ^8.5.10 + version: 8.18.1 action-grammar-compiler: specifier: workspace:* version: link:../../actionGrammarCompiler @@ -2660,6 +2666,9 @@ importers: typescript: specifier: ~5.4.5 version: 5.4.5 + ws: + specifier: ^8.18.0 + version: 8.20.1 packages/agents/osNotifications: dependencies: @@ -3678,7 +3687,7 @@ importers: version: link:../telemetry ts-node: specifier: ^10.9.1 - version: 10.9.2(@types/node@22.19.19)(typescript@5.4.5) + version: 10.9.2(@types/node@25.9.1)(typescript@5.4.5) typechat-utils: specifier: workspace:* version: link:../utils/typechatUtils @@ -3697,7 +3706,7 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.4.5)) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -3706,7 +3715,7 @@ importers: version: 6.0.1 ts-jest: specifier: ^29.4.9 - version: 29.4.9(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.4.5)))(typescript@5.4.5) + version: 29.4.9(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.4.5)))(typescript@5.4.5) typescript: specifier: ~5.4.5 version: 5.4.5 @@ -5250,7 +5259,7 @@ importers: devDependencies: '@electron-toolkit/tsconfig': specifier: ^1.0.1 - version: 1.0.1(@types/node@22.19.19) + version: 1.0.1(@types/node@25.9.1) '@fontsource/lato': specifier: ^5.2.5 version: 5.2.5 @@ -5286,10 +5295,10 @@ importers: version: 26.8.1(dmg-builder@26.8.1) electron-vite: specifier: ^4.0.1 - version: 4.0.1(vite@6.4.2(@types/node@22.19.19)(jiti@2.5.1)(less@4.3.0)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.0.1(vite@6.4.2(@types/node@25.9.1)(jiti@2.5.1)(less@4.3.0)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.3)) jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.4.5)) less: specifier: ^4.2.0 version: 4.3.0 @@ -5307,7 +5316,7 @@ importers: version: 5.4.5 vite: specifier: ^6.4.2 - version: 6.4.2(@types/node@22.19.19)(jiti@2.5.1)(less@4.3.0)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.3) + version: 6.4.2(@types/node@25.9.1)(jiti@2.5.1)(less@4.3.0)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.3) packages/telemetry: dependencies: @@ -16514,7 +16523,7 @@ snapshots: '@anthropic-ai/claude-agent-sdk@0.2.105': dependencies: - '@anthropic-ai/sdk': 0.81.0(zod@3.25.76) + '@anthropic-ai/sdk': 0.81.0(zod@4.1.13) '@modelcontextprotocol/sdk': 1.29.0 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 @@ -18114,9 +18123,9 @@ snapshots: dependencies: electron: 40.8.5 - '@electron-toolkit/tsconfig@1.0.1(@types/node@22.19.19)': + '@electron-toolkit/tsconfig@1.0.1(@types/node@25.9.1)': dependencies: - '@types/node': 22.19.19 + '@types/node': 25.9.1 '@electron/asar@3.4.1': dependencies: @@ -18954,14 +18963,14 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 22.15.18 + '@types/node': 22.19.19 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@22.15.18)(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.4.5)) + jest-config: 29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.4.5)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -18989,14 +18998,14 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 22.15.18 + '@types/node': 22.19.19 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@22.15.18)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.4.5)) + jest-config: 29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.4.5)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -19024,14 +19033,14 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 22.15.18 + '@types/node': 22.19.19 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@22.15.18)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.4.5)) + jest-config: 29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.4.5)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -19056,7 +19065,7 @@ snapshots: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 22.15.18 + '@types/node': 22.19.19 jest-mock: 29.7.0 '@jest/expect-utils@29.7.0': @@ -19074,7 +19083,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 22.15.18 + '@types/node': 22.19.19 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -19166,7 +19175,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.3 - '@types/node': 22.15.18 + '@types/node': 22.19.19 '@types/yargs': 17.0.29 chalk: 4.1.2 @@ -19855,7 +19864,7 @@ snapshots: json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod-to-json-schema: 3.25.1(zod@3.25.76) + zod-to-json-schema: 3.25.1(zod@4.1.13) transitivePeerDependencies: - supports-color @@ -21110,16 +21119,16 @@ snapshots: '@types/better-sqlite3@7.6.11': dependencies: - '@types/node': 22.15.18 + '@types/node': 22.19.19 '@types/better-sqlite3@7.6.13': dependencies: - '@types/node': 22.15.18 + '@types/node': 22.19.19 '@types/body-parser@1.19.5': dependencies: '@types/connect': 3.4.38 - '@types/node': 22.15.18 + '@types/node': 22.19.19 '@types/bonjour@3.5.13': dependencies: @@ -21174,7 +21183,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 22.15.18 + '@types/node': 22.19.19 '@types/content-disposition@0.5.9': {} @@ -21189,7 +21198,7 @@ snapshots: '@types/cors@2.8.18': dependencies: - '@types/node': 22.15.18 + '@types/node': 22.19.19 '@types/cytoscape-dagre@2.3.3': dependencies: @@ -21338,7 +21347,7 @@ snapshots: '@types/express-serve-static-core@4.17.41': dependencies: - '@types/node': 22.15.18 + '@types/node': 22.19.19 '@types/qs': 6.15.0 '@types/range-parser': 1.2.7 '@types/send': 0.17.4 @@ -21388,11 +21397,11 @@ snapshots: '@types/fs-extra@11.0.4': dependencies: '@types/jsonfile': 6.1.4 - '@types/node': 22.15.18 + '@types/node': 22.19.19 '@types/fs-extra@8.1.5': dependencies: - '@types/node': 22.15.18 + '@types/node': 22.19.19 '@types/fs-extra@9.0.13': dependencies: @@ -21454,13 +21463,13 @@ snapshots: '@types/jsdom@20.0.1': dependencies: - '@types/node': 22.15.18 + '@types/node': 22.19.19 '@types/tough-cookie': 4.0.5 parse5: 7.3.0 '@types/jsdom@28.0.0': dependencies: - '@types/node': 22.15.18 + '@types/node': 22.19.19 '@types/tough-cookie': 4.0.5 parse5: 7.3.0 undici-types: 7.24.4 @@ -21469,7 +21478,7 @@ snapshots: '@types/jsonfile@6.1.4': dependencies: - '@types/node': 22.15.18 + '@types/node': 22.19.19 '@types/jsonpath@0.2.4': {} @@ -21514,7 +21523,7 @@ snapshots: '@types/mailparser@3.4.6': dependencies: - '@types/node': 22.15.18 + '@types/node': 22.19.19 iconv-lite: 0.6.3 '@types/markdown-it@14.1.2': @@ -21542,7 +21551,7 @@ snapshots: '@types/node-fetch@2.6.12': dependencies: - '@types/node': 22.15.18 + '@types/node': 22.19.19 form-data: 4.0.4 '@types/node@18.19.130': @@ -21568,7 +21577,6 @@ snapshots: '@types/node@25.9.1': dependencies: undici-types: 7.24.6 - optional: true '@types/normalize-package-data@2.4.4': {} @@ -21639,7 +21647,7 @@ snapshots: dependencies: '@types/http-errors': 2.0.4 '@types/mime': 3.0.4 - '@types/node': 22.15.18 + '@types/node': 22.19.19 '@types/shimmer@1.2.0': {} @@ -21695,11 +21703,11 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 22.15.18 + '@types/node': 22.19.19 '@types/xml2js@0.4.14': dependencies: - '@types/node': 22.15.18 + '@types/node': 22.19.19 '@types/yargs-parser@21.0.2': {} @@ -24004,7 +24012,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron-vite@4.0.1(vite@6.4.2(@types/node@22.19.19)(jiti@2.5.1)(less@4.3.0)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.3)): + electron-vite@4.0.1(vite@6.4.2(@types/node@25.9.1)(jiti@2.5.1)(less@4.3.0)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@babel/core': 7.28.4 '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.28.4) @@ -24012,7 +24020,7 @@ snapshots: esbuild: 0.25.11 magic-string: 0.30.17 picocolors: 1.1.1 - vite: 6.4.2(@types/node@22.19.19)(jiti@2.5.1)(less@4.3.0)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.3) + vite: 6.4.2(@types/node@25.9.1)(jiti@2.5.1)(less@4.3.0)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -25894,7 +25902,7 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@22.15.18)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.4.5)): + jest-config@29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.4.5)): dependencies: '@babel/core': 7.28.4 '@jest/test-sequencer': 29.7.0 @@ -25919,13 +25927,13 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - '@types/node': 22.15.18 - ts-node: 10.9.2(@types/node@22.19.19)(typescript@5.4.5) + '@types/node': 22.19.19 + ts-node: 10.9.2(@types/node@22.15.18)(typescript@5.4.5) transitivePeerDependencies: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@22.15.18)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.4.5)): + jest-config@29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.4.5)): dependencies: '@babel/core': 7.28.4 '@jest/test-sequencer': 29.7.0 @@ -25950,13 +25958,13 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - '@types/node': 22.15.18 - ts-node: 10.9.2(@types/node@25.9.1)(typescript@5.4.5) + '@types/node': 22.19.19 + ts-node: 10.9.2(@types/node@22.19.19)(typescript@5.4.5) transitivePeerDependencies: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.4.5)): + jest-config@29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.4.5)): dependencies: '@babel/core': 7.28.4 '@jest/test-sequencer': 29.7.0 @@ -25982,7 +25990,7 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 22.19.19 - ts-node: 10.9.2(@types/node@22.19.19)(typescript@5.4.5) + ts-node: 10.9.2(@types/node@25.9.1)(typescript@5.4.5) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -26043,7 +26051,7 @@ snapshots: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 '@types/jsdom': 20.0.1 - '@types/node': 22.15.18 + '@types/node': 22.19.19 jest-mock: 29.7.0 jest-util: 29.7.0 jsdom: 20.0.3 @@ -26106,7 +26114,7 @@ snapshots: jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 22.15.18 + '@types/node': 22.19.19 jest-util: 29.7.0 jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): @@ -26215,7 +26223,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 22.15.18 + '@types/node': 22.19.19 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -29693,12 +29701,12 @@ snapshots: '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.28.4) - ts-jest@29.4.9(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.4.5)))(typescript@5.4.5): + ts-jest@29.4.9(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.4.5)))(typescript@5.4.5): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 handlebars: 4.7.9 - jest: 29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.4.5)) + jest: 29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.4.5)) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 @@ -29769,6 +29777,7 @@ snapshots: typescript: 5.4.5 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + optional: true ts-node@10.9.2(@types/node@25.9.1)(typescript@5.4.5): dependencies: @@ -29787,7 +29796,6 @@ snapshots: typescript: 5.4.5 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - optional: true tslib@1.14.1: {} @@ -29929,8 +29937,7 @@ snapshots: undici-types@7.24.4: {} - undici-types@7.24.6: - optional: true + undici-types@7.24.6: {} undici@6.25.0: {} @@ -30108,23 +30115,6 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 - vite@6.4.2(@types/node@22.19.19)(jiti@2.5.1)(less@4.3.0)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.3): - dependencies: - esbuild: 0.25.11 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.14 - rollup: 4.59.0 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 22.19.19 - fsevents: 2.3.3 - jiti: 2.5.1 - less: 4.3.0 - terser: 5.39.2 - tsx: 4.21.0 - yaml: 2.8.3 - vite@6.4.2(@types/node@25.9.1)(jiti@2.5.1)(less@4.3.0)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.25.11 From cfc4bd7cd29cd652077aea9b8de9f509c8ca5185 Mon Sep 17 00:00:00 2001 From: Tal Zaccai Date: Tue, 2 Jun 2026 20:40:11 -0700 Subject: [PATCH 6/8] fix(onboarding): escape req.url in view-UI placeholder to prevent reflected XSS Addresses CodeQL alert #309 on the view-UI scaffolded template. The placeholder handler echoed eq.url into HTML unescaped, which would generate XSS-vulnerable code for any user who scaffolded a view-UI agent from this template. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/scaffolder/templates/viewUiHandler.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/viewUiHandler.ts b/ts/packages/agents/onboarding/src/scaffolder/templates/viewUiHandler.ts index 52b30123ad..d77e4ab0dd 100644 --- a/ts/packages/agents/onboarding/src/scaffolder/templates/viewUiHandler.ts +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/viewUiHandler.ts @@ -71,7 +71,20 @@ function startViewServer( // 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(`

__AgentName__ view

Path: ${req.url}

`); + // Escape req.url before echoing it into HTML — it is attacker- + // controlled and would otherwise be a reflected XSS sink. + const safePath = String(req.url ?? "/").replace( + /[&<>"']/g, + (c) => + ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[c] as string, + ); + res.end(`

__AgentName__ view

Path: ${safePath}

`); }); let settled = false; const onError = (e: Error) => { From 91e7a43f1808937e4c05cdaa942f2d00a8215c9f Mon Sep 17 00:00:00 2001 From: Tal Zaccai Date: Tue, 2 Jun 2026 23:14:20 -0700 Subject: [PATCH 7/8] fix(onboarding): serialize websocket-bridge enable/disable per session The bridge template's updateAgentContext mutated enabledSchemas before awaiting ensureSharedBridge, so two concurrent enables for the same session could interleave: the second saw size>0 and skipped registration, then the first failed and rolled back, leaving the session 'enabled' with no portRegistration. A later disable would then decrement sharedRefCount this session never incremented, tearing down the bridge for any other session that still depended on it. Fix: wrap updateAgentContext + closeAgentContext bodies in a per-session async lock (withSessionLock) and use ctx.portRegistration !== undefined as the sole source of truth for 'this session has incremented sharedRefCount'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../templates/websocketBridgeHandler.ts | 122 +++++++++++------- 1 file changed, 76 insertions(+), 46 deletions(-) diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/websocketBridgeHandler.ts b/ts/packages/agents/onboarding/src/scaffolder/templates/websocketBridgeHandler.ts index b040f39395..08d3f13fc0 100644 --- a/ts/packages/agents/onboarding/src/scaffolder/templates/websocketBridgeHandler.ts +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/websocketBridgeHandler.ts @@ -234,6 +234,16 @@ async function ensureSharedBridge(): Promise<__AgentName__Bridge> { type __AgentName__Context = { enabledSchemas: Set; portRegistration?: { release: () => void }; + // Serializes concurrent updateAgentContext / closeAgentContext calls + // for this session so the (mutate set, await ensureSharedBridge, + // register port, bump refcount) sequence is atomic. Without this, + // an interleaved second enable could observe a non-empty set before + // the first call registers, skip registration itself, and then the + // first call rolling back on failure would leave the session + // "enabled" with no registration — and a later disable would + // decrement sharedRefCount it never incremented, tearing down the + // bridge another session still depends on. + pending?: Promise; }; export function instantiate(): AppAgent { @@ -249,6 +259,24 @@ async function initializeAgentContext(): Promise<__AgentName__Context> { return { enabledSchemas: new Set() }; } +// Chain `fn` after any in-flight operation for this session. Prior +// failures don't poison the chain (we swallow them when waiting), but +// the caller of `fn` still sees its own thrown error. +async function withSessionLock( + ctx: __AgentName__Context, + fn: () => Promise, +): Promise { + const prev = ctx.pending ?? Promise.resolve(); + let release!: () => void; + ctx.pending = new Promise((r) => (release = r)); + try { + await prev.catch(() => {}); + return await fn(); + } finally { + release(); + } +} + /** * Backstop cleanup invoked by the dispatcher when a session closes * without an explicit per-schema disable (crash, client disconnect, @@ -261,20 +289,22 @@ async function closeAgentContext( context: SessionContext<__AgentName__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; - } + await withSessionLock(ctx, async () => { + const hadRegistration = ctx.portRegistration !== undefined; + ctx.enabledSchemas.clear(); + ctx.portRegistration?.release(); + delete ctx.portRegistration; + if (!hadRegistration) 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( @@ -283,13 +313,16 @@ async function updateAgentContext( 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 { + await withSessionLock(ctx, async () => { + if (enable) { + if (ctx.enabledSchemas.has(schemaName)) return; const bridge = await ensureSharedBridge(); - if (isFirstForSession) { + // Register + bump refcount only on the first schema for this + // session. `ctx.portRegistration` (not set size) is the source + // of truth for "this session has incremented sharedRefCount", + // so a later disable / closeAgentContext won't double-decrement + // even if a prior enable failed mid-way. + if (ctx.portRegistration === undefined) { // Per-session registration: the registrar allows multiple // entries for ("__agentName__", "default") across sessions and // lookup returns the most recent, so each active session @@ -300,35 +333,32 @@ async function updateAgentContext( ); 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.add(schemaName); + } else { + if (!ctx.enabledSchemas.has(schemaName)) return; 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; + if ( + ctx.enabledSchemas.size === 0 && + ctx.portRegistration !== undefined + ) { + // 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( From 489eda2359960b136919b12e48b0ffb7487b5b80 Mon Sep 17 00:00:00 2001 From: Tal Zaccai Date: Tue, 2 Jun 2026 23:45:48 -0700 Subject: [PATCH 8/8] fix(onboarding): address Copilot review on scaffolder templates - websocketBridgeHandler/Template: reject pending requests when the last client disconnects (so callers don't hang) and surface ws.send errors via the send callback (so a socket-close race between readyState check and send no longer leaks pending entries). - viewUiHandler: log post-listen HTTP server errors via console.error instead of swallowing them as a TODO. - templateLoader: drop stale references to a __placeholders__.d.ts stub that no longer exists (the schema stub __agentName__Schema.ts is the only reserved template name). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/scaffolder/templateLoader.ts | 11 +++---- .../src/scaffolder/templates/viewUiHandler.ts | 6 ++-- .../templates/websocketBridgeHandler.ts | 29 +++++++++++++++++-- .../templates/websocketBridgeTemplate.ts | 29 +++++++++++++++++-- 4 files changed, 62 insertions(+), 13 deletions(-) diff --git a/ts/packages/agents/onboarding/src/scaffolder/templateLoader.ts b/ts/packages/agents/onboarding/src/scaffolder/templateLoader.ts index a23cad310c..5567452d1d 100644 --- a/ts/packages/agents/onboarding/src/scaffolder/templateLoader.ts +++ b/ts/packages/agents/onboarding/src/scaffolder/templateLoader.ts @@ -5,9 +5,9 @@ // // Templates live in `src/scaffolder/templates/*.ts` so a reviewer can // read them as plain TypeScript (with syntax highlighting and -// `tsc`-level verification — see `__placeholders__.d.ts` and -// `__agentName__Schema.ts` for the type-check stubs) instead of wading -// through 200-line template literals inside scaffolderHandler.ts. +// `tsc`-level verification — see `__agentName__Schema.ts` for the +// type-check stub) instead of wading through 200-line template +// literals inside scaffolderHandler.ts. // // Placeholders use the `__TOKEN__` convention — chosen so the templates // remain valid TypeScript identifiers (`class __AgentName__Bridge {}`, @@ -37,10 +37,7 @@ function templatePath(filename: string): string { // Files in the templates directory that are type-check scaffolding only // (stubs for placeholder identifiers) and must never be loaded as a // template at scaffold time. -const RESERVED_TEMPLATE_NAMES = new Set([ - "__agentName__Schema.ts", - "__placeholders__.d.ts", -]); +const RESERVED_TEMPLATE_NAMES = new Set(["__agentName__Schema.ts"]); /** * Load `filename` from `src/scaffolder/templates/` and substitute every diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/viewUiHandler.ts b/ts/packages/agents/onboarding/src/scaffolder/templates/viewUiHandler.ts index d77e4ab0dd..93a6229a15 100644 --- a/ts/packages/agents/onboarding/src/scaffolder/templates/viewUiHandler.ts +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/viewUiHandler.ts @@ -109,8 +109,10 @@ function startViewServer( } // Re-attach a permanent error handler so post-listen errors // are logged rather than crashing the process. - server.on("error", () => { - /* TODO: log */ + server.on("error", (err) => { + console.error( + `[__agentName__ view] post-listen http server error: ${err.message}`, + ); }); resolve({ server, port: addr.port }); }; diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/websocketBridgeHandler.ts b/ts/packages/agents/onboarding/src/scaffolder/templates/websocketBridgeHandler.ts index 08d3f13fc0..18b8a87a5f 100644 --- a/ts/packages/agents/onboarding/src/scaffolder/templates/websocketBridgeHandler.ts +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/websocketBridgeHandler.ts @@ -85,8 +85,23 @@ class __AgentName__Bridge { // Ignore malformed payloads. } }); - ws.on("close", () => this.clients.delete(id)); - ws.on("error", () => this.clients.delete(id)); + // Reject any pending requests routed through the bridge when + // the last connected client drops; without this, in-flight + // callers hang until the bridge is closed. + const onDisconnect = () => { + this.clients.delete(id); + if (this.clients.size === 0 && this.pending.size > 0) { + const err = new Error( + "Host plugin disconnected before responding.", + ); + for (const entry of this.pending.values()) { + entry.reject(err); + } + this.pending.clear(); + } + }; + ws.on("close", onDisconnect); + ws.on("error", onDisconnect); }); } @@ -192,6 +207,16 @@ class __AgentName__Bridge { actionName, parameters, } satisfies BridgeRequest), + (err) => { + // ws.send errors surface here; without this, a send + // failure (socket closed between readyState check and + // send) would leak the pending entry and hang the + // caller. + if (err) { + this.pending.delete(id); + reject(err); + } + }, ); }); } diff --git a/ts/packages/agents/onboarding/src/scaffolder/templates/websocketBridgeTemplate.ts b/ts/packages/agents/onboarding/src/scaffolder/templates/websocketBridgeTemplate.ts index d6b6c47794..7c34cc1931 100644 --- a/ts/packages/agents/onboarding/src/scaffolder/templates/websocketBridgeTemplate.ts +++ b/ts/packages/agents/onboarding/src/scaffolder/templates/websocketBridgeTemplate.ts @@ -65,8 +65,23 @@ export class __AgentName__Bridge { // Ignore malformed payloads. } }); - ws.on("close", () => this.clients.delete(id)); - ws.on("error", () => this.clients.delete(id)); + // Reject any pending commands routed through the bridge when + // the last connected client drops; without this, in-flight + // callers hang until the bridge is closed. + const onDisconnect = () => { + this.clients.delete(id); + if (this.clients.size === 0 && this.pending.size > 0) { + const err = new Error( + "Host plugin disconnected before responding.", + ); + for (const entry of this.pending.values()) { + entry.reject(err); + } + this.pending.clear(); + } + }; + ws.on("close", onDisconnect); + ws.on("error", onDisconnect); }); } @@ -171,6 +186,16 @@ export class __AgentName__Bridge { actionName, parameters, } satisfies BridgeCommand), + (err) => { + // ws.send errors surface here; without this, a send + // failure (socket closed between readyState check and + // send) would leak the pending entry and hang the + // caller. + if (err) { + this.pending.delete(id); + reject(err); + } + }, ); }); }