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 +108 -11
- package/bin/martty.js +52 -5
- package/lib/acp-client.js +296 -20
- package/lib/acp-registry.snapshot.json +1450 -0
- package/lib/acp-session-config.js +23 -0
- package/lib/acp-session-plan.js +14 -2
- package/lib/acp-session-stats.js +22 -5
- package/lib/acp-session-status.js +70 -25
- package/lib/agent.js +24 -4
- package/lib/boot.js +29 -5
- package/lib/command-args.js +28 -0
- package/lib/download.js +165 -0
- package/lib/harness-discovery.js +41 -0
- package/lib/harness-package.js +200 -0
- package/lib/harness-registry.js +468 -0
- package/lib/harness-removal.js +88 -0
- package/lib/harness-view.js +639 -46
- package/lib/harnesses.js +1053 -41
- package/lib/index.js +4 -0
- package/lib/mux.js +23 -0
- package/lib/plan-view.js +18 -3
- package/lib/status-view.js +2 -0
- package/lib/tui-commands.js +7 -1
- package/lib/tui-overlay.js +27 -5
- package/package.json +7 -4
- package/vendor/darwin-arm64/martty +0 -0
- package/vendor/darwin-x64/martty +0 -0
- package/vendor/linux-arm64/martty +0 -0
- package/vendor/linux-x64/martty +0 -0
- package/vendor/win32-x64/martty.exe +0 -0
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.
|
|
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
|
|
72
|
-
martty harness
|
|
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.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
90
|
+
exitCode = interrupted || result.code
|
|
58
91
|
} catch (error) {
|
|
59
92
|
console.error(`martty harness: ${error instanceof Error ? error.message : String(error)}`)
|
|
60
|
-
|
|
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
|
|
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
|
|
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', '
|
|
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.
|
|
107
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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) {
|