@platless/axom-cli 0.1.0
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 +64 -0
- package/dist/args.js +29 -0
- package/dist/cli.js +76 -0
- package/dist/commands/auth.js +52 -0
- package/dist/commands/execute.js +40 -0
- package/dist/commands/guide.js +38 -0
- package/dist/commands/record.js +99 -0
- package/dist/commands/skills.js +69 -0
- package/dist/commands/struct.js +50 -0
- package/dist/commands/update.js +88 -0
- package/dist/commands/whoami.js +8 -0
- package/dist/commands/workstation.js +171 -0
- package/dist/config.js +131 -0
- package/dist/credentialBroker.js +340 -0
- package/dist/credentials.js +211 -0
- package/dist/install/agentsDir.js +35 -0
- package/dist/install/claude/commands.js +89 -0
- package/dist/install/claude/materialize.js +42 -0
- package/dist/install/installer.js +45 -0
- package/dist/install/targets/claude.js +154 -0
- package/dist/mcp.js +152 -0
- package/dist/output.js +37 -0
- package/dist/workstation/acp/acpAgents.js +1287 -0
- package/dist/workstation/acp/acpClient.js +691 -0
- package/dist/workstation/acp/acpSession.js +1047 -0
- package/dist/workstation/acp/adoption.js +396 -0
- package/dist/workstation/acp/auth.js +80 -0
- package/dist/workstation/acp/authUrlSniffer.js +52 -0
- package/dist/workstation/acp/binaryInstall.js +411 -0
- package/dist/workstation/acp/configOptions.js +257 -0
- package/dist/workstation/acp/contract.js +494 -0
- package/dist/workstation/acp/draftInterceptor.js +154 -0
- package/dist/workstation/acp/draftSubagents.js +38 -0
- package/dist/workstation/acp/installErrors.js +89 -0
- package/dist/workstation/acp/installProbe.js +363 -0
- package/dist/workstation/acp/permission.js +327 -0
- package/dist/workstation/acp/registry/catalog.js +29 -0
- package/dist/workstation/acp/registry/launch.js +128 -0
- package/dist/workstation/acp/registry/registryClient.js +160 -0
- package/dist/workstation/acp/registry/registryTypes.js +8 -0
- package/dist/workstation/acp/sessionUpdateMapper.js +602 -0
- package/dist/workstation/acp/test/fakeAgent.js +435 -0
- package/dist/workstation/acp/test/sessionUpdateFixtures.js +165 -0
- package/dist/workstation/acp/timedCommand.js +47 -0
- package/dist/workstation/acp/uvxInstall.js +248 -0
- package/dist/workstation/acp/validate.js +134 -0
- package/dist/workstation/agentSessions.js +1628 -0
- package/dist/workstation/channels/agent.js +96 -0
- package/dist/workstation/channels/computer.js +91 -0
- package/dist/workstation/channels/management.js +231 -0
- package/dist/workstation/channels/url.js +42 -0
- package/dist/workstation/config.js +47 -0
- package/dist/workstation/connection/encryptedChannel.js +261 -0
- package/dist/workstation/connection/retryPolicy.js +14 -0
- package/dist/workstation/connection/supervisor.js +353 -0
- package/dist/workstation/crypto.js +63 -0
- package/dist/workstation/daemon.js +225 -0
- package/dist/workstation/daemonctl.js +95 -0
- package/dist/workstation/exec.js +112 -0
- package/dist/workstation/journal.js +257 -0
- package/dist/workstation/names.js +17 -0
- package/dist/workstation/oauth/browser.js +24 -0
- package/dist/workstation/oauth/deviceAuthorization.js +198 -0
- package/dist/workstation/osinfo.js +4 -0
- package/dist/workstation/protocol/agent.js +606 -0
- package/dist/workstation/protocol/common.js +136 -0
- package/dist/workstation/protocol/computer.js +31 -0
- package/dist/workstation/protocol/management.js +116 -0
- package/dist/workstation/register.js +42 -0
- package/dist/workstation/service/installer.js +362 -0
- package/dist/workstation/service/launchd.js +46 -0
- package/dist/workstation/service/loginShell.js +36 -0
- package/dist/workstation/service/systemd.js +48 -0
- package/dist/workstation/service/types.js +73 -0
- package/dist/workstation/supervision.js +52 -0
- package/dist/workstation/systemMetrics.js +271 -0
- package/dist/workstation/update/artifact.js +61 -0
- package/dist/workstation/update/coordinator.js +240 -0
- package/dist/workstation/update/npmInstaller.js +91 -0
- package/dist/workstation/update/redact.js +36 -0
- package/dist/workstation/update/runUpdate.js +215 -0
- package/dist/workstation/update/stateStore.js +87 -0
- package/dist/workstation/update/taskRegistry.js +142 -0
- package/dist/workstation/update/types.js +3 -0
- package/dist/workstation/update/watchdog.js +311 -0
- package/dist/workstation/version.js +12 -0
- package/package.json +39 -0
- package/skills/axom/SKILL.md +83 -0
- package/skills/axom-definition/SKILL.md +74 -0
- package/skills/axom-platform/SKILL.md +78 -0
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { Readable, Writable } from 'node:stream';
|
|
4
|
+
import { client, ndJsonStream, PROTOCOL_VERSION, RequestError } from '@agentclientprotocol/sdk';
|
|
5
|
+
import { sniffAuthUrl } from './authUrlSniffer.js';
|
|
6
|
+
import { airCapabilityMeta, asSdkClientCapabilities, AIR_ASYNC_TASKS_CAPABILITY, AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY, DRAFT_SUBAGENTS_CAPABILITY, } from './draftSubagents.js';
|
|
7
|
+
import { interceptDraftUpdates } from './draftInterceptor.js';
|
|
8
|
+
export class UnsupportedNodeVersionError extends Error {
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* A subprocess failed to start, or died before it finished responding to `initialize`.
|
|
12
|
+
*
|
|
13
|
+
* 带着子进程的 stderr 尾巴。启动期出事的时候,唯一说得清「到底哪儿不对」的就是子进程自己往 stderr
|
|
14
|
+
* 上写的那几行 —— npx 的 `npm error code ETARGET`、adapter 的崩溃栈、代理的拒绝。这里以前是
|
|
15
|
+
* `stdio: 'inherit'`,那几行只落进 daemon.log,调用方拿到的是一句
|
|
16
|
+
* "exited before responding to initialize (code=1)",服务端那头再转手就成了一条内容为空的
|
|
17
|
+
* "ACP connection closed" —— 排查的人手上什么都没有。见 {@link isMissingPackageVersion}:那条判定
|
|
18
|
+
* 需要的是证据,不是从「管子断了」倒推出来的猜测。
|
|
19
|
+
*/
|
|
20
|
+
export class AgentSpawnError extends Error {
|
|
21
|
+
stderr;
|
|
22
|
+
constructor(message, stderr = '') {
|
|
23
|
+
super(message);
|
|
24
|
+
this.stderr = stderr;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** The agent opened a connection but never answered `initialize` within the allotted time. */
|
|
28
|
+
export class AgentInitializeTimeoutError extends Error {
|
|
29
|
+
/** 同 {@link AgentSpawnError.stderr}:超时那一刻子进程已经写出来的东西。 */
|
|
30
|
+
stderr;
|
|
31
|
+
constructor(message, stderr = '') {
|
|
32
|
+
super(message);
|
|
33
|
+
this.stderr = stderr;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* npm 的 `ETARGET`:**包在,但你点名的那个版本不在**。
|
|
38
|
+
*
|
|
39
|
+
* 这是唯一一个能把「registry 把版本钉错了」跟「这台机器上这个 agent 坏了」分开的信号,所以回退只
|
|
40
|
+
* 认它。npm 会同时打出机器可读的 `npm error code ETARGET` 和人话 `notarget No matching version
|
|
41
|
+
* found for <spec>`;这里匹配前者 —— 后者的措辞跨 npm 大版本改过,错误码没有。
|
|
42
|
+
*
|
|
43
|
+
* 特意**不**认 `E404`(整个包都不存在)和别的任何失败:去掉版本对它们毫无帮助,而每多认一种,
|
|
44
|
+
* 就等于给一次真实的失败额外加一整轮冷启动。
|
|
45
|
+
*/
|
|
46
|
+
const NPM_ETARGET = /(?:^|[^A-Z])ETARGET(?![A-Z])/;
|
|
47
|
+
/**
|
|
48
|
+
* 判定规则本身,脱离 {@link AgentSpawnError}/{@link AgentInitializeTimeoutError} 单独导出 ——
|
|
49
|
+
* `installNpxPackage`(`installProbe.ts`)是这条规则的第二个读者:它下载适配器失败时报的是一段
|
|
50
|
+
* `InstallOutcome.error`/`stderr` 字符串,不是这两个类里的一个实例,没有 `.stderr` 字段可供
|
|
51
|
+
* {@link isMissingPackageVersion} 去 `instanceof` 窄化。两个调用点都要认同一件事——npm 自己打在
|
|
52
|
+
* stderr 上的 `ETARGET` 错误码——所以正则只在这一处,两个调用点都直接测字符串,而不是各自维护一份。
|
|
53
|
+
*/
|
|
54
|
+
export function stderrShowsMissingPackageVersion(stderr) {
|
|
55
|
+
return NPM_ETARGET.test(stderr);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 这次启动失败,是不是因为 registry 钉的版本 npm 根本没有。
|
|
59
|
+
*
|
|
60
|
+
* 只看 {@link AgentSpawnError.stderr} / {@link AgentInitializeTimeoutError.stderr} 里的原始文本 ——
|
|
61
|
+
* 超时也算进来是因为实测过:npx 在一台慢机器上可以把 20s 的 initialize 预算耗光之后才让子进程死掉,
|
|
62
|
+
* 那时候 ETARGET 早就写在 stderr 上了,只是赢下 race 的是计时器。
|
|
63
|
+
*/
|
|
64
|
+
export function isMissingPackageVersion(error) {
|
|
65
|
+
if (!(error instanceof AgentSpawnError) && !(error instanceof AgentInitializeTimeoutError))
|
|
66
|
+
return false;
|
|
67
|
+
return stderrShowsMissingPackageVersion(error.stderr);
|
|
68
|
+
}
|
|
69
|
+
/** The agent negotiated a protocol version this client does not speak. */
|
|
70
|
+
export class UnsupportedProtocolVersionError extends Error {
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Minimum Node.js major version a known agent package requires, keyed by its npx package name
|
|
74
|
+
* (unversioned — matched against both a bare specifier and a version-pinned one; see
|
|
75
|
+
* `argMatchesPackage`).
|
|
76
|
+
*
|
|
77
|
+
* The registry has no field for this — verified fact, not an oversight to fix here. The only place
|
|
78
|
+
* a Node requirement is declared is the agent package's own `package.json#engines`, and `npx` does
|
|
79
|
+
* not enforce it: it just runs the package under whatever `node` is already on PATH. Getting this
|
|
80
|
+
* wrong means the user sees a subprocess crash stack instead of an actionable message (ruling R7),
|
|
81
|
+
* so this table exists to catch the one adapter known, as of 2026-08-21, to need it.
|
|
82
|
+
*/
|
|
83
|
+
const KNOWN_MIN_NODE_MAJOR = [
|
|
84
|
+
{ packageName: '@agentclientprotocol/claude-agent-acp', minMajor: 22 },
|
|
85
|
+
];
|
|
86
|
+
/**
|
|
87
|
+
* True when `arg` is exactly `packageName`, or `packageName` followed by an `@version` suffix.
|
|
88
|
+
* `arg.startsWith(packageName)` alone would also match an unrelated package that merely shares a
|
|
89
|
+
* prefix (e.g. `@agentclientprotocol/claude-agent-acp-extra`); anchoring on an exact match or an
|
|
90
|
+
* `@` boundary rules that out while still matching ruling R3's user-supplied, unpinned
|
|
91
|
+
* `command`/`args` override, which has no version suffix to key off at all.
|
|
92
|
+
*/
|
|
93
|
+
function argMatchesPackage(arg, packageName) {
|
|
94
|
+
return arg === packageName || arg.startsWith(`${packageName}@`);
|
|
95
|
+
}
|
|
96
|
+
/** Exported for tests: derives the minimum Node major version a launch spec requires, if known. */
|
|
97
|
+
export function requiredNodeMajorFor(spec) {
|
|
98
|
+
const match = KNOWN_MIN_NODE_MAJOR.find((entry) => spec.args.some((arg) => argMatchesPackage(arg, entry.packageName)));
|
|
99
|
+
return match?.minMajor;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Throws a message a user can act on when this machine's Node is older than a known agent
|
|
103
|
+
* requires. `nodeVersion` defaults to the real running Node; it takes an explicit override so tests
|
|
104
|
+
* can exercise both sides of the gate without depending on the host's actual Node version —
|
|
105
|
+
* `process.versions.node` is a frozen property and cannot be stubbed.
|
|
106
|
+
*/
|
|
107
|
+
export function assertNodeVersionSupported(spec, nodeVersion = process.versions.node) {
|
|
108
|
+
const required = requiredNodeMajorFor(spec);
|
|
109
|
+
if (required === undefined)
|
|
110
|
+
return;
|
|
111
|
+
const currentMajor = Number.parseInt(nodeVersion.split('.')[0] ?? '', 10);
|
|
112
|
+
if (Number.isNaN(currentMajor) || currentMajor < required) {
|
|
113
|
+
throw new UnsupportedNodeVersionError(`This agent requires Node.js >= ${required}, but this machine is running Node.js ${nodeVersion}. ` +
|
|
114
|
+
'Install a newer Node.js and try again.');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Whether an `InitializeResponse._meta` advertises the `_session/steering` extension.
|
|
119
|
+
*
|
|
120
|
+
* ## `_session/steering` is not ACP
|
|
121
|
+
*
|
|
122
|
+
* It is absent from the schema this client is pinned to (`grep steer
|
|
123
|
+
* node_modules/@agentclientprotocol/sdk/dist/schema/types.gen.d.ts` returns nothing). It is an
|
|
124
|
+
* extension `claude-agent-acp` implements: the method constant lives at its `acp-agent.js@0.76.0:164`
|
|
125
|
+
* and its own comment calls it a *"Custom (extension) request method ... Named `_session/steering`
|
|
126
|
+
* per the agreed ACP steering wire protocol"*. The leading underscore is ACP's extension namespace.
|
|
127
|
+
*
|
|
128
|
+
* ## Why the top-level `_meta` specifically
|
|
129
|
+
*
|
|
130
|
+
* The adapter advertises it at `InitializeResponse._meta.steering.supported`, a **sibling of
|
|
131
|
+
* `agentCapabilities`** (`acp-agent.js@0.76.0:944-950`), not a key inside it. `_meta` is typed
|
|
132
|
+
* `{[key: string]: unknown} | null` (`types.gen.d.ts:1498-1500`), so every hop down has to be
|
|
133
|
+
* narrowed rather than indexed through a cast.
|
|
134
|
+
*
|
|
135
|
+
* Strictly `=== true`. An agent that sends `steering: {}` (the shape ACP uses for its *own*
|
|
136
|
+
* capability markers) has not said `supported`, and this extension does not define that spelling —
|
|
137
|
+
* reading it as a yes would be this client inventing a wire contract.
|
|
138
|
+
*/
|
|
139
|
+
export function steeringAdvertised(meta) {
|
|
140
|
+
if (typeof meta !== 'object' || meta === null)
|
|
141
|
+
return false;
|
|
142
|
+
const steering = meta.steering;
|
|
143
|
+
if (typeof steering !== 'object' || steering === null)
|
|
144
|
+
return false;
|
|
145
|
+
return steering.supported === true;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The delivery timings the `_session/steering` extension defines — when the agent hands an injected
|
|
149
|
+
* message to the model. `next` is the only lossless one: `now` aborts the running generation and
|
|
150
|
+
* discards whatever tool calls were still outstanding.
|
|
151
|
+
*/
|
|
152
|
+
export const STEER_TIMINGS = ['now', 'next', 'later'];
|
|
153
|
+
/**
|
|
154
|
+
* Which delivery timings an `InitializeResponse._meta` says the agent accepts —
|
|
155
|
+
* `_meta.steering.timings`, the sibling of the `supported` flag {@link steeringAdvertised} reads,
|
|
156
|
+
* and read from the **top-level** `_meta` for exactly the same reason.
|
|
157
|
+
*
|
|
158
|
+
* ## Empty is the answer, not the absence of one
|
|
159
|
+
*
|
|
160
|
+
* An agent that declares no timings has told us we may not name one. That is not "assume the
|
|
161
|
+
* default" — the extension ignores an unrecognised `priority` **silently** (the measurement behind
|
|
162
|
+
* ruling N4: sending `next` to today's adapters lands as `now`, which throws away the user's
|
|
163
|
+
* in-flight work). So an empty result must reach the caller as "do not specify a timing at all",
|
|
164
|
+
* and today — before the upstream PR that makes adapters broadcast this — every agent in the wild
|
|
165
|
+
* produces exactly that. Steady state, not a bug.
|
|
166
|
+
*
|
|
167
|
+
* ## Unknown values are dropped, never rejected
|
|
168
|
+
*
|
|
169
|
+
* A timing this build has never heard of is skipped and the known ones beside it survive. This
|
|
170
|
+
* client is deployed on user machines and can be older than both the agent it launches and the
|
|
171
|
+
* server it reports to; throwing (or discarding the whole array) on a value from a future version
|
|
172
|
+
* of the extension would take steering away from an agent that does support it. Non-strings are
|
|
173
|
+
* dropped the same way: `_meta` is typed `{[key: string]: unknown}`, so nothing here is guaranteed.
|
|
174
|
+
*
|
|
175
|
+
* Duplicates collapse — the wire shape is a JSON array but the fact is a set.
|
|
176
|
+
*/
|
|
177
|
+
export function steeringTimings(meta) {
|
|
178
|
+
if (typeof meta !== 'object' || meta === null)
|
|
179
|
+
return [];
|
|
180
|
+
const steering = meta.steering;
|
|
181
|
+
if (typeof steering !== 'object' || steering === null)
|
|
182
|
+
return [];
|
|
183
|
+
const declared = steering.timings;
|
|
184
|
+
if (!Array.isArray(declared))
|
|
185
|
+
return [];
|
|
186
|
+
const known = [];
|
|
187
|
+
for (const entry of declared) {
|
|
188
|
+
if (typeof entry !== 'string')
|
|
189
|
+
continue;
|
|
190
|
+
if (!STEER_TIMINGS.includes(entry))
|
|
191
|
+
continue;
|
|
192
|
+
if (known.includes(entry))
|
|
193
|
+
continue;
|
|
194
|
+
known.push(entry);
|
|
195
|
+
}
|
|
196
|
+
return known;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* The JSON-RPC error code an agent answered with, or `undefined` when the failure was not one an
|
|
200
|
+
* agent answered at all.
|
|
201
|
+
*
|
|
202
|
+
* ## Why this exists
|
|
203
|
+
*
|
|
204
|
+
* Both failure paths that can carry a sign-in — a refused `session/new` and a refused
|
|
205
|
+
* `session/prompt` — reach us as the SDK's `RequestError`, built from the peer's own
|
|
206
|
+
* `{code, message, data}` (`jsonrpc.js:844`). Everything downstream used to see only
|
|
207
|
+
* `errorMessage(err)`, which throws the code away and leaves free text; the server then had no way
|
|
208
|
+
* to tell `auth_required` from a context window overflow, and drew a login card for both. This is
|
|
209
|
+
* the one read that keeps the code, and it is placed where the original error object still exists
|
|
210
|
+
* rather than anywhere that would have to parse the flattened string back.
|
|
211
|
+
*
|
|
212
|
+
* ## Why `instanceof`, and why the two guards
|
|
213
|
+
*
|
|
214
|
+
* `instanceof RequestError` rather than a structural `typeof err.code === 'number'`: Node's own
|
|
215
|
+
* errors carry a `code` too (a string, but a duck-typed read invites the next one that does not),
|
|
216
|
+
* and the SDK class is the only thing in this process that means "a JSON-RPC peer said no".
|
|
217
|
+
*
|
|
218
|
+
* `code` is nevertheless whatever the remote agent put in its JSON — the SDK does not validate it —
|
|
219
|
+
* so it is checked before it can reach a frame. The Kotlin twin of these fields is an `Int`, and a
|
|
220
|
+
* fractional or out-of-range number does not lose precision on the way: it fails to decode and
|
|
221
|
+
* takes the **whole frame** with it, which is how `stat().mtimeMs` once cost an entire agent
|
|
222
|
+
* channel. A malformed code is reported as no code at all, which downgrades to an ordinary error
|
|
223
|
+
* card — the same thing every pre-v12 peer already gets.
|
|
224
|
+
*/
|
|
225
|
+
export function jsonRpcErrorCode(error) {
|
|
226
|
+
if (!(error instanceof RequestError))
|
|
227
|
+
return undefined;
|
|
228
|
+
const { code } = error;
|
|
229
|
+
if (!Number.isInteger(code))
|
|
230
|
+
return undefined;
|
|
231
|
+
return code >= -2_147_483_648 && code <= 2_147_483_647 ? code : undefined;
|
|
232
|
+
}
|
|
233
|
+
function translateSpawnError(command, error, stderr) {
|
|
234
|
+
if (error.code === 'ENOENT') {
|
|
235
|
+
return new AgentSpawnError(`Could not launch '${command}': no such command on PATH. ` +
|
|
236
|
+
'If this is npx or uvx, make sure it is installed and reachable from this process.', stderr);
|
|
237
|
+
}
|
|
238
|
+
return new AgentSpawnError(`Failed to launch '${command}': ${error.message}`, stderr);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* 子进程 stderr 留多少字节。装得下 npm 的整段报错(ETARGET 那几行不到 1KB),又不至于让一个活了
|
|
242
|
+
* 一整天的 adapter 把日志攒在内存里 —— 这条尾巴在连接建立之后还会继续被写。
|
|
243
|
+
*/
|
|
244
|
+
const STDERR_TAIL_LIMIT = 8_192;
|
|
245
|
+
/**
|
|
246
|
+
* 子进程退出事件和它 stderr 上最后一段数据,谁先落到事件循环上,Node 不做保证 —— 而 npm 的 ETARGET
|
|
247
|
+
* 恰恰就在最后那几行里。所以 `'exit'` 之后再给 stderr 一个收尾窗口。
|
|
248
|
+
*
|
|
249
|
+
* 有界,是因为孙进程可能攥着这根管子不放(npx 正常路径下就会 spawn 真正的 adapter),那种情况下
|
|
250
|
+
* `'end'` 永远不来;等不到就带着此刻手上的那份走,而不是把调用方吊死。这段时间只花在**失败**路径上。
|
|
251
|
+
*/
|
|
252
|
+
const STDERR_FLUSH_GRACE_MS = 250;
|
|
253
|
+
/**
|
|
254
|
+
* 把子进程的 stderr 接出来:一边原样转发到本进程的 stderr,一边留一份有界的尾巴给启动期的报错带上,
|
|
255
|
+
* 一边逐行喂给 {@link sniffAuthUrl} —— 有的 agent(实测 antigravity)不走 `elicitation/create` 递
|
|
256
|
+
* 登录链接,而是直接打到 stderr 上(见 `authUrlSniffer.ts` 的文件头注释)。
|
|
257
|
+
*
|
|
258
|
+
* 转发这件事是必须补的。这里以前是 `stdio: 'inherit'`,adapter 的诊断输出直接落进守护进程的
|
|
259
|
+
* stdio(即 daemon.log);改成 `pipe` 之后没人转,那些输出就凭空消失了 —— 修一个可观测性问题的
|
|
260
|
+
* 过程中砸掉另一个。
|
|
261
|
+
*
|
|
262
|
+
* 用 `'data'` 手动写而不是 `pipe(process.stderr)`:pipe 在源 end 时会把目的地一起 end 掉,而这里的
|
|
263
|
+
* 目的地是守护进程自己的 stderr。一个 adapter 退出就把 daemon.log 的写端关掉,比丢日志糟得多。
|
|
264
|
+
*
|
|
265
|
+
* `present` 是可选的,和 Task 3 的 `PresentElicitationUrl` 同一条规矩:没有真实的下游可以展示 URL
|
|
266
|
+
* 时,不伪造"已经展示"这件事 —— 这里的表现是干脆不嗅、不生成 elicitationId,而不是嗅到了却传给一个
|
|
267
|
+
* 空调用。嗅到的 URL 没有真实的 `elicitationId`(它压根不是通过 `elicitation/create` 到达的),所以
|
|
268
|
+
* 这里现造一个,前缀标出它的来源,便于下游区分"这是 stderr 嗅探出来的,不是协议本身的一次请求"。
|
|
269
|
+
*/
|
|
270
|
+
function captureChildStderr(stream, present) {
|
|
271
|
+
if (!stream)
|
|
272
|
+
return { text: () => '', flushed: () => Promise.resolve() };
|
|
273
|
+
let tail = '';
|
|
274
|
+
let lineBuffer = '';
|
|
275
|
+
// One login can print its own URL more than once — the ordinary shape of an OAuth CLI is "here's
|
|
276
|
+
// the link" immediately followed by "if your browser didn't open, here it is again", verbatim
|
|
277
|
+
// the same URL. Without this, that becomes two permanent, identical, un-retractable cards for a
|
|
278
|
+
// reader who only ever did one thing. Scoped to this connection's lifetime (a fresh `Set` per
|
|
279
|
+
// `captureChildStderr` call), not global, so a *different* connection that happens to hit the
|
|
280
|
+
// same authorize endpoint still gets its own card.
|
|
281
|
+
const presentedUrls = new Set();
|
|
282
|
+
const ended = new Promise((resolve) => {
|
|
283
|
+
stream.once('end', () => resolve());
|
|
284
|
+
stream.once('close', () => resolve());
|
|
285
|
+
});
|
|
286
|
+
const scanForAuthUrl = (text) => {
|
|
287
|
+
if (!present)
|
|
288
|
+
return;
|
|
289
|
+
lineBuffer += text;
|
|
290
|
+
const lines = lineBuffer.split('\n');
|
|
291
|
+
// The last element is either '' (the chunk ended on a newline) or a partial line still being
|
|
292
|
+
// written — either way it is not a complete line yet, so it stays in the buffer for the next
|
|
293
|
+
// chunk (or for the final scan below, once the stream ends). Capped the same way as `tail`
|
|
294
|
+
// (`STDERR_TAIL_LIMIT`): a child emitting a long run of `\r`-delimited progress output with no
|
|
295
|
+
// `\n` must not accumulate unboundedly here while waiting for a line to ever complete.
|
|
296
|
+
lineBuffer = (lines.pop() ?? '').slice(-STDERR_TAIL_LIMIT);
|
|
297
|
+
for (const line of lines) {
|
|
298
|
+
const url = sniffAuthUrl(line);
|
|
299
|
+
if (!url || presentedUrls.has(url))
|
|
300
|
+
continue;
|
|
301
|
+
presentedUrls.add(url);
|
|
302
|
+
try {
|
|
303
|
+
present(`stderr-auth-url-${randomUUID()}`, url);
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
// A caller's `present` must never take the whole daemon down over an unhandled exception
|
|
307
|
+
// thrown from inside a `'data'` listener — there is no `uncaughtException` handler
|
|
308
|
+
// anywhere in `src/` (`daemon.ts` registers only `unhandledRejection`), so letting this
|
|
309
|
+
// escape would kill every session on the workstation over one bad transport write, not
|
|
310
|
+
// just this login. Same reasoning as `stream.on('error', () => {})` a few lines below,
|
|
311
|
+
// applied to the injected callback instead of the stream itself.
|
|
312
|
+
console.error(`[acp] present() threw while surfacing a login url sniffed from stderr: ${error instanceof Error ? error.message : String(error)}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
stream.on('data', (chunk) => {
|
|
317
|
+
const text = chunk.toString();
|
|
318
|
+
tail = (tail + text).slice(-STDERR_TAIL_LIMIT);
|
|
319
|
+
process.stderr.write(chunk);
|
|
320
|
+
scanForAuthUrl(text);
|
|
321
|
+
});
|
|
322
|
+
// 读端出错(SIGKILL 掉子进程时管子会断)没有谁需要处理,但一个没有监听器的 `'error'` 会把整个
|
|
323
|
+
// 守护进程掀翻 —— 这行不是防御性代码,是 EventEmitter 的硬性要求。
|
|
324
|
+
stream.on('error', () => { });
|
|
325
|
+
// A final unterminated line (no trailing `\n` before the process exits) is still a real line —
|
|
326
|
+
// scan whatever is left in the buffer once the stream itself says there is no more coming.
|
|
327
|
+
void ended.then(() => scanForAuthUrl('\n'));
|
|
328
|
+
return {
|
|
329
|
+
text: () => tail,
|
|
330
|
+
flushed: (ms) => settledWithin(ended, ms).then(() => undefined),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* 子进程死掉之后,SDK 会用一句 `ACP connection closed` 把还没回来的 `initialize` 拒掉 —— 它是从
|
|
335
|
+
* stdout 上读到 EOF 推出来的,里面没有一个字说明为什么关。真正的原因在子进程的 stderr 上,由
|
|
336
|
+
* {@link watchChildLifecycle} 收着;但这两条 reject 谁先到达 `Promise.race` 完全不确定,实测 SDK
|
|
337
|
+
* 那条几乎总是先到 —— 生产上看到的那句内容为空的 "ACP connection closed" 就是它。
|
|
338
|
+
*
|
|
339
|
+
* 所以失败时不认「谁先到」:再等一小会儿,子进程要是确实死了,就换成它那条带 stderr 的错误。上限比
|
|
340
|
+
* {@link STDERR_FLUSH_GRACE_MS} 宽一点,因为那条错误本身就要等满收尾窗口才发得出来。
|
|
341
|
+
*/
|
|
342
|
+
const CHILD_FAILURE_GRACE_MS = STDERR_FLUSH_GRACE_MS + 100;
|
|
343
|
+
/** `failure` 在 `ms` 之内 reject 出来的那个错误,否则 `undefined`。计时器总会被清掉。 */
|
|
344
|
+
function rejectionWithin(failure, ms) {
|
|
345
|
+
return new Promise((resolve) => {
|
|
346
|
+
const timer = setTimeout(() => resolve(undefined), ms);
|
|
347
|
+
failure.catch((error) => {
|
|
348
|
+
clearTimeout(timer);
|
|
349
|
+
resolve(error);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
/** 报错正文里只放最后几行;完整尾巴留在 `.stderr` 上给 {@link isMissingPackageVersion} 这类程序读。 */
|
|
354
|
+
function lastStderrLines(text, count) {
|
|
355
|
+
return text.split('\n').map((line) => line.trim()).filter(Boolean).slice(-count).join(' | ');
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Watches a freshly spawned child for the two ways it can die before answering `initialize`: a
|
|
359
|
+
* failed spawn (`'error'`, e.g. ENOENT when the launcher binary is missing — the daemon's launchd
|
|
360
|
+
* environment has a minimal PATH, so this is not hypothetical) and an early exit. Node throws an
|
|
361
|
+
* unhandled exception if a `ChildProcess` ever emits `'error'` with no listener attached, so this
|
|
362
|
+
* must be wired up synchronously, right after `spawn()`, before anything else has a chance to await.
|
|
363
|
+
*
|
|
364
|
+
* The returned promise is pre-`.catch()`-ed so that when `initialize` or the timeout wins the race
|
|
365
|
+
* in `connectAgent` instead, a later `'error'`/`'exit'` on this same child does not surface as an
|
|
366
|
+
* unhandled rejection.
|
|
367
|
+
*
|
|
368
|
+
* 退出报错要等 {@link ChildStderr.flushed} 才发出去:一个 `code=1` 的退出码本身什么也没说,把子进程
|
|
369
|
+
* 死前写在 stderr 上的那几行一起带上,才是这个错误对调用方唯一有价值的部分。
|
|
370
|
+
*/
|
|
371
|
+
function watchChildLifecycle(child, command, stderr) {
|
|
372
|
+
let onError;
|
|
373
|
+
let onExit;
|
|
374
|
+
const failure = new Promise((_, reject) => {
|
|
375
|
+
onError = (error) => reject(translateSpawnError(command, error, stderr.text()));
|
|
376
|
+
onExit = (code, signal) => {
|
|
377
|
+
const summary = `Agent process for '${command}' exited before responding to initialize ` +
|
|
378
|
+
`(code=${code ?? 'null'}, signal=${signal ?? 'null'}).`;
|
|
379
|
+
void stderr.flushed(STDERR_FLUSH_GRACE_MS).then(() => {
|
|
380
|
+
const detail = stderr.text().trim();
|
|
381
|
+
reject(new AgentSpawnError(detail ? `${summary} stderr: ${lastStderrLines(detail, 6)}` : summary, detail));
|
|
382
|
+
});
|
|
383
|
+
};
|
|
384
|
+
child.once('error', onError);
|
|
385
|
+
child.once('exit', onExit);
|
|
386
|
+
});
|
|
387
|
+
failure.catch(() => { });
|
|
388
|
+
return {
|
|
389
|
+
failure,
|
|
390
|
+
dispose() {
|
|
391
|
+
child.off('error', onError);
|
|
392
|
+
child.off('exit', onExit);
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* How long {@link closeAgentTransport} waits for a signalled child to actually exit, per signal.
|
|
398
|
+
* SIGTERM's default disposition ends a Node process immediately, so this is only ever spent on an
|
|
399
|
+
* adapter that installs its own handler — and even then, spending it is the point: the wait is what
|
|
400
|
+
* keeps our read end attached until the child is gone.
|
|
401
|
+
*/
|
|
402
|
+
const CHILD_EXIT_GRACE_MS = 2_000;
|
|
403
|
+
/** Resolves `true` if `settled` wins the race, `false` if `ms` elapses first. The timer is always cleared, so a fast exit never keeps the event loop alive. */
|
|
404
|
+
function settledWithin(settled, ms) {
|
|
405
|
+
return new Promise((resolve) => {
|
|
406
|
+
const timer = setTimeout(() => resolve(false), ms);
|
|
407
|
+
void settled.then(() => {
|
|
408
|
+
clearTimeout(timer);
|
|
409
|
+
resolve(true);
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* End the agent subprocess **before** tearing the protocol streams down, and exported so a test can
|
|
415
|
+
* pin that order.
|
|
416
|
+
*
|
|
417
|
+
* The order used to be the other way round, and it made a mid-turn close kill the adapter with an
|
|
418
|
+
* uncaught `EPIPE` (reproduced on a real ACP adapter; closing an idle connection was always clean).
|
|
419
|
+
* `connection.close()` cancels the reader over the child's stdout, which destroys our end of that
|
|
420
|
+
* pipe; an adapter still writing its turn then takes `EPIPE` on its next write, has no listener for
|
|
421
|
+
* it, and dies printing a full Node crash stack. The child's stderr is forwarded verbatim into this
|
|
422
|
+
* process's own stderr (see `captureChildStderr`) and the daemon's stdio is redirected into
|
|
423
|
+
* `daemon.log`, so every mid-turn close left a crash stack in the user's log — noise sitting exactly
|
|
424
|
+
* where someone would look for a real fault.
|
|
425
|
+
*
|
|
426
|
+
* Signalling first and *waiting for the exit* is what fixes it: the child is gone before anything
|
|
427
|
+
* touches the pipe, so there is no broken pipe left to hand it. Signal-then-teardown without the
|
|
428
|
+
* wait would only narrow the race, not close it — `kill()` returns as soon as the signal is sent.
|
|
429
|
+
*
|
|
430
|
+
* Chosen over the other candidate fix (close stdin, let the adapter exit on EOF, then kill after a
|
|
431
|
+
* grace) because that one pays its grace period on *every* close for any adapter that does not
|
|
432
|
+
* happen to exit on stdin EOF, and nothing in ACP v1 requires one to. This costs nothing in the
|
|
433
|
+
* common case: SIGTERM's default disposition is immediate termination, so the wait is already over
|
|
434
|
+
* by the next tick. Nothing is lost by not shutting down more gently — a crashed adapter's session
|
|
435
|
+
* still replays in full through `session/load` on reconnect (verified on a real adapter), because
|
|
436
|
+
* the adapter persists it itself.
|
|
437
|
+
*/
|
|
438
|
+
export async function closeAgentTransport(child, connection, graceMs = CHILD_EXIT_GRACE_MS) {
|
|
439
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
440
|
+
const exited = new Promise((resolve) => {
|
|
441
|
+
child.once('exit', () => resolve());
|
|
442
|
+
});
|
|
443
|
+
child.kill();
|
|
444
|
+
// SIGKILL for an adapter that traps SIGTERM and lingers: waiting on it forever would hang every
|
|
445
|
+
// caller of `close()`, and tearing the streams down while it still runs is the defect itself.
|
|
446
|
+
if (!(await settledWithin(exited, graceMs))) {
|
|
447
|
+
child.kill('SIGKILL');
|
|
448
|
+
await settledWithin(exited, graceMs);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
connection.close();
|
|
452
|
+
await connection.closed.catch(() => { });
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* One `initialize` auth method, or `null` when the agent did not advertise a usable one.
|
|
456
|
+
*
|
|
457
|
+
* `initResponse` is another process's JSON that arrived over a pipe; `AuthMethod` is what this
|
|
458
|
+
* client *asked* for, not what it was handed. Both variants of that union make `id` and `name`
|
|
459
|
+
* required (`@agentclientprotocol/sdk` `types.gen.d.ts:2241-2246` for `AuthMethodTerminal`,
|
|
460
|
+
* `:2281-2286` for `AuthMethodAgent`), so an agent that omits them is off-spec -- but nothing
|
|
461
|
+
* between its stdout and here checks, and the snapshot then carries `undefined` in two positions
|
|
462
|
+
* its own type declares non-optional. That lie does not stay local: `JSON.stringify` erases an
|
|
463
|
+
* `undefined` value entirely, so the object passes every `typeof x.id` check in this process and
|
|
464
|
+
* still reaches a peer with the keys missing.
|
|
465
|
+
*
|
|
466
|
+
* `agoragentic-acp` advertises exactly `{type: 'terminal', description: '…'}`. Observing it once
|
|
467
|
+
* put a malformed `AuthMethodInfo` in the roster, which rides in every subsequent
|
|
468
|
+
* `management_hello`, which the server then cannot decode -- and a hello the server cannot decode
|
|
469
|
+
* closes the socket with nothing said, so the workstation retried into a permanent OFFLINE.
|
|
470
|
+
*
|
|
471
|
+
* Dropping rather than repairing: `id` is the value `authenticate` is called with, so inventing
|
|
472
|
+
* one would trade a missing sign-in method for a broken one.
|
|
473
|
+
*/
|
|
474
|
+
function toAuthMethodSnapshot(method) {
|
|
475
|
+
if (typeof method.id !== 'string' || typeof method.name !== 'string')
|
|
476
|
+
return null;
|
|
477
|
+
return {
|
|
478
|
+
id: method.id,
|
|
479
|
+
name: method.name,
|
|
480
|
+
...('type' in method && method.type === 'terminal' ? { type: 'terminal' } : {}),
|
|
481
|
+
...(method.description != null ? { description: method.description } : {}),
|
|
482
|
+
// `args` only exists on `AuthMethodTerminal` — an `agent`-type method has no such field, and
|
|
483
|
+
// `'args' in method` is false for it, which is exactly "nothing to show" (see `AgentAuthMethod`).
|
|
484
|
+
...('args' in method && Array.isArray(method.args) ? { args: method.args } : {}),
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
const DEFAULT_INITIALIZE_TIMEOUT_MS = 30_000;
|
|
488
|
+
/**
|
|
489
|
+
* Spawns an ACP agent subprocess, connects to it over ndjson stdio, and negotiates capabilities
|
|
490
|
+
* via `initialize`. `env` and `agentEnv` are overlay layers on top of the current process
|
|
491
|
+
* environment, not full environments (existing contract carried over from the deleted bridge
|
|
492
|
+
* transport): if `PATH` is present in either, it has already been resolved by the caller and must
|
|
493
|
+
* not be prefixed with anything else here.
|
|
494
|
+
*/
|
|
495
|
+
export async function connectAgent(spec, env, agentEnv, options = {}) {
|
|
496
|
+
assertNodeVersionSupported(spec, options.nodeVersion);
|
|
497
|
+
const child = spawn(spec.command, spec.args, {
|
|
498
|
+
// stderr 是 `pipe` 而不是 `inherit`,而且**依然不接进协议流** —— 它被 `captureChildStderr`
|
|
499
|
+
// 单独读走:原样转发到 daemon.log(`inherit` 以前免费提供的那件事),同时留一份尾巴给启动期的
|
|
500
|
+
// 报错。启动失败时 npm 写在这里的文字是唯一的一手证据。
|
|
501
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
502
|
+
env: { ...process.env, ...spec.env, ...env, ...agentEnv },
|
|
503
|
+
});
|
|
504
|
+
const stderrTail = captureChildStderr(child.stderr, options.present);
|
|
505
|
+
const lifecycle = watchChildLifecycle(child, spec.command, stderrTail);
|
|
506
|
+
// Argument order reads backwards from what the names suggest: argument 1 is the child's stdin
|
|
507
|
+
// (we write to it), argument 2 is the child's stdout (we read from it). Swapping them still
|
|
508
|
+
// compiles and still "connects" — the initialize request just never arrives, so the failure mode
|
|
509
|
+
// is a hang, not an error.
|
|
510
|
+
// Filled in by `bindDraftUpdates` once a session exists. Held in closure variables rather than
|
|
511
|
+
// passed in, because the interceptor has to be in place before `initialize` — the agent may send
|
|
512
|
+
// its first update before anyone has had a chance to register anything.
|
|
513
|
+
let ownSessionId = null;
|
|
514
|
+
let draftSink = null;
|
|
515
|
+
// Sessions this connection handles by some route other than an `AcpSession` — today only
|
|
516
|
+
// adoption's replay. Kept beside `ownSessionId` rather than folded into it because they mean
|
|
517
|
+
// different things: one is "the session we drive", these are "sessions someone else on this
|
|
518
|
+
// connection is already reading".
|
|
519
|
+
const knownSessionIds = new Set();
|
|
520
|
+
const stream = ndJsonStream(Writable.toWeb(child.stdin),
|
|
521
|
+
// The child's stdout, minus the notifications the pinned SDK cannot deliver. Everything else
|
|
522
|
+
// reaches it byte-for-byte; see `draftInterceptor.ts` for the measurements behind this.
|
|
523
|
+
interceptDraftUpdates(Readable.toWeb(child.stdout), {
|
|
524
|
+
// Unbound means unknown, and unknown must not be claimed as ours: treating a session we have
|
|
525
|
+
// not been told about as our own would send its frames to the SDK, which drops them silently.
|
|
526
|
+
// Diverting is the recoverable direction — the sink can decide, and it can log.
|
|
527
|
+
isOwnSession: (sessionId) => (ownSessionId !== null && sessionId === ownSessionId) || knownSessionIds.has(sessionId),
|
|
528
|
+
onDiverted: (update) => {
|
|
529
|
+
if (draftSink) {
|
|
530
|
+
draftSink(update);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
// No sink yet: say so once per update rather than dropping in silence. This is reachable
|
|
534
|
+
// only before the session is bound, which is before any prompt, so it should stay rare —
|
|
535
|
+
// and if it stops being rare, that is worth seeing.
|
|
536
|
+
console.warn(`[acp] dropped a ${update.reason} session/update for '${update.sessionId}': ` +
|
|
537
|
+
'no session is bound to this connection yet');
|
|
538
|
+
},
|
|
539
|
+
}));
|
|
540
|
+
const app = client({ name: 'axom' });
|
|
541
|
+
options.configure?.(app);
|
|
542
|
+
const connection = app.connect(stream);
|
|
543
|
+
const timeoutMs = options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS;
|
|
544
|
+
let timeoutHandle;
|
|
545
|
+
const timeout = new Promise((_, reject) => {
|
|
546
|
+
timeoutHandle = setTimeout(() => {
|
|
547
|
+
// 超时也把 stderr 带上:npx 在慢机器上会把整个 initialize 预算耗光之后才让子进程死掉,那时候
|
|
548
|
+
// 真正的原因(比如 ETARGET)早就写在这里了,只是赢下 race 的是计时器而不是 `'exit'`。
|
|
549
|
+
const detail = stderrTail.text().trim();
|
|
550
|
+
reject(new AgentInitializeTimeoutError(`Agent '${spec.command}' did not respond to initialize within ${timeoutMs}ms.` +
|
|
551
|
+
(detail ? ` Last stderr: ${lastStderrLines(detail, 6)}` : ''), detail));
|
|
552
|
+
}, timeoutMs);
|
|
553
|
+
});
|
|
554
|
+
timeout.catch(() => { });
|
|
555
|
+
let initResponse;
|
|
556
|
+
try {
|
|
557
|
+
initResponse = await Promise.race([
|
|
558
|
+
connection.agent.request('initialize', {
|
|
559
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
560
|
+
// Three things are declared here, and they are not interchangeable — see
|
|
561
|
+
// `draftSubagents.ts` for the tier each one sits in.
|
|
562
|
+
//
|
|
563
|
+
// 1. `subagents: {}` is the **canonical** draft field from ACP RFD #1992. Both official
|
|
564
|
+
// adapters gate native subagent sessions on it, and both answer with
|
|
565
|
+
// `agentCapabilities.sessionCapabilities.subagents` (measured against each).
|
|
566
|
+
//
|
|
567
|
+
// 2. The AIR list is sent as well, for two separate reasons. It is the *only* channel for
|
|
568
|
+
// `asyncTasks` — nothing canonical exists for background work — and it is the fallback
|
|
569
|
+
// for `nativeSubagentSessions`, because a released SDK sitting between us and an agent
|
|
570
|
+
// can strip a draft field it has no type for (upstream says so in #1017's compatibility
|
|
571
|
+
// note). Declaring both is not belt-and-braces: they cover different failure modes.
|
|
572
|
+
//
|
|
573
|
+
// 3. `subagent-transcript: true` stays. It is claude-agent-acp's older vendor key
|
|
574
|
+
// (`dist/acp-agent.js:155-158`), and it still governs the **fallback** path taken when
|
|
575
|
+
// native subagent sessions are not negotiated — an older adapter, or a stripped
|
|
576
|
+
// capability. Without it that path strips every subagent's text and thinking
|
|
577
|
+
// (`stripSubagentTextAndThinking`, `:3926`), leaving nothing but tool-call wreckage.
|
|
578
|
+
// Adapters that have never heard of any of these keys are required by the spec to
|
|
579
|
+
// ignore them.
|
|
580
|
+
clientCapabilities: asSdkClientCapabilities({
|
|
581
|
+
[DRAFT_SUBAGENTS_CAPABILITY]: {},
|
|
582
|
+
// `ClientSessionCapabilities.configOptions` (`types.gen.d.ts:4306`), part of the schema
|
|
583
|
+
// proper — unlike `subagents` above, this survives the pinned SDK's `zInitializeRequest`
|
|
584
|
+
// stripping because it's a known field, not a draft one. `{}` for `boolean` tells the
|
|
585
|
+
// agent it may send `type: "boolean"` entries in `SessionConfigOption` and that this
|
|
586
|
+
// client will send `session/set_config_option` with a boolean `value`
|
|
587
|
+
// (`SessionConfigOptionsCapabilities`, `types.gen.d.ts:4333-4353`); without it an agent
|
|
588
|
+
// with a boolean toggle downgrades it to a two-value select.
|
|
589
|
+
session: { configOptions: { boolean: {} } },
|
|
590
|
+
// `ClientCapabilities.elicitation` (`types.gen.d.ts:4479`) is a known field of the schema
|
|
591
|
+
// proper, not a draft one, so it survives `zInitializeRequest` stripping as-is. `{}` for
|
|
592
|
+
// `url` (`ElicitationUrlCapabilities`, `:4616`) means the client can put a URL in front of
|
|
593
|
+
// the user; this is the precondition for the whole device-code login shape to exist at all
|
|
594
|
+
// — Codex's own `getCodexAuthMethods` gates its `chat-gpt-device-code` auth method on
|
|
595
|
+
// `clientCapabilities?.elicitation?.url != null`. Without declaring it, Codex never offers
|
|
596
|
+
// that method and `elicitation/create` with `mode: 'url'` never arrives to answer (see
|
|
597
|
+
// `permission.ts`'s `handleCreateElicitation`).
|
|
598
|
+
elicitation: { url: {} },
|
|
599
|
+
_meta: {
|
|
600
|
+
'subagent-transcript': true,
|
|
601
|
+
...airCapabilityMeta(AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY, AIR_ASYNC_TASKS_CAPABILITY),
|
|
602
|
+
},
|
|
603
|
+
}),
|
|
604
|
+
}),
|
|
605
|
+
lifecycle.failure,
|
|
606
|
+
timeout,
|
|
607
|
+
]);
|
|
608
|
+
}
|
|
609
|
+
catch (error) {
|
|
610
|
+
// 先决定要抛哪一个,**再**动子进程:`child.kill()` 之后 `'exit'` 一样会让 lifecycle reject,
|
|
611
|
+
// 那时候拿到的"子进程死了"是我们自己造成的,拿它盖掉一个真实的协议错误就是在制造假线索。
|
|
612
|
+
const failure = error instanceof AgentSpawnError || error instanceof AgentInitializeTimeoutError
|
|
613
|
+
? error
|
|
614
|
+
: ((await rejectionWithin(lifecycle.failure, CHILD_FAILURE_GRACE_MS)) ?? error);
|
|
615
|
+
connection.close(failure);
|
|
616
|
+
child.kill();
|
|
617
|
+
throw failure;
|
|
618
|
+
}
|
|
619
|
+
finally {
|
|
620
|
+
clearTimeout(timeoutHandle);
|
|
621
|
+
lifecycle.dispose();
|
|
622
|
+
}
|
|
623
|
+
// Global Constraint 1 is v1-only; the schema calls for disconnecting when the agent didn't
|
|
624
|
+
// accept the version this client sent.
|
|
625
|
+
if (initResponse.protocolVersion !== PROTOCOL_VERSION) {
|
|
626
|
+
const error = new UnsupportedProtocolVersionError(`Agent '${spec.command}' negotiated protocol version ${initResponse.protocolVersion}, ` +
|
|
627
|
+
`but this client only supports version ${PROTOCOL_VERSION}.`);
|
|
628
|
+
connection.close(error);
|
|
629
|
+
child.kill();
|
|
630
|
+
throw error;
|
|
631
|
+
}
|
|
632
|
+
const sessionCapabilities = initResponse.agentCapabilities?.sessionCapabilities;
|
|
633
|
+
const capabilities = {
|
|
634
|
+
// Capability markers are empty objects (`{ _meta? }`); supplying `{}` means "supported", and
|
|
635
|
+
// omitting the field (or explicit `null`) means "not supported". A truthiness check reads `{}`
|
|
636
|
+
// correctly by accident but reads `null`/`undefined` correctly too, which hides the real bug:
|
|
637
|
+
// this must stay an explicit `!= null`, never `Boolean(...)` or a bare truthy test.
|
|
638
|
+
list: sessionCapabilities?.list != null,
|
|
639
|
+
resume: sessionCapabilities?.resume != null,
|
|
640
|
+
loadSession: initResponse.agentCapabilities?.loadSession === true,
|
|
641
|
+
logout: initResponse.agentCapabilities?.auth?.logout != null,
|
|
642
|
+
/**
|
|
643
|
+
* ACP `PromptCapabilities.image` (`types.gen.d.ts:1601`): "Agent supports
|
|
644
|
+
* [`ContentBlock::Image`]". Every field of that type is optional, so an agent that says
|
|
645
|
+
* nothing supports text only -- `=== true` rather than `!== false`.
|
|
646
|
+
*
|
|
647
|
+
* Verified against codex-acp 1.7.0, which declares `{embeddedContext: true, image: true}`.
|
|
648
|
+
* `audio` is deliberately not read: no agent we target declares it, and an unread capability
|
|
649
|
+
* is a field nobody can act on.
|
|
650
|
+
*/
|
|
651
|
+
promptImage: initResponse.agentCapabilities?.promptCapabilities?.image === true,
|
|
652
|
+
};
|
|
653
|
+
// Honour the advertised capability, not what the agent happens to route: some adapters (e.g.
|
|
654
|
+
// codex-acp routing `session/fork`) implement more than they declare in `agentCapabilities`.
|
|
655
|
+
// Reading `capabilities` above off `initResponse` — never probing the agent for what it accepts
|
|
656
|
+
// — is what keeps that distinction intact.
|
|
657
|
+
const advertisedAuthMethods = initResponse.authMethods ?? [];
|
|
658
|
+
const authMethods = advertisedAuthMethods
|
|
659
|
+
.map(toAuthMethodSnapshot)
|
|
660
|
+
.filter((method) => method !== null);
|
|
661
|
+
if (authMethods.length !== advertisedAuthMethods.length) {
|
|
662
|
+
// Named and counted rather than dropped in silence: the user loses a sign-in method they can
|
|
663
|
+
// see advertised in the agent's own docs, and the only honest answer to "why" is which agent
|
|
664
|
+
// sent what. `agentInfo.name` first because most agents launch through `npx`/`uvx` and
|
|
665
|
+
// `spec.command` would then name the launcher, which identifies none of the ~40 registry
|
|
666
|
+
// entries. See `toAuthMethodSnapshot` for what accepting these costs instead.
|
|
667
|
+
console.warn(`[acp] agent '${initResponse.agentInfo?.name ?? spec.command}' advertised ` +
|
|
668
|
+
`${advertisedAuthMethods.length - authMethods.length} auth method(s) without the required ` +
|
|
669
|
+
'id/name; ignoring them');
|
|
670
|
+
}
|
|
671
|
+
return {
|
|
672
|
+
capabilities,
|
|
673
|
+
authMethods,
|
|
674
|
+
supportsSteering: steeringAdvertised(initResponse._meta),
|
|
675
|
+
steeringTimings: steeringTimings(initResponse._meta),
|
|
676
|
+
agentInfo: initResponse.agentInfo ?? undefined,
|
|
677
|
+
connection,
|
|
678
|
+
agentProcessAlive: () => child.exitCode === null && child.signalCode === null,
|
|
679
|
+
bindDraftUpdates(sessionId, sink) {
|
|
680
|
+
ownSessionId = sessionId;
|
|
681
|
+
draftSink = sink;
|
|
682
|
+
},
|
|
683
|
+
markSessionKnown(sessionId) {
|
|
684
|
+
knownSessionIds.add(sessionId);
|
|
685
|
+
return () => knownSessionIds.delete(sessionId);
|
|
686
|
+
},
|
|
687
|
+
// Child first, streams second — see `closeAgentTransport` for why that order is the fix and not
|
|
688
|
+
// an incidental detail.
|
|
689
|
+
close: () => closeAgentTransport(child, connection),
|
|
690
|
+
};
|
|
691
|
+
}
|