From 4db47e12b068617c83a64efdab991459339ce5fe Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 24 Jul 2026 21:58:35 +0000 Subject: [PATCH 1/8] test(js): reproduce interactive agent capture requirements --- js/test/fixtures/fake-agent-tui.mjs | 39 ++++++++++++ js/test/tui.test.mjs | 96 +++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 js/test/fixtures/fake-agent-tui.mjs create mode 100644 js/test/tui.test.mjs diff --git a/js/test/fixtures/fake-agent-tui.mjs b/js/test/fixtures/fake-agent-tui.mjs new file mode 100644 index 0000000..a0fd7cf --- /dev/null +++ b/js/test/fixtures/fake-agent-tui.mjs @@ -0,0 +1,39 @@ +const clear = '\u001b[2J\u001b[H'; +const tool = process.argv[2]; +const initialDimensions = `${process.stdout.columns}x${process.stdout.rows}`; +let state = `ready:${tool}:${initialDimensions}`; + +process.stdin.setRawMode?.(true); +process.stdin.resume(); +process.stdout.write(`${clear}${state}`); + +let input = ''; +process.stdin.on('data', (chunk) => { + for (const character of chunk.toString()) { + if (character === '\r') { + state = [ + `user:${input}`, + 'tool_call:read README.md', + `waiting-resize:${process.stdout.columns}x${process.stdout.rows}`, + ].join('\n'); + process.stdout.write(`${clear}${state}`); + input = ''; + } else { + input += character; + } + } +}); + +const resizeWatcher = setInterval(() => { + const dimensions = `${process.stdout.columns}x${process.stdout.rows}`; + if (dimensions !== initialDimensions) { + clearInterval(resizeWatcher); + state = [ + state, + `assistant:${tool} completed`, + `resized:${dimensions}`, + ].join('\n'); + process.stdout.write(`${clear}${state}`); + setTimeout(() => process.exit(0), 20); + } +}, 5); diff --git a/js/test/tui.test.mjs b/js/test/tui.test.mjs new file mode 100644 index 0000000..8371b5c --- /dev/null +++ b/js/test/tui.test.mjs @@ -0,0 +1,96 @@ +import assert from 'node:assert'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { + buildAgentTuiLaunch, + captureAgentTui, + normalizeTuiTranscript, +} from '../src/index.mjs'; + +const directory = dirname(fileURLToPath(import.meta.url)); +const tools = ['claude', 'codex', 'opencode', 'agent', 'gemini', 'qwen']; +const isDeno = typeof globalThis.Deno !== 'undefined'; + +test('buildAgentTuiLaunch selects interactive mode for every supported tool', () => { + for (const tool of tools) { + const launch = buildAgentTuiLaunch({ + tool, + workingDirectory: '/workspace', + executable: `${tool}-test`, + model: 'test-model', + }); + + assert.strictEqual(launch.file, `${tool}-test`); + assert.strictEqual(launch.cwd, '/workspace'); + assert.ok(!launch.args.includes('exec'), tool); + assert.ok(!launch.args.includes('run'), tool); + assert.ok(!launch.args.includes('--json'), tool); + assert.ok(!launch.args.includes('stream-json'), tool); + } +}); + +test( + 'captureAgentTui drives and normalizes all agent clients', + { skip: isDeno }, + async (t) => { + const artifactRoot = await mkdtemp(join(tmpdir(), 'agent-tui-')); + t.after(() => rm(artifactRoot, { recursive: true, force: true })); + + for (const tool of tools) { + const artifactDirectory = join(artifactRoot, tool); + const capture = await captureAgentTui({ + tool, + workingDirectory: process.cwd(), + executable: process.execPath, + prefixArgs: [join(directory, 'fixtures/fake-agent-tui.mjs'), tool], + prompt: 'hello', + cols: 24, + rows: 6, + interactions: [ + { + after: 'waiting-resize', + resize: { cols: 40, rows: 10 }, + }, + ], + artifactDirectory, + }); + + assert.strictEqual(capture.exitCode, 0, tool); + assert.deepStrictEqual( + capture.events.map(({ type }) => type), + ['message', 'tool_call', 'message'], + tool + ); + assert.strictEqual(capture.events[0].role, 'user', tool); + assert.strictEqual(capture.events[2].content, `${tool} completed`, tool); + assert.match(capture.transcript, /resized:40x10/, tool); + assert.ok( + (await readFile(join(artifactDirectory, 'recording.svg'))).length > 0, + tool + ); + } + } +); + +test('normalizeTuiTranscript keeps repeated semantic events in order', () => { + assert.deepStrictEqual( + normalizeTuiTranscript( + [ + 'user:same', + 'assistant:first', + 'user:same', + 'tool_call:read README.md', + ].join('\n') + ), + [ + { type: 'message', role: 'user', content: 'same' }, + { type: 'message', role: 'assistant', content: 'first' }, + { type: 'message', role: 'user', content: 'same' }, + { type: 'tool_call', name: 'read', input: 'README.md' }, + ] + ); +}); From c9623a8e2c7c73bd3e0e16d34ac84e7e10a1bdaf Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 24 Jul 2026 22:09:45 +0000 Subject: [PATCH 2/8] feat(js): capture interactive agent sessions --- js/.changeset/tidy-taxis-capture.md | 6 + js/README.md | 34 ++++ js/src/index.mjs | 5 + js/src/tui.mjs | 258 ++++++++++++++++++++++++++++ js/test/fixtures/fake-agent-tui.mjs | 6 +- 5 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 js/.changeset/tidy-taxis-capture.md create mode 100644 js/src/tui.mjs diff --git a/js/.changeset/tidy-taxis-capture.md b/js/.changeset/tidy-taxis-capture.md new file mode 100644 index 0000000..8fc687d --- /dev/null +++ b/js/.changeset/tidy-taxis-capture.md @@ -0,0 +1,6 @@ +--- +'agent-commander': minor +--- + +Add cross-client real-TUI launch, input and resize control, semantic transcript +normalization, and terminal artifact capture backed by command-stream. diff --git a/js/README.md b/js/README.md index df0a68e..604a1cd 100644 --- a/js/README.md +++ b/js/README.md @@ -84,6 +84,40 @@ console.log(result.metadata); `result.metadata` is a normalized summary for `claude`, `codex`, `opencode`, and `agent` runs. It includes success and error classification, session ID, usage-limit reset details, result summary, cost estimates, stream token usage, optional model usage, and sub-agent call summaries. +### Real TUI capture + +Use `captureAgentTui()` when a test must exercise the same interactive terminal +interface a person sees. It supports `claude`, `codex`, `opencode`, `agent`, +`gemini`, and `qwen` without switching them to their JSON/headless modes: + +```javascript +import { captureAgentTui } from 'agent-commander'; + +const capture = await captureAgentTui({ + tool: 'codex', + workingDirectory: '/tmp/project', + prompt: 'Inspect the failing test', + cols: 100, + rows: 30, + interactions: [ + { after: 'Choose an option', key: 'DOWN' }, + { after: 'Second option', key: 'ENTER' }, + { after: 'Working', resize: { cols: 120, rows: 40 } }, + ], + stopMarker: 'Finished', + artifactDirectory: 'artifacts/codex', +}); + +console.log(capture.transcript); +console.log(capture.events); // normalized message and tool_call events +``` + +The result includes command-stream's unrolled transcript, settled frames, raw +asciicast event stream, and normalized semantic events. The artifact directory +contains `transcript.txt`, `frames.json`, `session.cast`, `snapshot.svg`, and +the self-contained animated `recording.svg`. Partial artifacts are also written +when the terminal command times out. + For large generated prompts, pass `promptFile` or let the controller create a temporary prompt file automatically for `claude`, `codex`, `opencode`, `agent`, `qwen`, and `gemini`: ```javascript diff --git a/js/src/index.mjs b/js/src/index.mjs index adbb406..069b899 100644 --- a/js/src/index.mjs +++ b/js/src/index.mjs @@ -654,6 +654,11 @@ export { tools, getTool, listTools, isToolSupported } from './tools/index.mjs'; export { JsonOutputStream, JsonInputStream } from './streaming/index.mjs'; export { parseNdjsonLine, stringifyNdjsonLine } from './streaming/ndjson.mjs'; export { buildNormalizedResultMetadata } from './result-metadata.mjs'; +export { + buildAgentTuiLaunch, + captureAgentTui, + normalizeTuiTranscript, +} from './tui.mjs'; export { ASK_SUPPORTED_TOOLS, ASK_DECISIONS, diff --git a/js/src/tui.mjs b/js/src/tui.mjs new file mode 100644 index 0000000..9930771 --- /dev/null +++ b/js/src/tui.mjs @@ -0,0 +1,258 @@ +import { captureTerminal } from 'command-stream'; + +import { getTool, isToolSupported } from './tools/index.mjs'; +import { normalizeExtraArgs, normalizeExtraEnv } from './tools/shell.mjs'; + +const READ_ONLY_OPENCODE_PERMISSION = + '{"edit":"deny","bash":"deny","task":"deny"}'; + +const mappedModel = (tool, model) => + model && tool.mapModelToId ? tool.mapModelToId({ model }) : model; + +const safetyArgs = ({ readOnly, approveEach, skipDefaultSafetyFlags }) => { + if (readOnly) { + return ['--sandbox', 'read-only', '--ask-for-approval', 'never']; + } + if (approveEach) { + return ['--ask-for-approval', 'on-request']; + } + return skipDefaultSafetyFlags + ? [] + : ['--dangerously-bypass-approvals-and-sandbox']; +}; + +const claudeArgs = (options, tool) => { + const args = []; + if (options.readOnly) { + args.push('--permission-mode', 'plan'); + } else if (options.approveEach) { + args.push('--permission-mode', 'default'); + } else if (!options.skipDefaultSafetyFlags) { + args.push('--dangerously-skip-permissions'); + } + if (options.model) { + args.push('--model', mappedModel(tool, options.model)); + } + if (options.systemPrompt) { + args.push('--append-system-prompt', options.systemPrompt); + } + if (options.resume) { + args.push('--resume', options.resume); + } + args.push('--ax-screen-reader'); + return args; +}; + +const codexArgs = (options, tool) => { + const args = safetyArgs(options); + if (options.model) { + args.unshift('--model', mappedModel(tool, options.model)); + } + args.push('--no-alt-screen'); + if (options.resume) { + args.push('resume', options.resume); + } + return args; +}; + +const opencodeArgs = (options, tool) => { + const args = ['--mini', '--no-replay']; + if (options.model) { + args.push('--model', mappedModel(tool, options.model)); + } + if (options.resume) { + args.push('--session', options.resume); + } + if (!options.readOnly && !options.skipDefaultSafetyFlags) { + args.push('--auto'); + } + return args; +}; + +const agentArgs = (options, tool) => { + const args = []; + if (options.model) { + args.push('--model', mappedModel(tool, options.model)); + } + if (options.readOnly) { + args.push('--permission-mode', 'readonly'); + } else if (options.planOnly) { + args.push('--permission-mode', 'plan'); + } else if (options.approveEach) { + args.push('--permission-mode', 'ask'); + } + if (options.resume) { + args.push('--resume', options.resume); + } + return args; +}; + +const geminiArgs = (options, tool) => { + const args = []; + if (options.model) { + args.push('--model', mappedModel(tool, options.model)); + } + if (options.readOnly) { + args.push('--approval-mode', 'plan'); + } else if (!options.skipDefaultSafetyFlags) { + args.push('--yolo'); + } + return args; +}; + +const qwenArgs = (options, tool) => { + const args = []; + if (options.model) { + args.push('--model', mappedModel(tool, options.model)); + } + if (options.readOnly) { + args.push('--approval-mode', 'plan'); + } else if (!options.skipDefaultSafetyFlags) { + args.push('--yolo'); + } + if (options.resume) { + args.push('--resume', options.resume); + } + return args; +}; + +const ARG_BUILDERS = { + claude: claudeArgs, + codex: codexArgs, + opencode: opencodeArgs, + agent: agentArgs, + gemini: geminiArgs, + qwen: qwenArgs, +}; + +const launchEnvironment = ({ extraEnv, tool, readOnly }) => { + const entries = normalizeExtraEnv(extraEnv); + if (tool === 'opencode' && readOnly) { + entries.push(['OPENCODE_PERMISSION', READ_ONLY_OPENCODE_PERMISSION]); + } + return { ...process.env, ...Object.fromEntries(entries) }; +}; + +/** + * Build an argv-based interactive launch without a shell or headless flags. + */ +export const buildAgentTuiLaunch = (options) => { + const { + tool: toolName, + workingDirectory, + executable, + prefixArgs = [], + extraArgs = [], + } = options; + if (!isToolSupported({ toolName })) { + throw new Error(`Unsupported TUI tool: ${toolName}`); + } + if (!workingDirectory) { + throw new Error('workingDirectory is required'); + } + + const tool = getTool({ toolName }); + const args = ARG_BUILDERS[toolName](options, tool); + return { + file: executable ?? tool.executable, + args: [ + ...normalizeExtraArgs(prefixArgs), + ...args, + ...normalizeExtraArgs(extraArgs), + ], + cwd: workingDirectory, + env: launchEnvironment({ + extraEnv: options.extraEnv, + tool: toolName, + readOnly: options.readOnly, + }), + }; +}; + +const messageEvent = (line) => { + const match = line.match(/^(?:[│┃>›❯•*]\s*)?(user|assistant):\s*(.*)$/i); + if (!match) { + return undefined; + } + return { + type: 'message', + role: match[1].toLowerCase(), + content: match[2], + }; +}; + +const toolCallEvent = (line) => { + const match = line.match( + /^(?:[│┃>›❯•*]\s*)?tool[_ ]call:\s*([A-Za-z0-9_.-]+)(?:\s+(.*))?$/i + ); + if (!match) { + return undefined; + } + return { + type: 'tool_call', + name: match[1], + input: match[2] ?? '', + }; +}; + +/** + * Extract a stable cross-client message/tool-call stream from TUI text. + */ +export const normalizeTuiTranscript = (transcript) => { + const events = []; + for (const rawLine of transcript.split('\n')) { + const line = rawLine.trim(); + const event = messageEvent(line) ?? toolCallEvent(line); + if (event) { + events.push(event); + } + } + return events; +}; + +/** + * Capture and drive an agent's real interactive terminal interface. + */ +export const captureAgentTui = async (options) => { + const { + tool, + prompt, + systemPrompt, + promptAfter, + promptKey = 'ENTER', + interactions = [], + cols, + rows, + settleMilliseconds, + stopMarker, + stopMarkerGraceMilliseconds, + timeoutMilliseconds, + artifactDirectory, + onTrace, + } = options; + const launch = buildAgentTuiLaunch(options); + const combinedPrompt = + systemPrompt && tool !== 'claude' + ? `${systemPrompt}\n\n${prompt ?? ''}` + : prompt; + const promptInteraction = combinedPrompt + ? [{ after: promptAfter, text: combinedPrompt, key: promptKey }] + : []; + const capture = await captureTerminal({ + ...launch, + cols, + rows, + settleMilliseconds, + interactions: [...promptInteraction, ...interactions], + stopMarker, + stopMarkerGraceMilliseconds, + timeoutMilliseconds, + artifactDirectory, + onTrace, + }); + return { + ...capture, + tool, + events: normalizeTuiTranscript(capture.transcript), + }; +}; diff --git a/js/test/fixtures/fake-agent-tui.mjs b/js/test/fixtures/fake-agent-tui.mjs index a0fd7cf..6ae0624 100644 --- a/js/test/fixtures/fake-agent-tui.mjs +++ b/js/test/fixtures/fake-agent-tui.mjs @@ -24,16 +24,16 @@ process.stdin.on('data', (chunk) => { } }); -const resizeWatcher = setInterval(() => { +const resizeWatcher = globalThis.setInterval(() => { const dimensions = `${process.stdout.columns}x${process.stdout.rows}`; if (dimensions !== initialDimensions) { - clearInterval(resizeWatcher); + globalThis.clearInterval(resizeWatcher); state = [ state, `assistant:${tool} completed`, `resized:${dimensions}`, ].join('\n'); process.stdout.write(`${clear}${state}`); - setTimeout(() => process.exit(0), 20); + globalThis.setTimeout(() => process.exit(0), 20); } }, 5); From 5c77acd2cd1acb6f8673027cb2611af99b22ac0e Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 24 Jul 2026 22:31:41 +0000 Subject: [PATCH 3/8] test(rust): reproduce interactive agent capture requirements --- rust/Cargo.lock | 664 +++++++++++++++++++++++++++++++++++++++- rust/Cargo.toml | 2 + rust/tests/tui_tests.rs | 126 ++++++++ 3 files changed, 786 insertions(+), 6 deletions(-) create mode 100644 rust/tests/tui_tests.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 7c2d43d..438c20b 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -6,31 +6,173 @@ version = 4 name = "agent-commander" version = "0.1.0" dependencies = [ + "command-stream", "serde", "serde_json", - "thiserror", + "tempfile", + "thiserror 2.0.18", "tokio", "tokio-test", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "command-stream" +version = "0.12.1" +source = "git+https://github.com/link-foundation/command-stream.git?rev=c1b3784ff61c90f9a780fa781003b30d299a73bf#c1b3784ff61c90f9a780fa781003b30d299a73bf" +dependencies = [ + "async-trait", + "chrono", + "filetime", + "glob", + "libc", + "nix 0.29.0", + "once_cell", + "portable-pty", + "regex", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "vt100", + "which", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + [[package]] name = "errno" version = "0.3.14" @@ -41,24 +183,139 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "futures-core" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "lock_api" version = "0.4.14" @@ -68,6 +325,12 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + [[package]] name = "memchr" version = "2.8.0" @@ -85,6 +348,45 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases 0.2.2", + "libc", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "parking_lot" version = "0.12.5" @@ -114,6 +416,27 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.28.0", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -132,15 +455,69 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.1", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "scopeguard" version = "1.2.0" @@ -174,7 +551,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -190,6 +567,39 @@ dependencies = [ "zmij", ] +[[package]] +name = "serial2" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1" +dependencies = [ + "cfg-if", + "libc", + "windows-sys", +] + +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -200,6 +610,12 @@ dependencies = [ "libc", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" @@ -227,13 +643,57 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -244,7 +704,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -272,7 +732,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -303,18 +763,195 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vt100" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84cd863bf0db7e392ba3bd04994be3473491b31e66340672af5d11943c6274de" +dependencies = [ + "itoa", + "log", + "unicode-width", + "vte", +] + +[[package]] +name = "vte" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197" +dependencies = [ + "arrayvec", + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte_generate_state_changes" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix", + "winsafe", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -324,6 +961,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "zmij" version = "1.0.21" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index e64b957..cb44116 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -22,12 +22,14 @@ name = "stop-agent" path = "src/bin/stop_agent.rs" [dependencies] +command-stream = { git = "https://github.com/link-foundation/command-stream.git", rev = "c1b3784ff61c90f9a780fa781003b30d299a73bf" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "2.0" tokio = { version = "1.0", features = ["full"] } [dev-dependencies] +tempfile = "3.20" tokio-test = "0.4" [lints.rust] diff --git a/rust/tests/tui_tests.rs b/rust/tests/tui_tests.rs new file mode 100644 index 0000000..2ec6a7c --- /dev/null +++ b/rust/tests/tui_tests.rs @@ -0,0 +1,126 @@ +#![cfg(unix)] + +use agent_commander::tui::{ + build_agent_tui_launch, capture_agent_tui, normalize_tui_transcript, AgentTuiEvent, + AgentTuiOptions, +}; +use command_stream::terminal::{TerminalInteraction, TerminalKey, TerminalResize}; +use std::time::Duration; +use tempfile::tempdir; + +const TOOLS: [&str; 6] = ["claude", "codex", "opencode", "agent", "gemini", "qwen"]; + +#[test] +fn selects_interactive_launches_for_every_supported_tool() { + for tool in TOOLS { + let launch = build_agent_tui_launch(&AgentTuiOptions { + tool: tool.into(), + working_directory: "/workspace".into(), + executable: Some(format!("{tool}-test")), + model: Some("test-model".into()), + ..AgentTuiOptions::default() + }) + .expect("interactive launch"); + + assert_eq!(launch.file, format!("{tool}-test")); + assert_eq!( + launch.cwd.expect("working directory").to_string_lossy(), + "/workspace" + ); + for headless in ["exec", "run", "--json", "stream-json"] { + assert!(!launch.args.iter().any(|argument| argument == headless), "{tool}"); + } + } +} + +#[test] +fn drives_and_normalizes_every_agent_client() { + let script = r#" +printf '\033[2J\033[Hready:%s:%s\n' "$0" "$(stty size)" +IFS= read -r answer +printf '\033[2J\033[Huser:%s\ntool_call:read README.md\nwaiting-resize:%s\n' "$answer" "$(stty size)" +while [ "$(stty size)" != "10 40" ]; do sleep 0.01; done +printf '\033[2J\033[Huser:%s\ntool_call:read README.md\nassistant:%s completed\nresized:%s\n' "$answer" "$0" "$(stty size)" +"#; + + for tool in TOOLS { + let artifacts = tempdir().expect("artifact directory"); + let capture = capture_agent_tui(AgentTuiOptions { + tool: tool.into(), + working_directory: std::env::current_dir().expect("current directory"), + executable: Some("/bin/sh".into()), + prefix_args: vec!["-c".into(), script.into(), tool.into()], + prompt: Some("hello".into()), + cols: 24, + rows: 6, + settle_duration: Duration::from_millis(10), + timeout: Duration::from_secs(3), + interactions: vec![TerminalInteraction { + after: Some("waiting-resize".into()), + resize: Some(TerminalResize { cols: 40, rows: 10 }), + ..TerminalInteraction::default() + }], + artifact_directory: Some(artifacts.path().into()), + ..AgentTuiOptions::default() + }) + .expect("interactive capture"); + + assert_eq!(capture.terminal.exit_code, 0, "{tool}"); + assert_eq!( + capture.events, + vec![ + AgentTuiEvent::Message { + role: "user".into(), + content: "hello".into(), + }, + AgentTuiEvent::ToolCall { + name: "read".into(), + input: "README.md".into(), + }, + AgentTuiEvent::Message { + role: "assistant".into(), + content: format!("{tool} completed"), + }, + ], + "{tool}" + ); + assert!(capture.terminal.transcript.contains("resized:10 40"), "{tool}"); + assert!(artifacts.path().join("recording.svg").is_file(), "{tool}"); + } +} + +#[test] +fn keeps_repeated_semantic_events_in_order() { + assert_eq!( + normalize_tui_transcript( + "user:same\nassistant:first\nuser:same\ntool_call:read README.md" + ), + vec![ + AgentTuiEvent::Message { + role: "user".into(), + content: "same".into(), + }, + AgentTuiEvent::Message { + role: "assistant".into(), + content: "first".into(), + }, + AgentTuiEvent::Message { + role: "user".into(), + content: "same".into(), + }, + AgentTuiEvent::ToolCall { + name: "read".into(), + input: "README.md".into(), + }, + ] + ); +} + +#[test] +fn prompt_uses_enter_control_key() { + let options = AgentTuiOptions { + prompt_key: TerminalKey::Enter, + ..AgentTuiOptions::default() + }; + assert_eq!(options.prompt_key, TerminalKey::Enter); +} From f4bff8a2c277eaf016fcbb36ebd03ae641a4c1f3 Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 24 Jul 2026 22:39:27 +0000 Subject: [PATCH 4/8] feat(rust): capture interactive agent sessions --- rust/README.md | 23 + ...20260724_223500_interactive_tui_capture.md | 7 + rust/src/lib.rs | 1 + rust/src/tui.rs | 392 ++++++++++++++++++ rust/tests/tui_tests.rs | 14 +- 5 files changed, 432 insertions(+), 5 deletions(-) create mode 100644 rust/changelog.d/20260724_223500_interactive_tui_capture.md create mode 100644 rust/src/tui.rs diff --git a/rust/README.md b/rust/README.md index 1270d52..ac382fa 100644 --- a/rust/README.md +++ b/rust/README.md @@ -144,3 +144,26 @@ cargo fmt --all -- --check cargo clippy --all-targets --all-features cargo test --all-features ``` + +## Interactive Terminal Capture + +Use `tui::capture_agent_tui` when a test needs the client's real terminal +interface rather than its headless JSON mode. The capture drives text, control +keys, and resizes through a PTY, then returns an unrolled transcript, normalized +message/tool-call events, settled frames, and asciicast-compatible replay data. +When `artifact_directory` is set it also writes a transcript, frame data, a +static snapshot, a cast, and an animated SVG suitable for CI artifacts. + +```rust,no_run +use agent_commander::tui::{capture_agent_tui, AgentTuiOptions}; + +let capture = capture_agent_tui(AgentTuiOptions { + tool: "codex".into(), + working_directory: std::env::current_dir()?, + prompt: Some("Summarize this repository".into()), + artifact_directory: Some("artifacts/codex".into()), + ..AgentTuiOptions::default() +})?; +println!("{}", capture.terminal.transcript); +# Ok::<(), Box>(()) +``` diff --git a/rust/changelog.d/20260724_223500_interactive_tui_capture.md b/rust/changelog.d/20260724_223500_interactive_tui_capture.md new file mode 100644 index 0000000..27e8c09 --- /dev/null +++ b/rust/changelog.d/20260724_223500_interactive_tui_capture.md @@ -0,0 +1,7 @@ +--- +bump: minor +--- + +### Added + +- Add real PTY-backed interactive agent capture with input, control-key, resize, unrolled transcripts, semantic events, and replay artifacts for Claude, Codex, OpenCode, Agent, Gemini, and Qwen. diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 30a6f4c..7a0efcf 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -16,6 +16,7 @@ pub mod permissions; pub mod result_metadata; pub mod streaming; pub mod tools; +pub mod tui; use serde_json::{json, Value}; use std::path::PathBuf; diff --git a/rust/src/tui.rs b/rust/src/tui.rs new file mode 100644 index 0000000..fc2e819 --- /dev/null +++ b/rust/src/tui.rs @@ -0,0 +1,392 @@ +//! Interactive terminal launch and capture for supported agent clients. + +use crate::tools::{agent, claude, codex, gemini, opencode, qwen}; +use command_stream::terminal::{ + capture_terminal, TerminalCapture, TerminalCaptureError, TerminalCaptureOptions, + TerminalInteraction, TerminalKey, +}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; +use thiserror::Error; + +const READ_ONLY_OPENCODE_PERMISSION: &str = r#"{"edit":"deny","bash":"deny","task":"deny"}"#; +const SUPPORTED_TOOLS: [&str; 6] = ["claude", "codex", "opencode", "agent", "gemini", "qwen"]; + +/// Options for launching and driving an agent's real interactive terminal UI. +#[derive(Debug, Clone)] +pub struct AgentTuiOptions { + /// Agent CLI name. + pub tool: String, + /// Directory in which the agent runs. + pub working_directory: PathBuf, + /// Override for the agent executable. + pub executable: Option, + /// Arguments inserted before the generated agent arguments. + pub prefix_args: Vec, + /// Additional agent arguments. + pub extra_args: Vec, + /// Additional environment variables. + pub extra_env: HashMap, + /// Optional model name or alias. + pub model: Option, + /// Initial user prompt. + pub prompt: Option, + /// Optional system prompt. + pub system_prompt: Option, + /// Marker that must be observed before sending the initial prompt. + pub prompt_after: Option, + /// Control key sent after the initial prompt. + pub prompt_key: TerminalKey, + /// Session identifier to resume. + pub resume: Option, + /// Request the client's native read-only mode. + pub read_only: bool, + /// Request the client's native plan-only mode where distinct. + pub plan_only: bool, + /// Request approval for individual operations. + pub approve_each: bool, + /// Suppress the default autonomous safety-bypass flags. + pub skip_default_safety_flags: bool, + /// Initial terminal width. + pub cols: u16, + /// Initial terminal height. + pub rows: u16, + /// Time without output before a frame is considered settled. + pub settle_duration: Duration, + /// Input, control-key, and resize interactions. + pub interactions: Vec, + /// Marker after which the capture process can be stopped. + pub stop_marker: Option, + /// Grace period after the stop marker. + pub stop_marker_grace: Duration, + /// Overall capture timeout. + pub timeout: Duration, + /// Directory for transcript, frame, cast, and animation artifacts. + pub artifact_directory: Option, +} + +impl Default for AgentTuiOptions { + fn default() -> Self { + Self { + tool: String::new(), + working_directory: PathBuf::new(), + executable: None, + prefix_args: Vec::new(), + extra_args: Vec::new(), + extra_env: HashMap::new(), + model: None, + prompt: None, + system_prompt: None, + prompt_after: None, + prompt_key: TerminalKey::Enter, + resume: None, + read_only: false, + plan_only: false, + approve_each: false, + skip_default_safety_flags: false, + cols: 80, + rows: 24, + settle_duration: Duration::from_millis(35), + interactions: Vec::new(), + stop_marker: None, + stop_marker_grace: Duration::from_millis(250), + timeout: Duration::from_secs(30), + artifact_directory: None, + } + } +} + +/// Executable, argv, cwd, and environment for an interactive agent launch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentTuiLaunch { + /// Executable to launch. + pub file: String, + /// Shell-free argument vector. + pub args: Vec, + /// Working directory. + pub cwd: Option, + /// Environment overrides. + pub env: HashMap, +} + +/// Stable semantic event extracted from an agent TUI transcript. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AgentTuiEvent { + /// User or assistant message. + Message { + /// Normalized lowercase role. + role: String, + /// Visible message content. + content: String, + }, + /// Tool invocation shown by the client. + ToolCall { + /// Tool name. + name: String, + /// Visible tool input. + input: String, + }, +} + +/// Terminal capture and normalized events for one agent. +#[derive(Debug, Clone)] +pub struct AgentTuiCapture { + /// Agent client name. + pub tool: String, + /// Lossless terminal capture and replay data. + pub terminal: TerminalCapture, + /// Stable semantic events extracted from the unrolled transcript. + pub events: Vec, +} + +/// Error while preparing or capturing an interactive agent terminal. +#[derive(Debug, Error)] +pub enum AgentTuiError { + /// Launch options are unsupported or incomplete. + #[error("{0}")] + Launch(String), + /// PTY capture failed. + #[error(transparent)] + Capture(#[from] TerminalCaptureError), +} + +fn mapped_model(tool: &str, model: &str) -> String { + match tool { + "claude" => claude::map_model_to_id(model), + "codex" => codex::map_model_to_id(model), + "opencode" => opencode::map_model_to_id(model), + "agent" => agent::map_model_to_id(model), + "gemini" => gemini::map_model_to_id(model), + "qwen" => qwen::map_model_to_id(model), + _ => model.to_string(), + } +} + +fn push_model(args: &mut Vec, options: &AgentTuiOptions) { + if let Some(model) = &options.model { + args.push("--model".into()); + args.push(mapped_model(&options.tool, model)); + } +} + +fn claude_args(options: &AgentTuiOptions) -> Vec { + let mut args = Vec::new(); + if options.read_only { + args.extend(["--permission-mode".into(), "plan".into()]); + } else if options.approve_each { + args.extend(["--permission-mode".into(), "default".into()]); + } else if !options.skip_default_safety_flags { + args.push("--dangerously-skip-permissions".into()); + } + push_model(&mut args, options); + if let Some(system_prompt) = &options.system_prompt { + args.extend(["--append-system-prompt".into(), system_prompt.clone()]); + } + if let Some(resume) = &options.resume { + args.extend(["--resume".into(), resume.clone()]); + } + args.push("--ax-screen-reader".into()); + args +} + +fn codex_args(options: &AgentTuiOptions) -> Vec { + let mut args = Vec::new(); + push_model(&mut args, options); + if options.read_only { + args.extend([ + "--sandbox".into(), + "read-only".into(), + "--ask-for-approval".into(), + "never".into(), + ]); + } else if options.approve_each { + args.extend(["--ask-for-approval".into(), "on-request".into()]); + } else if !options.skip_default_safety_flags { + args.push("--dangerously-bypass-approvals-and-sandbox".into()); + } + args.push("--no-alt-screen".into()); + if let Some(resume) = &options.resume { + args.extend(["resume".into(), resume.clone()]); + } + args +} + +fn opencode_args(options: &AgentTuiOptions) -> Vec { + let mut args = vec!["--mini".into(), "--no-replay".into()]; + push_model(&mut args, options); + if let Some(resume) = &options.resume { + args.extend(["--session".into(), resume.clone()]); + } + if !options.read_only && !options.skip_default_safety_flags { + args.push("--auto".into()); + } + args +} + +fn agent_args(options: &AgentTuiOptions) -> Vec { + let mut args = Vec::new(); + push_model(&mut args, options); + let permission = if options.read_only { + Some("readonly") + } else if options.plan_only { + Some("plan") + } else if options.approve_each { + Some("ask") + } else { + None + }; + if let Some(permission) = permission { + args.extend(["--permission-mode".into(), permission.into()]); + } + if let Some(resume) = &options.resume { + args.extend(["--resume".into(), resume.clone()]); + } + args +} + +fn gemini_args(options: &AgentTuiOptions) -> Vec { + let mut args = Vec::new(); + push_model(&mut args, options); + if options.read_only { + args.extend(["--approval-mode".into(), "plan".into()]); + } else if !options.skip_default_safety_flags { + args.push("--yolo".into()); + } + args +} + +fn qwen_args(options: &AgentTuiOptions) -> Vec { + let mut args = Vec::new(); + push_model(&mut args, options); + if options.read_only { + args.extend(["--approval-mode".into(), "plan".into()]); + } else if !options.skip_default_safety_flags { + args.push("--yolo".into()); + } + if let Some(resume) = &options.resume { + args.extend(["--resume".into(), resume.clone()]); + } + args +} + +/// Build a shell-free interactive launch for a supported agent client. +pub fn build_agent_tui_launch(options: &AgentTuiOptions) -> Result { + if !SUPPORTED_TOOLS.contains(&options.tool.as_str()) { + return Err(AgentTuiError::Launch(format!( + "unsupported TUI tool: {}", + options.tool + ))); + } + if options.working_directory.as_os_str().is_empty() { + return Err(AgentTuiError::Launch( + "working_directory is required".into(), + )); + } + let generated = match options.tool.as_str() { + "claude" => claude_args(options), + "codex" => codex_args(options), + "opencode" => opencode_args(options), + "agent" => agent_args(options), + "gemini" => gemini_args(options), + "qwen" => qwen_args(options), + _ => unreachable!("supported tools were validated"), + }; + let mut args = + Vec::with_capacity(options.prefix_args.len() + generated.len() + options.extra_args.len()); + args.extend(options.prefix_args.clone()); + args.extend(generated); + args.extend(options.extra_args.clone()); + let mut env = options.extra_env.clone(); + if options.tool == "opencode" && options.read_only { + env.insert( + "OPENCODE_PERMISSION".into(), + READ_ONLY_OPENCODE_PERMISSION.into(), + ); + } + Ok(AgentTuiLaunch { + file: options + .executable + .clone() + .unwrap_or_else(|| options.tool.clone()), + args, + cwd: Some(options.working_directory.clone()), + env, + }) +} + +fn strip_marker(line: &str) -> &str { + line.trim_start_matches(|character: char| { + matches!(character, '│' | '┃' | '>' | '›' | '❯' | '•' | '*') || character.is_whitespace() + }) +} + +/// Extract stable message and tool-call events without removing repeated states. +pub fn normalize_tui_transcript(transcript: &str) -> Vec { + transcript + .lines() + .filter_map(|raw_line| { + let line = strip_marker(raw_line.trim()); + let (kind, content) = line.split_once(':')?; + if kind.eq_ignore_ascii_case("user") || kind.eq_ignore_ascii_case("assistant") { + return Some(AgentTuiEvent::Message { + role: kind.to_ascii_lowercase(), + content: content.trim().into(), + }); + } + if kind.eq_ignore_ascii_case("tool_call") || kind.eq_ignore_ascii_case("tool call") { + let (name, input) = content + .trim() + .split_once(char::is_whitespace) + .unwrap_or_else(|| (content.trim(), "")); + return Some(AgentTuiEvent::ToolCall { + name: name.into(), + input: input.trim().into(), + }); + } + None + }) + .collect() +} + +/// Capture and drive an agent's real interactive terminal interface. +pub fn capture_agent_tui(options: AgentTuiOptions) -> Result { + let launch = build_agent_tui_launch(&options)?; + let combined_prompt = match (&options.system_prompt, &options.prompt) { + (Some(system), Some(prompt)) if options.tool != "claude" => { + Some(format!("{system}\n\n{prompt}")) + } + (Some(system), None) if options.tool != "claude" => Some(system.clone()), + (_, prompt) => prompt.clone(), + }; + let mut interactions = + Vec::with_capacity(options.interactions.len() + usize::from(combined_prompt.is_some())); + if let Some(prompt) = combined_prompt { + interactions.push(TerminalInteraction { + after: options.prompt_after.clone(), + text: Some(prompt), + key: Some(options.prompt_key.clone()), + resize: None, + }); + } + interactions.extend(options.interactions.clone()); + let terminal = capture_terminal(TerminalCaptureOptions { + file: launch.file, + args: launch.args, + cwd: launch.cwd, + env: launch.env, + cols: options.cols, + rows: options.rows, + settle_duration: options.settle_duration, + interactions, + stop_marker: options.stop_marker, + stop_marker_grace: options.stop_marker_grace, + timeout: options.timeout, + artifact_directory: options.artifact_directory, + })?; + Ok(AgentTuiCapture { + tool: options.tool, + events: normalize_tui_transcript(&terminal.transcript), + terminal, + }) +} diff --git a/rust/tests/tui_tests.rs b/rust/tests/tui_tests.rs index 2ec6a7c..33ff8da 100644 --- a/rust/tests/tui_tests.rs +++ b/rust/tests/tui_tests.rs @@ -28,7 +28,10 @@ fn selects_interactive_launches_for_every_supported_tool() { "/workspace" ); for headless in ["exec", "run", "--json", "stream-json"] { - assert!(!launch.args.iter().any(|argument| argument == headless), "{tool}"); + assert!( + !launch.args.iter().any(|argument| argument == headless), + "{tool}" + ); } } } @@ -84,7 +87,10 @@ printf '\033[2J\033[Huser:%s\ntool_call:read README.md\nassistant:%s completed\n ], "{tool}" ); - assert!(capture.terminal.transcript.contains("resized:10 40"), "{tool}"); + assert!( + capture.terminal.transcript.contains("resized:10 40"), + "{tool}" + ); assert!(artifacts.path().join("recording.svg").is_file(), "{tool}"); } } @@ -92,9 +98,7 @@ printf '\033[2J\033[Huser:%s\ntool_call:read README.md\nassistant:%s completed\n #[test] fn keeps_repeated_semantic_events_in_order() { assert_eq!( - normalize_tui_transcript( - "user:same\nassistant:first\nuser:same\ntool_call:read README.md" - ), + normalize_tui_transcript("user:same\nassistant:first\nuser:same\ntool_call:read README.md"), vec![ AgentTuiEvent::Message { role: "user".into(), From 25ea213aae16129a997555ae4d71dd2acc360454 Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 24 Jul 2026 23:17:40 +0000 Subject: [PATCH 5/8] test: require native interactive safety mappings --- js/test/tui.test.mjs | 51 +++++++++++++++++++++++++++++++++++++++++ rust/tests/tui_tests.rs | 50 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/js/test/tui.test.mjs b/js/test/tui.test.mjs index 8371b5c..2c57585 100644 --- a/js/test/tui.test.mjs +++ b/js/test/tui.test.mjs @@ -33,6 +33,57 @@ test('buildAgentTuiLaunch selects interactive mode for every supported tool', () } }); +test('buildAgentTuiLaunch maps interactive safety and resume options', () => { + const launch = (tool, options = {}) => + buildAgentTuiLaunch({ + tool, + workingDirectory: '/workspace', + executable: `${tool}-test`, + resume: 'session-1', + ...options, + }).args; + + assert.deepStrictEqual(launch('claude', { readOnly: true }), [ + '--permission-mode', + 'plan', + '--resume', + 'session-1', + '--ax-screen-reader', + ]); + assert.deepStrictEqual(launch('codex', { readOnly: true }), [ + '--sandbox', + 'read-only', + '--ask-for-approval', + 'never', + '--no-alt-screen', + 'resume', + 'session-1', + ]); + assert.deepStrictEqual(launch('opencode', { approveEach: true }), [ + '--mini', + '--no-replay', + '--session', + 'session-1', + ]); + assert.deepStrictEqual(launch('agent', { approveEach: true }), [ + '--permission-mode', + 'ask', + '--resume', + 'session-1', + ]); + assert.deepStrictEqual(launch('gemini', { approveEach: true }), [ + '--approval-mode', + 'default', + '--resume', + 'session-1', + ]); + assert.deepStrictEqual(launch('qwen', { readOnly: true }), [ + '--read-only', + '--resume', + 'session-1', + ]); +}); + test( 'captureAgentTui drives and normalizes all agent clients', { skip: isDeno }, diff --git a/rust/tests/tui_tests.rs b/rust/tests/tui_tests.rs index 33ff8da..727de3d 100644 --- a/rust/tests/tui_tests.rs +++ b/rust/tests/tui_tests.rs @@ -36,6 +36,56 @@ fn selects_interactive_launches_for_every_supported_tool() { } } +#[test] +fn maps_interactive_safety_and_resume_options() { + let launch = |tool: &str, read_only: bool, approve_each: bool| { + build_agent_tui_launch(&AgentTuiOptions { + tool: tool.into(), + working_directory: "/workspace".into(), + executable: Some(format!("{tool}-test")), + resume: Some("session-1".into()), + read_only, + approve_each, + ..AgentTuiOptions::default() + }) + .expect("interactive launch") + .args + }; + + assert_eq!( + launch("claude", true, false), + ["--permission-mode", "plan", "--resume", "session-1", "--ax-screen-reader"] + ); + assert_eq!( + launch("codex", true, false), + [ + "--sandbox", + "read-only", + "--ask-for-approval", + "never", + "--no-alt-screen", + "resume", + "session-1", + ] + ); + assert_eq!( + launch("opencode", false, true), + ["--mini", "--no-replay", "--session", "session-1"] + ); + assert_eq!( + launch("agent", false, true), + ["--permission-mode", "ask", "--resume", "session-1"] + ); + assert_eq!( + launch("gemini", false, true), + ["--approval-mode", "default", "--resume", "session-1"] + ); + assert_eq!( + launch("qwen", true, false), + ["--read-only", "--resume", "session-1"] + ); +} + #[test] fn drives_and_normalizes_every_agent_client() { let script = r#" From 6405d67b36944f2c5c3b7e07343e7fe5748b7ef9 Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 24 Jul 2026 23:18:12 +0000 Subject: [PATCH 6/8] fix: map native interactive safety options --- js/src/tui.mjs | 15 +++++++++++---- rust/src/tui.rs | 11 +++++++---- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/js/src/tui.mjs b/js/src/tui.mjs index 9930771..89bffcc 100644 --- a/js/src/tui.mjs +++ b/js/src/tui.mjs @@ -63,7 +63,11 @@ const opencodeArgs = (options, tool) => { if (options.resume) { args.push('--session', options.resume); } - if (!options.readOnly && !options.skipDefaultSafetyFlags) { + if ( + !options.readOnly && + !options.approveEach && + !options.skipDefaultSafetyFlags + ) { args.push('--auto'); } return args; @@ -94,9 +98,14 @@ const geminiArgs = (options, tool) => { } if (options.readOnly) { args.push('--approval-mode', 'plan'); + } else if (options.approveEach) { + args.push('--approval-mode', 'default'); } else if (!options.skipDefaultSafetyFlags) { args.push('--yolo'); } + if (options.resume) { + args.push('--resume', options.resume); + } return args; }; @@ -106,9 +115,7 @@ const qwenArgs = (options, tool) => { args.push('--model', mappedModel(tool, options.model)); } if (options.readOnly) { - args.push('--approval-mode', 'plan'); - } else if (!options.skipDefaultSafetyFlags) { - args.push('--yolo'); + args.push('--read-only'); } if (options.resume) { args.push('--resume', options.resume); diff --git a/rust/src/tui.rs b/rust/src/tui.rs index fc2e819..aa99ffa 100644 --- a/rust/src/tui.rs +++ b/rust/src/tui.rs @@ -218,7 +218,7 @@ fn opencode_args(options: &AgentTuiOptions) -> Vec { if let Some(resume) = &options.resume { args.extend(["--session".into(), resume.clone()]); } - if !options.read_only && !options.skip_default_safety_flags { + if !options.read_only && !options.approve_each && !options.skip_default_safety_flags { args.push("--auto".into()); } args @@ -250,9 +250,14 @@ fn gemini_args(options: &AgentTuiOptions) -> Vec { push_model(&mut args, options); if options.read_only { args.extend(["--approval-mode".into(), "plan".into()]); + } else if options.approve_each { + args.extend(["--approval-mode".into(), "default".into()]); } else if !options.skip_default_safety_flags { args.push("--yolo".into()); } + if let Some(resume) = &options.resume { + args.extend(["--resume".into(), resume.clone()]); + } args } @@ -260,9 +265,7 @@ fn qwen_args(options: &AgentTuiOptions) -> Vec { let mut args = Vec::new(); push_model(&mut args, options); if options.read_only { - args.extend(["--approval-mode".into(), "plan".into()]); - } else if !options.skip_default_safety_flags { - args.push("--yolo".into()); + args.push("--read-only".into()); } if let Some(resume) = &options.resume { args.extend(["--resume".into(), resume.clone()]); From d76a38b0826214e0865e5571d9091e0a34b950e8 Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 24 Jul 2026 23:45:04 +0000 Subject: [PATCH 7/8] build: consume released command-stream --- js/package-lock.json | 44 ++++++++++++++++++++++++++++++++++- js/package.json | 6 +++-- js/test/root-package.test.mjs | 1 + package.json | 6 +++-- rust/Cargo.lock | 5 ++-- rust/Cargo.toml | 2 +- rust/tests/tui_tests.rs | 8 ++++++- 7 files changed, 63 insertions(+), 9 deletions(-) diff --git a/js/package-lock.json b/js/package-lock.json index 325b51a..3773ae2 100644 --- a/js/package-lock.json +++ b/js/package-lock.json @@ -8,6 +8,9 @@ "name": "agent-commander", "version": "0.8.0", "license": "Unlicense", + "dependencies": { + "command-stream": "^0.15.0" + }, "bin": { "start-agent": "bin/start-agent.mjs", "stop-agent": "bin/stop-agent.mjs" @@ -24,7 +27,7 @@ "prettier": "^3.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@babel/helper-string-parser": { @@ -956,6 +959,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@xterm/headless": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz", + "integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1383,6 +1395,20 @@ "node": ">=0.1.90" } }, + "node_modules/command-stream": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/command-stream/-/command-stream-0.15.0.tgz", + "integrity": "sha512-Lphe8u3lZocvxdLspqPesAl+EBAFe2VAbL3Ag6wU4XcHCVWJDHQE/KXdU6J2XevipXn0ZzoIqDCYCHaHsXhWtg==", + "license": "Unlicense", + "dependencies": { + "@xterm/headless": "^6.0.0", + "node-pty": "^1.2.0-beta.14" + }, + "engines": { + "bun": ">=1.0.0", + "node": ">=20.0.0" + } + }, "node_modules/commander": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", @@ -2896,6 +2922,22 @@ "dev": true, "license": "MIT" }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/node-pty": { + "version": "1.2.0-beta.14", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.14.tgz", + "integrity": "sha512-XORU9BQgpxVgqr7WivjJ17mLenOHUgKKWzuZZNaw3NDYgHc/wPJQMSaoLDrpEgqV6aU1nNwil1o/OqYj6lWmUA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, "node_modules/node-sarif-builder": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-2.0.3.tgz", diff --git a/js/package.json b/js/package.json index 75b1c7b..334d8a3 100644 --- a/js/package.json +++ b/js/package.json @@ -46,9 +46,11 @@ "author": "", "license": "Unlicense", "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" + }, + "dependencies": { + "command-stream": "^0.15.0" }, - "dependencies": {}, "devDependencies": { "@changesets/cli": "^2.29.7", "@eslint/js": "^9.35.0", diff --git a/js/test/root-package.test.mjs b/js/test/root-package.test.mjs index ac36e77..16cd11a 100644 --- a/js/test/root-package.test.mjs +++ b/js/test/root-package.test.mjs @@ -22,6 +22,7 @@ test('root package manifest exposes the JavaScript package for GitHub installs', assert.strictEqual(rootPackage.type, jsPackage.type); assert.strictEqual(rootPackage.license, jsPackage.license); assert.deepStrictEqual(rootPackage.engines, jsPackage.engines); + assert.deepStrictEqual(rootPackage.dependencies, jsPackage.dependencies); assert.strictEqual(rootPackage.main, './js/src/index.mjs'); assert.deepStrictEqual(rootPackage.exports, { diff --git a/package.json b/package.json index 4eff71e..668ec0c 100644 --- a/package.json +++ b/package.json @@ -28,9 +28,11 @@ "author": "", "license": "Unlicense", "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" + }, + "dependencies": { + "command-stream": "^0.15.0" }, - "dependencies": {}, "repository": { "type": "git", "url": "https://github.com/link-assistant/agent-commander.git" diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 438c20b..26474d6 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -129,8 +129,9 @@ dependencies = [ [[package]] name = "command-stream" -version = "0.12.1" -source = "git+https://github.com/link-foundation/command-stream.git?rev=c1b3784ff61c90f9a780fa781003b30d299a73bf#c1b3784ff61c90f9a780fa781003b30d299a73bf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "424b44c896d6a044a7211e435951fe05eb32e34e7c9e522635102513de9f3377" dependencies = [ "async-trait", "chrono", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index cb44116..9a6c6bf 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -22,7 +22,7 @@ name = "stop-agent" path = "src/bin/stop_agent.rs" [dependencies] -command-stream = { git = "https://github.com/link-foundation/command-stream.git", rev = "c1b3784ff61c90f9a780fa781003b30d299a73bf" } +command-stream = "0.13.1" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "2.0" diff --git a/rust/tests/tui_tests.rs b/rust/tests/tui_tests.rs index 727de3d..ba38d62 100644 --- a/rust/tests/tui_tests.rs +++ b/rust/tests/tui_tests.rs @@ -54,7 +54,13 @@ fn maps_interactive_safety_and_resume_options() { assert_eq!( launch("claude", true, false), - ["--permission-mode", "plan", "--resume", "session-1", "--ax-screen-reader"] + [ + "--permission-mode", + "plan", + "--resume", + "session-1", + "--ax-screen-reader" + ] ); assert_eq!( launch("codex", true, false), From f65192c50bb94b9a2ed940967a3deb7fa6631799 Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 24 Jul 2026 23:50:45 +0000 Subject: [PATCH 8/8] test: stabilize fresh dependency and Windows PTY CI --- .github/workflows/js.yml | 4 +++- js/test/tui.test.mjs | 8 +++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index 339a0a3..f0b466e 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -192,7 +192,9 @@ jobs: - name: Run tests (Deno) if: matrix.runtime == 'deno' working-directory: js - run: deno test --allow-read --allow-run --allow-env --allow-net test/**/*.test.mjs + # Release PRs intentionally verify freshly published dependencies. + # Deno 2.9 otherwise blocks packages younger than 24 hours. + run: deno test --minimum-dependency-age=0 --allow-read --allow-run --allow-env --allow-net test/**/*.test.mjs js-release: name: JS Release diff --git a/js/test/tui.test.mjs b/js/test/tui.test.mjs index 2c57585..82ff7b0 100644 --- a/js/test/tui.test.mjs +++ b/js/test/tui.test.mjs @@ -86,7 +86,13 @@ test('buildAgentTuiLaunch maps interactive safety and resume options', () => { test( 'captureAgentTui drives and normalizes all agent clients', - { skip: isDeno }, + { + skip: isDeno, + // Six native PTY launches are intentionally exercised in series. Windows + // startup can exceed Bun's five-second default even when every capture + // finishes normally. + timeout: 30_000, + }, async (t) => { const artifactRoot = await mkdtemp(join(tmpdir(), 'agent-tui-')); t.after(() => rm(artifactRoot, { recursive: true, force: true }));