From 5ca3e076756cec5022ea96445f9ead7f002d1795 Mon Sep 17 00:00:00 2001 From: George Ng Date: Mon, 13 Apr 2026 17:08:26 -0700 Subject: [PATCH 1/4] Implement requestInteraction for askYesNo questions with multiple clients. --- ts/packages/cli/src/enhancedConsole.ts | 176 ++++++++++++++++++++++--- 1 file changed, 159 insertions(+), 17 deletions(-) diff --git a/ts/packages/cli/src/enhancedConsole.ts b/ts/packages/cli/src/enhancedConsole.ts index 9815755dc..c47aed6b1 100644 --- a/ts/packages/cli/src/enhancedConsole.ts +++ b/ts/packages/cli/src/enhancedConsole.ts @@ -25,6 +25,8 @@ import type { Dispatcher, IAgentMessage, TemplateEditConfig, + PendingInteractionRequest, + PendingInteractionResponse, } from "agent-dispatcher"; import chalk from "chalk"; import fs from "fs"; @@ -858,9 +860,116 @@ export function createEnhancedClientIO( } })(); }, - // Async deferred pattern stubs (used by server, no-op in CLI) - requestInteraction(): void { - // CLI does not support deferred interactions + // Async deferred pattern — handle interactions pushed from the server + requestInteraction(interaction: PendingInteractionRequest): void { + (async () => { + if (!dispatcherRef?.current) { + return; + } + const dispatcher = dispatcherRef.current; + let response: PendingInteractionResponse; + + if (interaction.type === "askYesNo") { + const wasSpinning = currentSpinner?.isActive(); + if (wasSpinning) { + currentSpinner!.stop(); + } + + const width = process.stdout.columns || 80; + const line = ANSI.dim + "─".repeat(width) + ANSI.reset; + const defaultHint = + interaction.defaultValue === undefined + ? "" + : interaction.defaultValue + ? " (default: yes)" + : " (default: no)"; + const prompt = `${chalk.cyan("?")} ${interaction.message}${chalk.dim(defaultHint)} ${chalk.dim("(y/n)")} `; + + process.stdout.write("\n" + line + "\n"); + const input = await question(prompt, rl); + process.stdout.write(line + "\n"); + + if (wasSpinning) { + currentSpinner = new EnhancedSpinner({ + text: "Processing...", + }); + currentSpinner.start(); + } + + let value: boolean; + if ( + input.toLowerCase() === "y" || + input.toLowerCase() === "yes" + ) { + value = true; + } else if ( + input.toLowerCase() === "n" || + input.toLowerCase() === "no" + ) { + value = false; + } else { + value = interaction.defaultValue ?? false; + } + response = { + interactionId: interaction.interactionId, + type: "askYesNo", + value, + }; + } else if (interaction.type === "popupQuestion") { + const wasSpinning = currentSpinner?.isActive(); + if (wasSpinning) { + currentSpinner!.stop(); + } + + const width = process.stdout.columns || 80; + const line = ANSI.dim + "─".repeat(width) + ANSI.reset; + process.stdout.write("\n" + line + "\n"); + process.stdout.write( + `${chalk.cyan("?")} ${interaction.message}\n`, + ); + interaction.choices.forEach((choice, i) => { + const isDefault = i === interaction.defaultId; + const prefix = chalk.cyan(`${i + 1}.`); + const suffix = isDefault ? chalk.dim(" (default)") : ""; + process.stdout.write( + ` ${prefix} ${choice}${suffix}\n`, + ); + }); + + const prompt = chalk.dim( + `Enter number (1-${interaction.choices.length}): `, + ); + const input = await question(prompt, rl); + process.stdout.write(line + "\n"); + + if (wasSpinning) { + currentSpinner = new EnhancedSpinner({ + text: "Processing...", + }); + currentSpinner.start(); + } + + const parsed = parseInt(input, 10) - 1; + const value = + parsed >= 0 && parsed < interaction.choices.length + ? parsed + : (interaction.defaultId ?? 0); + response = { + interactionId: interaction.interactionId, + type: "popupQuestion", + value, + }; + } else { + // proposeAction — not supported in CLI yet + return; + } + + try { + await dispatcher.respondToInteraction(response); + } catch { + // Interaction may have already timed out + } + })(); }, interactionResolved(): void { // CLI does not support deferred interactions @@ -1442,23 +1551,56 @@ async function question( message: string, rl?: readline.promises.Interface, ): Promise { - const closeOnExit = !rl; - if (!rl) { - rl = createInterface({ - input: process.stdin, - output: process.stdout, - terminal: true, - }); + if (rl) { + return rl.question(message); } - try { - // Let readline handle cursor positioning natively - return await rl.question(message); - } finally { - if (closeOnExit) { - rl.close(); + // No readline interface — stdin is owned by questionWithCompletion in raw + // mode. Read character-by-character directly so we don't conflict. + return new Promise((resolve) => { + const stdin = process.stdin; + process.stdout.write(message); + + let input = ""; + const wasRaw = stdin.isRaw; + if (stdin.isTTY) { + stdin.setRawMode(true); } - } + stdin.resume(); + stdin.setEncoding("utf8"); + + const onData = (data: string) => { + const code = data.charCodeAt(0); + if (data === "\r" || data === "\n") { + // Enter — commit + stdin.removeListener("data", onData); + if (stdin.isTTY) { + stdin.setRawMode(wasRaw || false); + } + process.stdout.write("\n"); + resolve(input); + } else if (code === 0x03) { + // Ctrl+C — treat as empty input + stdin.removeListener("data", onData); + if (stdin.isTTY) { + stdin.setRawMode(wasRaw || false); + } + process.stdout.write("\n"); + resolve(""); + } else if (code === 0x7f || code === 0x08) { + // Backspace + if (input.length > 0) { + input = input.slice(0, -1); + process.stdout.write("\b \b"); + } + } else if (code >= 32 && code < 127) { + input += data; + process.stdout.write(data); + } + }; + + stdin.on("data", onData); + }); } /** From e99416185b3acb26b5457d72960ffa2b587a4699 Mon Sep 17 00:00:00 2001 From: George Ng Date: Mon, 13 Apr 2026 21:04:31 -0700 Subject: [PATCH 2/4] Add tests and implement interactions on CLI --- .../docs/multi-client-interactions.md | 142 +++++ ts/packages/cli/jest.config.js | 29 + ts/packages/cli/package.json | 4 + ts/packages/cli/src/enhancedConsole.ts | 290 ++++++--- .../cli/test/multiClientInteraction.spec.ts | 370 +++++++++++ ts/packages/cli/test/temp.spec.ts | 0 ts/packages/cli/test/tsconfig.json | 17 + ts/packages/cli/tsconfig.json | 2 - ts/pnpm-lock.yaml | 577 +++++------------- 9 files changed, 910 insertions(+), 521 deletions(-) create mode 100644 ts/packages/agentServer/docs/multi-client-interactions.md create mode 100644 ts/packages/cli/jest.config.js create mode 100644 ts/packages/cli/test/multiClientInteraction.spec.ts create mode 100644 ts/packages/cli/test/temp.spec.ts create mode 100644 ts/packages/cli/test/tsconfig.json diff --git a/ts/packages/agentServer/docs/multi-client-interactions.md b/ts/packages/agentServer/docs/multi-client-interactions.md new file mode 100644 index 000000000..d90360b4a --- /dev/null +++ b/ts/packages/agentServer/docs/multi-client-interactions.md @@ -0,0 +1,142 @@ +--- +layout: docs +title: Multi-Client Interaction Protocol +--- + +## Overview + +When multiple clients are connected to the same session via the AgentServer, the dispatcher needs to present interactive prompts (yes/no confirmations, choice menus, template editing) to the user. Because any connected client could be the active one, these interactions follow a **broadcast-and-race** pattern: the server sends the prompt to all clients simultaneously, and the first client to respond wins. + +This document describes the protocol, the server-side machinery, and the responsibilities of each client implementation. + +## Server-Side: SharedDispatcher + +A `SharedDispatcher` is a single dispatcher instance shared among all clients connected to the same session. It owns a `PendingInteractionManager` — a map of in-flight interactions, each backed by a deferred promise that the dispatcher awaits to unblock execution. + +### Interaction lifecycle + +``` +Server dispatcher Client A Client B +───────────────── ──────── ──────── +askYesNo() called + create PendingInteraction + broadcast requestInteraction ──► show prompt show prompt + await promise... + + user answers "y" + respondToInteraction ──► server resolves promise + broadcast interactionResolved ──► dismiss prompt + promise resolves + execution continues +``` + +The same flow applies to `proposeAction` and `popupQuestion`. + +### Broadcast vs. targeted routing + +Most `ClientIO` calls are **targeted**: they carry a `requestId.connectionId` that identifies which client initiated the request, and the server routes the call only to that client. + +Interaction calls are **broadcast** to all clients, regardless of which client initiated the originating request. This is intentional: in a multi-client session the active user may be on any client. + +| ClientIO method | Routing | Notes | +| ---------------------- | --------------------------------- | ------------------------------------------------------------------ | +| `setDisplay` | Broadcast (filtered by requestId) | All clients see output | +| `askYesNo` | Deferred broadcast | Creates a `PendingInteraction`; broadcast via `requestInteraction` | +| `proposeAction` | Deferred broadcast | Same pattern | +| `popupQuestion` | Deferred broadcast | Same pattern | +| `requestInteraction` | Broadcast | Sent to all clients to show the prompt | +| `interactionResolved` | Broadcast | Sent to all clients after any one responds | +| `interactionCancelled` | Broadcast | Sent to all clients after `cancelInteraction` or timeout | + +### Timeouts + +Pending interactions have a 10-minute timeout (configurable per-type in `sharedDispatcher.ts`). On timeout the deferred promise rejects and `interactionCancelled` is broadcast to all clients. + +### Reconnects + +Each `join()` call mints a new ephemeral `connectionId`. On reconnect a client receives a fresh `connectionId`, so interactions created before the disconnect (whose `requestId.connectionId` names the old connection) are not re-broadcast to the new connection. A joining client receives any still-pending interactions via `JoinSessionResult.pendingInteractions` and is responsible for displaying them and potentially responding. + +## Client Responsibilities + +Every `ClientIO` implementation must handle three interaction-related methods. + +### `requestInteraction(interaction)` + +Called when the server needs the client to show a prompt. The client must: + +1. Display the appropriate UI for the interaction type (`askYesNo`, `popupQuestion`, or `proposeAction`). +2. Register the interaction locally (keyed by `interaction.interactionId`) so it can be dismissed later. +3. When the user responds, call `dispatcher.respondToInteraction(response)`. +4. Remove the local registration after responding or after being dismissed. + +Only one client needs to respond — the server ignores duplicate responses for the same `interactionId`. + +### `interactionResolved(interactionId, response)` + +Called on all clients after any client successfully calls `respondToInteraction`. The client must: + +1. Look up the active prompt by `interactionId`. +2. Cancel or dismiss the prompt without sending another response. +3. Optionally show a brief notice (e.g. `[answered by another client]`) so the user understands why the prompt disappeared. + +### `interactionCancelled(interactionId)` + +Called on all clients after `cancelInteraction` is called or the interaction times out. The client must: + +1. Look up the active prompt by `interactionId`. +2. Cancel or dismiss the prompt. +3. Optionally show a notice (e.g. `[interaction cancelled]`). + +## CLI Implementation Requirements + +The CLI (`enhancedConsole.ts`) owns stdin and renders prompts inline in the terminal. To support multiple clients it needs: + +### Active prompt registry + +A `Map void }>` keyed by `interactionId`, local to `createEnhancedClientIO`. Each entry holds a function that aborts the in-progress `question()` call for that interaction. + +```typescript +const activeInteractions = new Map void }>(); +``` + +### Cancellable `question()` races + +`requestInteraction` wraps the `question()` call in a `Promise.race` against a cancellation promise. The cancellation promise rejects when `cancel()` is called (by `interactionResolved` or `interactionCancelled`). + +``` +requestInteraction(interaction): + cancelled = false + cancelFn = () => { cancelled = true; rejectCancelPromise() } + activeInteractions.set(interaction.interactionId, { cancel: cancelFn }) + + try: + input = await Promise.race([question(prompt), cancelPromise]) + if (!cancelled): + build response + await dispatcher.respondToInteraction(response) + catch (CancelledError): + // dismissed — print notice if resolved by another client vs. cancelled + finally: + activeInteractions.delete(interaction.interactionId) +``` + +The `question()` function itself must also clean up its stdin listener when the outer race rejects, to avoid a leaked `data` listener on stdin. + +### `interactionResolved` and `interactionCancelled` + +```typescript +interactionResolved(interactionId, response): void { + activeInteractions.get(interactionId)?.cancel(); + // "resolved" variant — optionally print "[answered by another client]" +}, +interactionCancelled(interactionId): void { + activeInteractions.get(interactionId)?.cancel(); + // "cancelled" variant — optionally print "[interaction cancelled]" +}, +``` + +The cancel functions must distinguish resolved vs. cancelled so the client can print the appropriate notice. This can be done by passing a reason string to `cancel()`, or by using two separate rejection types. + +## Shell Implementation Notes + +The Shell (`main.ts`) is not yet implemented (stubs in place). The same pattern applies: hold a `Map` and call `dismissFn` from `interactionResolved`/`interactionCancelled`. The UI (modal dialog or inline card) should be dismissed programmatically and a toast shown if resolved by a remote client. diff --git a/ts/packages/cli/jest.config.js b/ts/packages/cli/jest.config.js new file mode 100644 index 000000000..c29f38db2 --- /dev/null +++ b/ts/packages/cli/jest.config.js @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @type {import("ts-jest").JestConfigWithTsJest} */ +export default { + preset: "ts-jest/presets/default-esm", + extensionsToTreatAsEsm: [".ts"], + testEnvironment: "node", + roots: ["/test/"], + testMatch: ["/test/**/*.spec.ts"], + transform: { + "^.+\\.ts$": [ + "ts-jest", + { + tsconfig: "/test/tsconfig.json", + useESM: true, + diagnostics: { + warnOnly: true, + ignoreCodes: [151002], + }, + }, + ], + }, + moduleNameMapper: { + "^(\\.{1,2}/.*)\\.js$": "$1", + }, + testPathIgnorePatterns: ["/node_modules/", "/dist/", "temp.spec.ts"], + moduleFileExtensions: ["ts", "js", "json", "node"], +}; diff --git a/ts/packages/cli/package.json b/ts/packages/cli/package.json index 12008de76..e81ea72e7 100644 --- a/ts/packages/cli/package.json +++ b/ts/packages/cli/package.json @@ -20,6 +20,7 @@ "scripts": { "build": "npm run tsc", "clean": "rimraf --glob dist *.tsbuildinfo *.done.build.log", + "jest-esm": "node --experimental-vm-modules ./node_modules/.bin/jest", "prettier": "prettier --check . --ignore-path ../../.prettierignore", "prettier:fix": "prettier --write . --ignore-path ../../.prettierignore", "regen": "node ./bin/run.js data regenerate ../defaultAgentProvider/test/data/explanations/**/**/*.json", @@ -27,6 +28,7 @@ "start": "node ./bin/run.js", "start:dev": "node --loader ts-node/esm --no-warnings=ExperimentalWarning ./bin/dev.js", "stat": "node ./bin/run.js data stat -f ../defaultAgentProvider/test/data/explanations/**/**/*.json", + "test": "npm run build && jest", "tsc": "tsc -b" }, "oclif": { @@ -73,12 +75,14 @@ "typechat-utils": "workspace:*" }, "devDependencies": { + "@jest/globals": "^29.7.0", "@types/debug": "^4.1.12", "@types/html-to-text": "^9.0.4", "@types/jest": "^29.5.7", "jest": "^29.7.0", "prettier": "^3.5.3", "rimraf": "^6.0.1", + "ts-jest": "^29.4.9", "typescript": "~5.4.5" } } diff --git a/ts/packages/cli/src/enhancedConsole.ts b/ts/packages/cli/src/enhancedConsole.ts index c47aed6b1..4d95a58f8 100644 --- a/ts/packages/cli/src/enhancedConsole.ts +++ b/ts/packages/cli/src/enhancedConsole.ts @@ -27,6 +27,7 @@ import type { TemplateEditConfig, PendingInteractionRequest, PendingInteractionResponse, + PendingInteractionEntry, } from "agent-dispatcher"; import chalk from "chalk"; import fs from "fs"; @@ -372,12 +373,23 @@ export function stopSpinner( /** * Create an enhanced ClientIO with terminal UI features */ +// Reason values passed to AbortController.abort() for pending interactions. +type InteractionResolvedReason = { + kind: "resolved-by-other"; + response: unknown; +}; +const INTERACTION_CANCELLED = "cancelled"; + export function createEnhancedClientIO( rl?: readline.promises.Interface, dispatcherRef?: { current?: Dispatcher }, ): ClientIO { let lastAppendMode: DisplayAppendMode | undefined; + // Active interaction prompts keyed by interactionId. Each entry holds an + // AbortController that, when aborted, dismisses the in-progress question(). + const activeInteractions = new Map(); + function displayContent( content: DisplayContent, appendMode?: DisplayAppendMode, @@ -867,103 +879,122 @@ export function createEnhancedClientIO( return; } const dispatcher = dispatcherRef.current; - let response: PendingInteractionResponse; - if (interaction.type === "askYesNo") { - const wasSpinning = currentSpinner?.isActive(); - if (wasSpinning) { - currentSpinner!.stop(); - } + if (interaction.type === "proposeAction") { + // Not supported in CLI yet + return; + } - const width = process.stdout.columns || 80; - const line = ANSI.dim + "─".repeat(width) + ANSI.reset; - const defaultHint = - interaction.defaultValue === undefined - ? "" - : interaction.defaultValue - ? " (default: yes)" - : " (default: no)"; - const prompt = `${chalk.cyan("?")} ${interaction.message}${chalk.dim(defaultHint)} ${chalk.dim("(y/n)")} `; - - process.stdout.write("\n" + line + "\n"); - const input = await question(prompt, rl); - process.stdout.write(line + "\n"); + const ac = new AbortController(); + activeInteractions.set(interaction.interactionId, ac); - if (wasSpinning) { - currentSpinner = new EnhancedSpinner({ - text: "Processing...", + const wasSpinning = currentSpinner?.isActive(); + if (wasSpinning) { + currentSpinner!.stop(); + } + + const width = process.stdout.columns || 80; + const line = ANSI.dim + "─".repeat(width) + ANSI.reset; + + let response: PendingInteractionResponse; + try { + if (interaction.type === "askYesNo") { + const defaultHint = + interaction.defaultValue === undefined + ? "" + : interaction.defaultValue + ? " (default: yes)" + : " (default: no)"; + const prompt = `${chalk.cyan("?")} ${interaction.message}${chalk.dim(defaultHint)} ${chalk.dim("(y/n)")} `; + + displayContent(line); + const input = await question(prompt, rl, ac.signal); + displayContent(line); + + let value: boolean; + if ( + input.toLowerCase() === "y" || + input.toLowerCase() === "yes" + ) { + value = true; + } else if ( + input.toLowerCase() === "n" || + input.toLowerCase() === "no" + ) { + value = false; + } else { + value = interaction.defaultValue ?? false; + } + response = { + interactionId: interaction.interactionId, + type: "askYesNo", + value, + }; + } else { + // popupQuestion + displayContent(line); + let popupText = `${chalk.cyan("?")} ${interaction.message}`; + interaction.choices.forEach((choice, i) => { + const isDefault = i === interaction.defaultId; + const prefix = chalk.cyan(`${i + 1}.`); + const suffix = isDefault + ? chalk.dim(" (default)") + : ""; + popupText += `\n ${prefix} ${choice}${suffix}`; }); - currentSpinner.start(); - } + displayContent(popupText); - let value: boolean; + const prompt = chalk.dim( + `Enter number (1-${interaction.choices.length}): `, + ); + const input = await question(prompt, rl, ac.signal); + displayContent(line); + + const parsed = parseInt(input, 10) - 1; + const value = + parsed >= 0 && parsed < interaction.choices.length + ? parsed + : (interaction.defaultId ?? 0); + response = { + interactionId: interaction.interactionId, + type: "popupQuestion", + value, + }; + } + } catch { + // Aborted by interactionResolved or interactionCancelled. + const reason = ac.signal.reason; if ( - input.toLowerCase() === "y" || - input.toLowerCase() === "yes" - ) { - value = true; - } else if ( - input.toLowerCase() === "n" || - input.toLowerCase() === "no" + reason !== null && + typeof reason === "object" && + reason.kind === "resolved-by-other" ) { - value = false; + // Primary client answered — no extra output needed here; + // the question prompt line already appeared when the + // interaction was first displayed. } else { - value = interaction.defaultValue ?? false; - } - response = { - interactionId: interaction.interactionId, - type: "askYesNo", - value, - }; - } else if (interaction.type === "popupQuestion") { - const wasSpinning = currentSpinner?.isActive(); - if (wasSpinning) { - currentSpinner!.stop(); + displayContent(chalk.yellow("Cancelled!")); } - const width = process.stdout.columns || 80; - const line = ANSI.dim + "─".repeat(width) + ANSI.reset; - process.stdout.write("\n" + line + "\n"); - process.stdout.write( - `${chalk.cyan("?")} ${interaction.message}\n`, - ); - interaction.choices.forEach((choice, i) => { - const isDefault = i === interaction.defaultId; - const prefix = chalk.cyan(`${i + 1}.`); - const suffix = isDefault ? chalk.dim(" (default)") : ""; - process.stdout.write( - ` ${prefix} ${choice}${suffix}\n`, - ); - }); - - const prompt = chalk.dim( - `Enter number (1-${interaction.choices.length}): `, - ); - const input = await question(prompt, rl); - process.stdout.write(line + "\n"); - if (wasSpinning) { currentSpinner = new EnhancedSpinner({ text: "Processing...", }); currentSpinner.start(); } - - const parsed = parseInt(input, 10) - 1; - const value = - parsed >= 0 && parsed < interaction.choices.length - ? parsed - : (interaction.defaultId ?? 0); - response = { - interactionId: interaction.interactionId, - type: "popupQuestion", - value, - }; - } else { - // proposeAction — not supported in CLI yet + activeInteractions.delete(interaction.interactionId); return; } + activeInteractions.delete(interaction.interactionId); + + if (wasSpinning) { + currentSpinner = new EnhancedSpinner({ + text: "Processing...", + }); + currentSpinner.start(); + } + try { await dispatcher.respondToInteraction(response); } catch { @@ -971,11 +1002,23 @@ export function createEnhancedClientIO( } })(); }, - interactionResolved(): void { - // CLI does not support deferred interactions + interactionResolved(interactionId: string, response: unknown): void { + const ac = activeInteractions.get(interactionId); + if (ac) { + activeInteractions.delete(interactionId); + const reason: InteractionResolvedReason = { + kind: "resolved-by-other", + response, + }; + ac.abort(reason); + } }, - interactionCancelled(): void { - // CLI does not support deferred interactions + interactionCancelled(interactionId: string): void { + const ac = activeInteractions.get(interactionId); + if (ac) { + activeInteractions.delete(interactionId); + ac.abort(INTERACTION_CANCELLED); + } }, takeAction(requestId: RequestId, action: string, data: unknown): void { if (action === "open-folder") { @@ -1550,16 +1593,25 @@ async function questionWithCompletion( async function question( message: string, rl?: readline.promises.Interface, + signal?: AbortSignal, ): Promise { if (rl) { - return rl.question(message); + return rl.question(message, { signal }); } // No readline interface — stdin is owned by questionWithCompletion in raw // mode. Read character-by-character directly so we don't conflict. - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const stdin = process.stdin; - process.stdout.write(message); + // If the scroll region is active (e.g. secondary client waiting at the + // input prompt), write the interaction prompt into the scrollable content + // area so it doesn't overwrite the fixed prompt region. + if (terminalLayout?.isActive) { + terminalLayout.writeContent(message); + activePromptRenderer?.redraw(); + } else { + process.stdout.write(message); + } let input = ""; const wasRaw = stdin.isRaw; @@ -1569,22 +1621,31 @@ async function question( stdin.resume(); stdin.setEncoding("utf8"); + const cleanup = () => { + stdin.removeListener("data", onData); + if (stdin.isTTY) { + stdin.setRawMode(wasRaw || false); + } + }; + + const onAbort = () => { + cleanup(); + reject(signal!.reason); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + const onData = (data: string) => { const code = data.charCodeAt(0); if (data === "\r" || data === "\n") { // Enter — commit - stdin.removeListener("data", onData); - if (stdin.isTTY) { - stdin.setRawMode(wasRaw || false); - } + signal?.removeEventListener("abort", onAbort); + cleanup(); process.stdout.write("\n"); resolve(input); } else if (code === 0x03) { // Ctrl+C — treat as empty input - stdin.removeListener("data", onData); - if (stdin.isTTY) { - stdin.setRawMode(wasRaw || false); - } + signal?.removeEventListener("abort", onAbort); + cleanup(); process.stdout.write("\n"); resolve(""); } else if (code === 0x7f || code === 0x08) { @@ -2017,6 +2078,8 @@ export async function replayDisplayHistory( ) + "\n", ); + const pendingInteractions = new Map(); + for (const entry of entries) { switch (entry.type) { case "user-request": @@ -2030,6 +2093,47 @@ export async function replayDisplayHistory( case "append-display": clientIO.appendDisplay(entry.message, entry.mode); break; + case "pending-interaction": + pendingInteractions.set(entry.interactionId, entry); + break; + case "interaction-resolved": { + const pending = pendingInteractions.get(entry.interactionId); + if (pending?.message !== undefined) { + let answerStr: string; + if (pending.interactionType === "askYesNo") { + answerStr = entry.response ? "yes" : "no"; + } else if ( + pending.interactionType === "popupQuestion" && + pending.choices !== undefined && + typeof entry.response === "number" + ) { + answerStr = + pending.choices[entry.response] ?? + String(entry.response); + } else { + answerStr = String(entry.response); + } + process.stdout.write( + chalk.dim(`? ${pending.message} → `) + + chalk.cyan(answerStr) + + "\n", + ); + } + pendingInteractions.delete(entry.interactionId); + break; + } + case "interaction-cancelled": { + const pending = pendingInteractions.get(entry.interactionId); + if (pending?.message !== undefined) { + process.stdout.write( + chalk.dim(`? ${pending.message} → `) + + chalk.yellow("[cancelled]") + + "\n", + ); + } + pendingInteractions.delete(entry.interactionId); + break; + } // notify and set-display-info are ephemeral — skip them } } diff --git a/ts/packages/cli/test/multiClientInteraction.spec.ts b/ts/packages/cli/test/multiClientInteraction.spec.ts new file mode 100644 index 000000000..e5f1cfc71 --- /dev/null +++ b/ts/packages/cli/test/multiClientInteraction.spec.ts @@ -0,0 +1,370 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Tests for multi-client interaction handling in createEnhancedClientIO. + * + * The scenario: multiple CLI clients connect to the same SharedDispatcher + * session. The server broadcasts requestInteraction to all clients; the first + * to call respondToInteraction wins. When a winner is found, the server + * broadcasts interactionResolved (or interactionCancelled on timeout/cancel) + * to all remaining clients so they dismiss their open prompts. + * + * These tests exercise the activeInteractions map and AbortController logic + * by replacing process.stdin with a controllable fake and verifying that + * respondToInteraction is called (or not called) at the right times. + */ + +import { describe, it, expect, beforeEach, afterEach } from "@jest/globals"; +import { EventEmitter } from "node:events"; +import { createEnhancedClientIO } from "../src/enhancedConsole.js"; +import type { PendingInteractionRequest, Dispatcher } from "agent-dispatcher"; + +// ── Stdin/stdout stubs ─────────────────────────────────────────────────────── + +/** Minimal fake stdin that lets tests push characters programmatically. */ +class FakeStdin extends EventEmitter { + isTTY = true; + isRaw = false; + encoding: BufferEncoding | null = null; + + setRawMode(raw: boolean) { + this.isRaw = raw; + return this; + } + setEncoding(enc: BufferEncoding) { + this.encoding = enc; + return this; + } + resume() { + return this; + } + pause() { + return this; + } + + /** Simulate the user typing a string followed by Enter. */ + typeAnswer(text: string) { + for (const ch of text) { + this.emit("data", ch); + } + this.emit("data", "\r"); + } +} + +let fakeStdin: FakeStdin; +let stdoutOutput: string[]; + +// ── Dispatcher stub ────────────────────────────────────────────────────────── + +interface CallRecord { + method: "respondToInteraction"; + args: unknown[]; +} + +function makeDispatcherStub(): { dispatcher: Dispatcher; calls: CallRecord[] } { + const calls: CallRecord[] = []; + const dispatcher = { + respondToInteraction: async (...args: unknown[]) => { + calls.push({ method: "respondToInteraction", args }); + }, + // The remaining Dispatcher methods are not exercised by these tests. + } as unknown as Dispatcher; + return { dispatcher, calls }; +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function makeAskYesNo(id: string): PendingInteractionRequest { + return { + interactionId: id, + type: "askYesNo", + message: "Are you sure?", + defaultValue: false, + requestId: { requestId: "req-1" }, + source: "test", + timestamp: Date.now(), + }; +} + +function makePopupQuestion(id: string): PendingInteractionRequest { + return { + interactionId: id, + type: "popupQuestion", + message: "Pick one", + choices: ["alpha", "beta", "gamma"], + defaultId: 0, + requestId: { requestId: "req-1" }, + source: "test", + timestamp: Date.now(), + }; +} + +/** Wait for microtasks and a short real tick so async IIFEs inside + * requestInteraction have a chance to reach the await question() call. */ +function flushAsync() { + return new Promise((resolve) => setImmediate(resolve)); +} + +// ── Test setup ─────────────────────────────────────────────────────────────── + +beforeEach(() => { + fakeStdin = new FakeStdin(); + stdoutOutput = []; + + // Redirect process.stdin and process.stdout for the duration of each test. + Object.defineProperty(process, "stdin", { + value: fakeStdin, + writable: true, + configurable: true, + }); + Object.defineProperty(process, "stdout", { + value: { + write: (s: string) => { + stdoutOutput.push(s); + return true; + }, + columns: 80, + }, + writable: true, + configurable: true, + }); +}); + +afterEach(() => { + // Restore real stdin/stdout (Jest resets the module between test files anyway, + // but explicit cleanup prevents cross-test leakage within this file). + Object.defineProperty(process, "stdin", { + value: process.stdin, + writable: true, + configurable: true, + }); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("requestInteraction — user answers", () => { + it("calls respondToInteraction with true when user types y", async () => { + const { dispatcher, calls } = makeDispatcherStub(); + const dispatcherRef = { current: dispatcher }; + const clientIO = createEnhancedClientIO(undefined, dispatcherRef); + + clientIO.requestInteraction(makeAskYesNo("int-1")); + await flushAsync(); // let the IIFE reach question() + + fakeStdin.typeAnswer("y"); + await flushAsync(); + + expect(calls).toHaveLength(1); + expect(calls[0].args[0]).toMatchObject({ + interactionId: "int-1", + type: "askYesNo", + value: true, + }); + }); + + it("calls respondToInteraction with false when user types n", async () => { + const { dispatcher, calls } = makeDispatcherStub(); + const clientIO = createEnhancedClientIO(undefined, { + current: dispatcher, + }); + + clientIO.requestInteraction(makeAskYesNo("int-2")); + await flushAsync(); + + fakeStdin.typeAnswer("n"); + await flushAsync(); + + expect(calls).toHaveLength(1); + expect(calls[0].args[0]).toMatchObject({ value: false }); + }); + + it("uses defaultValue when user types unrecognised input", async () => { + const { dispatcher, calls } = makeDispatcherStub(); + const clientIO = createEnhancedClientIO(undefined, { + current: dispatcher, + }); + + const interaction = makeAskYesNo("int-3"); + (interaction as any).defaultValue = true; + clientIO.requestInteraction(interaction); + await flushAsync(); + + fakeStdin.typeAnswer("maybe"); + await flushAsync(); + + expect(calls[0].args[0]).toMatchObject({ value: true }); + }); + + it("calls respondToInteraction with correct index for popupQuestion", async () => { + const { dispatcher, calls } = makeDispatcherStub(); + const clientIO = createEnhancedClientIO(undefined, { + current: dispatcher, + }); + + clientIO.requestInteraction(makePopupQuestion("int-4")); + await flushAsync(); + + fakeStdin.typeAnswer("2"); // 1-based → index 1 = "beta" + await flushAsync(); + + expect(calls[0].args[0]).toMatchObject({ + interactionId: "int-4", + type: "popupQuestion", + value: 1, + }); + }); +}); + +describe("interactionResolved — dismisses pending prompt", () => { + it("aborts question() and does NOT call respondToInteraction", async () => { + const { dispatcher, calls } = makeDispatcherStub(); + const clientIO = createEnhancedClientIO(undefined, { + current: dispatcher, + }); + + clientIO.requestInteraction(makeAskYesNo("int-5")); + await flushAsync(); // IIFE reaches question(), now waiting on stdin + + // Another client answered — server tells this client to dismiss + clientIO.interactionResolved("int-5", true); + await flushAsync(); + + // The user hasn't typed anything; no respondToInteraction should have fired + expect(calls).toHaveLength(0); + }); + + it("prints a notice to stdout", async () => { + const { dispatcher } = makeDispatcherStub(); + const clientIO = createEnhancedClientIO(undefined, { + current: dispatcher, + }); + + clientIO.requestInteraction(makeAskYesNo("int-6")); + await flushAsync(); + + clientIO.interactionResolved("int-6", true); + await flushAsync(); + + const combined = stdoutOutput.join(""); + expect(combined).toContain("answered by another client"); + }); +}); + +describe("interactionCancelled — dismisses pending prompt", () => { + it("aborts question() and does NOT call respondToInteraction", async () => { + const { dispatcher, calls } = makeDispatcherStub(); + const clientIO = createEnhancedClientIO(undefined, { + current: dispatcher, + }); + + clientIO.requestInteraction(makeAskYesNo("int-7")); + await flushAsync(); + + clientIO.interactionCancelled("int-7"); + await flushAsync(); + + expect(calls).toHaveLength(0); + }); + + it("prints a cancellation notice to stdout", async () => { + const { dispatcher } = makeDispatcherStub(); + const clientIO = createEnhancedClientIO(undefined, { + current: dispatcher, + }); + + clientIO.requestInteraction(makeAskYesNo("int-8")); + await flushAsync(); + + clientIO.interactionCancelled("int-8"); + await flushAsync(); + + const combined = stdoutOutput.join(""); + expect(combined).toContain("Cancelled!"); + }); +}); + +describe("interactionResolved / interactionCancelled with unknown id", () => { + it("is a no-op and does not throw for resolved", () => { + const clientIO = createEnhancedClientIO(undefined, { + current: makeDispatcherStub().dispatcher, + }); + expect(() => + clientIO.interactionResolved("no-such-id", true), + ).not.toThrow(); + }); + + it("is a no-op and does not throw for cancelled", () => { + const clientIO = createEnhancedClientIO(undefined, { + current: makeDispatcherStub().dispatcher, + }); + expect(() => clientIO.interactionCancelled("no-such-id")).not.toThrow(); + }); +}); + +describe("multiple concurrent interactions", () => { + it("dismissing one interaction does not affect the other", async () => { + const { dispatcher, calls } = makeDispatcherStub(); + const clientIO = createEnhancedClientIO(undefined, { + current: dispatcher, + }); + + // Start two prompts simultaneously + clientIO.requestInteraction(makeAskYesNo("int-A")); + clientIO.requestInteraction(makeAskYesNo("int-B")); + await flushAsync(); + + // Resolve int-A (answered by another client) + clientIO.interactionResolved("int-A", true); + await flushAsync(); + + // int-B is still active — the user can still answer it + fakeStdin.typeAnswer("y"); + await flushAsync(); + + // Only int-B should have called respondToInteraction + expect(calls).toHaveLength(1); + expect(calls[0].args[0]).toMatchObject({ interactionId: "int-B" }); + }); + + it("resolving an already-dismissed interaction is a no-op", async () => { + const { dispatcher, calls } = makeDispatcherStub(); + const clientIO = createEnhancedClientIO(undefined, { + current: dispatcher, + }); + + clientIO.requestInteraction(makeAskYesNo("int-C")); + await flushAsync(); + + // User answers + fakeStdin.typeAnswer("y"); + await flushAsync(); + + // Server sends interactionResolved after the fact (race condition in delivery) + expect(() => clientIO.interactionResolved("int-C", true)).not.toThrow(); + expect(calls).toHaveLength(1); // still just the one from the user's answer + }); +}); + +describe("proposeAction", () => { + it("is silently ignored (not yet supported)", async () => { + const { dispatcher, calls } = makeDispatcherStub(); + const clientIO = createEnhancedClientIO(undefined, { + current: dispatcher, + }); + + const interaction: PendingInteractionRequest = { + interactionId: "int-D", + type: "proposeAction", + actionTemplates: {} as any, + requestId: { requestId: "req-1" }, + source: "test", + timestamp: Date.now(), + }; + + clientIO.requestInteraction(interaction); + await flushAsync(); + + expect(calls).toHaveLength(0); + }); +}); diff --git a/ts/packages/cli/test/temp.spec.ts b/ts/packages/cli/test/temp.spec.ts new file mode 100644 index 000000000..e69de29bb diff --git a/ts/packages/cli/test/tsconfig.json b/ts/packages/cli/test/tsconfig.json new file mode 100644 index 000000000..2eea8c676 --- /dev/null +++ b/ts/packages/cli/test/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": ".", + "outDir": "./dist-test", + "types": ["node", "jest"], + "module": "esnext", + "target": "esnext", + "isolatedModules": true + }, + "include": ["./**/*"], + "ts-node": { + "esm": true + }, + "references": [{ "path": "../src" }] +} diff --git a/ts/packages/cli/tsconfig.json b/ts/packages/cli/tsconfig.json index af235736e..b2923c3db 100644 --- a/ts/packages/cli/tsconfig.json +++ b/ts/packages/cli/tsconfig.json @@ -2,8 +2,6 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "composite": true, - - // These settings need to match src/tsconfig.json and needed here for oclif dev mode to work for ts-node to work "rootDir": "./src", "outDir": "./dist" }, diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index a368486bf..bb440496f 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -150,7 +150,7 @@ importers: version: 8.18.1 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -527,7 +527,7 @@ importers: version: 16.5.0 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -758,7 +758,7 @@ importers: version: 12.0.2(webpack@5.105.0) jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -899,7 +899,7 @@ importers: version: 12.0.2(webpack@5.105.0) jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -957,7 +957,7 @@ importers: version: 2.0.0 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -1007,7 +1007,7 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) rimraf: specifier: ^6.0.1 version: 6.0.1 @@ -1051,7 +1051,7 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -1079,7 +1079,7 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -1330,7 +1330,7 @@ importers: version: 2.4.1 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -1599,10 +1599,10 @@ importers: version: 11.3.0 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@20.19.23)(ts-node@10.9.2(@types/node@20.19.23)(typescript@5.4.5)) + version: 29.7.0(@types/node@20.19.23) jest-chrome: specifier: ^0.8.0 - version: 0.8.0(jest@29.7.0(@types/node@20.19.23)(ts-node@10.9.2(@types/node@20.19.23)(typescript@5.4.5))) + version: 0.8.0(jest@29.7.0(@types/node@20.19.23)) jest-environment-jsdom: specifier: ^29.7.0 version: 29.7.0 @@ -1614,7 +1614,7 @@ importers: version: 3.5.0 ts-jest: specifier: ^29.3.2 - version: 29.3.3(@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))(esbuild@0.25.12)(jest@29.7.0(@types/node@20.19.23)(ts-node@10.9.2(@types/node@20.19.23)(typescript@5.4.5)))(typescript@5.4.5) + version: 29.3.3(@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))(esbuild@0.25.12)(jest@29.7.0(@types/node@20.19.23))(typescript@5.4.5) ts-loader: specifier: ^9.5.1 version: 9.5.2(typescript@5.4.5)(webpack@5.105.0(esbuild@0.25.12)) @@ -2118,7 +2118,7 @@ importers: version: 2.4.1 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -2209,7 +2209,7 @@ importers: version: 12.0.2(webpack@5.105.0) jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.2.5 version: 3.5.3 @@ -2344,7 +2344,7 @@ importers: version: 9.1.2 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@22.15.18) rimraf: specifier: ^6.0.1 version: 6.0.1 @@ -2476,7 +2476,7 @@ importers: version: 2.4.1 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -2548,7 +2548,7 @@ importers: version: 12.0.2(webpack@5.105.0) jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -2702,7 +2702,7 @@ importers: version: 16.5.0 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) rimraf: specifier: ^6.0.1 version: 6.0.1 @@ -2787,7 +2787,7 @@ importers: version: 8.18.1 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -2848,7 +2848,7 @@ importers: version: 16.5.0 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) rimraf: specifier: ^6.0.1 version: 6.0.1 @@ -2909,7 +2909,7 @@ importers: version: 2.0.0 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -2949,7 +2949,7 @@ importers: version: 5.6.3(webpack@5.105.0) jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -3072,6 +3072,9 @@ importers: specifier: workspace:* version: link:../utils/typechatUtils devDependencies: + '@jest/globals': + specifier: ^29.7.0 + version: 29.7.0 '@types/debug': specifier: ^4.1.12 version: 4.1.12 @@ -3090,6 +3093,9 @@ importers: rimraf: specifier: ^6.0.1 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@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)))(typescript@5.4.5) typescript: specifier: ~5.4.5 version: 5.4.5 @@ -3255,7 +3261,7 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.2.5 version: 3.5.3 @@ -3264,7 +3270,7 @@ importers: version: 5.0.10 ts-jest: specifier: ^29.1.2 - version: 29.3.3(@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@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)))(typescript@5.4.5) + version: 29.3.3(@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@29.7.0(@types/node@25.5.2))(typescript@5.4.5) typescript: specifier: ~5.4.5 version: 5.4.5 @@ -3433,7 +3439,7 @@ importers: version: 16.5.0 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -3581,7 +3587,7 @@ importers: version: 16.5.0 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -3621,7 +3627,7 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -3652,7 +3658,7 @@ importers: version: 22.15.18 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@22.15.18) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -3736,7 +3742,7 @@ importers: version: 16.5.0 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -3831,7 +3837,7 @@ importers: version: 16.5.0 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -3871,7 +3877,7 @@ importers: version: 12.0.2(webpack@5.105.0) jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -3898,7 +3904,7 @@ importers: dependencies: '@anthropic-ai/claude-agent-sdk': specifier: ^0.2.92 - version: 0.2.92 + version: 0.2.92(zod@4.1.13) aiclient: specifier: workspace:* version: link:../aiclient @@ -4015,7 +4021,7 @@ importers: version: 16.5.0 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -4131,7 +4137,7 @@ importers: version: 16.5.0 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.2.5 version: 3.5.3 @@ -4225,7 +4231,7 @@ importers: version: 16.5.0 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -4386,7 +4392,7 @@ importers: version: 4.0.1(vite@6.4.2(@types/node@25.5.2)(jiti@2.5.1)(less@4.3.0)(terser@5.39.2)(yaml@2.8.3)) jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) less: specifier: ^4.2.0 version: 4.3.0 @@ -4435,7 +4441,7 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -4466,7 +4472,7 @@ importers: version: 16.5.0 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -4534,7 +4540,7 @@ importers: version: 16.5.0 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -4562,7 +4568,7 @@ importers: version: 0.25.11 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -4593,7 +4599,7 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -4642,7 +4648,7 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -8701,11 +8707,6 @@ packages: browser-stdout@1.3.1: resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==} - browserslist@4.24.5: - resolution: {integrity: sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - browserslist@4.28.1: resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -8819,9 +8820,6 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - caniuse-lite@1.0.30001718: - resolution: {integrity: sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==} - caniuse-lite@1.0.30001769: resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} @@ -9745,9 +9743,6 @@ packages: electron-publish@26.8.1: resolution: {integrity: sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w==} - electron-to-chromium@1.5.155: - resolution: {integrity: sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng==} - electron-to-chromium@1.5.286: resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} @@ -10488,6 +10483,11 @@ packages: handle-thing@2.0.1: resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==} + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + engines: {node: '>=0.4.7'} + hasBin: true + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -12161,9 +12161,6 @@ packages: node-pty@1.1.0: resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==} - node-releases@2.0.19: - resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} - node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -13846,6 +13843,33 @@ packages: esbuild: optional: true + ts-jest@29.4.9: + resolution: {integrity: sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==} + engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/transform': ^29.0.0 || ^30.0.0 + '@jest/types': ^29.0.0 || ^30.0.0 + babel-jest: ^29.0.0 || ^30.0.0 + esbuild: '*' + jest: ^29.0.0 || ^30.0.0 + jest-util: ^29.0.0 || ^30.0.0 + typescript: '>=4.3 <7' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/transform': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + jest-util: + optional: true + ts-loader@9.5.2: resolution: {integrity: sha512-Qo4piXvOTWcMGIgRiuFa6nHNm+54HbYaZCKqc9eeZCLRy3XqafQgwX2F7mofrbJG3g7EEb+lkiR+z2Lic2s3Zw==} engines: {node: '>=12.0.0'} @@ -13970,6 +13994,11 @@ packages: ufo@1.6.1: resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -14066,12 +14095,6 @@ packages: resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} engines: {node: '>=8'} - update-browserslist-db@1.1.3: - resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -14678,24 +14701,6 @@ snapshots: '@antfu/utils@8.1.1': {} - '@anthropic-ai/claude-agent-sdk@0.2.92': - dependencies: - '@anthropic-ai/sdk': 0.80.0(zod@4.1.13) - '@modelcontextprotocol/sdk': 1.29.0 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 - transitivePeerDependencies: - - '@cfworker/json-schema' - - supports-color - '@anthropic-ai/claude-agent-sdk@0.2.92(zod@4.1.13)': dependencies: '@anthropic-ai/sdk': 0.80.0(zod@4.1.13) @@ -15697,7 +15702,7 @@ snapshots: dependencies: '@babel/compat-data': 7.28.4 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.24.5 + browserslist: 4.28.1 lru-cache: 5.1.1 semver: 6.3.1 @@ -16984,111 +16989,6 @@ snapshots: jest-util: 29.7.0 slash: 3.0.0 - '@jest/core@29.7.0(ts-node@10.9.2(@types/node@20.19.23)(typescript@5.4.5))': - dependencies: - '@jest/console': 29.7.0 - '@jest/reporters': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.39 - 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@20.19.39)(ts-node@10.9.2(@types/node@20.19.23)(typescript@5.4.5)) - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-resolve-dependencies: 29.7.0 - jest-runner: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - jest-watcher: 29.7.0 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-ansi: 6.0.1 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - ts-node - - '@jest/core@29.7.0(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.4.5))': - dependencies: - '@jest/console': 29.7.0 - '@jest/reporters': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.39 - 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@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.4.5)) - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-resolve-dependencies: 29.7.0 - jest-runner: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - jest-watcher: 29.7.0 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-ansi: 6.0.1 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - ts-node - - '@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.4.5))': - dependencies: - '@jest/console': 29.7.0 - '@jest/reporters': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.39 - 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@20.19.39)(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 - jest-resolve: 29.7.0 - jest-resolve-dependencies: 29.7.0 - jest-runner: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - jest-watcher: 29.7.0 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-ansi: 6.0.1 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - ts-node - '@jest/core@29.7.0(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5))': dependencies: '@jest/console': 29.7.0 @@ -17793,27 +17693,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@modelcontextprotocol/sdk@1.29.0': - dependencies: - '@hono/node-server': 1.19.13(hono@4.12.12) - ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) - content-type: 1.0.5 - cors: 2.8.5 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.0.6 - express: 5.2.1 - express-rate-limit: 8.3.1(express@5.2.1) - hono: 4.12.12 - jose: 6.1.3 - json-schema-typed: 8.0.2 - pkce-challenge: 5.0.1 - raw-body: 3.0.2 - zod-to-json-schema: 3.25.1(zod@4.3.6) - transitivePeerDependencies: - - supports-color - '@modelcontextprotocol/sdk@1.29.0(zod@4.1.13)': dependencies: '@hono/node-server': 1.19.13(hono@4.12.12) @@ -20167,13 +20046,6 @@ snapshots: browser-stdout@1.3.1: {} - browserslist@4.24.5: - dependencies: - caniuse-lite: 1.0.30001718 - electron-to-chromium: 1.5.155 - node-releases: 2.0.19 - update-browserslist-db: 1.1.3(browserslist@4.24.5) - browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.9.19 @@ -20328,8 +20200,6 @@ snapshots: camelcase@6.3.0: {} - caniuse-lite@1.0.30001718: {} - caniuse-lite@1.0.30001769: {} caseless@0.12.0: {} @@ -20758,28 +20628,13 @@ snapshots: dependencies: buffer: 5.7.1 - create-jest@29.7.0(@types/node@20.19.23)(ts-node@10.9.2(@types/node@20.19.23)(typescript@5.4.5)): - dependencies: - '@jest/types': 29.6.3 - chalk: 4.1.2 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.19.23)(ts-node@10.9.2(@types/node@20.19.23)(typescript@5.4.5)) - jest-util: 29.7.0 - prompts: 2.4.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - - create-jest@29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.4.5)): + create-jest@29.7.0(@types/node@20.19.23): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.4.5)) + jest-config: 29.7.0(@types/node@20.19.23) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -20788,13 +20643,13 @@ snapshots: - supports-color - ts-node - create-jest@29.7.0(@types/node@22.15.18)(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.4.5)): + create-jest@29.7.0(@types/node@22.15.18): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - 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.15.18) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -21399,8 +21254,6 @@ snapshots: transitivePeerDependencies: - supports-color - electron-to-chromium@1.5.155: {} - electron-to-chromium@1.5.286: {} electron-updater@6.6.2: @@ -22438,6 +22291,15 @@ snapshots: handle-thing@2.0.1: {} + handlebars@4.7.9: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + has-bigints@1.1.0: {} has-flag@3.0.0: {} @@ -23050,10 +22912,10 @@ snapshots: jest-util: 29.7.0 p-limit: 3.1.0 - jest-chrome@0.8.0(jest@29.7.0(@types/node@20.19.23)(ts-node@10.9.2(@types/node@20.19.23)(typescript@5.4.5))): + jest-chrome@0.8.0(jest@29.7.0(@types/node@20.19.23)): dependencies: '@types/chrome': 0.0.114 - jest: 29.7.0(@types/node@20.19.23)(ts-node@10.9.2(@types/node@20.19.23)(typescript@5.4.5)) + jest: 29.7.0(@types/node@20.19.23) jest-circus@29.7.0: dependencies: @@ -23081,16 +22943,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@20.19.23)(ts-node@10.9.2(@types/node@20.19.23)(typescript@5.4.5)): + jest-cli@29.7.0(@types/node@20.19.23): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.23)(typescript@5.4.5)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.19.23)(ts-node@10.9.2(@types/node@20.19.23)(typescript@5.4.5)) + create-jest: 29.7.0(@types/node@20.19.23) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@20.19.23)(ts-node@10.9.2(@types/node@20.19.23)(typescript@5.4.5)) + jest-config: 29.7.0(@types/node@20.19.23) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -23100,16 +22962,16 @@ snapshots: - supports-color - ts-node - jest-cli@29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.4.5)): + jest-cli@29.7.0(@types/node@22.15.18): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.4.5)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.4.5)) + create-jest: 29.7.0(@types/node@22.15.18) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.4.5)) + jest-config: 29.7.0(@types/node@22.15.18) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -23119,16 +22981,16 @@ snapshots: - supports-color - ts-node - jest-cli@29.7.0(@types/node@22.15.18)(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.4.5)): + jest-cli@29.7.0(@types/node@25.5.2): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.4.5)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@22.15.18)(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.4.5)) + create-jest: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) exit: 0.1.2 import-local: 3.2.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@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -23157,7 +23019,7 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@20.19.23)(ts-node@10.9.2(@types/node@20.19.23)(typescript@5.4.5)): + jest-config@29.7.0(@types/node@20.19.23): dependencies: '@babel/core': 7.28.4 '@jest/test-sequencer': 29.7.0 @@ -23183,100 +23045,6 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 20.19.23 - ts-node: 10.9.2(@types/node@20.19.23)(typescript@5.4.5) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - jest-config@29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.23)(typescript@5.4.5)): - dependencies: - '@babel/core': 7.28.4 - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.28.4) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0 - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 20.19.39 - ts-node: 10.9.2(@types/node@20.19.23)(typescript@5.4.5) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - jest-config@29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.4.5)): - dependencies: - '@babel/core': 7.28.4 - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.28.4) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0 - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 20.19.39 - ts-node: 10.9.2(@types/node@20.19.39)(typescript@5.4.5) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - jest-config@29.7.0(@types/node@20.19.39)(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 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.28.4) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0 - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 20.19.39 - ts-node: 10.9.2(@types/node@22.15.18)(typescript@5.4.5) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -23312,7 +23080,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.15.18)(typescript@5.4.5)): + jest-config@29.7.0(@types/node@22.15.18): dependencies: '@babel/core': 7.28.4 '@jest/test-sequencer': 29.7.0 @@ -23338,7 +23106,6 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 22.15.18 - ts-node: 10.9.2(@types/node@22.15.18)(typescript@5.4.5) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -23610,36 +23377,36 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@20.19.23)(ts-node@10.9.2(@types/node@20.19.23)(typescript@5.4.5)): + jest@29.7.0(@types/node@20.19.23): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.23)(typescript@5.4.5)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@20.19.23)(ts-node@10.9.2(@types/node@20.19.23)(typescript@5.4.5)) + jest-cli: 29.7.0(@types/node@20.19.23) transitivePeerDependencies: - '@types/node' - babel-plugin-macros - supports-color - ts-node - jest@29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.4.5)): + jest@29.7.0(@types/node@22.15.18): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.4.5)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.4.5)) + jest-cli: 29.7.0(@types/node@22.15.18) transitivePeerDependencies: - '@types/node' - babel-plugin-macros - supports-color - ts-node - jest@29.7.0(@types/node@22.15.18)(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.4.5)): + jest@29.7.0(@types/node@25.5.2): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.4.5)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@22.15.18)(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.4.5)) + jest-cli: 29.7.0(@types/node@25.5.2) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -24834,8 +24601,6 @@ snapshots: dependencies: node-addon-api: 7.1.1 - node-releases@2.0.19: {} - node-releases@2.0.27: {} node-rsa@1.1.1: @@ -26853,12 +26618,12 @@ snapshots: ts-deepmerge@7.0.2: {} - ts-jest@29.3.3(@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))(esbuild@0.25.12)(jest@29.7.0(@types/node@20.19.23)(ts-node@10.9.2(@types/node@20.19.23)(typescript@5.4.5)))(typescript@5.4.5): + ts-jest@29.3.3(@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))(esbuild@0.25.12)(jest@29.7.0(@types/node@20.19.23))(typescript@5.4.5): dependencies: bs-logger: 0.2.6 ejs: 3.1.10 fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(@types/node@20.19.23)(ts-node@10.9.2(@types/node@20.19.23)(typescript@5.4.5)) + jest: 29.7.0(@types/node@20.19.23) jest-util: 29.7.0 json5: 2.2.3 lodash.memoize: 4.1.2 @@ -26874,12 +26639,12 @@ snapshots: babel-jest: 29.7.0(@babel/core@7.28.4) esbuild: 0.25.12 - ts-jest@29.3.3(@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@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)))(typescript@5.4.5): + ts-jest@29.3.3(@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@29.7.0(@types/node@25.5.2))(typescript@5.4.5): dependencies: bs-logger: 0.2.6 ejs: 3.1.10 fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + jest: 29.7.0(@types/node@25.5.2) jest-util: 29.7.0 json5: 2.2.3 lodash.memoize: 4.1.2 @@ -26894,6 +26659,26 @@ 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@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(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@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.4 + type-fest: 4.41.0 + typescript: 5.4.5 + yargs-parser: 21.1.1 + optionalDependencies: + '@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 + ts-loader@9.5.2(typescript@5.4.5)(webpack@5.105.0(esbuild@0.25.12)): dependencies: chalk: 4.1.2 @@ -26914,63 +26699,6 @@ snapshots: typescript: 5.4.5 webpack: 5.105.0(webpack-cli@5.1.4) - ts-node@10.9.2(@types/node@20.19.23)(typescript@5.4.5): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.9 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 20.19.23 - acorn: 8.11.1 - acorn-walk: 8.3.0 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.4 - make-error: 1.3.6 - typescript: 5.4.5 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - optional: true - - ts-node@10.9.2(@types/node@20.19.39)(typescript@5.4.5): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.9 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 20.19.39 - acorn: 8.11.1 - acorn-walk: 8.3.0 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.4 - make-error: 1.3.6 - typescript: 5.4.5 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - optional: true - - ts-node@10.9.2(@types/node@22.15.18)(typescript@5.4.5): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.9 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 22.15.18 - acorn: 8.11.1 - acorn-walk: 8.3.0 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.4 - make-error: 1.3.6 - 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.5.2)(typescript@5.4.5): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -27091,6 +26819,9 @@ snapshots: ufo@1.6.1: {} + uglify-js@3.19.3: + optional: true + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -27187,12 +26918,6 @@ snapshots: untildify@4.0.0: {} - update-browserslist-db@1.1.3(browserslist@4.24.5): - dependencies: - browserslist: 4.24.5 - escalade: 3.2.0 - picocolors: 1.1.1 - update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 From 56c47ac488461c9cc7f7cafe958b641a31865fe5 Mon Sep 17 00:00:00 2001 From: George Ng Date: Mon, 13 Apr 2026 23:30:32 -0500 Subject: [PATCH 3/4] Update documentation and remove scratch file accidentally committed --- .../docs/multi-client-interactions.md | 22 +++++++++++++++++++ ts/packages/cli/test/temp.spec.ts | 0 2 files changed, 22 insertions(+) delete mode 100644 ts/packages/cli/test/temp.spec.ts diff --git a/ts/packages/agentServer/docs/multi-client-interactions.md b/ts/packages/agentServer/docs/multi-client-interactions.md index d90360b4a..99f226a2d 100644 --- a/ts/packages/agentServer/docs/multi-client-interactions.md +++ b/ts/packages/agentServer/docs/multi-client-interactions.md @@ -140,3 +140,25 @@ The cancel functions must distinguish resolved vs. cancelled so the client can p ## Shell Implementation Notes The Shell (`main.ts`) is not yet implemented (stubs in place). The same pattern applies: hold a `Map` and call `dismissFn` from `interactionResolved`/`interactionCancelled`. The UI (modal dialog or inline card) should be dismissed programmatically and a toast shown if resolved by a remote client. + +## Future Work + +### Unify `askYesNo` and `popupQuestion` into a single `question` type + +`askYesNo` is a special case of `popupQuestion` — a two-choice prompt where choices are implicitly `["Yes", "No"]` and the response is a boolean rather than an index. The protocol could be simplified by collapsing both into a single `question` interaction type: + +```typescript +// Unified request +{ type: "question"; message: string; choices: string[]; defaultId?: number } + +// Unified response +{ interactionId: string; type: "question"; value: number } +``` + +Caller ergonomics on `SessionContext` can be preserved with thin wrappers that map the boolean/index conversion. `proposeAction` remains intentionally separate — it renders a structured template editor rather than a text prompt, and its response type is `unknown`. + +Benefits: + +- One code path in all `ClientIO` implementations instead of two +- Simpler discriminated union in `PendingInteractionRequest` / `PendingInteractionResponse` +- Consistent rendering logic across CLI, Shell, and future clients diff --git a/ts/packages/cli/test/temp.spec.ts b/ts/packages/cli/test/temp.spec.ts deleted file mode 100644 index e69de29bb..000000000 From 493399c9a9f545ad37ac1c48f32a647321d12984 Mon Sep 17 00:00:00 2001 From: George Ng Date: Mon, 13 Apr 2026 23:48:46 -0500 Subject: [PATCH 4/4] Address comments --- ts/packages/cli/src/enhancedConsole.ts | 15 ++++++++++----- .../cli/test/multiClientInteraction.spec.ts | 16 ++++++++++++---- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/ts/packages/cli/src/enhancedConsole.ts b/ts/packages/cli/src/enhancedConsole.ts index 4d95a58f8..7b6780563 100644 --- a/ts/packages/cli/src/enhancedConsole.ts +++ b/ts/packages/cli/src/enhancedConsole.ts @@ -389,6 +389,8 @@ export function createEnhancedClientIO( // Active interaction prompts keyed by interactionId. Each entry holds an // AbortController that, when aborted, dismisses the in-progress question(). const activeInteractions = new Map(); + // Serial queue for interactions — ensures only one stdin prompt is active at a time. + let interactionQueue: Promise = Promise.resolve(); function displayContent( content: DisplayContent, @@ -874,7 +876,7 @@ export function createEnhancedClientIO( }, // Async deferred pattern — handle interactions pushed from the server requestInteraction(interaction: PendingInteractionRequest): void { - (async () => { + interactionQueue = interactionQueue.then(async () => { if (!dispatcherRef?.current) { return; } @@ -969,9 +971,9 @@ export function createEnhancedClientIO( typeof reason === "object" && reason.kind === "resolved-by-other" ) { - // Primary client answered — no extra output needed here; - // the question prompt line already appeared when the - // interaction was first displayed. + displayContent( + chalk.gray("[answered by another client]"), + ); } else { displayContent(chalk.yellow("Cancelled!")); } @@ -1000,7 +1002,7 @@ export function createEnhancedClientIO( } catch { // Interaction may have already timed out } - })(); + }); }, interactionResolved(interactionId: string, response: unknown): void { const ac = activeInteractions.get(interactionId); @@ -1601,6 +1603,9 @@ async function question( // No readline interface — stdin is owned by questionWithCompletion in raw // mode. Read character-by-character directly so we don't conflict. + if (signal?.aborted) { + return Promise.reject(signal.reason); + } return new Promise((resolve, reject) => { const stdin = process.stdin; // If the scroll region is active (e.g. secondary client waiting at the diff --git a/ts/packages/cli/test/multiClientInteraction.spec.ts b/ts/packages/cli/test/multiClientInteraction.spec.ts index e5f1cfc71..15ed1f210 100644 --- a/ts/packages/cli/test/multiClientInteraction.spec.ts +++ b/ts/packages/cli/test/multiClientInteraction.spec.ts @@ -54,6 +54,8 @@ class FakeStdin extends EventEmitter { let fakeStdin: FakeStdin; let stdoutOutput: string[]; +let realStdin: NodeJS.ReadStream; +let realStdout: NodeJS.WriteStream; // ── Dispatcher stub ────────────────────────────────────────────────────────── @@ -112,7 +114,10 @@ beforeEach(() => { fakeStdin = new FakeStdin(); stdoutOutput = []; - // Redirect process.stdin and process.stdout for the duration of each test. + // Capture originals before overriding so afterEach can restore them. + realStdin = process.stdin; + realStdout = process.stdout; + Object.defineProperty(process, "stdin", { value: fakeStdin, writable: true, @@ -132,10 +137,13 @@ beforeEach(() => { }); afterEach(() => { - // Restore real stdin/stdout (Jest resets the module between test files anyway, - // but explicit cleanup prevents cross-test leakage within this file). Object.defineProperty(process, "stdin", { - value: process.stdin, + value: realStdin, + writable: true, + configurable: true, + }); + Object.defineProperty(process, "stdout", { + value: realStdout, writable: true, configurable: true, });