@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,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
|
+
}
|