@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.
Files changed (90) hide show
  1. package/README.md +64 -0
  2. package/dist/args.js +29 -0
  3. package/dist/cli.js +76 -0
  4. package/dist/commands/auth.js +52 -0
  5. package/dist/commands/execute.js +40 -0
  6. package/dist/commands/guide.js +38 -0
  7. package/dist/commands/record.js +99 -0
  8. package/dist/commands/skills.js +69 -0
  9. package/dist/commands/struct.js +50 -0
  10. package/dist/commands/update.js +88 -0
  11. package/dist/commands/whoami.js +8 -0
  12. package/dist/commands/workstation.js +171 -0
  13. package/dist/config.js +131 -0
  14. package/dist/credentialBroker.js +340 -0
  15. package/dist/credentials.js +211 -0
  16. package/dist/install/agentsDir.js +35 -0
  17. package/dist/install/claude/commands.js +89 -0
  18. package/dist/install/claude/materialize.js +42 -0
  19. package/dist/install/installer.js +45 -0
  20. package/dist/install/targets/claude.js +154 -0
  21. package/dist/mcp.js +152 -0
  22. package/dist/output.js +37 -0
  23. package/dist/workstation/acp/acpAgents.js +1287 -0
  24. package/dist/workstation/acp/acpClient.js +691 -0
  25. package/dist/workstation/acp/acpSession.js +1047 -0
  26. package/dist/workstation/acp/adoption.js +396 -0
  27. package/dist/workstation/acp/auth.js +80 -0
  28. package/dist/workstation/acp/authUrlSniffer.js +52 -0
  29. package/dist/workstation/acp/binaryInstall.js +411 -0
  30. package/dist/workstation/acp/configOptions.js +257 -0
  31. package/dist/workstation/acp/contract.js +494 -0
  32. package/dist/workstation/acp/draftInterceptor.js +154 -0
  33. package/dist/workstation/acp/draftSubagents.js +38 -0
  34. package/dist/workstation/acp/installErrors.js +89 -0
  35. package/dist/workstation/acp/installProbe.js +363 -0
  36. package/dist/workstation/acp/permission.js +327 -0
  37. package/dist/workstation/acp/registry/catalog.js +29 -0
  38. package/dist/workstation/acp/registry/launch.js +128 -0
  39. package/dist/workstation/acp/registry/registryClient.js +160 -0
  40. package/dist/workstation/acp/registry/registryTypes.js +8 -0
  41. package/dist/workstation/acp/sessionUpdateMapper.js +602 -0
  42. package/dist/workstation/acp/test/fakeAgent.js +435 -0
  43. package/dist/workstation/acp/test/sessionUpdateFixtures.js +165 -0
  44. package/dist/workstation/acp/timedCommand.js +47 -0
  45. package/dist/workstation/acp/uvxInstall.js +248 -0
  46. package/dist/workstation/acp/validate.js +134 -0
  47. package/dist/workstation/agentSessions.js +1628 -0
  48. package/dist/workstation/channels/agent.js +96 -0
  49. package/dist/workstation/channels/computer.js +91 -0
  50. package/dist/workstation/channels/management.js +231 -0
  51. package/dist/workstation/channels/url.js +42 -0
  52. package/dist/workstation/config.js +47 -0
  53. package/dist/workstation/connection/encryptedChannel.js +261 -0
  54. package/dist/workstation/connection/retryPolicy.js +14 -0
  55. package/dist/workstation/connection/supervisor.js +353 -0
  56. package/dist/workstation/crypto.js +63 -0
  57. package/dist/workstation/daemon.js +225 -0
  58. package/dist/workstation/daemonctl.js +95 -0
  59. package/dist/workstation/exec.js +112 -0
  60. package/dist/workstation/journal.js +257 -0
  61. package/dist/workstation/names.js +17 -0
  62. package/dist/workstation/oauth/browser.js +24 -0
  63. package/dist/workstation/oauth/deviceAuthorization.js +198 -0
  64. package/dist/workstation/osinfo.js +4 -0
  65. package/dist/workstation/protocol/agent.js +606 -0
  66. package/dist/workstation/protocol/common.js +136 -0
  67. package/dist/workstation/protocol/computer.js +31 -0
  68. package/dist/workstation/protocol/management.js +116 -0
  69. package/dist/workstation/register.js +42 -0
  70. package/dist/workstation/service/installer.js +362 -0
  71. package/dist/workstation/service/launchd.js +46 -0
  72. package/dist/workstation/service/loginShell.js +36 -0
  73. package/dist/workstation/service/systemd.js +48 -0
  74. package/dist/workstation/service/types.js +73 -0
  75. package/dist/workstation/supervision.js +52 -0
  76. package/dist/workstation/systemMetrics.js +271 -0
  77. package/dist/workstation/update/artifact.js +61 -0
  78. package/dist/workstation/update/coordinator.js +240 -0
  79. package/dist/workstation/update/npmInstaller.js +91 -0
  80. package/dist/workstation/update/redact.js +36 -0
  81. package/dist/workstation/update/runUpdate.js +215 -0
  82. package/dist/workstation/update/stateStore.js +87 -0
  83. package/dist/workstation/update/taskRegistry.js +142 -0
  84. package/dist/workstation/update/types.js +3 -0
  85. package/dist/workstation/update/watchdog.js +311 -0
  86. package/dist/workstation/version.js +12 -0
  87. package/package.json +39 -0
  88. package/skills/axom/SKILL.md +83 -0
  89. package/skills/axom-definition/SKILL.md +74 -0
  90. package/skills/axom-platform/SKILL.md +78 -0
@@ -0,0 +1,1287 @@
1
+ import path from 'node:path';
2
+ import semver from 'semver';
3
+ import { connectAgent, isMissingPackageVersion, jsonRpcErrorCode, stderrShowsMissingPackageVersion } from './acpClient.js';
4
+ import { AcpSession } from './acpSession.js';
5
+ import { createReplayCapture, listNativeSessions, replayNativeSession } from './adoption.js';
6
+ import { AcpAuth } from './auth.js';
7
+ import { binaryTargetFor, installBinary, installedBinaryVersion, platformTriple } from './binaryInstall.js';
8
+ import { normalizeConfigOptions } from './configOptions.js';
9
+ import { friendlyOrdinaryInstallError, friendlyPinnedVersionUnavailableAndFallbackFailed, logInstallFailureDetail, } from './installErrors.js';
10
+ import { ADAPTER_INSTALL_TIMEOUT_MS, findOnPath, installNpxPackage, npmPackageName, npxCacheVersion, probeEnvironment, readNpxCache, runDependencyInstall, } from './installProbe.js';
11
+ import { createDeferredPermissionSink, registerPermissionHandlers } from './permission.js';
12
+ import { catalogEntryFor } from './registry/catalog.js';
13
+ import { resolveLaunch, unpinnedFallbackFor } from './registry/launch.js';
14
+ import { RegistryClient } from './registry/registryClient.js';
15
+ import { installUvTool, listUvTools, uvPackageName, uvToolVersion } from './uvxInstall.js';
16
+ function errorMessage(error) {
17
+ return error instanceof Error ? error.message : String(error);
18
+ }
19
+ /**
20
+ * A session **the agent itself refused**, after its `initialize` had already succeeded — the shape
21
+ * an ACP `auth_required` on `session/new` arrives in.
22
+ *
23
+ * `AcpAgents.open` throws for reasons on both sides of that line: `resolveLaunchFor` finds nothing
24
+ * to launch, `connectResolved` fails to spawn or times out waiting for `initialize`, and only then
25
+ * does `startSession` ask for a session. Everything before the `initialize` is a *machine* failure,
26
+ * and the agent has said nothing at all — including nothing about credentials.
27
+ *
28
+ * The distinction has to be drawn here because this is the only place that knows it, and it has to
29
+ * be drawn at all because `observed` outlives one connection: a daemon that talked to this agent an
30
+ * hour ago still holds its method table, so a spawn `ENOENT` reported with `authMethods` beside it
31
+ * satisfies the engine's `error != null && authMethods.isNotEmpty()` and draws a login card for a
32
+ * missing binary. This mirrors what the probe path already does the long way round — `verify`
33
+ * catches a connect failure and answers with **no** methods, so there too the pairing can only be
34
+ * produced by a connection whose `initialize` succeeded.
35
+ *
36
+ * The message is the underlying failure's own, verbatim, so nothing downstream that reads or logs
37
+ * it sees a wrapper appear; `cause` keeps the original for anyone who needs more.
38
+ *
39
+ * {@link errorCode} is the second thing the wrapper preserves, and the reason it is captured *here*
40
+ * rather than read back later: this constructor is the last place the original error object exists
41
+ * as an object. `errorMessage(cause)` is a one-way door — after it, `auth_required` and "context
42
+ * window exceeded" are two English sentences, and telling them apart would mean matching on text an
43
+ * agent may rewrite in its next release. `undefined` when the refusal was not a JSON-RPC one.
44
+ */
45
+ export class AgentRefusedSessionError extends Error {
46
+ errorCode;
47
+ constructor(cause) {
48
+ super(errorMessage(cause), { cause });
49
+ this.name = 'AgentRefusedSessionError';
50
+ this.errorCode = jsonRpcErrorCode(cause);
51
+ }
52
+ }
53
+ /**
54
+ * Wall-clock budget for one on-demand verification or authentication, spawn included.
55
+ *
56
+ * Both operations start a real subprocess for an agent the user may never have run on this machine
57
+ * — an npx package that is not cached yet, a wrapper script with a typo in it, an adapter that opens
58
+ * stdio and then sits there. Without a ceiling, one such agent parks a request on the management
59
+ * channel until the *server's* 30s deadline fires, and the user is told the workstation is
60
+ * unresponsive rather than that this one agent is. Kept below that server deadline on purpose, so
61
+ * the answer that comes back is this daemon's diagnostic and not a timeout.
62
+ *
63
+ * 这是**没人指定预算时**的取值。`agent_verify` 现在可以带一个 `timeoutMs`,让发起方把自己的
64
+ * 预算带过来(见 `agentSessions.ts` 的 `clampProbeTimeout`);上面那句「留在服务端 deadline
65
+ * 以下」只对那条不带预算的路成立。带预算是为了后台预热:那条路上没有人在等,而一次真正的冷
66
+ * 启动(node 解析 + 拉 adapter + agent CLI 首跑)本来就翻得过 20 秒这道墙。
67
+ */
68
+ export const AGENT_PROBE_TIMEOUT_MS = 20_000;
69
+ /**
70
+ * The ceiling on one whole `authenticate`, and **it is a leak guard, not a statement about how long
71
+ * a login should take.**
72
+ *
73
+ * A login is not a probe. {@link AGENT_PROBE_TIMEOUT_MS} answers "how long may an agent take to
74
+ * respond to a machine", and a person reading a device code out of a browser is not answering a
75
+ * machine — spec §6.2 measured an agent whose `authenticate` had still not returned after 25
76
+ * seconds because it was waiting on a browser callback. Ending that at twenty seconds does not make
77
+ * the login fail faster; it makes a login that was working look like a bug.
78
+ *
79
+ * So why a ceiling at all: every `authenticate` holds an agent subprocess and a connection open for
80
+ * as long as it runs, and nothing else will ever come back for them. If the reader closes the tab,
81
+ * walks away, or the agent simply never answers, this is the only thing that reclaims them. Ten
82
+ * minutes is chosen to be far past any real sign-in and far short of "until the daemon restarts" —
83
+ * it is deliberately not tuned, because tuning it would mean pretending it measures something.
84
+ */
85
+ export const AGENT_LOGIN_CEILING_MS = 10 * 60_000;
86
+ /**
87
+ * The budget one `authenticate` actually gets: `override` when it is a usable number of
88
+ * milliseconds, {@link AGENT_LOGIN_CEILING_MS} otherwise.
89
+ *
90
+ * Exists as a named function rather than an inline `??` so that "a login does not use the probe
91
+ * budget" is a claim a test can make in one call instead of having to wait out either budget in
92
+ * real time — see `acpAgents.test.ts`'s "gives a login a ceiling in minutes".
93
+ */
94
+ export function loginDeadlineMs(override) {
95
+ if (typeof override !== 'number' || !Number.isFinite(override) || override <= 0)
96
+ return AGENT_LOGIN_CEILING_MS;
97
+ return override;
98
+ }
99
+ /** The reason {@link AgentLoginSink.signal} gives when it ends a login. */
100
+ export class AgentLoginCancelledError extends Error {
101
+ }
102
+ /**
103
+ * `work`, but rejecting as soon as `signal` aborts.
104
+ *
105
+ * Deliberately does not cancel `work` — nothing in ACP can cancel an `authenticate` already sent, the
106
+ * same limitation `withDeadline` documents. The caller's `connection.close()` is what actually ends
107
+ * the agent; this only stops waiting on it. The abandoned promise gets a no-op `catch` for the same
108
+ * reason it does there: it usually rejects long after nobody is listening.
109
+ */
110
+ function cancellable(work, signal, what) {
111
+ if (!signal)
112
+ return work;
113
+ if (signal.aborted) {
114
+ work.catch(() => { });
115
+ return Promise.reject(new AgentLoginCancelledError(`${what} was cancelled.`));
116
+ }
117
+ return new Promise((resolve, reject) => {
118
+ const onAbort = () => reject(new AgentLoginCancelledError(`${what} was cancelled.`));
119
+ signal.addEventListener('abort', onAbort, { once: true });
120
+ work.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
121
+ });
122
+ }
123
+ /**
124
+ * One login's URL bookkeeping: what has been shown, what is still on screen, and the fact that the
125
+ * login is over.
126
+ *
127
+ * Extracted from {@link AcpAgents.authenticate} rather than left as closures inside it because
128
+ * **two of its rules are otherwise unobservable**. Deduplication and the retraction on settle can be
129
+ * driven through a real agent; "a URL that arrives after the login ended is not shown" cannot — the
130
+ * only thing that could produce one is a child that is already being killed, so a test for it would
131
+ * be a race with a subprocess rather than an assertion. Here it is a function call.
132
+ *
133
+ * Every collection is per-call, not per-agent: two logins must not silently swallow each other's
134
+ * URLs because an identity provider handed out the same authorize endpoint.
135
+ */
136
+ export function createLoginUrlSink(login) {
137
+ const shownUrls = new Set();
138
+ const outstanding = new Set();
139
+ let over = false;
140
+ const dismiss = (elicitationId) => {
141
+ // `delete` returns false for an id that was never presented — an `elicitation/complete` for a
142
+ // URL deduplicated away below, or one the agent invented — and for an id already retracted.
143
+ // Both must be silent: a retraction for a card that was never shown is not an event.
144
+ if (!outstanding.delete(elicitationId))
145
+ return;
146
+ login.dismiss?.(elicitationId);
147
+ };
148
+ return {
149
+ // **Absent when the caller has no `present`, never a wrapper around nothing.** A wrapper would
150
+ // be a function, and a function is all `handleCreateElicitation` and `captureChildStderr` look
151
+ // at: the elicitation would be *accepted* — claiming "this URL is in front of the user" — and
152
+ // stderr would be scanned, both on behalf of a reader who does not exist. Absence is the whole
153
+ // mechanism by which those two decline instead (Task 3), and it has to survive this hop.
154
+ ...(login.present
155
+ ? {
156
+ present: (elicitationId, url) => {
157
+ // The login is over. A URL still arriving belongs to nothing — the stderr scan runs until
158
+ // the child's stream ends, which is after the kill that ends the login — and showing it
159
+ // would raise a card *after* the retraction, so nothing would ever take that one down.
160
+ if (over)
161
+ return;
162
+ // Deduplication across *provenances*, not just within one. `captureChildStderr` already
163
+ // deduplicates stderr against itself, but a device-code agent typically elicits the URL
164
+ // and prints the same one, and those two arrive with different ids — so without this, one
165
+ // login leaves two cards that nothing can ever merge.
166
+ if (shownUrls.has(url))
167
+ return;
168
+ shownUrls.add(url);
169
+ outstanding.add(elicitationId);
170
+ login.present?.(elicitationId, url);
171
+ },
172
+ }
173
+ : {}),
174
+ dismiss,
175
+ finish: () => {
176
+ // Order matters: `over` first, so a line scanned between here and the last `dismiss` is
177
+ // dropped rather than presented into a login nobody is holding any more.
178
+ over = true;
179
+ for (const elicitationId of [...outstanding])
180
+ dismiss(elicitationId);
181
+ },
182
+ };
183
+ }
184
+ /** A verification or authentication ran past {@link AGENT_PROBE_TIMEOUT_MS}. */
185
+ export class AgentProbeTimeoutError extends Error {
186
+ }
187
+ /**
188
+ * Reject with {@link AgentProbeTimeoutError} once `timeoutMs` has passed, whatever `work` is still
189
+ * doing.
190
+ *
191
+ * `work` is deliberately *not* cancelled here — nothing in ACP can cancel an `initialize` or a
192
+ * `session/new` in flight. What actually stops it is the caller's `connection.close()`, which kills
193
+ * the subprocess; this only stops the *waiting*. The abandoned promise gets a no-op `catch` because
194
+ * it will usually reject (the child died under it) long after nobody is listening, and an unhandled
195
+ * rejection would take the daemon down.
196
+ */
197
+ async function withDeadline(work, timeoutMs, what) {
198
+ let timer;
199
+ const deadline = new Promise((_, reject) => {
200
+ timer = setTimeout(() => reject(new AgentProbeTimeoutError(`${what} did not finish within ${timeoutMs}ms.`)), timeoutMs);
201
+ });
202
+ deadline.catch(() => { });
203
+ try {
204
+ return await Promise.race([work, deadline]);
205
+ }
206
+ finally {
207
+ clearTimeout(timer);
208
+ work.catch(() => { });
209
+ }
210
+ }
211
+ /**
212
+ * 等**第一条** `available_commands_update` 的窗口。一条都没等到就收工,`observed` 保持 undefined
213
+ * ——按「缺席 ≠ 空表」的约定,服务端据此不写命令表那一半(见 {@link verify} 的 KDoc)。
214
+ *
215
+ * 数值沿用原来那个固定 settle 窗口(`docs/superpowers/notes/2026-08-31-codex-command-table-race.md`)。
216
+ * **没有证据支持把它调大**:真机上量到同一个 agent 在一台机器上回 72 条、在另一台回 0 条,但那两次
217
+ * 的耗时差(8.4s vs 2.1s)完全可能全部落在 spawn/connect 上,而不是广播来晚了。要区分这两件事,
218
+ * 得先有 {@link verify} 里那条「没等到表」的日志,不是先改这个数字。
219
+ */
220
+ const COMMAND_FIRST_BROADCAST_MS = 1500;
221
+ /**
222
+ * 第一条广播到手之后,每再来一条就重新计时;安静这么久没有新的就认为表定下来了。
223
+ *
224
+ * 它替掉的是「无论如何都睡满 {@link COMMAND_FIRST_BROADCAST_MS}」那个写法,买到两件事:
225
+ * 常见情况**更快**(第一条 100ms 到、第二条 300ms 到 → 1050ms 收工,而不是雷打不动 1500ms),
226
+ * 以及第一条卡在窗口末尾时**不再把第二条丢掉**(Claude 会广播两次,第二条才是过滤掉终端专用命令
227
+ * 之后能用的那份)。一条都没等到的路径完全不受影响 —— 这是它严格不劣于原实现的原因。
228
+ */
229
+ const COMMAND_QUIET_MS = 750;
230
+ /**
231
+ * 绝对上限:第一条广播之后总共最多再等这么久,防一个不停重推的 agent 把安静期无限续下去。
232
+ * 必须显著小于 {@link AGENT_PROBE_TIMEOUT_MS} —— 连接和 `session/new` 还要在这之前各花一段。
233
+ */
234
+ const COMMAND_SETTLE_CEILING_MS = 5_000;
235
+ /** ACP 的 `AvailableCommand` → 隧道形状:`input.hint` 拍平成 `inputHint`。 */
236
+ function toAvailableCommandWire(raw) {
237
+ const command = raw;
238
+ return {
239
+ name: typeof command.name === 'string' ? command.name : '',
240
+ description: typeof command.description === 'string' ? command.description : '',
241
+ ...(typeof command.input?.hint === 'string' ? { inputHint: command.input.hint } : {}),
242
+ };
243
+ }
244
+ /**
245
+ * Everything one live connection reveals about an agent, in the one shape {@link AcpAgents.observe}
246
+ * merges.
247
+ *
248
+ * A function rather than four separate object literals so that a field added to
249
+ * {@link AuthObservation} cannot be recorded on some connection paths and silently missed on
250
+ * others — `verify` and `listNativeSessions` are exactly the paths a settings page depends on, and
251
+ * they are the ones nobody remembers.
252
+ */
253
+ function observationOf(connection) {
254
+ return { authMethods: connection.authMethods, supportsSteering: connection.supportsSteering };
255
+ }
256
+ /**
257
+ * The version of `agent`'s ACP adapter actually found on this machine, dispatched by distribution
258
+ * channel — the npx cache (`npxCacheVersion`), a hoisted `uv tool list` snapshot (`uvVersions`), or
259
+ * the binary install root (`installedBinaryVersion`). Unlike `adapterInstalled`, which only ever
260
+ * answered for npx, this covers all three, and `undefined` here is a genuine "not installed" rather
261
+ * than "did not look" — every branch is a cheap local read this daemon always performs.
262
+ *
263
+ * An agent with more than one distribution (uncommon on the live registry, but not ruled out by the
264
+ * types) is checked npx-first, then uvx, then binary — the same preference order `resolveLaunch`
265
+ * uses to pick which one actually launches.
266
+ *
267
+ * `uvVersions` is a pre-read `uv tool list` snapshot ({@link listUvTools}), not a live call: `uv` is
268
+ * a real subprocess, and this function runs once per registry entry inside {@link AcpAgents.roster}'s
269
+ * map — spawning it per uvx agent there is exactly the per-agent-subprocess defect the roster's own
270
+ * doc comment rules out (39 entries, on every scan). The caller reads `uv tool list` exactly once and
271
+ * hands the parsed result in here for a plain map lookup.
272
+ */
273
+ function adapterVersionFor(agent, npxCache, adapterRoot, uvVersions) {
274
+ const { npx, uvx, binary } = agent.distribution;
275
+ if (npx)
276
+ return npxCacheVersion(npx.package, npxCache);
277
+ if (uvx)
278
+ return uvVersions.get(uvPackageName(uvx.package));
279
+ if (binary)
280
+ return installedBinaryVersion(agent.id, adapterRoot, agent.version);
281
+ return undefined;
282
+ }
283
+ /**
284
+ * Whether `agent` cannot be launched on this machine **at all** — not "its `binary` archive is
285
+ * missing for this platform", which is what this used to check on its own, independent of whatever
286
+ * else the agent offers.
287
+ *
288
+ * `resolveLaunch` (`registry/launch.ts`) tries npx first, then uvx, then binary, and an agent with
289
+ * either of the first two is launchable through it regardless of what its `binary` block does or
290
+ * does not cover — `kilo` and `sigit` on the live registry both carry `npx` *and* `binary`, and a
291
+ * platform missing their binary archive is still fully served by npx. So this derives its answer
292
+ * from the same preference order rather than asking a second, independent question that can drift
293
+ * from it: `false` the moment `npx` or `uvx` is present, whatever `binary` says; otherwise `true`
294
+ * when there is no `binary` distribution either (nothing left to launch through), or when there is
295
+ * one but it publishes no archive for this machine's platform triple. See `binaryInstall.ts`'s
296
+ * module doc for the coverage numbers the binary-only case was measured against.
297
+ */
298
+ function isUnsupportedPlatform(agent, platform, arch) {
299
+ const { npx, uvx, binary } = agent.distribution;
300
+ if (npx || uvx)
301
+ return false;
302
+ if (!binary)
303
+ return true;
304
+ const triple = platformTriple(platform, arch);
305
+ return !triple || !binaryTargetFor(agent, triple);
306
+ }
307
+ export class AcpAgents {
308
+ registry;
309
+ platform;
310
+ arch;
311
+ probe;
312
+ runCommand;
313
+ /**
314
+ * Where `binaryInstall.ts` unpacks binary-distributed adapters —
315
+ * `<workstation config dir>/acp-adapters`, derived from the same `cacheDir` the registry cache
316
+ * itself lives in directly (ruling F2 in the plan's pre-flight scan: `resolveLaunch`'s binary
317
+ * branch needs this root and nothing else in this class's options carries it).
318
+ */
319
+ adapterRoot;
320
+ /** Last observation per registry agent id; in-process only, by design — see {@link AuthObservation}. */
321
+ observed = new Map();
322
+ /** Injectable `uv` runner for {@link roster}'s single hoisted `uv tool list` read; see {@link AcpAgentsOptions.runUv}. */
323
+ runUv;
324
+ /** Injectable `npx` binary for {@link installAdapter}'s npx branch; see {@link AcpAgentsOptions.npxCommand}. */
325
+ npxCommand;
326
+ constructor(options) {
327
+ this.registry = options.registry ?? new RegistryClient({ cacheDir: options.cacheDir });
328
+ this.platform = options.platform ?? process.platform;
329
+ this.arch = options.arch ?? process.arch;
330
+ this.probe = options.probe ?? probeEnvironment();
331
+ this.runCommand = options.runCommand ?? runDependencyInstall;
332
+ this.runUv = options.runUv;
333
+ this.npxCommand = options.npxCommand;
334
+ this.adapterRoot = path.join(options.cacheDir, 'acp-adapters');
335
+ }
336
+ /**
337
+ * The roster reported in the `hello` frame and answered to an external-agents scan, one entry per
338
+ * registry agent.
339
+ *
340
+ * `available` means *launchable by this build*, which is "the registry gives it an npx or uvx
341
+ * distribution" — a binary-only agent stays in the roster and reports `false` so the settings page
342
+ * can say why it cannot be started (ruling R6), rather than vanishing from the list.
343
+ *
344
+ * ## `available` is not, and never was, an installation
345
+ *
346
+ * It is a property of the **registry entry**: identical on every machine on earth. A settings page
347
+ * that rendered it as "Ready to launch" told readers that agents they had never installed were
348
+ * ready, and told one reader that the Antigravity he *had* installed was unusable. `adapterInstalled`
349
+ * is the one field that does describe this machine (has the ACP adapter already been downloaded
350
+ * here), spread in only when it was genuinely established.
351
+ *
352
+ * `recommended` / `defaultName` / `defaultLabel` come from `registry/catalog.ts` — the curated list
353
+ * of agents Axom has actually verified, which replaced the old "official CLI on PATH" table. They
354
+ * are spread in only for a catalog hit; an agent outside the catalog is not claimed either way.
355
+ *
356
+ * Never throws, for the same reason the deleted `rosterFor` swallowed a failing probe: a registry
357
+ * that is unreachable on a cold-start machine must not be able to stop the daemon from connecting.
358
+ * An empty roster is the honest answer there — this build genuinely knows of no agents yet.
359
+ *
360
+ * `authMethods`/`authenticated` are carried **only for agents this process has already connected
361
+ * to** (see {@link AuthObservation}). Building the roster does not spawn anything, so a freshly
362
+ * started daemon reports neither field for anything — that absence is the truthful answer and the
363
+ * server contract spells out that it means "unknown", not "not authenticated".
364
+ */
365
+ async roster() {
366
+ let agents;
367
+ try {
368
+ agents = await this.registry.load();
369
+ }
370
+ catch (error) {
371
+ console.warn(`[acp] agent registry unavailable, reporting an empty roster: ${errorMessage(error)}`);
372
+ return [];
373
+ }
374
+ // Read once for the whole roster, not once per agent: this is a directory scan, and the
375
+ // registry has 39 entries.
376
+ const npxCache = readNpxCache(this.probe);
377
+ // Same reasoning, for `uv`: a directory scan has no subprocess to spawn, but `uv tool list` does,
378
+ // and building the roster must not spawn anything per agent (see this method's own doc comment).
379
+ // Read it exactly once here and let every uvx-distributed agent below look itself up in the
380
+ // result, instead of `adapterVersionFor` shelling out to `uv` again for each one. `uv` is now a
381
+ // non-blocking call (see `uvxInstall.ts`'s `RunUv`), hence the `await` — it still happens only
382
+ // this once per roster build, not once per agent.
383
+ const uvTools = await listUvTools(this.runUv);
384
+ const uvVersions = new Map(uvTools.map((entry) => [entry.name, entry.version]));
385
+ // Hoisted for the same reason as `npxCache`/`uvTools`: it is the same value for every entry in
386
+ // this roster (a fact about this machine, not this agent), so it is computed once rather than
387
+ // once per agent inside the `.map` below.
388
+ const triple = platformTriple(this.platform, this.arch);
389
+ // `Promise.all` over an async map, not a plain `.map`, only because `resolvable` below now
390
+ // returns a `Promise` (it calls the now-async `resolveLaunch`) — every entry still resolves
391
+ // synchronously off the already-hoisted `uvTools` snapshot, so this adds no real waiting and no
392
+ // extra `uv` spawns, just Promise bookkeeping around work that was already computed once.
393
+ return Promise.all(agents.map(async (agent) => {
394
+ const observation = this.observed.get(agent.id);
395
+ const catalog = catalogEntryFor(agent.id);
396
+ // Computed once and used by both `adapterInstalled` and `adapterVersion` below: they are two
397
+ // halves of one disk read, and deriving them separately is exactly how they drifted apart.
398
+ const adapterVersion = adapterVersionFor(agent, npxCache, this.adapterRoot, uvVersions);
399
+ return {
400
+ type: agent.id,
401
+ // `uvTools` is the same hoisted `uv tool list` snapshot `adapterVersion` below looks up in
402
+ // (`uvVersions`) — handed through so this doesn't make `resolveLaunch` spawn `uv` again for
403
+ // every uvx-distributed agent (see `resolvable`'s own doc comment).
404
+ available: await this.resolvable(agent, uvTools),
405
+ version: agent.version,
406
+ // Always present: the registry document itself always carries a version for every entry,
407
+ // unlike `adapterVersion` below, which depends on what this particular machine has done.
408
+ registryVersion: agent.version,
409
+ // **Presence, at any version — never a match against the registry's pin.**
410
+ //
411
+ // This field used to compare the cached version against `registry/launch.ts`'s pinned one,
412
+ // which is the very conflation this whole redesign exists to remove: a configured, working
413
+ // `claude-acp` cached at 0.74.0 against a registry that had moved to 0.75.0 read as *not
414
+ // installed*, which made `installedVersion` null on the card, which made `updatable` false,
415
+ // which meant the one reader who most needed an `Update` button was the one reader who
416
+ // never got offered one (design §11). Which version is on disk is `adapterVersion`'s job
417
+ // below; whether it is behind the pin is the card's `updatable` comparison. Neither belongs
418
+ // here. Range handling survives only where a genuine range specifier can appear — the
419
+ // *launch* decision in `registry/launch.ts` — not in this presence check.
420
+ //
421
+ // Covers all three distribution channels, so `false` is now a real answer rather than
422
+ // "did not look": `adapterVersionFor` reads the npx cache, the hoisted `uv tool list`
423
+ // snapshot and our own binary install root, and every one of them is a local read.
424
+ //
425
+ // `dependencySatisfied` folds in the catalog's extra prerequisite (design §4.4): `pi-acp`'s
426
+ // adapter sitting in the npx cache with no `pi` on `PATH` is not an agent this machine can
427
+ // add — the card would offer `Add`, the try-run would fail, and the reader would be told
428
+ // nothing about the missing CLI. Reporting it not-installed is what makes `Install` fetch
429
+ // the dependency too.
430
+ adapterInstalled: adapterVersion !== undefined && this.dependencySatisfied(agent.id),
431
+ // The version half of the same answer — see `adapterVersionFor`'s doc comment. Absent
432
+ // rather than null when nothing is installed, on this file's usual "never write down what
433
+ // nobody looked at" rule.
434
+ ...(adapterVersion ? { adapterVersion } : {}),
435
+ // `true`-or-absent, like `recommended`/`supportsSteering` below: this only ever fires for a
436
+ // `binary` distribution missing an archive for this machine's platform triple.
437
+ //
438
+ // `platformTriple` rides along only in this branch: it exists to explain *this* refusal (a
439
+ // settings page rendering "Not available for `<platform triple>`" must quote the identifier
440
+ // that actually decided it, not reconstruct one from the workstation's separately-reported
441
+ // OS name/arch, which can drift from the registry's own triple vocabulary), so an agent this
442
+ // machine can launch has no use for it. Absent when the machine's own platform/arch falls
443
+ // outside the registry's six triples (see `binaryInstall.ts`'s `platformTriple`) — `true`
444
+ // with no triple to quote is still the correct, honest signal in that case.
445
+ ...(isUnsupportedPlatform(agent, this.platform, this.arch)
446
+ ? { unsupportedPlatform: true, ...(triple ? { platformTriple: triple } : {}) }
447
+ : {}),
448
+ // The registry publishes a human-facing name for every agent; carrying it is what stops the
449
+ // other end from having to invent one out of the id. Spread on the same "absent, not empty"
450
+ // principle as the auth fields below: a registry entry with a blank `name` has published
451
+ // nothing, and `displayName: ''` would hand a settings page an empty label to render where
452
+ // its id-derived fallback would have said something.
453
+ ...(agent.name ? { displayName: agent.name } : {}),
454
+ ...(agent.description ? { description: agent.description } : {}),
455
+ // Spread from `registry/catalog.ts` only for a catalog hit — `recommended` has no `false`,
456
+ // the same "absent means unknown/no" rule as `supportsSteering` below.
457
+ ...(catalog ? { recommended: true, defaultName: catalog.defaultName } : {}),
458
+ ...(catalog?.defaultLabel ? { defaultLabel: catalog.defaultLabel } : {}),
459
+ // Spread rather than assign: an explicit `authMethods: undefined` would still be an own
460
+ // property, and "the key is here but empty" is exactly the ambiguity these fields exist to
461
+ // avoid on the roster's other end.
462
+ ...(observation ? { authMethods: observation.authMethods } : {}),
463
+ ...(observation?.authenticated ? { authenticated: observation.authenticated } : {}),
464
+ // Spread on the same principle, and `=== true` rather than truthy: an agent this process
465
+ // has connected to and found *not* steering-capable publishes nothing, because absent
466
+ // already means "do not offer it". See `BridgeInfo.supportsSteering`.
467
+ ...(observation?.supportsSteering === true ? { supportsSteering: true } : {}),
468
+ };
469
+ }));
470
+ }
471
+ /**
472
+ * Record what a just-established connection revealed about one agent. Called on every connection,
473
+ * including the throwaway one {@link listNativeSessions} makes, because the `initialize` response
474
+ * is the only place `authMethods` ever appears.
475
+ *
476
+ * Merges rather than replaces, so a connection that only got as far as `initialize` cannot erase
477
+ * the `authenticated` proof an earlier session left behind.
478
+ */
479
+ observe(agentId, observation) {
480
+ const previous = this.observed.get(agentId);
481
+ this.observed.set(agentId, { ...previous, ...observation });
482
+ }
483
+ /**
484
+ * What one agent was last seen to advertise as ways of signing in, or `undefined` when this
485
+ * process has never connected to it.
486
+ *
487
+ * The read half of {@link observe}, for the **session** path. {@link roster} folds the same
488
+ * observation into a `BridgeInfo`, but the roster travels once, in the `agent_hello` handshake —
489
+ * a snapshot taken before anything has been connected to, so it is empty for exactly the agent
490
+ * nobody has used yet. A session's failure frames need the answer at the moment they are built,
491
+ * which is after `open` recorded it (deliberately before `startSession`, so an
492
+ * `auth_required` rejection still has the table behind it).
493
+ *
494
+ * **`undefined` and `[]` are different answers and both are meaningful**: `undefined` = never
495
+ * connected, so nothing can be said; `[]` = connected, and the agent advertised no sign-in
496
+ * method. Callers put that difference straight on the wire; see
497
+ * `AGENT_WIRE_SESSION_AUTH_METHODS_VERSION`.
498
+ *
499
+ * Keyed by **registry agent id** ({@link AcpOpenSpec.agentId}), never by the workstation's
500
+ * configured entry name — the two are different values, and looking up by the wrong one would
501
+ * report "never connected" for every session ever opened.
502
+ */
503
+ observedAuthMethods(agentId) {
504
+ return this.observed.get(agentId)?.authMethods;
505
+ }
506
+ /**
507
+ * Start one session: spawn the agent, negotiate `initialize`, and either continue in
508
+ * {@link AcpOpenSpec.nativeSessionId} or open a fresh one.
509
+ *
510
+ * The subprocess is killed on every failure path after it was spawned — an agent left running for
511
+ * a session that never registered is exactly the orphan the manager's own tombstone machinery
512
+ * exists to prevent one layer up.
513
+ */
514
+ async open(spec) {
515
+ const resolved = await this.resolveLaunchFor(spec.agentId, spec.command, spec.args);
516
+ const capture = createReplayCapture();
517
+ const permissions = createDeferredPermissionSink();
518
+ const connection = await this.connectResolved(spec.agentId, resolved, spec.env, spec.agentEnv, {
519
+ // Both registrations go in one hook: `ClientApp.connect()` copies its handler table once, so
520
+ // this is the only moment either of them can be installed (see `acpClient.ts`'s
521
+ // `ConnectAgentOptions.configure`).
522
+ //
523
+ // The capture goes FIRST, and the order is load-bearing rather than stylistic. The SDK awaits
524
+ // every handler in registration order (`jsonrpc.js`'s `processIncomingMessage`), so each one
525
+ // ahead of the capture costs it a microtask hop before it ever sees a `session/update` — while
526
+ // the response that ends the same `session/load` / `session/prompt` is applied synchronously
527
+ // off the reader. Registering the capture behind the three permission handlers is what made
528
+ // that gap wide enough to observe (measured: a turn's trailing deltas arriving after its own
529
+ // `stop`). `settleUpdates` is the actual fix — this is defence in depth, and it is not a
530
+ // substitute: nothing here forces the capture to stay first.
531
+ //
532
+ // ## Prerequisite for anything added to this hook: **every handler registered here must be
533
+ // ## synchronous with respect to `session/update`**
534
+ //
535
+ // This is where that property is established, so this is where it is written down —
536
+ // `settleUpdates` (`adoption.ts`) depends on it and cannot check it. It waits one macrotask
537
+ // boundary, which drains a chain of *synchronous* handlers' microtask continuations and
538
+ // nothing more; a handler that awaits a real macrotask (a timer, a file read, a subprocess,
539
+ // an HTTP call) before returning `Handled.no` moves the rest of the chain past that boundary
540
+ // and the wait ends with **zero** updates delivered — measured, and silent apart from
541
+ // `settleUpdates`' own maxRounds diagnostic, which this failure does not even trip.
542
+ //
543
+ // Both current registrations satisfy it. `capture.register` is a plain synchronous
544
+ // `onNotification` body. The permission handlers do block on a human answer, but only for
545
+ // `session/request_permission` and `elicitation/*`; for a `session/update` they fall through
546
+ // without awaiting anything real. Anything added here later has to clear the same bar — if it
547
+ // genuinely needs to await, it must do that work *after* handing the message on, not before.
548
+ configure: (app) => {
549
+ capture.register(app);
550
+ // **No `present` here, and that is a decision rather than an omission.** A session
551
+ // connection lives for hours; a `url` elicitation raised on one is not a sign-in the reader
552
+ // asked for, and there is no login for a card on screen to belong to. So
553
+ // `handleCreateElicitation` declines it rather than accepting into a void, which is an
554
+ // answer the agent can act on. The one connection that *does* wire both callbacks is the
555
+ // throwaway one {@link authenticate} opens — see {@link connectFor}. The stderr half of the
556
+ // same rule is enforced by the compiler: `ConnectAgentOptions.present` is typed `never`, so
557
+ // this call's sibling option cannot arm the sniffer on a session either.
558
+ //
559
+ // `dismiss` is a no-op for the same reason: nothing was presented, so nothing can be
560
+ // retracted, and an agent that sends `elicitation/complete` anyway must not crash the
561
+ // connection over it.
562
+ registerPermissionHandlers(app, permissions.sink, { dismiss: () => { } });
563
+ },
564
+ });
565
+ this.observe(spec.agentId, observationOf(connection));
566
+ try {
567
+ const started = await this.startSession(connection, capture, spec);
568
+ // Only here, never before: the agent accepted a session, which is the one piece of evidence
569
+ // ACP offers that it is not waiting on credentials. Recording it up at `initialize` time
570
+ // would claim authentication for an agent about to reject `session/new` with auth_required.
571
+ this.observe(spec.agentId, { ...observationOf(connection), authenticated: true });
572
+ permissions.bind(started.session);
573
+ return {
574
+ session: hostedSession(started.session, connection, started.history, started.configOptions),
575
+ nativeSessionId: started.nativeSessionId,
576
+ };
577
+ }
578
+ catch (error) {
579
+ await connection.close().catch(() => undefined);
580
+ // Marked, not merely rethrown: everything above this `try` failed before the agent said
581
+ // anything, and only a failure from here on can be an `auth_required`. See
582
+ // {@link AgentRefusedSessionError} — `agentSessions.ts` is the reader.
583
+ throw new AgentRefusedSessionError(error);
584
+ }
585
+ }
586
+ /**
587
+ * List one agent's native sessions for adoption, via a throwaway connection — listing happens
588
+ * before any session exists, so there is nothing running to ask.
589
+ *
590
+ * Degrades to an empty list rather than an error when the agent does not advertise
591
+ * `sessionCapabilities.list` (that gate lives in `adoption.ts`); a *connection* failure still
592
+ * throws, because "this agent is not installed" and "this agent has no sessions" must not look
593
+ * alike to the user.
594
+ */
595
+ async listNativeSessions(spec) {
596
+ const resolved = await this.resolveLaunchFor(spec.agentId, spec.command, spec.args);
597
+ const connection = await this.connectResolved(spec.agentId, resolved, spec.env, spec.agentEnv);
598
+ this.observe(spec.agentId, observationOf(connection));
599
+ try {
600
+ const result = await listNativeSessions(connection, { cwd: spec.filterCwd });
601
+ return result.sessions;
602
+ }
603
+ finally {
604
+ await connection.close().catch(() => undefined);
605
+ }
606
+ }
607
+ /**
608
+ * Verify one agent on demand: spawn it, negotiate `initialize`, try to open a session, then throw
609
+ * the whole thing away. Nothing is kept running — a verification is a question, not a resource.
610
+ *
611
+ * This is the only way the roster's auth fields can ever be populated for an agent nobody has
612
+ * started a session with, which is the case a settings page is looking at: {@link roster} reads
613
+ * {@link AuthObservation} and building the roster spawns nothing, so before the first verification
614
+ * (or the first real session) every agent reports "unknown" and stays that way. What is learned
615
+ * here goes through {@link observe}, so the next `hello`/scan carries it.
616
+ *
617
+ * ### Two outcomes, and neither of them is "not authenticated"
618
+ *
619
+ * A session that opened proves the agent is not waiting on credentials — that is `authenticated:
620
+ * true`. A session that did not open proves nothing at all: the agent may need signing in, or its
621
+ * cwd may not exist, or it may have crashed. That case comes back as {@link
622
+ * AcpVerification.sessionError} — the observed facts, with no verdict attached — and the
623
+ * observation is *not* updated, so an earlier proof is not erased by a later inconclusive attempt.
624
+ *
625
+ * Failing to connect at all throws, because "this agent is not installed" and "this agent is
626
+ * installed and would not start a session" are different answers to the user.
627
+ */
628
+ async verify(spec, timeoutMs = AGENT_PROBE_TIMEOUT_MS) {
629
+ const connection = await this.connectFor(spec, timeoutMs);
630
+ const authMethods = connection.authMethods;
631
+ this.observe(spec.agentId, observationOf(connection));
632
+ try {
633
+ let observed;
634
+ let observedConfig;
635
+ await withDeadline((async () => {
636
+ const opened = await AcpSession.open(connection, { cwd: spec.cwd });
637
+ // Captured off `session/new`'s own response, not off the settle wait below: the snapshot
638
+ // exists the moment `AcpSession.open` resolves, unlike the command table, which the agent
639
+ // only broadcasts afterwards.
640
+ observedConfig = opened.configOptions;
641
+ // The table is broadcast *after* `session/new` is answered (`acp-agent.js:752-757`), so a
642
+ // verification that closed here — as this one used to — could never see it. The sink is
643
+ // attached after `open` on purpose and is still safe: `AcpSession` buffers events until
644
+ // one is attached (`onEvent`'s `drain()`), so nothing that arrived in between is lost.
645
+ // Settle rather than "first push wins": Claude broadcasts twice — once on `session/new`
646
+ // and again when the init frame lets it filter out terminal-bound commands. Taking the
647
+ // first would leave commands in the menu that do nothing over ACP.
648
+ //
649
+ // 但**不能靠一个固定的等待**。这里原本是 `setTimeout(1500)` 死等,于是这段代码在和
650
+ // agent 的广播赛跑,而且**机器越快越容易输**:`session/new` 秒回时,1.5 秒窗口内广播还
651
+ // 没到,`observed` 保持 undefined,整张命令表就此丢失。真机上量到过同一个 agent 在热机器
652
+ // 上 2.1 秒回来 0 条、在慢机器上 8.4 秒回来 72 条 —— 差别只是后者慢到"顺便"等到了。
653
+ //
654
+ // 原来是无条件 `setTimeout(1500)` 睡满。改成两段:一条都没来 → 到
655
+ // [COMMAND_FIRST_BROADCAST_MS] 就收工(与原实现逐字同样的时间与结果);来了第一条 →
656
+ // 改用安静期收尾,并受 [COMMAND_SETTLE_CEILING_MS] 兜底。**严格不劣于原实现**:
657
+ // 没有广播的路径一模一样,有广播的路径要么更快,要么多收到一条原本会被丢掉的。
658
+ let finish;
659
+ const settled = new Promise((resolve) => {
660
+ finish = resolve;
661
+ });
662
+ let quietTimer;
663
+ let ceilingTimer;
664
+ let firstTimer = setTimeout(() => finish?.(), COMMAND_FIRST_BROADCAST_MS);
665
+ opened.session.onEvent((event) => {
666
+ if (event.eventName !== 'available_commands_update')
667
+ return;
668
+ const commands = event.payload.availableCommands;
669
+ if (Array.isArray(commands))
670
+ observed = commands.map(toAvailableCommandWire);
671
+ // 第一条到手:等第一条的窗口作废,换成"安静就收工"+ 绝对上限。
672
+ if (firstTimer !== undefined) {
673
+ clearTimeout(firstTimer);
674
+ firstTimer = undefined;
675
+ ceilingTimer = setTimeout(() => finish?.(), COMMAND_SETTLE_CEILING_MS);
676
+ }
677
+ // 每再来一条就把安静计时重新起头 —— Claude 的第二条才是能用的那份。
678
+ if (quietTimer !== undefined)
679
+ clearTimeout(quietTimer);
680
+ quietTimer = setTimeout(() => finish?.(), COMMAND_QUIET_MS);
681
+ });
682
+ await settled;
683
+ if (firstTimer !== undefined)
684
+ clearTimeout(firstTimer);
685
+ if (quietTimer !== undefined)
686
+ clearTimeout(quietTimer);
687
+ if (ceilingTimer !== undefined)
688
+ clearTimeout(ceilingTimer);
689
+ if (observed === undefined) {
690
+ // A 那条日志的 daemon 侧对应物。没有它,"这个 agent 在这个目录下没有命令" 与
691
+ // "表来晚了、窗口没接住" 在任何一层都分不开 —— 而这两件事的修法完全不同。
692
+ console.warn(`[acp] agent '${spec.agentId}' broadcast no command table within ` +
693
+ `${COMMAND_FIRST_BROADCAST_MS}ms of opening a session in ${spec.cwd}`);
694
+ }
695
+ await opened.session.close().catch(() => undefined);
696
+ })(), timeoutMs, `Opening a session on agent '${spec.agentId}'`);
697
+ this.observe(spec.agentId, { ...observationOf(connection), authenticated: true });
698
+ return {
699
+ authMethods,
700
+ authenticated: true,
701
+ ...(observed !== undefined ? { availableCommands: observed } : {}),
702
+ ...(observedConfig !== undefined ? { configOptions: observedConfig } : {}),
703
+ };
704
+ }
705
+ catch (error) {
706
+ // Prefixed rather than relayed raw: the SDK masks an agent-thrown error as "Internal error",
707
+ // and a settings page showing that alone tells the user nothing about which agent it came from
708
+ // or what was being attempted.
709
+ return { authMethods, sessionError: `Agent '${spec.agentId}' did not open a session: ${errorMessage(error)}` };
710
+ }
711
+ finally {
712
+ await connection.close().catch(() => undefined);
713
+ }
714
+ }
715
+ /**
716
+ * Run one agent's `authenticate` for a `methodId` **it advertised**, over a throwaway connection.
717
+ *
718
+ * The refusal for an unadvertised (or terminal) `methodId` is {@link AcpAuth}'s and happens before
719
+ * anything is written to the agent — which is why this hands `AcpAuth` the connection rather than
720
+ * re-deriving the check: there is exactly one place that rule lives.
721
+ *
722
+ * Deliberately does not report an auth state. `authenticate` resolving means the agent accepted
723
+ * the call, which is not the same as a session it will now open; the caller asks {@link verify}
724
+ * for that, and gets an observation made the only way ACP allows one to be made. What is recorded
725
+ * here is `authMethods`, because `initialize` happened.
726
+ */
727
+ async authenticate(spec, methodId, login = {}) {
728
+ const urls = createLoginUrlSink(login);
729
+ // **The retraction has to cover the connect, not just the login.** The sniffer is armed inside
730
+ // `connectAgent` at `spawn()`, before `initialize` — so a URL can be presented while this is
731
+ // still awaiting, and every way connecting fails (initialize timeout, spawn error, an
732
+ // unsupported protocol version, the unpinned retry failing too) would otherwise leave the card
733
+ // up with nothing left running that could ever take it down. Measured, not reasoned: a probe of
734
+ // the first version reported `PRESENTED [ 'stderr-auth-url-…' ] RETRACTED []`. And the trigger
735
+ // is ordinary — the scan covers the `npx` install, whose own output can carry a "visit this
736
+ // URL" line, and a login whose agent then fails to install is exactly when that appears.
737
+ try {
738
+ const connection = await this.connectFor(spec, AGENT_PROBE_TIMEOUT_MS, {
739
+ ...(urls.present ? { present: urls.present } : {}),
740
+ dismiss: urls.dismiss,
741
+ });
742
+ this.observe(spec.agentId, observationOf(connection));
743
+ try {
744
+ const what = `Signing in to agent '${spec.agentId}' with method '${methodId}'`;
745
+ await withDeadline(cancellable(new AcpAuth(connection).authenticate(methodId), login.signal, what), loginDeadlineMs(login.ceilingMs), what);
746
+ }
747
+ finally {
748
+ // The connection dies here and not a moment earlier — this `finally` runs when the login
749
+ // *settles*, never on a probe-sized deadline part-way through one.
750
+ await connection.close().catch(() => undefined);
751
+ }
752
+ }
753
+ finally {
754
+ // The retraction trigger a stderr-sourced URL can never get from the protocol — see
755
+ // {@link createLoginUrlSink}. This runs on every exit, including every way connecting failed.
756
+ urls.finish();
757
+ }
758
+ }
759
+ /**
760
+ * The spawn half shared by {@link verify} and {@link authenticate}. No session either way — but
761
+ * **not "no handlers" any more**, and the difference is the whole of Task 4b: `authenticate` is a
762
+ * round trip through a human, so its connection carries the elicitation/permission handlers and
763
+ * the stderr sniffer that a login URL reaches the reader through. `verify` passes no `login` and
764
+ * so gets exactly what it always got: a bare connection nobody can raise a card on.
765
+ *
766
+ * **This is the only method in this class that builds a `present`**, and that is the mechanism, not
767
+ * a convention: {@link connectResolved} — the route every other connection takes, including
768
+ * {@link open}'s long-lived session — accepts only {@link ConnectAgentOptions}, whose `present` is
769
+ * typed `never`. Arming the stderr sniffer anywhere else would mean writing a fresh `connectAgent`
770
+ * call by hand, which is a visibly deliberate act rather than one more argument. What that buys is
771
+ * in `acpClient.ts`'s `ConnectLoginAgentOptions.present`: the scan starts at `spawn()` and cannot
772
+ * tell a third party's "visit this URL" from a login's, which is survivable for the seconds a
773
+ * login connection lives and is not survivable for the hours a session connection does.
774
+ *
775
+ * The *elicitation* half of the same rule is convention, not type: `registerPermissionHandlers`
776
+ * takes an optional `present` from any caller, and {@link open} passes none on purpose (see the
777
+ * comment there). It is the lesser of the two — that callback fires only on a real `url`
778
+ * elicitation, never on install output — but it is not mechanised, and a reader should not have to
779
+ * infer which half is which.
780
+ *
781
+ * `timeoutMs` budgets **the connect**, not what the caller then does with the connection: it is
782
+ * `connectAgent`'s `initialize` deadline, whose expiry kills the child. A login's own budget is
783
+ * {@link AGENT_LOGIN_CEILING_MS} and is applied around `authenticate` alone.
784
+ */
785
+ async connectFor(spec, timeoutMs, login) {
786
+ const resolved = await this.resolveLaunchFor(spec.agentId, spec.command, spec.args);
787
+ // The same budget as the caller's, not the 30s default: `connectAgent` kills the child when it
788
+ // fires, which is the only thing that stops an agent that opened stdio and never spoke.
789
+ if (!login)
790
+ return this.connectResolved(spec.agentId, resolved, spec.env, spec.agentEnv, { initializeTimeoutMs: timeoutMs });
791
+ const options = {
792
+ initializeTimeoutMs: timeoutMs,
793
+ ...(login.present ? { present: login.present } : {}),
794
+ configure: (app) => {
795
+ // The same registration the session connection makes (see `open`), with the two
796
+ // elicitation callbacks actually wired — this is the only connection they can be reached
797
+ // on, because `authenticate` does not run on a session's connection.
798
+ //
799
+ // The sink is deferred and never bound, deliberately: there is no `AcpSession` here, so an
800
+ // agent that sent `session/request_permission` on a login connection would be referring to
801
+ // a session that cannot exist. `createDeferredPermissionSink` answers that by throwing a
802
+ // named error, which the SDK turns into a JSON-RPC error for the agent — loud and
803
+ // attributable, rather than a card dropped into nothing.
804
+ registerPermissionHandlers(app, createDeferredPermissionSink().sink, {
805
+ present: login.present,
806
+ dismiss: login.dismiss ?? (() => { }),
807
+ });
808
+ },
809
+ };
810
+ return withUnpinnedFallback(spec.agentId, resolved, (launch) => connectAgent(launch, spec.env, spec.agentEnv, options));
811
+ }
812
+ /**
813
+ * Continue in an already-existing native session when we have an id for one and the agent can load
814
+ * it; otherwise open a new session and report the new id truthfully.
815
+ *
816
+ * Two things are deliberately *not* failures here. An agent that does not advertise
817
+ * `loadSession` still gets a session — the manager already tolerates the id it asked for coming
818
+ * back different (it warns and re-points), so refusing to open would be strictly worse than
819
+ * continuing in a fresh one. A `session/load` that is advertised but rejected (a stale id the
820
+ * agent has since deleted, most likely) is treated the same way, for the same reason.
821
+ *
822
+ * `AcpSession.attach` runs **before** the replay, not after: it subscribes to the connection's live
823
+ * `session/update` stream, and `ReplayCapture` withholds updates from that subscription for exactly
824
+ * as long as the id is armed — i.e. for the whole `session/load`. Subscribing afterwards would
825
+ * leave a gap between the replay's `take()` and the subscription in which a live update is dropped.
826
+ */
827
+ async startSession(connection, capture, spec) {
828
+ const nativeSessionId = spec.nativeSessionId;
829
+ if (nativeSessionId) {
830
+ if (!connection.capabilities.loadSession) {
831
+ console.warn(`[acp] agent '${spec.agentId}' does not support session/load; starting a new session ` +
832
+ `instead of continuing '${nativeSessionId}'`);
833
+ }
834
+ else {
835
+ // `AcpSession.attach` reads `initial.configOptions` lazily (see its own doc comment) —
836
+ // exactly so this can hand it a mutable object *before* the snapshot exists, and fill it in
837
+ // below once `session/load`'s response arrives. Snapshotting eagerly here (e.g. passing
838
+ // `{ configOptions: [] }` outright) would leave the attached session's `configOptions`
839
+ // empty forever, because nothing would ever revisit it.
840
+ const initial = { configOptions: [] };
841
+ const session = AcpSession.attach(connection, nativeSessionId, capture, initial);
842
+ try {
843
+ const history = [];
844
+ for await (const event of replayNativeSession(connection, capture, {
845
+ cwd: spec.cwd,
846
+ nativeSessionId,
847
+ onLoaded: (response) => {
848
+ initial.configOptions = normalizeConfigOptions(response.configOptions, response.modes);
849
+ },
850
+ })) {
851
+ // Folded and validated either way (one code path, no second copy of the replay rules);
852
+ // retained only when someone is going to ask for it, so a long history is not held for
853
+ // the life of a session that will never replay it.
854
+ if (spec.backfill)
855
+ history.push(event);
856
+ }
857
+ return { session, nativeSessionId, history, configOptions: initial.configOptions };
858
+ }
859
+ catch (error) {
860
+ console.warn(`[acp] session/load for '${nativeSessionId}' on agent '${spec.agentId}' failed ` +
861
+ `(${errorMessage(error)}); starting a new session instead`);
862
+ await session.close().catch(() => undefined);
863
+ }
864
+ }
865
+ }
866
+ const opened = await AcpSession.open(connection, { cwd: spec.cwd });
867
+ return { session: opened.session, nativeSessionId: opened.nativeSessionId, history: [], configOptions: opened.configOptions };
868
+ }
869
+ /**
870
+ * Fetch one agent's ACP adapter onto this machine, ahead of the first launch that would otherwise
871
+ * have to. What the settings page's **Config** button does before it writes anything.
872
+ *
873
+ * ## Why an explicit step exists at all
874
+ *
875
+ * `resolveLaunch` produces `npx --yes <adapter>@<version>`, and npx downloads the adapter the
876
+ * first time it runs one — which meant the very first `open` on a fresh machine paid for a
877
+ * multi-megabyte download inside a session the user had already started, with nothing on screen
878
+ * saying so. Doing it here moves that cost to a button press, and — because it is the same cache
879
+ * npx reads — makes the roster's `adapterInstalled` true afterwards. The uvx and binary paths
880
+ * install into `uv tool dir` and `<workstation config dir>/acp-adapters` respectively, which are
881
+ * likewise exactly where the roster and the launch path look.
882
+ *
883
+ * ## Three answers, and the third one is not a failure
884
+ *
885
+ * `ok` with `alreadyInstalled` means there was nothing to do. `ok` without it means this call
886
+ * downloaded it. `unsupported` means there is nothing for *this machine* to fetch — the entry
887
+ * declares no launchable distribution, or it is binary-only with no archive for this platform
888
+ * triple. Reported as its own outcome rather than as an error so the page can grey the card out
889
+ * instead of offering a retry that cannot succeed.
890
+ *
891
+ * All three distributions are fetched, not just npx — see {@link installAdapter}, which dispatches
892
+ * npx → uvx → binary in `resolveLaunch`'s own preference order.
893
+ *
894
+ * ## The catalog's `dependency` runs first, and can refuse to run anything at all
895
+ *
896
+ * `registry/catalog.ts` may curate one extra prerequisite for an agent (`pi-acp` needs the `pi`
897
+ * CLI) that the adapter fetch above does nothing to provide. That block is handled *before* the
898
+ * adapter step, in two gates:
899
+ *
900
+ * 1. `requiresNode`, checked against this daemon's own `process.version` (or the injected
901
+ * `nodeVersion`, for tests) — because npm only *warns*, never refuses, on an `engines`
902
+ * mismatch. On a Node-20 machine `npm install -g` for `pi` "succeeds", `findOnPath` then finds
903
+ * the binary, and a check that stopped at presence-on-`PATH` would report the dependency
904
+ * satisfied right before the agent fails at run time with an engines error nobody could trace
905
+ * back to install. Failing this gate returns `nodeTooOld` and runs no command at all — not
906
+ * even the `findOnPath` probe below, since there would be nothing useful to install anyway.
907
+ * 2. `check` on `PATH` (via `findOnPath`) — only when that comes back empty does this call the
908
+ * catalog's literal `install` argv through {@link AcpAgents.runCommand}, which is
909
+ * `execFile`-based and never a shell (see `catalog.ts`'s security note on that array).
910
+ *
911
+ * An agent outside the catalog, or one with no `dependency` block, skips straight to the adapter
912
+ * step below — nothing here changes for `claude-acp`, `codex-acp`, or any other uncurated agent.
913
+ *
914
+ * Never starts the agent: see {@link installNpxPackage}.
915
+ */
916
+ async install(agentId, options = {}) {
917
+ const dependency = catalogEntryFor(agentId)?.dependency;
918
+ if (dependency) {
919
+ const nodeVersion = options.nodeVersion ?? process.version;
920
+ if (dependency.requiresNode && !semver.satisfies(nodeVersion, dependency.requiresNode)) {
921
+ return {
922
+ ok: false,
923
+ error: `Agent '${agentId}' needs Node ${dependency.requiresNode}, but this daemon is running ` +
924
+ `${nodeVersion}.`,
925
+ nodeTooOld: { required: dependency.requiresNode, found: nodeVersion },
926
+ };
927
+ }
928
+ }
929
+ let dependencyInstalled;
930
+ if (dependency && findOnPath(dependency.check, this.probe) === undefined) {
931
+ const [command, ...args] = dependency.install;
932
+ const outcome = await this.runCommand(command, args, { timeoutMs: options.timeoutMs });
933
+ if (!outcome.ok) {
934
+ return { ok: false, error: outcome.error };
935
+ }
936
+ dependencyInstalled = true;
937
+ }
938
+ const agents = await this.registry.load();
939
+ const agent = agents.find((candidate) => candidate.id === agentId);
940
+ if (!agent) {
941
+ return { ok: false, error: `Agent '${agentId}' is not in the ACP agent registry.` };
942
+ }
943
+ const outcome = await this.installAdapter(agent, options.timeoutMs);
944
+ if (!outcome.ok)
945
+ return outcome;
946
+ return {
947
+ ok: true,
948
+ adapterInstalled: true,
949
+ alreadyInstalled: outcome.alreadyInstalled,
950
+ ...(dependencyInstalled ? { dependencyInstalled } : {}),
951
+ ...(outcome.pinnedVersionUnavailable ? { pinnedVersionUnavailable: outcome.pinnedVersionUnavailable } : {}),
952
+ };
953
+ }
954
+ /**
955
+ * Fetch one agent's ACP adapter through **whichever distribution it actually ships**, dispatching
956
+ * npx → uvx → binary: the same preference order `resolveLaunch` uses to decide which one will be
957
+ * launched, so `Install` always fetches the thing that will later be started.
958
+ *
959
+ * ## Why this exists
960
+ *
961
+ * {@link install} used to refuse anything without an `npx` block outright, which made
962
+ * `installBinary` (`binaryInstall.ts`) and `installUvTool` (`uvxInstall.ts`) unreachable from
963
+ * production: they were written, tested, and called by nothing. On the live registry snapshot that
964
+ * is 18 of 39 agents — 16 binary-only and 2 uvx-only — every one of them rendering an `Install`
965
+ * button whose task could only ever fail. Design §4.1's "three distributions, one model" is this
966
+ * function.
967
+ *
968
+ * ## `unsupported` still exists, and now means something narrower
969
+ *
970
+ * Two cases, and neither of them is "this build has not got round to it yet":
971
+ *
972
+ * - the registry entry declares no launchable distribution at all, so there is nothing to fetch;
973
+ * - the entry is binary-only and publishes no archive for **this machine's** platform triple —
974
+ * `windows-aarch64` covers 9 of 18 binary entries (see `binaryInstall.ts`'s module doc). Checked
975
+ * here rather than left to `installBinary`'s own identical check so the answer carries
976
+ * `unsupported`, which is what a card renders as `Unsupported` instead of a retryable error.
977
+ *
978
+ * Every other failure is an ordinary `error` the reader can retry.
979
+ *
980
+ * `checksumVerified` from the binary path is deliberately not propagated into
981
+ * {@link AcpInstallResult}: a registry entry with no `sha256` (`antigravity-acp`, `cursor`)
982
+ * installs successfully and this result must not imply either that it was verified or that
983
+ * anything went wrong. `binaryInstall.ts` logs the fact where it is established.
984
+ */
985
+ async installAdapter(agent, timeoutMs) {
986
+ const { npx, uvx, binary } = agent.distribution;
987
+ if (npx) {
988
+ return this.installNpxWithFallback(agent, npx.package, timeoutMs);
989
+ }
990
+ if (uvx) {
991
+ // `uv tool install <pkg>==<ver>`, not `uvx <pkg>`: the launch path reads the entry point out
992
+ // of `uv tool dir`, so this has to populate that same place (design §4.2).
993
+ const outcome = await (this.runUv ? installUvTool(uvx.package, this.runUv) : installUvTool(uvx.package));
994
+ if (outcome.ok)
995
+ return { ok: true, alreadyInstalled: outcome.alreadyInstalled };
996
+ // What's already on disk, read fresh at the moment of failure -- never the version this call
997
+ // just tried to fetch, and never assumed: `installUvTool` resolves before touching the
998
+ // environment (measured: a bad pin's resolution failure leaves whatever was already `uv tool
999
+ // install`ed running, untouched), so whatever `uv tool list` reports right now is exactly what
1000
+ // survived this attempt.
1001
+ const existingVersion = await uvToolVersion(uvPackageName(uvx.package), this.runUv);
1002
+ logInstallFailureDetail(agent.id, 'uvx', uvx.package, outcome.error);
1003
+ return { ok: false, error: friendlyOrdinaryInstallError(agent.id, outcome.error, existingVersion) };
1004
+ }
1005
+ if (binary) {
1006
+ const triple = platformTriple(this.platform, this.arch);
1007
+ if (!triple || !binaryTargetFor(agent, triple)) {
1008
+ return {
1009
+ ok: false,
1010
+ unsupported: true,
1011
+ error: `Agent '${agent.id}' publishes no archive for ${triple ?? `${this.platform}-${this.arch}`}.`,
1012
+ };
1013
+ }
1014
+ const outcome = await installBinary(agent, {
1015
+ root: this.adapterRoot,
1016
+ platform: this.platform,
1017
+ arch: this.arch,
1018
+ });
1019
+ if (outcome.ok)
1020
+ return { ok: true, alreadyInstalled: outcome.alreadyInstalled };
1021
+ // Same "read what's actually there, right now" rule as the uvx branch above: `installBinary`
1022
+ // never touches a sibling version directory on failure (it only ever removes the *new*
1023
+ // version's own, half-written one -- see that function's own doc), so this reads whichever
1024
+ // version, if any, survived the attempt untouched.
1025
+ const existingVersion = installedBinaryVersion(agent.id, this.adapterRoot);
1026
+ logInstallFailureDetail(agent.id, 'binary', agent.version, outcome.error);
1027
+ return { ok: false, error: friendlyOrdinaryInstallError(agent.id, outcome.error, existingVersion) };
1028
+ }
1029
+ return {
1030
+ ok: false,
1031
+ unsupported: true,
1032
+ error: `Agent '${agent.id}' declares no launchable distribution, so there is no adapter to install.`,
1033
+ };
1034
+ }
1035
+ /**
1036
+ * `installNpxPackage` for the registry-pinned specifier and, when — and only when — that failure
1037
+ * is npm's own `ETARGET`, one retry with the pin stripped. The install-time sibling of
1038
+ * {@link connectResolved}, which does the same thing for a launch that is already running; see its
1039
+ * doc comment for why the condition is ETARGET specifically and why the retry happens at most once.
1040
+ *
1041
+ * ## Why `Install`/`Update` needed this and `connectResolved` alone was not enough
1042
+ *
1043
+ * The launch-time fallback only ever fires *after* an `Install` has already put something usable
1044
+ * on disk — it exists to recover a session that is about to open, not to get the adapter onto the
1045
+ * machine in the first place. Before this method, a registry entry pinned to an npm version that
1046
+ * does not exist failed here, at `Install`/`Update`, every single time, and never got anywhere
1047
+ * near `connectResolved`: the card offered a button whose task could only ever fail, worse than
1048
+ * the launch-time defect it was fixed for.
1049
+ */
1050
+ async installNpxWithFallback(agent, pinnedSpec, timeoutMs) {
1051
+ const outcome = await installNpxPackage(pinnedSpec, {
1052
+ env: this.probe,
1053
+ timeoutMs: timeoutMs ?? ADAPTER_INSTALL_TIMEOUT_MS,
1054
+ npxCommand: this.npxCommand,
1055
+ });
1056
+ if (outcome.ok)
1057
+ return { ok: true, alreadyInstalled: outcome.alreadyInstalled };
1058
+ // What (if anything) of this package is already on disk, read *before* any fallback runs --
1059
+ // `installNpxPackage`'s own failure never touches an existing npx cache entry (measured: `npm
1060
+ // exec --package=` only ever adds to the cache), so this is a fact about what survives this
1061
+ // attempt no matter which branch below is taken.
1062
+ const existingVersion = npxCacheVersion(npmPackageName(pinnedSpec), readNpxCache(this.probe));
1063
+ const fallback = unpinnedFallbackFor(agent, this.platform);
1064
+ if (!fallback || !stderrShowsMissingPackageVersion(outcome.stderr)) {
1065
+ logInstallFailureDetail(agent.id, 'npx', pinnedSpec, outcome.stderr || outcome.error);
1066
+ return {
1067
+ ok: false,
1068
+ error: friendlyOrdinaryInstallError(agent.id, outcome.stderr || outcome.error, existingVersion),
1069
+ };
1070
+ }
1071
+ // 两条都是 warn,而且都要能被一个月后读日志的人看懂——他不知道今晚发生了什么,只看得见这两行。
1072
+ // 措辞跟 `connectResolved` 里那两条故意保持一致,只把"启动"换成"安装"。
1073
+ console.warn(`[acp] agent '${agent.id}' could not be installed at the version the ACP registry pins ` +
1074
+ `('${fallback.pinned}'): npm has no published version matching it (npm error code ` +
1075
+ 'ETARGET). This is a bad entry published upstream, not a broken install on this machine.');
1076
+ console.warn(`[acp] retrying the install for agent '${agent.id}' once as '${fallback.unpinned}', with the ` +
1077
+ "version pin removed, so npm resolves whatever it currently calls 'latest'. That is " +
1078
+ 'deliberately NOT the version the registry asked for: if this agent then misbehaves, ' +
1079
+ 'suspect the version before suspecting the agent.');
1080
+ const retryOutcome = await installNpxPackage(fallback.unpinned, {
1081
+ env: this.probe,
1082
+ timeoutMs: timeoutMs ?? ADAPTER_INSTALL_TIMEOUT_MS,
1083
+ npxCommand: this.npxCommand,
1084
+ });
1085
+ if (!retryOutcome.ok) {
1086
+ // 不钉版本那次再失败,抛出去的是把两件事都说清楚的一句话:registry 钉的版本不存在,而且
1087
+ // 绕过去的那次尝试自己也没成 —— 而不是原来那条已经绕过去的 ETARGET。
1088
+ logInstallFailureDetail(agent.id, 'npx', fallback.unpinned, retryOutcome.stderr || retryOutcome.error);
1089
+ return {
1090
+ ok: false,
1091
+ error: friendlyPinnedVersionUnavailableAndFallbackFailed(agent.id, fallback.pinned, retryOutcome.stderr || retryOutcome.error, existingVersion),
1092
+ };
1093
+ }
1094
+ // The fallback succeeded: this machine now has *some* version of the adapter, but never the one
1095
+ // the registry actually pins -- npm does not have it, so `Update` could offer to fetch it again
1096
+ // forever and never converge. Reading the version back off the cache (rather than trusting
1097
+ // `fallback.unpinned`'s own unversioned specifier) is what lets the caller persist a concrete
1098
+ // fact ("Y is installed") instead of a vague "not the pinned one".
1099
+ const installedInstead = npxCacheVersion(fallback.unpinned, readNpxCache(this.probe));
1100
+ return {
1101
+ ok: true,
1102
+ alreadyInstalled: retryOutcome.alreadyInstalled,
1103
+ ...(installedInstead ? { pinnedVersionUnavailable: { pinned: fallback.pinned, installedInstead } } : {}),
1104
+ };
1105
+ }
1106
+ /**
1107
+ * Whether the catalog's extra prerequisite for this agent (`registry/catalog.ts`'s `dependency`)
1108
+ * is already on `PATH` — never whether the adapter itself has been fetched, and never whether
1109
+ * this daemon's Node satisfies `requiresNode` (that gate only matters at {@link install} time, not
1110
+ * for reporting what is already on the machine). An agent outside the catalog, or one with no
1111
+ * `dependency` block, needs nothing extra and reads as satisfied.
1112
+ *
1113
+ * {@link roster} folds this into `adapterInstalled`: an agent whose adapter is on disk but whose
1114
+ * curated prerequisite is not reports **not installed**, so its card offers `Install` (which
1115
+ * fetches the dependency) instead of an `Add` whose try-run was always going to fail on
1116
+ * `Could not start pi: executable not found`. Design §4.4, point 2.
1117
+ */
1118
+ dependencySatisfied(agentId, probe = this.probe) {
1119
+ const dependency = catalogEntryFor(agentId)?.dependency;
1120
+ if (!dependency)
1121
+ return true;
1122
+ return findOnPath(dependency.check, probe) !== undefined;
1123
+ }
1124
+ /** A config-supplied `command` is the whole launch spec (ruling R3); otherwise the registry decides. */
1125
+ async resolveLaunchFor(agentId, command, args) {
1126
+ // 裁决 R3 的覆盖没有回退可言:用户自己写下的命令行就是**全部**,这里不该去猜他们的哪一段
1127
+ // 参数是版本号。
1128
+ if (command)
1129
+ return { launch: { command, args, env: {} } };
1130
+ const agents = await this.registry.load();
1131
+ const agent = agents.find((candidate) => candidate.id === agentId);
1132
+ if (!agent) {
1133
+ throw new Error(`Agent '${agentId}' is not in the ACP agent registry. Check the id, refresh the registry, ` +
1134
+ 'or give this agent an explicit command in the workstation config.');
1135
+ }
1136
+ // 每次启动都从刚读到的 registry 现算一遍,没有任何一处把回退记下来:上游把版本修好的那一刻,
1137
+ // 钉版本的那条路自己就恢复了,不需要谁去清什么状态。
1138
+ return {
1139
+ launch: await resolveLaunch(agent, this.platform, this.arch, this.adapterRoot),
1140
+ fallback: unpinnedFallbackFor(agent, this.platform),
1141
+ };
1142
+ }
1143
+ /**
1144
+ * Connect a launch that is not a login — every connection this class makes except
1145
+ * {@link connectFor}'s login one — with {@link withUnpinnedFallback}'s ETARGET retry around it.
1146
+ *
1147
+ * **`options` is {@link ConnectAgentOptions}, whose `present` is `never`**, and that is the whole
1148
+ * point of this method still existing as a separate one: the stderr sniffer cannot be armed
1149
+ * through the route {@link open} and {@link listNativeSessions} take. It used to take a second
1150
+ * `login` parameter for `connectFor`'s benefit, which meant the same door stood open one argument
1151
+ * further along; `connectFor` builds its own options now.
1152
+ */
1153
+ async connectResolved(agentId, resolved, env, agentEnv, options = {}) {
1154
+ return withUnpinnedFallback(agentId, resolved, (launch) => connectAgent(launch, env, agentEnv, options));
1155
+ }
1156
+ /**
1157
+ * Whether `resolveLaunch` can produce a spec for this agent on this machine (see {@link roster}).
1158
+ *
1159
+ * `uvTools` is {@link roster}'s single hoisted `uv tool list` snapshot, passed straight through to
1160
+ * `resolveLaunch`'s uvx branch. Without it, `resolveLaunch` falls back to reading `uv tool list`
1161
+ * itself via its default runner — a real `uv` subprocess per uvx-distributed agent, called from
1162
+ * inside `roster()`'s per-agent map, which is exactly the per-agent-spawn defect `adapterVersionFor`
1163
+ * was hoisted out of. This is the second call site that same invariant covers: building the roster
1164
+ * must start nothing per agent, no matter how many code paths ask "is this agent launchable".
1165
+ *
1166
+ * `resolveLaunch` itself is `async` now (`uv` is a non-blocking call — see `uvxInstall.ts`'s
1167
+ * `RunUv`), so this awaits it; with `uvTools` always supplied here, that branch never actually
1168
+ * waits on anything; it resolves off the already-hoisted snapshot exactly as synchronously as
1169
+ * before, just wrapped in a settled `Promise`.
1170
+ */
1171
+ async resolvable(agent, uvTools) {
1172
+ try {
1173
+ await resolveLaunch(agent, this.platform, this.arch, this.adapterRoot, undefined, uvTools);
1174
+ return true;
1175
+ }
1176
+ catch {
1177
+ return false;
1178
+ }
1179
+ }
1180
+ }
1181
+ /**
1182
+ * Run `attempt` on the launch the registry pins, and — only when npm answers ETARGET — once more on
1183
+ * the unpinned fallback.
1184
+ *
1185
+ * Extracted from `connectResolved` so that the login path can reuse the retry **without** reusing
1186
+ * `connectResolved`'s options parameter, which is typed to forbid `present`. Both callers get the
1187
+ * same retry and the same two log lines; only one of them can arm the stderr sniffer, and it says so
1188
+ * in its own signature rather than by passing an extra argument the other caller could also pass.
1189
+ *
1190
+ * ## Only on ETARGET, and only once
1191
+ *
1192
+ * 只在 npm 自己说「这个版本不存在」时重试。一次冷启动要几十秒(npx 得先把适配器下下来),所以
1193
+ * 「失败就重试」等于把每一次真实故障的代价翻倍。这里认的是 npm 打在子进程 stderr 上的错误码,见
1194
+ * `acpClient.ts` 的 `isMissingPackageVersion` —— 那是证据;从一根断掉的管子倒推「大概是版本不对吧」
1195
+ * 是猜测,两者在日志里长得一样,后果不一样。
1196
+ *
1197
+ * 不钉版本那次再失败,抛出去的就是**它自己**的错误。把原来的 ETARGET 报上去会让读日志的人接着去
1198
+ * 追一个已经绕过去的问题。
1199
+ */
1200
+ async function withUnpinnedFallback(agentId, resolved, attempt) {
1201
+ try {
1202
+ return await attempt(resolved.launch);
1203
+ }
1204
+ catch (error) {
1205
+ const fallback = resolved.fallback;
1206
+ if (!fallback || !isMissingPackageVersion(error))
1207
+ throw error;
1208
+ // 两条都是 warn,而且都要能被一个月后读日志的人看懂 —— 他不知道今晚发生了什么,只看得见这两行。
1209
+ console.warn(`[acp] agent '${agentId}' could not be launched at the version the ACP registry pins ` +
1210
+ `('${fallback.pinned}'): npm has no published version matching it (npm error code ` +
1211
+ 'ETARGET). This is a bad entry published upstream, not a broken install on this machine.');
1212
+ console.warn(`[acp] retrying agent '${agentId}' once as '${fallback.unpinned}', with the version pin ` +
1213
+ "removed, so npm resolves whatever it currently calls 'latest'. That is deliberately NOT " +
1214
+ 'the version the registry asked for: if this agent now misbehaves, suspect the version ' +
1215
+ 'before suspecting the agent.');
1216
+ return await attempt(fallback.launch);
1217
+ }
1218
+ }
1219
+ /**
1220
+ * Wrap one `AcpSession` plus the connection it runs on into the shape the manager holds.
1221
+ *
1222
+ * Two behaviours are added on top of `AcpSession`. Lifetime: `close()` must end the subprocess as
1223
+ * well as the session, or `agent_close` would leave a live agent behind holding its cwd and its
1224
+ * quota. `backfill` is attached only when there is history to replay, because its *absence* is what
1225
+ * the manager reads as "nothing to replay". And the config option snapshot: {@link
1226
+ * AcpAgentSession.configOptions} must answer with the **latest** one, not the one this session
1227
+ * opened with, so the cell below is what `setConfigOption` writes back into and what the getter
1228
+ * reads — see that field's own doc for what a stale answer costs on every reconnect.
1229
+ */
1230
+ function hostedSession(session, connection, history, configOptions) {
1231
+ // `AcpSession` keeps a cell of its own for the same value, but does not expose it — and the two
1232
+ // are written from the same place (the wrapper below is the only caller of its setter), so this
1233
+ // one cannot drift from it.
1234
+ let snapshot = configOptions;
1235
+ const hosted = {
1236
+ // Verbatim. This used to be the one place a string became a `ContentBlock[]`
1237
+ // (`session.prompt([{ type: 'text', text }])`) — a stopgap for as long as `agent_prompt` could
1238
+ // only carry text. That seam is gone: the frame now carries blocks, so wrapping here would be a
1239
+ // second, narrower opinion about the shape of a user message.
1240
+ prompt: (blocks) => session.prompt(blocks),
1241
+ // Both halves come off the live agent: the process's own exit state, and the session's own
1242
+ // outstanding-prompt list. Nothing here is remembered by this daemon.
1243
+ get liveness() {
1244
+ return {
1245
+ agentAlive: connection.agentProcessAlive() && session.readable,
1246
+ turnRunning: session.turnRunning,
1247
+ };
1248
+ },
1249
+ answer: (requestId, outcome) => session.answer(requestId, outcome),
1250
+ interrupt: () => session.interrupt(),
1251
+ setConfigOption: async (configId, value) => {
1252
+ snapshot = await session.setConfigOption(configId, value);
1253
+ return snapshot;
1254
+ },
1255
+ get configOptions() {
1256
+ return snapshot;
1257
+ },
1258
+ steer: (blocks, priority) => session.steer(blocks, priority),
1259
+ // Straight off the connection that opened this session — never inferred, and never read back
1260
+ // from the roster, whose entry for this agent may still be the pre-connection one.
1261
+ supportsSteering: connection.supportsSteering,
1262
+ // Same source, same rule, and likewise never defaulted: an empty list here is the agent saying
1263
+ // nothing about *when*, which ruling N4 reads as "the server may not name a timing".
1264
+ steeringTimings: connection.steeringTimings,
1265
+ agentInfo: connection.agentInfo,
1266
+ supportsImageInput: connection.capabilities.promptImage,
1267
+ onEvent: (cb) => session.onEvent(cb),
1268
+ onTurnDone: (cb) => session.onTurnDone(cb),
1269
+ async close() {
1270
+ try {
1271
+ await session.close();
1272
+ }
1273
+ finally {
1274
+ // Runs even if the session's own shutdown threw: the subprocess is the expensive thing to
1275
+ // leak, and nothing else in the daemon will come back for it.
1276
+ await connection.close().catch(() => undefined);
1277
+ }
1278
+ },
1279
+ };
1280
+ if (history.length > 0) {
1281
+ hosted.backfill = async function* backfill() {
1282
+ for (const event of history)
1283
+ yield event;
1284
+ };
1285
+ }
1286
+ return hosted;
1287
+ }