martty 0.2.33 → 0.2.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -64,24 +64,121 @@ profiles remain compatible.
64
64
  The profile Host mounts the ACP plugin on Base, then starts a separate TUI
65
65
  Client process over standard ACP stdin/stdout. For standalone use, run
66
66
  `martty` and use `--agent <cmd>` plus repeated `--agent-arg <arg>` for another
67
- ACP server. Named standalone harnesses can also be discovered, saved, and selected:
67
+ ACP server.
68
+
69
+ ### Harness CLI quick start
70
+
71
+ Run these commands in a system terminal. Browse first and replace `<id>` with the ID printed
72
+ by `find`, not the display name. The angle brackets are placeholders, not literal shell input.
73
+
74
+ ```sh
75
+ martty harness find # Browse Registry and local candidates
76
+ martty harness add <id> # Install/save configuration
77
+ martty harness use <id> # Set the next-launch default
78
+ martty # Start the TUI in the current directory
79
+ ```
80
+
81
+ `add` and `use` do not switch a running TUI; use `/harness` inside it to switch now.
82
+ To inspect, refresh, or remove configuration:
68
83
 
69
84
  ```sh
70
85
  martty harness list
71
- martty harness add local --command local-acp --arg --stdio
72
- martty harness use local
86
+ martty harness find --refresh
87
+ martty harness remove <id> --cleanup --dry-run # Preview only
88
+ martty harness remove <id> # Confirm configuration-only removal
89
+ martty harness remove <id> --cleanup # Also delete exclusive private binaries
73
90
  ```
74
91
 
92
+ In a source checkout where `martty` is not on PATH, run `node npm/bin/martty.js harness --help`
93
+ from the repository root and use that entrypoint in place of `martty` in the other commands.
94
+ For an unlisted ACP server, use `martty harness add local --command /absolute/path/to/local-acp
95
+ --arg --stdio`, replacing the path and arguments with your server's actual ACP invocation.
96
+
97
+ ### Registry and TUI setup
98
+
99
+ `harness find` reads a cached/bundled snapshot of the official ACP Registry from
100
+ `https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json` and supplements it
101
+ with local `*-acp` / `*_acp` PATH discovery; it is not an npm package search or an immediate
102
+ switch. `npx` / `uvx` entries become launch recipes without implicit `--yes` or
103
+ `--prefer-offline` flags. Selecting a binary distribution downloads it into
104
+ `$MARTTY_HOME/bin/<id>/<version>/<platform>`, verifies its declared SHA-256, and configures
105
+ the resulting executable without modifying the system PATH. The same flow is available in
106
+ the TUI with `/harness find`; manual `--command` remains available for unregistered agents.
107
+ Use `harness find --refresh` for a network refresh; offline refresh keeps the local catalog.
108
+ CLI `add` only installs/saves configuration and reuses saved recipes; `use` sets the next-launch
109
+ default without starting/authenticating an agent. `harness remove <id>` previews and confirms
110
+ configuration removal; `--cleanup` also removes exclusive private binary resources, `--dry-run`
111
+ only previews, and `--yes` confirms in scripts. Global programs, shared caches, history and
112
+ credentials are kept. Stop other instances using the target before cleanup. Downloads report
113
+ progress to stderr, and Ctrl-C cancels and clears staging without saving incomplete configuration.
114
+ Windows uses `%MARTTY_HOME%\bin\<id>\<version>\<platform>` (`windows-x86_64` or `windows-aarch64`) and
115
+ resolves package runners through `PATHEXT` (`npx.cmd`, `uvx.exe`, and similar launchers).
116
+ When a record also has a binary for the current platform, missing npx/uvx falls back to the
117
+ managed binary installer. Package-only records explain how to install Node.js/npm or uv and
118
+ are not persisted until their runner is available.
119
+ Run `harness find` without a query to browse the full catalog; users do not need to know an
120
+ agent name in advance. Query text is only an optional filter, and every unconfigured CLI result
121
+ prints the corresponding `martty harness add <id>` command.
122
+
123
+ ### Harness TUI workflow
124
+
125
+ Run `/harness` inside Martty. The current item is pinned first and disabled; selecting another
126
+ configured item uses the normal switch flow. Choose **+ Add Harness…** to browse/search the Registry.
127
+ Press Delete on a saved, non-current item in the switch menu to remove configuration and optionally
128
+ exclusive private binaries. Escape returns from removal confirmation to removal mode, then to the
129
+ list, preserving selection. Use `/auth` for sign-in and `/status` to inspect runtime state.
130
+ Error panels show the reason and diagnostics directly; Enter retries.
131
+
132
+ In the TUI, choose **+ Add Harness** from `/harness` or run `/harness add` to open the
133
+ searchable catalog. Verified local npm/uv programs are reused where possible. Missing
134
+ dependencies offer installation guidance and recheck; Registry failures keep local choices.
135
+ Local discovery runs off the UI event loop alongside the Registry request. The picker
136
+ fills progressively, keeping search and selection, with non-selectable dividers for
137
+ `Installed / configured` and `Not downloaded`. A saved recipe is not proof that its
138
+ package has already been downloaded. Binary transfers time out only on connection
139
+ failure or lack of progress, not on total download duration.
140
+ Binary transfer uses `node-downloader-helper` in the existing Node.js client;
141
+ no separate downloader executable is required. Failed transfers can be retried
142
+ from the panel; partial downloads are cleaned up, not resumed across restarts.
143
+ npx/uvx preparation and binary downloads run in a progress panel. Enter keeps unfinished
144
+ downloads open; Escape hides the panel and continues in the background while Martty stays
145
+ open. Completion/failure is announced in the composer. Installation automatically saves
146
+ configuration; Enter on completion uses the normal `/harness` switch flow, while Escape only
147
+ closes the panel and Enter retries failures. Add/Install/Connect do not automatically switch or
148
+ authenticate. Press Enter on completion or select the configured Harness from `/harness`
149
+ to switch. Quitting stops unfinished jobs.
150
+ Installer output is captured, never written over the terminal. A switch updates
151
+ the default only after ACP `initialize` and `session/new` succeed, with retry on failure.
152
+
153
+ From a source checkout, `node scripts/harness-discovery-scenario.mjs --check` creates
154
+ and probes six isolated discovery states. Use `--tui` to open them in the local Martty
155
+ binary, then `/harness add`. The fixture uses a private temporary directory and fake
156
+ ACP replies; no real model, package download, or user Harness settings are used.
157
+
75
158
  The same registry is available through three entry points: edit
76
159
  `$MARTTY_HOME/settings.json`, use `martty harness`, or run `/harness` (or
77
- `/harness <id>`) inside the TUI. A saved choice takes effect on the next
78
- standalone launch, which starts a fresh ACP session. It does not replace a
79
- running or profile-owned Host, and it never carries a session across Harnesses.
80
-
81
- The selected entry is stored in `$MARTTY_HOME/settings.json` and takes effect
82
- on the next standalone launch through a new `session/new`. `--agent` and
83
- `DSH_TUI_AGENT` remain higher priority. This does not replace the Host-owned
84
- runtime or session of `dsh --profile martty`.
160
+ `/harness <id>`) inside the TUI. In a standalone TUI, `/harness` immediately
161
+ switches before the current session has started. After the first prompt has
162
+ started a session, or an existing session was loaded, it first confirms that
163
+ the selected Harness will start another session; the current session remains
164
+ available in `/session`. Confirming stops the current ACP child and initializes
165
+ the selected Harness without closing Martty, then immediately sends `session/new`
166
+ to bind an empty session. This does not start a model turn. It never carries one session
167
+ across Harnesses. The settings and CLI entry points select the Harness for the
168
+ next standalone launch; a profile-owned Host cannot be replaced by its Client.
169
+
170
+ Every selection is stored in `$MARTTY_HOME/settings.json` as `defaultHarness`. CLI/settings
171
+ selection takes effect on the next standalone launch; TUI selection also
172
+ updates the running standalone process immediately. Standalone startup only
173
+ initializes the selected Harness and binds an empty ACP session before showing Ready.
174
+ The product's internal `forcedHarness` initialization value is empty by
175
+ default and is not a CLI/user startup argument. When the product supplies it,
176
+ startup selects it before consulting the persisted `defaultHarness`; when
177
+ empty, startup uses that saved default and otherwise the bundled fallback.
178
+ `/harness` updates the saved `defaultHarness`, never the product forced value.
179
+ `--agent` and `DSH_TUI_AGENT` remain higher startup priorities.
180
+ None of these replace the Host-owned runtime or session of
181
+ `dsh --profile martty`.
85
182
 
86
183
  The Node Client process owns a Cordis tree and starts the Rust painter. A sibling
87
184
  `tui-cordis-client-runner` publishes TUI Client capabilities and evaluates
package/bin/martty.js CHANGED
@@ -4,9 +4,10 @@ import fs from 'node:fs'
4
4
  import path from 'node:path'
5
5
  import { spawnSync } from 'node:child_process'
6
6
  import { fileURLToPath } from 'node:url'
7
+ import { createInterface } from 'node:readline/promises'
7
8
  import { resolveDependencyStack } from '../lib/agent.js'
8
9
  import { bootClient, parseClientArgv, painterArgs, uiSettingsPath } from '../lib/boot.js'
9
- import { runHarnessCommand } from '../lib/harnesses.js'
10
+ import { runHarnessCommandAsync } from '../lib/harnesses.js'
10
11
 
11
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
12
13
  const platformKey = process.platform + '-' + process.arch
@@ -35,6 +36,18 @@ const wantHelp = argv.includes('-h') || argv.includes('--help')
35
36
  const wantVersion = argv.includes('-V') || argv.includes('--version')
36
37
 
37
38
  if (argv[0] === 'harness') {
39
+ const controller = new AbortController()
40
+ let interrupted = 0
41
+ const interrupt = (code) => {
42
+ interrupted = code
43
+ controller.abort(new Error('Harness operation cancelled'))
44
+ }
45
+ const onInterrupt = () => interrupt(130)
46
+ const onTerminate = () => interrupt(143)
47
+ process.on('SIGINT', onInterrupt)
48
+ process.on('SIGTERM', onTerminate)
49
+ let lastPhase, lastProgress = 0
50
+ let exitCode = 0
38
51
  let defaults = []
39
52
  try {
40
53
  const agent = resolveDependencyStack()
@@ -48,16 +61,51 @@ if (argv[0] === 'harness') {
48
61
  // Source checkouts without installed dependencies still list PATH entries.
49
62
  }
50
63
  try {
51
- const result = runHarnessCommand(argv.slice(1), {
64
+ const result = await runHarnessCommandAsync(argv.slice(1), {
52
65
  settingsPath: uiSettingsPath(),
53
66
  defaults,
67
+ columns: process.stdout.columns,
68
+ color: process.stdout.isTTY && process.env.NO_COLOR === undefined,
69
+ cwd: process.cwd(),
70
+ signal: controller.signal,
71
+ onProgress({ phase, receivedBytes }) {
72
+ const now = Date.now()
73
+ if (phase === lastPhase && now - lastProgress < 1000) return
74
+ lastPhase = phase; lastProgress = now
75
+ process.stderr.write(`Harness install: ${phase}${Number.isFinite(receivedBytes) ? ` · ${receivedBytes} bytes` : ''}\n`)
76
+ },
77
+ ...(process.stdin.isTTY && process.stderr.isTTY ? { async confirmRemoval(preview) {
78
+ process.stderr.write(preview)
79
+ const prompt = createInterface({ input: process.stdin, output: process.stderr })
80
+ prompt.once('SIGINT', onInterrupt)
81
+ try {
82
+ const closed = new Promise(resolve => prompt.once('close', () => resolve('')))
83
+ const answer = await Promise.race([prompt.question('Remove this Harness? [y/N] ', { signal: controller.signal }), closed])
84
+ return /^y(es)?$/i.test(answer.trim())
85
+ } finally { prompt.close() }
86
+ } } : {}),
54
87
  })
55
88
  if (result.stdout) process.stdout.write(result.stdout)
56
89
  if (result.stderr) process.stderr.write(result.stderr)
57
- process.exit(result.code)
90
+ exitCode = interrupted || result.code
58
91
  } catch (error) {
59
92
  console.error(`martty harness: ${error instanceof Error ? error.message : String(error)}`)
60
- process.exit(1)
93
+ exitCode = interrupted || (Number.isInteger(error?.exitCode) ? error.exitCode : 1)
94
+ } finally {
95
+ process.removeListener('SIGINT', onInterrupt)
96
+ process.removeListener('SIGTERM', onTerminate)
97
+ }
98
+ process.exit(exitCode)
99
+ }
100
+
101
+ // Validate startup arguments before resolving the native painter. A source
102
+ // checkout must report the same usage errors as an installed package.
103
+ let parsed
104
+ if (!wantHelp && !wantVersion && !wantDemo && !wantDemoSkin) {
105
+ try { parsed = parseClientArgv(argv) }
106
+ catch (error) {
107
+ console.error(`martty: ${error instanceof Error ? error.message : String(error)}`)
108
+ process.exit(Number.isInteger(error?.exitCode) ? error.exitCode : 1)
61
109
  }
62
110
  }
63
111
 
@@ -108,5 +156,4 @@ if (wantDemoSkin) {
108
156
  }
109
157
 
110
158
  process.env.MARTTY_BIN ??= binaryPath
111
- const parsed = parseClientArgv(argv)
112
159
  await bootClient({ agent: parsed.agent, extraArgs: painterArgs(parsed) })
package/lib/acp-client.js CHANGED
@@ -6,11 +6,13 @@
6
6
  * Switching agents is `{ command, args }` (or `config.stream`).
7
7
  */
8
8
 
9
- import { spawn } from 'node:child_process'
9
+ import spawn from 'cross-spawn'
10
+ import { PassThrough } from 'node:stream'
10
11
  import { installAcpClientEvents } from './acp-client-events.js'
11
12
  import { installAcpSessionConfig } from './acp-session-config.js'
12
13
  import { installAcpSessionPlan } from './acp-session-plan.js'
13
14
  import { installAcpSessionStats } from './acp-session-stats.js'
15
+ import { tokenizeCommandArgs } from './command-args.js'
14
16
 
15
17
  export { installAcpSessionConfig } from './acp-session-config.js'
16
18
  export { installAcpSessionPlan } from './acp-session-plan.js'
@@ -20,7 +22,7 @@ export { installAcpClientEvents } from './acp-client-events.js'
20
22
  export const name = 'acp-client'
21
23
  export const inject = []
22
24
 
23
- /** @type {null | { command: string, args: string[], child: import('node:child_process').ChildProcess, stdin: import('node:stream').Writable, stdout: import('node:stream').Readable, kind: 'spawn' }} */
25
+ /** @type {null | { command: string, args: string[], env?: Record<string, string>, child: import('node:child_process').ChildProcess, stdin: import('node:stream').Writable, stdout: import('node:stream').Readable, kind: 'spawn' }} */
24
26
  let liveAgent = null
25
27
 
26
28
  /**
@@ -50,7 +52,8 @@ export function resolveAgent(config = {}) {
50
52
  }
51
53
  const envCmd = process.env.DSH_TUI_AGENT
52
54
  if (typeof envCmd === 'string' && envCmd.trim().length > 0) {
53
- const tokens = envCmd.trim().split(/\s+/)
55
+ const tokens = tokenizeCommandArgs(envCmd)
56
+ if (!tokens[0]) throw new Error('DSH_TUI_AGENT needs a non-empty command')
54
57
  return { command: tokens[0], args: tokens.slice(1) }
55
58
  }
56
59
  return { command: 'dsh-acp', args: [] }
@@ -84,6 +87,7 @@ export function apply(ctx, config = {}) {
84
87
  && liveAgent.child.exitCode === null
85
88
  && liveAgent.command === agent.command
86
89
  && JSON.stringify(liveAgent.args) === JSON.stringify(agent.args ?? [])
90
+ && JSON.stringify(liveAgent.env ?? {}) === JSON.stringify(agent.env ?? {})
87
91
  ) {
88
92
  provide(ctx, liveAgent)
89
93
  return
@@ -97,43 +101,315 @@ export function apply(ctx, config = {}) {
97
101
  }
98
102
  }
99
103
  liveAgent = null
104
+ const service = createSpawnService(agent)
105
+ liveAgent = service
106
+ provide(ctx, service)
107
+ }
108
+
109
+ function spawnAgent(agent) {
100
110
  const child = spawn(agent.command, agent.args ?? [], {
101
- stdio: ['pipe', 'pipe', 'inherit'],
111
+ stdio: ['pipe', 'pipe', 'pipe'],
102
112
  env: { ...process.env, ...(agent.env ?? {}) },
103
113
  })
104
114
  child.stdin.on('error', () => {})
105
115
  child.stdout.on('error', () => {})
106
- child.on('error', (err) => {
107
- // A failed spawn (ENOENT, EACCES) emits 'error' on the child; without a
108
- // listener it is an uncaught exception. Surface EOF to the transport
109
- // instead so pending requests fail instead of hanging, and drop the
110
- // cached handle so the next apply() retries the spawn.
111
- console.error(`acp-client: failed to spawn agent ${agent.command}: ${err.message}`)
112
- if (liveAgent?.child === child) liveAgent = null
113
- child.stdin.destroy()
114
- child.stdout.destroy()
115
- })
116
- const service = {
116
+ const diagnostics = drainDiagnostics(child.stderr)
117
+ return {
117
118
  kind: 'spawn',
118
119
  command: agent.command,
119
120
  args: agent.args ?? [],
121
+ ...(agent.env !== undefined ? { env: { ...agent.env } } : {}),
120
122
  stdin: child.stdin,
121
123
  stdout: child.stdout,
124
+ diagnostics,
122
125
  child,
123
126
  }
124
- liveAgent = service
125
- provide(ctx, service)
126
- if (liveAgentExitHook !== null) {
127
- process.removeListener('exit', liveAgentExitHook)
127
+ }
128
+
129
+ function drainDiagnostics(stream) {
130
+ const limit = 8192
131
+ let tail = Buffer.alloc(0)
132
+ let state = 'text'
133
+ // stderr is not a UI surface. Drain it even without a diagnostics consumer,
134
+ // strip control strings incrementally, and never retain their payloads.
135
+ stream.setEncoding('utf8')
136
+ stream.on('error', () => {})
137
+ stream.on('data', (chunk) => {
138
+ let text = ''
139
+ for (const char of chunk) {
140
+ const code = char.codePointAt(0)
141
+ if (state === 'osc' || state === 'string') {
142
+ if (code === 0x9c || (state === 'osc' && code === 7)) state = 'text'
143
+ else if (code === 27) state += '-escape'
144
+ continue
145
+ }
146
+ if (state === 'osc-escape' || state === 'string-escape') {
147
+ if (char === '\\' || code === 0x9c || (state === 'osc-escape' && code === 7)) state = 'text'
148
+ else if (code !== 27) state = state.replace('-escape', '')
149
+ continue
150
+ }
151
+ if (code === 27) { state = 'escape'; continue }
152
+ if (state === 'escape') {
153
+ if (char === '[') state = 'csi'
154
+ else if (char === ']') state = 'osc'
155
+ else if (['P', 'X', '^', '_'].includes(char)) state = 'string'
156
+ else state = code >= 0x20 && code <= 0x2f ? 'intermediate' : 'text'
157
+ continue
158
+ }
159
+ if (state === 'csi' || state === 'intermediate') {
160
+ if (code >= (state === 'csi' ? 0x40 : 0x30) && code <= 0x7e) state = 'text'
161
+ continue
162
+ }
163
+ if (code === 0x9b) { state = 'csi'; continue }
164
+ if (code === 0x9d) { state = 'osc'; continue }
165
+ if ([0x90, 0x98, 0x9e, 0x9f].includes(code)) { state = 'string'; continue }
166
+ if ((code < 0x20 && code !== 10) || (code >= 0x7f && code <= 0x9f)) continue
167
+ text += char
168
+ }
169
+ if (text.length === 0) return
170
+ const combined = Buffer.concat([tail, Buffer.from(text)])
171
+ let start = Math.max(0, combined.length - limit)
172
+ // Never split a UTF-8 code point or retain the large incoming allocation.
173
+ while (start < combined.length && (combined[start] & 0xc0) === 0x80) start++
174
+ tail = Buffer.from(combined.subarray(start))
175
+ })
176
+ return () => tail.toString('utf8').trim()
177
+ }
178
+
179
+ function diagnosticError(error, handle) {
180
+ if (typeof error?.diagnostics === 'string') return error
181
+ const diagnostics = handle.diagnostics()
182
+ if (!diagnostics) return error
183
+ return Object.assign(new Error(`${error.message}\nAgent stderr:\n${diagnostics}`, { cause: error }), {
184
+ ...error, diagnostics,
185
+ })
186
+ }
187
+
188
+ function waitForSpawn(handle) {
189
+ if (handle.child.pid !== undefined) return Promise.resolve()
190
+ return new Promise((resolve, reject) => {
191
+ const spawned = () => {
192
+ handle.child.off('error', failed)
193
+ resolve()
194
+ }
195
+ const failed = (error) => {
196
+ handle.child.off('spawn', spawned)
197
+ reject(error)
198
+ }
199
+ handle.child.once('spawn', spawned)
200
+ handle.child.once('error', failed)
201
+ })
202
+ }
203
+
204
+ function createSpawnService(agent) {
205
+ const input = new PassThrough()
206
+ const output = new PassThrough()
207
+ const switchListeners = new Set()
208
+ const failureListeners = new Set()
209
+ let current = spawnAgent(agent)
210
+ let closed = false
211
+ let pendingSwitch
212
+ let hasSwitched = false
213
+
214
+ const attach = (handle) => {
215
+ input.pipe(handle.stdin, { end: false })
216
+ handle.stdout.pipe(output, { end: false })
217
+ }
218
+ const detach = (handle) => {
219
+ input.unpipe(handle.stdin)
220
+ handle.stdout.unpipe(output)
221
+ }
222
+ attach(current)
223
+
224
+ const service = {
225
+ kind: 'spawn',
226
+ command: current.command,
227
+ args: current.args,
228
+ ...(current.env !== undefined ? { env: current.env } : {}),
229
+ stdin: input,
230
+ stdout: output,
231
+ child: current.child,
232
+ diagnostics() { return current.diagnostics() },
233
+ onSwitch(listener) {
234
+ if (typeof listener !== 'function') throw new Error('acpClient.onSwitch needs a function')
235
+ switchListeners.add(listener)
236
+ return () => switchListeners.delete(listener)
237
+ },
238
+ onFailure(listener) {
239
+ if (typeof listener !== 'function') throw new Error('acpClient.onFailure needs a function')
240
+ failureListeners.add(listener)
241
+ return () => failureListeners.delete(listener)
242
+ },
243
+ observeClient(message) {
244
+ if (pendingSwitch === undefined || message?.id === undefined) return
245
+ if (!['initialize', 'authenticate', 'session/new'].includes(message.method)) return
246
+ pendingSwitch.requests.set(message.id, message.method)
247
+ // Browser/device sign-in is user-driven, not a machine setup operation.
248
+ if (message.method === 'authenticate') pendingSwitch.pause()
249
+ else pendingSwitch.arm()
250
+ },
251
+ observeAgent(message) {
252
+ const pending = pendingSwitch
253
+ if (pending === undefined || message?.id === undefined) return
254
+ const method = pending.requests.get(message.id)
255
+ if (method === undefined) return
256
+ pending.requests.delete(message.id)
257
+ if (message.error !== undefined) {
258
+ // Authentication is user-driven; do not time out while an auth form is open.
259
+ if (method !== 'initialize' && message.error?.code === -32000) {
260
+ pending.pause()
261
+ return
262
+ }
263
+ pending.reject(Object.assign(new Error(`${method}: ${message.error?.message ?? 'ACP setup failed'}`), {
264
+ method, acpError: structuredClone(message.error),
265
+ }))
266
+ return
267
+ }
268
+ if (method === 'initialize') {
269
+ if (!Number.isInteger(message.result?.protocolVersion)) {
270
+ pending.reject(new Error('initialize: invalid ACP response'))
271
+ return
272
+ }
273
+ pending.initialized = true
274
+ pending.server = message.result?.agentInfo?.name
275
+ } else if (method === 'authenticate') {
276
+ // Once sign-in returns, the ensuing session/new must finish promptly.
277
+ pending.arm()
278
+ } else if (method === 'session/new') {
279
+ const sessionId = message.result?.sessionId
280
+ if (!pending.initialized || typeof sessionId !== 'string' || sessionId.length === 0) {
281
+ pending.reject(new Error('session/new: invalid ACP session response'))
282
+ return
283
+ }
284
+ pending.resolve({
285
+ sessionId,
286
+ ...(typeof pending.server === 'string' ? { server: pending.server } : {}),
287
+ })
288
+ }
289
+ },
290
+ async switchAgent(nextAgent, { timeoutMs = 20 * 60_000 } = {}) {
291
+ if (closed) throw new Error('acpClient is closed')
292
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error('ACP setup timeout must be positive')
293
+ const spec = resolveAgent({ agent: nextAgent })
294
+ const next = spawnAgent(spec)
295
+ try {
296
+ await waitForSpawn(next)
297
+ for (const listener of switchListeners) await listener(next, current)
298
+ if (next.child.exitCode !== null || next.child.signalCode !== null) {
299
+ throw new Error(`ACP process exited (${next.child.signalCode ?? next.child.exitCode}) during handoff`)
300
+ }
301
+ } catch (error) {
302
+ try {
303
+ next.child.kill('SIGTERM')
304
+ } catch {
305
+ // failed spawns may not own a process
306
+ }
307
+ throw diagnosticError(error, next)
308
+ }
309
+ pendingSwitch?.reject(new Error('Harness switch was superseded'))
310
+ const previous = current
311
+ detach(previous)
312
+ attach(next)
313
+ current = next
314
+ service.command = next.command
315
+ service.args = next.args
316
+ service.env = next.env
317
+ service.child = next.child
318
+ hasSwitched = true
319
+ const ready = waitForReady(timeoutMs)
320
+ watchCurrent(next)
321
+ try {
322
+ previous.child.kill('SIGTERM')
323
+ } catch {
324
+ // already gone
325
+ }
326
+ // Rust starts initialize only after the local command returns. Awaiting
327
+ // ready here would deadlock; callers persist selection when this settles.
328
+ return { ready }
329
+ },
330
+ close(error = new Error('acpClient is closed')) {
331
+ if (closed) return
332
+ failTransport(error)
333
+ closed = true
334
+ detach(current)
335
+ try {
336
+ current.child.kill('SIGTERM')
337
+ } catch {
338
+ // already gone
339
+ }
340
+ input.destroy()
341
+ output.destroy()
342
+ if (liveAgent === service) liveAgent = null
343
+ },
344
+ }
345
+
346
+ function waitForReady(timeoutMs) {
347
+ let resolve
348
+ let reject
349
+ let timer
350
+ const ready = new Promise((yes, no) => { resolve = yes; reject = no })
351
+ // A child may fail before the caller has received the handoff result.
352
+ ready.catch(() => {})
353
+ const pending = {
354
+ requests: new Map(),
355
+ initialized: false,
356
+ server: undefined,
357
+ arm() {
358
+ clearTimeout(timer)
359
+ timer = setTimeout(() => {
360
+ failTransport(new Error(`ACP setup timed out after ${timeoutMs / 1000}s`))
361
+ detach(current)
362
+ try { current.child.kill('SIGTERM') } catch { /* already gone */ }
363
+ }, timeoutMs)
364
+ timer.unref?.()
365
+ },
366
+ pause() { clearTimeout(timer) },
367
+ resolve(value) { settle(resolve, value) },
368
+ reject(error) { settle(reject, diagnosticError(error, current)) },
369
+ }
370
+ const settle = (complete, value) => {
371
+ if (pendingSwitch !== pending) return
372
+ pendingSwitch = undefined
373
+ clearTimeout(timer)
374
+ complete(value)
375
+ }
376
+ pendingSwitch = pending
377
+ pending.arm()
378
+ return ready
379
+ }
380
+
381
+ function failTransport(error) {
382
+ const failure = diagnosticError(error, current)
383
+ pendingSwitch?.reject(failure)
384
+ for (const listener of failureListeners) listener(failure)
385
+ }
386
+
387
+ function watchCurrent(handle) {
388
+ handle.child.on('error', (err) => {
389
+ if (current !== handle || closed) return
390
+ if (hasSwitched) {
391
+ failTransport(err)
392
+ return
393
+ }
394
+ service.close(err)
395
+ })
396
+ // 'close' follows the stdio drain, so final startup errors are not lost.
397
+ handle.child.once('close', (code, signal) => {
398
+ if (current !== handle || closed) return
399
+ failTransport(new Error(`ACP process exited (${signal ?? code ?? 'unknown'}) before completing the request`))
400
+ })
128
401
  }
402
+ watchCurrent(current)
403
+ if (liveAgentExitHook !== null) process.removeListener('exit', liveAgentExitHook)
129
404
  liveAgentExitHook = () => {
130
405
  try {
131
- child.kill('SIGTERM')
406
+ service.close()
132
407
  } catch {
133
408
  // already gone
134
409
  }
135
410
  }
136
411
  process.once('exit', liveAgentExitHook)
412
+ return service
137
413
  }
138
414
 
139
415
  function provide(ctx, service) {