@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,1628 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { AGENT_WIRE_AUTH_URL_VERSION, AGENT_WIRE_HEARTBEAT_INVENTORY_VERSION, AGENT_WIRE_ORPHAN_OFFER_VERSION, AGENT_WIRE_SESSION_AUTH_METHODS_VERSION, AGENT_WIRE_SESSION_LOST_VERSION, AGENT_WIRE_VERSION, outboundAgentFrameRejection, outboundFailureReply, } from './protocol/agent.js';
4
+ import { DEFAULT_JOURNAL_MAX_BYTES, SessionJournal } from './journal.js';
5
+ import { AGENT_PROBE_TIMEOUT_MS, AgentRefusedSessionError } from './acp/acpAgents.js';
6
+ import { validateWireEvent } from './acp/validate.js';
7
+ /**
8
+ * 从线上收来的验证预算的绝对上限。
9
+ *
10
+ * 服务端今天最长的那个预算是 180s(连接后台预热用的),比它留一截余量,是为了服务端把那个数
11
+ * 往上调时不必先发一版 daemon —— 预算跟着请求走的全部意义就在这里。但它必须**有**一个上限:
12
+ * 这个数直接变成一个 `setTimeout`,而在它到期之前,一个卡死的 agent 子进程一直活在用户自己的
13
+ * 机器上。没有上限,对面一个 bug(或者一个不该信的对面)就能把一个进程钉在那儿钉到 daemon
14
+ * 重启为止。五分钟不是一个精确的阈值,它只是「久到任何真实的冷启动都够用,短到即使白等也是
15
+ * 分钟级而不是无限」。
16
+ *
17
+ * 注意 {@link AcpAgents.verify} 对 connect 与 `session/new` **各用一次**这个数,所以一次验证
18
+ * 最坏是它的两倍。这个倍数是刻意留着的:要消掉它就得改 `acpAgents.ts` 里那两段的分账,而服务端
19
+ * 自己的 `withTimeout` 本来就还在外面兜底。
20
+ */
21
+ export const MAX_PROBE_TIMEOUT_MS = 300_000;
22
+ /**
23
+ * 把帧上的预算变成一个能用的毫秒数。
24
+ *
25
+ * 缺席 / `null` → {@link AGENT_PROBE_TIMEOUT_MS},即这个字段出现之前逐字相同的行为(**新
26
+ * daemon 收到老服务端的帧走的就是这条**)。成形但过大 → 夹到 {@link MAX_PROBE_TIMEOUT_MS}。
27
+ *
28
+ * 下界不在这里夹,由 `protocol/agent.ts` 的 `isNullableProbeTimeoutMs` 兜住(`> 0` 且有限):
29
+ * 一个比本地默认还短的预算是**调用方自己**的取舍(想快点失败就快点失败),拦下来只会让 daemon
30
+ * 单方面推翻一个没有害处的决定;而 `NaN` / 负数不是取舍,是错话,那种帧根本不该被解出来。这里
31
+ * 仍然把非数一并兜到默认值,是因为本函数也服务于绕过解码器直接构帧的调用者(测试是其一)。
32
+ */
33
+ export function clampProbeTimeout(requested) {
34
+ if (typeof requested !== 'number' || !Number.isFinite(requested) || requested <= 0)
35
+ return AGENT_PROBE_TIMEOUT_MS;
36
+ return Math.min(requested, MAX_PROBE_TIMEOUT_MS);
37
+ }
38
+ /**
39
+ * One `agent_prompt` **or `agent_steer`** frame as the ACP `ContentBlock[]` both requests take
40
+ * (`PromptRequest.prompt`, `types.gen.d.ts:5160`; the steering extension's `parseSteerRequest` takes
41
+ * the same array and hands it to the same `promptToClaude(params)`).
42
+ *
43
+ * **Shared by both on purpose, not merely because the two frames happen to have the same fields.**
44
+ * A steered message and a prompted one *are* the same message — the reader's text plus whatever
45
+ * they attached — so assembling them differently is how one of them ends up carrying less than the
46
+ * other, which is exactly what used to happen while `agent_steer` had no `images` at all.
47
+ *
48
+ * **This is the only place a user message is assembled**, which is the point of doing it here
49
+ * rather than deeper: `acpAgents.ts` used to wrap the frame's `text` into a lone text block, and
50
+ * that wrapper was structurally incapable of carrying anything else — an image that had been
51
+ * uploaded, stored, put on the timeline and shown to the reader stopped dead at it, with no error
52
+ * anywhere.
53
+ *
54
+ * Order is text-then-images, matching the frame and matching what the platform's own runtime feeds
55
+ * its agent, so a bridged and a built-in session present the same message the same way.
56
+ *
57
+ * **An empty text block is dropped only when there are images to carry the message instead.** A
58
+ * reader who attached a picture and typed nothing said nothing, and an empty text part would put
59
+ * words in their mouth (several vendors reject a zero-length text part outright). With no images
60
+ * the empty block stays: that is the text-only path every existing bridged conversation runs on,
61
+ * and it must not shift by a byte.
62
+ *
63
+ * The image blocks are forwarded **verbatim** — `ImageBlockWire` is ACP `ImageContent` and its
64
+ * `data` is base64 on both sides of the tunnel, so nothing here decodes, re-encodes or re-wraps a
65
+ * payload that is ~10.7 MB for an 8 MiB picture.
66
+ */
67
+ function promptBlocks(frame) {
68
+ const images = frame.images ?? [];
69
+ const text = frame.text.length > 0 || images.length === 0
70
+ ? [{ type: 'text', text: frame.text }]
71
+ : [];
72
+ return [...text, ...images];
73
+ }
74
+ /**
75
+ * The server's applied-through watermark on an open frame, sanitized. Absent means "nothing yet".
76
+ *
77
+ * The finiteness check is load-bearing, not defensive noise: the channel decoder is a bare `JSON.parse`
78
+ * with no runtime validation, so this field can arrive as a string, an object, or `Infinity`, and
79
+ * `Math.trunc` turns all of those into `NaN`. Since the value now seeds the seq allocator, a NaN
80
+ * would make `nextSeq()` return NaN forever and ship every later frame as `seq: null` — the same
81
+ * permanent, silent wedge the seeding exists to prevent, through a different door. Garbage is read
82
+ * as "the server has applied nothing", which costs at most a replay of entries it already has (its
83
+ * own de-duplication drops them).
84
+ */
85
+ function resumeFromSeqOf(frame) {
86
+ const seq = Math.trunc(frame.resumeFromSeq ?? 0);
87
+ return Number.isFinite(seq) ? Math.max(0, seq) : 0;
88
+ }
89
+ function errorMessage(err) {
90
+ return err instanceof Error ? err.message : String(err);
91
+ }
92
+ /**
93
+ * Batch size for `agent_backfill_events`. Kept modest so a session with a long history does not
94
+ * arrive as one giant frame; the last batch of a stream is whatever remains below this size.
95
+ */
96
+ export const BACKFILL_BATCH_SIZE = 64;
97
+ /**
98
+ * Defensively turns the wire query of `agent_list_native` / `agent_verify` / `agent_authenticate`
99
+ * — one shape, because all three ask the same thing of an agent that has no session yet: where to
100
+ * run and how to launch — into an {@link AcpListSpec} (Global Constraint 3
101
+ * — the channel decoder is a bare `JSON.parse`, so `frame.query` could be missing fields, or missing
102
+ * entirely, at runtime regardless of what the wire type claims). Returns `null` rather than
103
+ * fabricating a `cwd` the agent would then list against.
104
+ *
105
+ * `cwd` is the only field that must be there. The wire query also carries `shell`, and the deleted
106
+ * SPI required it because a bridge spawned its listing process through one; an ACP agent is launched
107
+ * directly from its `command`/`args`, so nothing reads it any more and rejecting a query for the
108
+ * lack of it would be a refusal on a field this daemon no longer uses.
109
+ *
110
+ * `env`/`agentEnv` are optional on the wire but not in the spec, so absent fields become `{}` here —
111
+ * never passed through as `undefined`.
112
+ */
113
+ function sanitizeNativeSessionQuery(agentId, query) {
114
+ if (!query || typeof query.cwd !== 'string' || !query.cwd)
115
+ return null;
116
+ return {
117
+ agentId,
118
+ cwd: query.cwd,
119
+ env: query.env ?? {},
120
+ agentEnv: query.agentEnv ?? {},
121
+ ...(query.command ? { command: query.command } : {}),
122
+ args: query.args ?? [],
123
+ };
124
+ }
125
+ /**
126
+ * Name an event for a diagnostic without trusting it: on a payload hostile enough that even
127
+ * `eventName` throws (the validator's 'event not inspectable' case), the log line itself must
128
+ * still come out.
129
+ */
130
+ function wireEventName(e) {
131
+ try {
132
+ return String(e?.eventName);
133
+ }
134
+ catch {
135
+ return '<uninspectable>';
136
+ }
137
+ }
138
+ /**
139
+ * Owns the workstation's live external-agent sessions and translates between the tunnel's
140
+ * `agent_*` frames and the ACP client.
141
+ *
142
+ * Lifetime: one manager per daemon process, constructed outside the reconnect loop — agent sessions
143
+ * (and the subprocesses behind them) must survive a dropped websocket. The send binding is swapped
144
+ * per connection via {@link bindSend} / {@link closeAll}.
145
+ *
146
+ * Delivery is at-least-once, not best-effort: every outbound event is journaled before it is sent
147
+ * ({@link SessionJournal}), so a send that goes nowhere — socket down, or the daemon restarted
148
+ * entirely — is replayed when the server re-opens the ref with its `resumeFromSeq` watermark.
149
+ */
150
+ export class AgentSessionManager {
151
+ agents;
152
+ journalDir;
153
+ maxJournalBytes;
154
+ tasks;
155
+ sessions = new Map();
156
+ /** In-flight opens, keyed by sessionRef, so two racing agent_open frames cannot spawn two agents. */
157
+ opening = new Map();
158
+ /**
159
+ * Refs whose open became pointless while the agent was still starting — either an
160
+ * `agent_close` arrived for a ref that had no live session yet, or the socket dropped (the server
161
+ * fails its pending open with ok:false and will never adopt the result). Without this, the native
162
+ * agent that finishes spawning afterwards is orphaned: alive, registered, and unknown to the
163
+ * server. {@link openSession} closes and discards any session whose ref is tombstoned, and uses
164
+ * the recorded {@link AbandonReason} to decide whether the journal dies with it.
165
+ */
166
+ closedDuringOpen = new Map();
167
+ /**
168
+ * Refs whose journal is still being torn down, and the promise that says when it is gone.
169
+ *
170
+ * A close and the reopen of the same ref are two independent inbound frames — the daemon does not
171
+ * serialize `handleFrame` (see daemon.ts's `ws.on('message', async …)`) — and `onClose` returns the
172
+ * ref to the "openable" state the moment it drops out of `sessions`, while the actual `destroy()`
173
+ * is still queued behind that session's pending writes. Letting the reopen build a second
174
+ * {@link SessionJournal} on the same path in that window is a data-loss race in both directions:
175
+ * the old `destroy()` can land after the new journal's first append and delete it, or the new
176
+ * `init()` can read a file the old `destroy()` then removes, leaving the new journal with a stale
177
+ * size and a seq counter recovered from bytes that no longer exist. The two instances also share a
178
+ * `.tmp` path, so a rewrite in one can be pulled out from under the other.
179
+ *
180
+ * {@link openSession} waits on this before constructing anything.
181
+ */
182
+ retiring = new Map();
183
+ /**
184
+ * sessionRefs swept while no v2-capable sender was bound. The sweep cannot un-reclaim them, but a
185
+ * server that still holds those refs only ever learns it from here — see {@link notifySwept}.
186
+ */
187
+ sweptWhileUnsent = [];
188
+ send;
189
+ /**
190
+ * How many frames `emit` has dropped since the sender went null, and when the mute began.
191
+ *
192
+ * Dropping while unbound is by design -- live events are replayed from the journal and pending
193
+ * requests are failed server-side -- but the *dangerous* variant is indistinguishable from the
194
+ * benign one without a record: a mute that never ends leaves the supervisor reporting CONNECTED
195
+ * while nothing reaches the server (see `closeAll`). One line at the start of the mute and one
196
+ * at its end make the two shapes tell apart in a log after the fact: a benign mute is a pair,
197
+ * a wedged one is an opening line with no partner.
198
+ */
199
+ mutedDrops = 0;
200
+ mutedSinceMs = null;
201
+ /**
202
+ * The agent-wire version the *server* advertised on the current connection. Frames introduced
203
+ * after v1 are only sent when it is high enough: an older server cannot decode the discriminator
204
+ * and drops the whole connection on receiving one.
205
+ *
206
+ * Defaults to this build's own version so a manager constructed without a connection (tests, the
207
+ * pre-`ready` window) behaves as fully capable rather than silently muting the notice.
208
+ */
209
+ serverAgentWire = AGENT_WIRE_VERSION;
210
+ /**
211
+ * One abort handle per login still running detached, so {@link closeAll} can end them.
212
+ *
213
+ * A login is the one piece of work here that outlives its own reply: it holds an agent subprocess
214
+ * for as long as a person takes in a browser, bounded only by `AGENT_LOGIN_CEILING_MS` (ten
215
+ * minutes). Sessions deliberately survive a dropped socket because a reconnect resumes them — a
216
+ * login cannot: its URL reaches the reader only as `agent_auth_url`, and {@link emit} drops every
217
+ * frame while the sender is unbound. So a login that outlives the disconnect is a subprocess
218
+ * running for a card nobody will ever see.
219
+ */
220
+ loginsInFlight = new Set();
221
+ constructor(
222
+ /** The one ACP client every external agent runs through; keyed per call by registry agent id. */
223
+ agents, send,
224
+ /** Root for per-session journals; they live under `<journalDir>/sessions`. */
225
+ journalDir,
226
+ /** Per-session journal cap. Beyond it the oldest half is dropped and the server told about the hole. */
227
+ maxJournalBytes = DEFAULT_JOURNAL_MAX_BYTES,
228
+ /** Shared update admission and force-cancellation authority, when this daemon supports updates. */
229
+ tasks) {
230
+ this.agents = agents;
231
+ this.journalDir = journalDir;
232
+ this.maxJournalBytes = maxJournalBytes;
233
+ this.tasks = tasks;
234
+ this.send = send;
235
+ }
236
+ /** Where journals live: `<journalDir>/sessions/<sessionRef>.jsonl`. */
237
+ get sessionsDir() {
238
+ return path.join(this.journalDir, 'sessions');
239
+ }
240
+ /** Number of daemon-owned external-agent sessions, for the management heartbeat projection. */
241
+ activeSessionCount() {
242
+ return [...this.sessions.values()].filter((session) => !session.closed).length;
243
+ }
244
+ /** Snapshot advertised once after an agent-channel reconnect; the server remains replay authority. */
245
+ /**
246
+ * The reconnect inventory: the sessions this daemon holds, plus the journals a previous daemon
247
+ * life left on disk.
248
+ *
249
+ * **The orphan half is what makes recovery automatic.** A journal whose agent is gone -- the
250
+ * daemon restarted, or the machine rebooted -- is not in {@link sessions}, so before this it
251
+ * appeared in no inventory at all, and the server (which ends a live turn precisely because its
252
+ * ref is *missing* from the offer) ended it. The file had not been lost; nobody had ever been told
253
+ * it was there. It would sit on disk until the user happened to speak to that session again.
254
+ *
255
+ * **Orphans carry `journalMaxSeq: 0`, and that is not a placeholder for a number worth reading.**
256
+ * The server drives an orphan off `agentAlive` and its own live-turn ledger, never off a watermark
257
+ * comparison, so nothing reads the field. Filling it honestly would mean a `SessionJournal.init`
258
+ * per file -- each one reads the whole file, up to 64 MB -- on every agent-channel reconnect, to
259
+ * produce a number that is then discarded.
260
+ *
261
+ * **Gated on {@link AGENT_WIRE_ORPHAN_OFFER_VERSION}, and that gate is load-bearing** in a way the
262
+ * heartbeat-inventory floor is not: this widens the meaning of an existing list whose absence the
263
+ * server acts on. A server below the floor would read an orphan as a ref the client still holds,
264
+ * stop ending its turn, and never reopen it -- leaving it RUNNING forever, which is worse than the
265
+ * honest failure it produces today.
266
+ */
267
+ async resumeOfferSessions() {
268
+ const live = this.liveInventory();
269
+ if (this.serverAgentWire < AGENT_WIRE_ORPHAN_OFFER_VERSION)
270
+ return live;
271
+ // Every ref this daemon still has a handle on, not merely the ones it would report as live:
272
+ // a ref that is mid-open, mid-retire, or closed-but-not-yet-forgotten is one we are still the
273
+ // owner of, and announcing it as abandoned invites a reopen against a session we hold.
274
+ const held = new Set([...this.sessions.keys(), ...this.opening.keys(), ...this.retiring.keys()]);
275
+ const orphans = await this.orphanedJournalRefs(held);
276
+ // `turnRunning: false` is an observation here, not a default: an orphan has no agent process at
277
+ // all, so there is nothing that could be running on it.
278
+ return [
279
+ ...live,
280
+ ...orphans.map((sessionRef) => ({ sessionRef, journalMaxSeq: 0, agentAlive: false, turnRunning: false })),
281
+ ];
282
+ }
283
+ /**
284
+ * Journal files on disk whose ref this daemon holds no handle for.
285
+ *
286
+ * Reads names only -- never contents. A directory that does not exist is the ordinary state of a
287
+ * daemon that has never opened a session, so it is not a warning; anything else is, but it still
288
+ * degrades to "offer what we hold" rather than failing the whole offer, because losing the live
289
+ * half of the inventory would be a far bigger regression than missing the orphan half.
290
+ */
291
+ async orphanedJournalRefs(held) {
292
+ let names;
293
+ try {
294
+ names = await fs.readdir(this.sessionsDir);
295
+ }
296
+ catch (error) {
297
+ if (error?.code !== 'ENOENT') {
298
+ console.warn(`[agent] listing journals for the resume offer failed: ${errorMessage(error)}`);
299
+ }
300
+ return [];
301
+ }
302
+ const refs = [];
303
+ for (const name of names) {
304
+ if (!name.endsWith('.jsonl'))
305
+ continue;
306
+ let ref;
307
+ try {
308
+ ref = decodeURIComponent(name.slice(0, -'.jsonl'.length));
309
+ }
310
+ catch {
311
+ // Not a name this daemon wrote (`encodeURIComponent` never emits a malformed escape), so
312
+ // there is no ref behind it to offer.
313
+ continue;
314
+ }
315
+ if (!held.has(ref))
316
+ refs.push(ref);
317
+ }
318
+ // readdir order is filesystem-defined; the offer should not be.
319
+ return refs.sort();
320
+ }
321
+ /**
322
+ * The live half, shared with the heartbeat -- which must stay synchronous and touch no disk.
323
+ *
324
+ * `agentAlive` and `turnRunning` are sampled from the agent itself at the moment this is called
325
+ * (see {@link AgentSessionLiveness}), never from anything this manager recorded earlier. A
326
+ * session whose agent died while this daemon kept running therefore reports `agentAlive: false`
327
+ * here, and lands in exactly the same server-side branch as a journal left by a previous daemon
328
+ * life -- which is the point: "the agent is gone" is one fact with one handling, not a special
329
+ * case per way of getting there.
330
+ */
331
+ liveInventory() {
332
+ return [...this.sessions.values()]
333
+ .filter((session) => !session.closed)
334
+ .map((session) => {
335
+ const liveness = session.session.liveness;
336
+ return {
337
+ sessionRef: session.sessionRef,
338
+ journalMaxSeq: session.journal.lastSeq,
339
+ agentAlive: liveness.agentAlive,
340
+ turnRunning: liveness.turnRunning,
341
+ };
342
+ });
343
+ }
344
+ /**
345
+ * The same inventory, for the heartbeat that goes out every ten seconds.
346
+ *
347
+ * **Why the periodic copy exists at all.** The reconnect offer is sent once, right after the
348
+ * handshake, so it can only ever catch a divergence that already existed at that moment. One that
349
+ * opens afterwards — on a connection that stays up, with the workstation reporting ONLINE — had no
350
+ * observer: this journal reached seq 885 while the server's `bridge_seq` sat at 689 for four
351
+ * minutes, and neither side raised anything, because {@link emit} is a silent no-op on an unbound
352
+ * sender and the server's dispatcher returns for a ref it has no handler for. The two numbers were
353
+ * both already known; nothing was comparing them after the handshake.
354
+ *
355
+ * Returns `undefined` rather than `[]` when there is nothing to say, so the heartbeat frame stays
356
+ * byte-identical to the one this client has always sent:
357
+ *
358
+ * - the server predates the field ({@link AGENT_WIRE_HEARTBEAT_INVENTORY_VERSION}) and would
359
+ * discard it — it is ignored, not rejected, but ten seconds of overhead forever is worth
360
+ * skipping;
361
+ * - or this daemon holds no live session, which is most of the time.
362
+ */
363
+ heartbeatInventory() {
364
+ if (this.serverAgentWire < AGENT_WIRE_HEARTBEAT_INVENTORY_VERSION)
365
+ return undefined;
366
+ const sessions = this.liveInventory();
367
+ return sessions.length > 0 ? sessions : undefined;
368
+ }
369
+ /**
370
+ * Bind (or rebind, after a reconnect) the outbound frame sink, along with the agent-wire version
371
+ * the server advertised on this connection — the version is per-connection, so it is rebound with
372
+ * the sender rather than set once.
373
+ */
374
+ bindSend(send, serverAgentWire = AGENT_WIRE_VERSION) {
375
+ if (this.mutedDrops > 0) {
376
+ const seconds = this.mutedSinceMs === null ? '?' : ((Date.now() - this.mutedSinceMs) / 1000).toFixed(1);
377
+ console.warn(`[agent] sender rebound after ${seconds}s unbound; ${this.mutedDrops} frame(s) were dropped while muted`);
378
+ }
379
+ this.mutedDrops = 0;
380
+ this.mutedSinceMs = null;
381
+ this.send = send;
382
+ this.serverAgentWire = serverAgentWire;
383
+ // Before any inbound frame on this connection can be answered: a sweep notice that arrived here
384
+ // after the server had already re-opened its ref would invalidate a healthy generation.
385
+ this.flushSwept();
386
+ }
387
+ /**
388
+ * Called when the websocket drops. Despite the name, this does **not** close agent sessions — the
389
+ * agent subprocesses keep running so a reconnect can resume them. What it does is unbind the
390
+ * sender, tombstone in-flight opens, and end in-flight *logins*.
391
+ *
392
+ * Events produced while unbound are journaled and their `send` is a no-op; the server gets them
393
+ * back when it re-opens the ref with a `resumeFromSeq`.
394
+ *
395
+ * A login is the one piece of work here that cannot survive the drop, which is why it is the one
396
+ * thing this really does end: its URL reaches the reader only as `agent_auth_url`, and that frame
397
+ * is not journaled the way session events are, so one emitted while unbound is gone rather than
398
+ * replayed — leaving an agent subprocess held open, for up to `AGENT_LOGIN_CEILING_MS`, for a card
399
+ * nobody will ever see. See {@link loginsInFlight}, and the comment at the abort itself for why it
400
+ * is scoped to the teardown that actually unbinds.
401
+ *
402
+ * In-flight opens are the other exception: the server fails its pending open with ok:false on
403
+ * disconnect, so a session that finishes spawning after this point would be orphaned. Those refs
404
+ * are tombstoned and the resulting bridge session is closed on arrival — but as `disconnected`,
405
+ * which keeps the journal: the server is coming back for it.
406
+ */
407
+ closeAll(boundSender) {
408
+ // Unbinding is scoped to the connection that did the binding. The sender is one shared slot on
409
+ // a manager that deliberately outlives every socket, so an unconditional `this.send = null`
410
+ // makes correctness depend on a global ordering argument -- that no connection's teardown can
411
+ // ever settle after a newer one has bound. Losing that race does not fail loudly: it leaves the
412
+ // manager permanently muted while the supervisor still reports the agent channel CONNECTED, and
413
+ // every event from then on is journaled and silently dropped, which is precisely the shape of
414
+ // the failure this whole change exists to make impossible. Comparing identities makes the
415
+ // invariant local, so no ordering argument has to hold.
416
+ //
417
+ // The argument-less call still unbinds unconditionally, for callers that own the only binding
418
+ // (tests, and a shutdown path with no reconnect behind it).
419
+ //
420
+ // The tombstoning below is deliberately *not* scoped: an in-flight open is abandoned by the
421
+ // server the moment any connection it was issued on drops, so it must be marked regardless of
422
+ // which sender is current -- otherwise an agent that finishes spawning afterwards is left alive,
423
+ // registered, and unknown to the server.
424
+ if (boundSender === undefined || this.send === boundSender) {
425
+ this.send = null;
426
+ // Logins are the exception to "sessions keep running" — see {@link loginsInFlight}. Scoped to
427
+ // the teardown that actually unbinds, unlike the tombstoning below: if a newer connection has
428
+ // already rebound, this login's frames still have somewhere to go and killing it would be
429
+ // gratuitous. When nothing is bound, they do not: `agent_auth_url` is not journaled the way
430
+ // session events are, so a URL emitted while unbound is gone for good rather than replayed —
431
+ // which leaves an agent subprocess running for a card nobody will ever see.
432
+ for (const login of [...this.loginsInFlight])
433
+ login.abort();
434
+ }
435
+ for (const sessionRef of this.opening.keys()) {
436
+ // Never downgrade an explicit close into a disconnect: that would strand the journal.
437
+ if (!this.closedDuringOpen.has(sessionRef))
438
+ this.closedDuringOpen.set(sessionRef, 'disconnected');
439
+ }
440
+ }
441
+ /**
442
+ * Awaits every queued journal write and the send behind it. Used by tests, and available to a
443
+ * shutdown path that wants events on disk before the process exits.
444
+ *
445
+ * Bounded on purpose. A session that is actively streaming keeps appending to its own chain, so
446
+ * "wait until nothing is queued" has no fixed point while an agent is talking — an unbounded loop
447
+ * would hang the very shutdown it is meant to serve. After `maxRounds` passes it returns with work
448
+ * possibly still queued; a caller that needs a true quiesce must stop the source first (the
449
+ * agent's `close()`, or at least {@link closeAll}).
450
+ */
451
+ async drain(maxRounds = 50) {
452
+ for (let round = 0; round < maxRounds; round += 1) {
453
+ let progressed = false;
454
+ for (const live of [...this.sessions.values()]) {
455
+ const chain = live.writes;
456
+ await chain;
457
+ if (live.writes !== chain)
458
+ progressed = true;
459
+ }
460
+ if (!progressed)
461
+ return;
462
+ }
463
+ }
464
+ async handleFrame(frame) {
465
+ switch (frame.t) {
466
+ case 'agent_open': {
467
+ const admission = this.reserveOpen(frame.id, frame.sessionRef);
468
+ if (admission === 'rejected')
469
+ return;
470
+ try {
471
+ await this.onOpen(frame);
472
+ }
473
+ finally {
474
+ if (admission === 'reserved')
475
+ this.tasks?.agentOpenFinished(frame.sessionRef);
476
+ }
477
+ return;
478
+ }
479
+ case 'agent_prompt': {
480
+ const live = this.routableSession(frame.sessionRef, 'prompt');
481
+ if (!live)
482
+ return;
483
+ if (this.tasks && !this.tasks.agentTurnStarted(frame.sessionRef)) {
484
+ // No `authMethods` here, deliberately, even though this is a failed turn on a session
485
+ // whose agent we have certainly connected to: the prompt never reached that agent, so
486
+ // nothing about credentials is being reported. This frame is built here rather than in
487
+ // `onAgentTurnDone`, so it never passes the {@link TurnResult.agentRefused} gate — the
488
+ // omission is structural, not a line someone has to remember.
489
+ this.record(live, (seq) => ({
490
+ t: 'agent_turn_done', sessionRef: frame.sessionRef, seq, status: 'failed',
491
+ error: 'Workstation is not accepting new work',
492
+ }));
493
+ return;
494
+ }
495
+ live.session.prompt(promptBlocks(frame));
496
+ return;
497
+ }
498
+ case 'agent_answer': {
499
+ const live = this.routableSession(frame.sessionRef, 'answer');
500
+ live?.session.answer(frame.requestId, frame.outcome);
501
+ return;
502
+ }
503
+ case 'agent_interrupt': {
504
+ const live = this.routableSession(frame.sessionRef, 'interrupt');
505
+ if (!live)
506
+ return;
507
+ try {
508
+ await live.session.interrupt();
509
+ }
510
+ catch (err) {
511
+ console.error(`[agent] interrupt failed for ${frame.sessionRef}: ${errorMessage(err)}`);
512
+ }
513
+ return;
514
+ }
515
+ /**
516
+ * A steer rides a turn that is **already** running, so it is deliberately not bracketed by
517
+ * `tasks.agentTurnStarted` / `agentTurnFinished` the way `agent_prompt` is: the agent still
518
+ * answers exactly one `session/prompt` and still sends exactly one `agent_turn_done`, so
519
+ * there is no second completion to close a second bracket with.
520
+ *
521
+ * The leak that would open is not hypothetical. The server steers whenever **it** believes a
522
+ * turn is running, and the agent is free to disagree and answer `promptRequired` — a steer on
523
+ * an idle session. Nothing will ever send a `turn_done` for that one, so the sessionRef would
524
+ * sit in `TaskRegistry`'s agent-task set forever and `canUpdate()` would be false for the rest
525
+ * of this daemon's life. Same shape as the hung-prompt leak `readLoopDead` exists for.
526
+ *
527
+ * Every exit answers. The server is holding the steered message in its own queue table
528
+ * (`session_message_queue`) while this request is in flight — the message is not on the
529
+ * timeline at all, and carries no "queued" mark: a `user_message` item exists only once some
530
+ * round (or a successful steer) has consumed it. Only the reply tells the server whether to
531
+ * keep holding that row or to deliver it the ordinary way. A silent drop parks it forever.
532
+ */
533
+ case 'agent_steer': {
534
+ const live = this.routableSession(frame.sessionRef, 'steer');
535
+ if (!live) {
536
+ this.emit({ t: 'agent_steered', id: frame.id, ok: false, error: `no live session '${frame.sessionRef}'` });
537
+ return;
538
+ }
539
+ try {
540
+ this.emit({
541
+ t: 'agent_steered',
542
+ id: frame.id,
543
+ ok: true,
544
+ // The whole message, images and all, assembled by the same function `agent_prompt` uses
545
+ // — see {@link promptBlocks}. `frame.priority` is passed through untouched, `undefined`
546
+ // included: absent means the server named no timing, and inventing one here would be
547
+ // this daemon deciding something the server deliberately left unsaid.
548
+ outcome: await live.session.steer(promptBlocks(frame), frame.priority),
549
+ });
550
+ }
551
+ catch (err) {
552
+ // Includes the `Method not found` an agent that never advertised the extension answers
553
+ // with, and the `startedNewTurn` refusal from `mapSteerOutcome`. Both are the server's to
554
+ // recover from — it degrades to an ordinary queued delivery — so they travel, not hide.
555
+ this.emit({ t: 'agent_steered', id: frame.id, ok: false, error: errorMessage(err) });
556
+ }
557
+ return;
558
+ }
559
+ /**
560
+ * Set one session config option, round-tripping ACP `session/set_config_option`. Same shape
561
+ * as `agent_steer` immediately above, and for the same reason: this is a real round trip the
562
+ * server is blocked waiting on, not a turn, so it is not bracketed by
563
+ * `tasks.agentTurnStarted` / `agentTurnFinished` — there is no second completion to close a
564
+ * second bracket with, and a bracket opened here would never close, leaking the ref for the
565
+ * life of the daemon.
566
+ *
567
+ * Every exit answers: a session that is not routable, an agent that throws (its own refusal,
568
+ * or `Method not found` from one that never advertised the method), all send
569
+ * `agent_config_option_set` with `ok: false` and the error. A silent drop parks the server on
570
+ * a request that never resolves.
571
+ */
572
+ case 'agent_set_config_option': {
573
+ const live = this.routableSession(frame.sessionRef, 'set_config_option');
574
+ if (!live) {
575
+ this.emit({ t: 'agent_config_option_set', id: frame.id, ok: false, error: `no live session '${frame.sessionRef}'` });
576
+ return;
577
+ }
578
+ try {
579
+ const configOptions = await live.session.setConfigOption(frame.configId, frame.value);
580
+ this.emit({ t: 'agent_config_option_set', id: frame.id, ok: true, configOptions });
581
+ }
582
+ catch (err) {
583
+ // Includes the agent's own refusal ("Invalid value…", "Unknown config option…") and a
584
+ // `Method not found` from an agent without the method — both are the server's to show.
585
+ this.emit({ t: 'agent_config_option_set', id: frame.id, ok: false, error: errorMessage(err) });
586
+ }
587
+ return;
588
+ }
589
+ case 'agent_close':
590
+ await this.onClose(frame.sessionRef);
591
+ return;
592
+ case 'agent_event_ack': {
593
+ // An ack for a ref we no longer hold is normal (close and ack cross on the wire), so unlike
594
+ // the control frames above this one is dropped silently.
595
+ const live = this.liveSession(frame.sessionRef);
596
+ if (!live)
597
+ return;
598
+ this.touch(live);
599
+ await this.enqueue(live, () => live.journal.truncateThrough(frame.throughSeq));
600
+ return;
601
+ }
602
+ case 'agent_list_native':
603
+ if (this.tasks && !this.tasks.accepting) {
604
+ this.emit({ t: 'agent_native_sessions', id: frame.id, ok: false, error: 'Workstation is not accepting new work' });
605
+ return;
606
+ }
607
+ await this.onListNative(frame);
608
+ return;
609
+ // Both of these spawn an agent subprocess, exactly as `agent_list_native` does, so they answer
610
+ // a draining workstation the same way rather than starting work it is trying to finish.
611
+ case 'agent_verify':
612
+ if (this.tasks && !this.tasks.accepting) {
613
+ this.emit({ t: 'agent_verified', id: frame.id, ok: false, error: 'Workstation is not accepting new work' });
614
+ return;
615
+ }
616
+ await this.onVerify(frame);
617
+ return;
618
+ case 'agent_authenticate':
619
+ if (this.tasks && !this.tasks.accepting) {
620
+ this.emit({ t: 'agent_authenticated', id: frame.id, ok: false, error: 'Workstation is not accepting new work' });
621
+ return;
622
+ }
623
+ await this.onAuthenticate(frame);
624
+ return;
625
+ // Downloads a package; starts nothing. Still refused while draining, because a workstation on
626
+ // its way down should not begin a multi-megabyte fetch it will be killed in the middle of.
627
+ case 'agent_install':
628
+ if (this.tasks && !this.tasks.accepting) {
629
+ this.emit({ t: 'agent_installed', id: frame.id, ok: false, error: 'Workstation is not accepting new work' });
630
+ return;
631
+ }
632
+ await this.onInstall(frame);
633
+ return;
634
+ default:
635
+ // Not an agent frame (or an outbound-only one) — the daemon routes those elsewhere.
636
+ return;
637
+ }
638
+ }
639
+ /**
640
+ * Classifies an open before its first await. A live or already-opening ref is continuation
641
+ * traffic, so graceful drain lets it replay/piggyback without acquiring (and later releasing)
642
+ * another reservation. Force-stop is different: it is destructive shutdown and rejects every
643
+ * open, including reconnects. A ref absent from both maps is genuinely new work and still needs
644
+ * the ordinary TaskRegistry admission reservation.
645
+ */
646
+ reserveOpen(openId, sessionRef) {
647
+ if (!this.tasks)
648
+ return 'continuation';
649
+ if (this.tasks.isForceStopping()) {
650
+ this.emit({ t: 'agent_opened', id: openId, ok: false, error: 'Workstation is not accepting new work' });
651
+ return 'rejected';
652
+ }
653
+ if (this.liveSession(sessionRef) || this.opening.has(sessionRef))
654
+ return 'continuation';
655
+ if (this.tasks.tryReserveAgentOpen(sessionRef))
656
+ return 'reserved';
657
+ this.emit({ t: 'agent_opened', id: openId, ok: false, error: 'Workstation is not accepting new work' });
658
+ return 'rejected';
659
+ }
660
+ /**
661
+ * Everything a failure frame says about a refusal **the agent itself** produced: the sign-in table
662
+ * it advertised, and the JSON-RPC code it answered with.
663
+ *
664
+ * ## One helper, because the reader makes one decision
665
+ *
666
+ * The server draws a login card only when the code is ACP's `auth_required` *and* the table is
667
+ * non-empty. Emitting the two from separate places is how the card's contents and the decision to
668
+ * draw it drift apart while each looks right, so the two keys are produced together, gated
669
+ * together, and every call site passes both or neither.
670
+ *
671
+ * ## Absent is not empty, and not zero
672
+ *
673
+ * Spread rather than assigned, because absent and `[]` are different answers and the card turns on
674
+ * the difference: `[]` says the agent was reached and advertised no way to sign in, absent says
675
+ * nobody has connected to it (or this server would not read the field anyway). A `?? []` anywhere
676
+ * on this path would collapse the two and make every cold daemon look like an agent with no login.
677
+ * `errorCode` is spread for the same reason — a refusal that was not a JSON-RPC one has no code,
678
+ * and `0` is a code.
679
+ *
680
+ * `agentId` is the **registry agent id** (`agent_open.bridge`, `LiveSession.agentId`), never the
681
+ * workstation's configured entry name: `AcpAgents.observed` is keyed by the former, so keying by
682
+ * the latter would report "never connected" for every session ever opened.
683
+ *
684
+ * The version check is a "worth sending" floor, not a compatibility gate — see
685
+ * {@link AGENT_WIRE_SESSION_AUTH_METHODS_VERSION}, which spells out why this one is unlike the
686
+ * login frames'. Both keys sit behind that one check because neither was worth a version of its
687
+ * own — **not** because they always go out together. The two spreads below are independent, and a
688
+ * refusal the agent made outside JSON-RPC really does emit the table with no code. The server is
689
+ * right to refuse that a login anyway: `auth_required` is a code, so a refusal without one is a
690
+ * different refusal.
691
+ */
692
+ refusalFields(agentId, errorCode) {
693
+ if (this.serverAgentWire < AGENT_WIRE_SESSION_AUTH_METHODS_VERSION)
694
+ return {};
695
+ const methods = this.agents.observedAuthMethods(agentId);
696
+ return {
697
+ ...(methods ? { authMethods: methods } : {}),
698
+ ...(errorCode !== undefined ? { errorCode } : {}),
699
+ };
700
+ }
701
+ liveSession(sessionRef) {
702
+ const live = this.sessions.get(sessionRef);
703
+ return live && !live.closed ? live : undefined;
704
+ }
705
+ /**
706
+ * Resolve a session for an inbound control frame, warning — and telling the server — when there is
707
+ * none.
708
+ *
709
+ * A silent drop here reads downstream as "the agent ignores me", so the daemon log must say what
710
+ * was discarded. But a log line on this machine is not enough: the server's own state still looks
711
+ * healthy (its applier is registered, the websocket never dropped), so it reuses that applier and
712
+ * keeps sending into a session that is gone. The user's message disappears with no reply, and so
713
+ * does every message after it. {@link reportSessionLost} is what makes that divergence
714
+ * self-healing — one frame is lost, and the user is told.
715
+ */
716
+ routableSession(sessionRef, action) {
717
+ const live = this.liveSession(sessionRef);
718
+ if (!live) {
719
+ console.warn(`[agent] dropped ${action} for unknown session '${sessionRef}'`);
720
+ this.reportSessionLost(sessionRef, action);
721
+ }
722
+ else
723
+ this.touch(live);
724
+ return live;
725
+ }
726
+ /**
727
+ * Tell the server this daemon holds no session for `sessionRef`, so it can retire its own state
728
+ * and fail the round instead of talking into a void.
729
+ *
730
+ * Two conditions suppress it, both to avoid making things worse than the silent drop:
731
+ *
732
+ * - **The server is older than the frame.** Its channel decoder fails on an unknown discriminator
733
+ * and the gateway loop breaks, so sending this to a v1 server would take down the whole
734
+ * multiplexed connection — every session, exec and heartbeat on this workstation.
735
+ * - **The ref is still opening.** The session genuinely exists (or is about to); the frame is lost
736
+ * either way, but reporting it would have the server void a generation it is in the middle of
737
+ * establishing. The server does not send control frames before `agent_opened`, so this is a
738
+ * belt-and-braces guard rather than a live path.
739
+ */
740
+ reportSessionLost(sessionRef, action) {
741
+ if (this.serverAgentWire < AGENT_WIRE_SESSION_LOST_VERSION)
742
+ return;
743
+ if (this.opening.has(sessionRef))
744
+ return;
745
+ this.emit({ t: 'agent_session_lost', sessionRef, action });
746
+ }
747
+ /** The action a sweep reports under: no user input was lost, so the server's card logic must
748
+ * read it as "invalidate your state" rather than "tell the user to resend". */
749
+ static SWEEP_LOST_ACTION = 'sweep';
750
+ /** Bounds {@link sweptWhileUnsent} against a server that stays below the frame's wire version
751
+ * across reconnect after reconnect — the notices cost the server nothing to miss. */
752
+ static SWEEP_LOST_QUEUE_CAP = 128;
753
+ /**
754
+ * The sweep-side sibling of {@link reportSessionLost}: the ref went away on the timer, not under
755
+ * a dropped frame, so the server can retire its state *before* the user's next message runs into
756
+ * the hole instead of after. With this notice the divergence heals invisibly — ensureOpen re-opens
757
+ * with the persisted nativeSessionId, the conversation continues — and the lazy reply stays as
758
+ * the fallback for every path this cannot reach.
759
+ *
760
+ * Unlike the lazy reply this one must survive an unbound sender: the sweep fires precisely when
761
+ * nobody is talking, which is disproportionately *also* when the socket is down — a laptop that
762
+ * slept through its own deadline. An unsent notice therefore queues (bounded by
763
+ * {@link SWEEP_LOST_QUEUE_CAP}) and rides the next connection; see {@link flushSwept}.
764
+ */
765
+ notifySwept(sessionRef) {
766
+ if (this.send !== null && this.serverAgentWire >= AGENT_WIRE_SESSION_LOST_VERSION) {
767
+ this.reportSessionLost(sessionRef, AgentSessionManager.SWEEP_LOST_ACTION);
768
+ return;
769
+ }
770
+ if (this.sweptWhileUnsent.length >= AgentSessionManager.SWEEP_LOST_QUEUE_CAP) {
771
+ const dropped = this.sweptWhileUnsent.shift();
772
+ console.warn(`[agent] sweep notice for '${dropped}' dropped: queue capped at ${AgentSessionManager.SWEEP_LOST_QUEUE_CAP}`);
773
+ }
774
+ this.sweptWhileUnsent.push(sessionRef);
775
+ }
776
+ /** Delivers every sweep notice the queue held, on the first connection able to carry them. */
777
+ flushSwept() {
778
+ if (this.sweptWhileUnsent.length === 0)
779
+ return;
780
+ if (this.send === null || this.serverAgentWire < AGENT_WIRE_SESSION_LOST_VERSION)
781
+ return;
782
+ for (const sessionRef of this.sweptWhileUnsent.splice(0)) {
783
+ this.reportSessionLost(sessionRef, AgentSessionManager.SWEEP_LOST_ACTION);
784
+ }
785
+ }
786
+ /**
787
+ * Records a sign of life. Reads the wall clock on purpose: it is the *stamping* that needs the
788
+ * real time, while {@link sweepIdle} is handed `now` by its caller so its behaviour stays
789
+ * testable without waiting six hours.
790
+ */
791
+ touch(live) {
792
+ live.lastActivityAt = Date.now();
793
+ }
794
+ /**
795
+ * Closes every session that has neither received a server frame nor produced an event within
796
+ * `ttlMs` of `now`, and returns their sessionRefs.
797
+ *
798
+ * This is the last line of defence for orphaned agent subprocesses. The server's session
799
+ * table is in memory: after a server restart it knows nothing of the sessions this workstation
800
+ * still holds, so their `agent_close` is never sent — and a workstation that was offline when a
801
+ * run was archived never hears the close either. Without this sweep those agent processes live
802
+ * until the daemon is restarted, holding their cwd, their model quota and whatever they were
803
+ * mid-way through.
804
+ *
805
+ * `now` is a parameter rather than a clock read so the sweep is deterministic under test.
806
+ * The teardown is deliberately the same one `agent_close` performs ({@link retire}) — a swept
807
+ * session must leave nothing behind that a later frame could resurrect — with one deliberate
808
+ * exception: **the journal survives**.
809
+ *
810
+ * That exception is the difference between `agent_close` and this sweep. `agent_close` is the
811
+ * server saying "I am done with this ref", so its journal can never be asked for again. A sweep is
812
+ * unilateral: the server has not asked for anything and may well re-open the ref with a
813
+ * `resumeFromSeq` the moment it next hears from the user. The sweep also fires precisely when a
814
+ * journal is most likely to be holding undelivered output — the workstation was offline past the
815
+ * TTL (a laptop shut overnight), so a turn that finished while the socket was down is journaled
816
+ * and unsent. Destroying it there loses that turn outright, and `droppedOldest` is per-instance so
817
+ * not even a transport-gap card would mark the hole. The same distinction already exists one layer
818
+ * up as {@link AbandonReason}; a sweep is `disconnected`-shaped, not `closed`-shaped. Kill the
819
+ * process, keep the file.
820
+ *
821
+ * Each sweep also reports itself to the server ({@link notifySwept}) so its stale session state is
822
+ * retired before the next user message runs into it, rather than after — the sweep heals the
823
+ * daemon's side of the divergence; the notice heals the server's.
824
+ */
825
+ async sweepIdle(ttlMs, now) {
826
+ const idle = [...this.sessions.values()].filter((live) => now - live.lastActivityAt >= ttlMs);
827
+ // Snapshot first: retire() mutates `sessions`, and it awaits.
828
+ for (const live of idle) {
829
+ await this.retire(live, 'keep');
830
+ // Reported once the teardown has settled: nothing is gained by a notice that outruns the kill,
831
+ // and a reopen racing the retire would only park on `retiring`. The daemon-side frame drop
832
+ // already covers any server frame that lands in the gap.
833
+ this.notifySwept(live.sessionRef);
834
+ }
835
+ return idle.map((live) => live.sessionRef);
836
+ }
837
+ async onOpen(frame) {
838
+ // Reconnect / duplicate open: reuse the live session as-is and replay what the server
839
+ // still misses.
840
+ const existing = this.liveSession(frame.sessionRef);
841
+ if (existing) {
842
+ // Reusing a live session: neither the process nor the native session moved.
843
+ await this.resumeAndReply(existing, frame, false);
844
+ // The session already exists, so any history it has was already streamed under whichever
845
+ // open first established it (or never will be, if that open didn't ask for it) — replaying it
846
+ // again here would duplicate what the platform already holds. But if THIS frame asked for
847
+ // backfill, the server is waiting on exactly one `agent_backfill_done` for THIS open's id:
848
+ // silence is not "no", it is a wait that never resolves. Answer it without replaying.
849
+ this.replyNoReplayNeeded(frame);
850
+ return;
851
+ }
852
+ const inFlight = this.opening.get(frame.sessionRef);
853
+ if (inFlight) {
854
+ // Piggyback on the attempt already running, and report its real failure — not a generic one.
855
+ const { live, error, agentRefused } = await inFlight;
856
+ if (live) {
857
+ // Piggybacking on the attempt already running: a non-null `live` means that attempt was a
858
+ // real open (`session/load` or `session/new`), which this frame is reporting secondhand.
859
+ await this.resumeAndReply(live, frame, true);
860
+ // Same reasoning as the reconnect branch above: whichever frame actually drove openSession
861
+ // already streamed this session's history (if it asked to); this frame is a second, racing
862
+ // open of the same ref and must not trigger a second replay — but still owes its own
863
+ // `agent_backfill_done` if it asked for one.
864
+ this.replyNoReplayNeeded(frame);
865
+ }
866
+ else {
867
+ this.emit({
868
+ t: 'agent_opened', id: frame.id, ok: false, error: error ?? 'concurrent open failed',
869
+ // This frame is a second, racing open of a ref whose attempt already ran; it reports that
870
+ // attempt's real failure, so it reports that attempt's agent's sign-in methods and that
871
+ // attempt's own error code — keyed by the id the outcome carries, never by this frame's
872
+ // own `bridge`, which is a different agent the moment two opens race on one ref with
873
+ // different bridges.
874
+ ...(agentRefused ? this.refusalFields(agentRefused.agentId, agentRefused.errorCode) : {}),
875
+ });
876
+ }
877
+ return;
878
+ }
879
+ const attempt = this.openSession(frame)
880
+ .catch((err) => ({ live: null, error: errorMessage(err) }));
881
+ this.opening.set(frame.sessionRef, attempt);
882
+ let outcome;
883
+ try {
884
+ outcome = await attempt;
885
+ }
886
+ finally {
887
+ this.opening.delete(frame.sessionRef);
888
+ // Clear the tombstone on EVERY exit, not just openSession's success path: an open that was
889
+ // tombstoned and then failed (ENOENT, bad resume) would otherwise leave the mark behind, and
890
+ // the next legitimate open of this ref would spawn a real agent only to be killed by it.
891
+ // Safe to do here: openSession consumes the tombstone before its promise resolves, and only
892
+ // microtasks separate that from this finally, so no inbound frame can interleave.
893
+ this.closedDuringOpen.delete(frame.sessionRef);
894
+ }
895
+ // Replay-then-reply happens AFTER the finally on purpose. It awaits disk I/O, which yields to
896
+ // the event loop; if it ran inside the try, the ref would sit in `sessions` and `opening` at
897
+ // once and an inbound agent_close could observe both. onClose checks `opening` first, so it
898
+ // would tombstone an already-registered session — and the finally above would then wipe the
899
+ // tombstone without anything ever closing it. Nothing but microtasks separates `attempt`
900
+ // resolving from the finally, and microtasks always drain before the next inbound frame.
901
+ if (outcome.live) {
902
+ // openSession returned successfully, having just done session/load or session/new.
903
+ await this.resumeAndReply(outcome.live, frame, true);
904
+ // Only the direct-attempt path: the existing-session and in-flight-piggyback branches above
905
+ // return before reaching here, so a racing duplicate `agent_open` (or a reconnect of an
906
+ // already-open ref) can never trigger a second replay of the same history.
907
+ if (frame.backfill)
908
+ await this.streamBackfill(frame.id, outcome.live.session);
909
+ }
910
+ else {
911
+ this.emit({
912
+ t: 'agent_opened', id: frame.id, ok: false, error: outcome.error,
913
+ // Only when the agent is the one that said no, and then it is the one refusal that can be
914
+ // an `auth_required`: `AcpAgents.open` records the `initialize` observation *before*
915
+ // `session/new`, so the method table is already there when the session it refused throws.
916
+ // The code says whether this particular refusal actually was that one.
917
+ ...(outcome.agentRefused
918
+ ? this.refusalFields(outcome.agentRefused.agentId, outcome.agentRefused.errorCode)
919
+ : {}),
920
+ });
921
+ }
922
+ }
923
+ /**
924
+ * Answers a backfill-flagged open that landed on an already-live (or already-being-opened)
925
+ * session, without replaying anything.
926
+ *
927
+ * Every `agent_open` with `backfill: true` owes the server exactly one `agent_backfill_done` for
928
+ * *that frame's* `id` — that is the only thing the server's adoption wait is keyed on (Task 12).
929
+ * The reconnect branch (an existing live session) and the in-flight-piggyback branch (a second
930
+ * `agent_open` racing an attempt already in `openSession`) both reuse a session whose history, if
931
+ * any, was already streamed once — under a *different* open's id, by {@link streamBackfill} off
932
+ * the direct-attempt path. Silently doing nothing here would leave the server waiting on this
933
+ * frame's id forever, since a wait with no reply is indistinguishable from one still in progress.
934
+ * `ok:true, count:0` is not a lie: from this frame's point of view there is genuinely nothing left
935
+ * to replay — it just was not this open that did the replaying.
936
+ */
937
+ replyNoReplayNeeded(frame) {
938
+ if (frame.backfill)
939
+ this.emit({ t: 'agent_backfill_done', id: frame.id, ok: true, count: 0 });
940
+ }
941
+ /**
942
+ * Streams an adopted session's history back to the server, after `agent_opened` and before
943
+ * anything else touches this ref. `agent_open` carries `backfill?: boolean` rather than a
944
+ * standalone request frame precisely so this can run here: the history only exists once the agent
945
+ * has actually loaded the native session (`AcpAgents.open` captures what `session/load` replays),
946
+ * and `agent_open` already has the whole open path (spec construction, ok/error reply) that a
947
+ * standalone frame would force the daemon to rebuild a second copy of.
948
+ *
949
+ * Deliberately outside the live event/journal machinery — no `bridge_seq`, no
950
+ * {@link SessionJournal} entry, no `agent_event_ack` round trip. `bridge_seq` means one specific
951
+ * thing: live-consumption progress, the only basis for de-duplication across reconnects. Backfill
952
+ * is a one-shot replay correlated purely by this open's `id`; folding it into that watermark would
953
+ * give it two meanings, which is exactly what produced two watermark bugs in an earlier phase.
954
+ *
955
+ * Exactly one `agent_backfill_done` is emitted on every path — success, a `backfill()` that throws
956
+ * mid-stream, and a session with no `backfill` method at all — via the single `emit` call below,
957
+ * which runs unconditionally after the try/catch rather than once per branch. A `backfill()` that
958
+ * throws still keeps every batch already emitted (partial history is the accepted degradation for
959
+ * a best-effort, third-party format); a session without `backfill()` is not an error (`ok:true,
960
+ * count:0` — that is every session that was not opened onto existing history). Must never throw:
961
+ * `handleFrame`'s caller is the frame-ingestion path, so an exception here would damage the whole
962
+ * tunnel rather than one request (same discipline as {@link onListNative}).
963
+ */
964
+ async streamBackfill(id, session) {
965
+ if (!session.backfill) {
966
+ this.emit({ t: 'agent_backfill_done', id, ok: true, count: 0 });
967
+ return;
968
+ }
969
+ let batch = [];
970
+ let count = 0;
971
+ let ok = true;
972
+ let error;
973
+ try {
974
+ // Called as `session.backfill()`, not through a destructured reference: an implementation may
975
+ // legitimately rely on `this`, and a bare local `const backfill = session.backfill` would call
976
+ // it unbound.
977
+ for await (const event of session.backfill()) {
978
+ batch.push(event);
979
+ count += 1;
980
+ if (batch.length >= BACKFILL_BATCH_SIZE) {
981
+ this.emit({ t: 'agent_backfill_events', id, events: batch });
982
+ batch = [];
983
+ }
984
+ }
985
+ }
986
+ catch (err) {
987
+ ok = false;
988
+ error = errorMessage(err);
989
+ }
990
+ if (batch.length > 0)
991
+ this.emit({ t: 'agent_backfill_events', id, events: batch });
992
+ this.emit({ t: 'agent_backfill_done', id, ok, count, ...(error !== undefined ? { error } : {}) });
993
+ }
994
+ /**
995
+ * Replays everything the server has not applied, then confirms the open.
996
+ *
997
+ * Replay comes first because the server registers its inbound handler *before* it sends
998
+ * `agent_open` (see AgentTunnelChannel): frames that arrive ahead of `agent_opened` are already
999
+ * dispatched, and going the other way round would leave a window in which a freshly produced
1000
+ * event overtakes an older replayed one.
1001
+ *
1002
+ * `reloaded` is a parameter, not something derived here, on purpose: the `LiveSession` this
1003
+ * receives looks identical whether the caller reused a live session, piggybacked on another
1004
+ * open's real result, or just performed the open itself — only the caller knows which of those
1005
+ * three happened, so any derivation from `live` alone would be guesswork.
1006
+ */
1007
+ async resumeAndReply(live, frame, reloaded) {
1008
+ this.touch(live);
1009
+ if (frame.nativeSessionId && frame.nativeSessionId !== live.nativeSessionId) {
1010
+ // Not an error — the server's persisted id can lag a native session the agent rotated — but
1011
+ // it is the only trace of "the conversation you think you are resuming is not this one".
1012
+ console.warn(`[agent] ${live.sessionRef}: server asked to resume native session '${frame.nativeSessionId}' ` +
1013
+ `but the live one is '${live.nativeSessionId}'`);
1014
+ }
1015
+ const resumeFrom = resumeFromSeqOf(frame);
1016
+ // On the session's own queue so an agent event produced mid-replay is sent after it, not into
1017
+ // the middle of it.
1018
+ await this.enqueue(live, async () => {
1019
+ const entries = await live.journal.entriesAfter(resumeFrom);
1020
+ if (this.hasGap(live, entries, resumeFrom)) {
1021
+ this.emit({
1022
+ t: 'agent_event',
1023
+ sessionRef: live.sessionRef,
1024
+ // The hole left by the overflow drop starts exactly here. Reusing that seq (rather than
1025
+ // taking a fresh one) is what makes the notice survive the server's `seq > bridgeSeq`
1026
+ // filter *without* pushing the watermark past the replayed entries behind it.
1027
+ seq: resumeFrom + 1,
1028
+ eventName: 'step',
1029
+ payload: { message: 'transport gap: oldest streamed output dropped', tone: 'neutral' },
1030
+ });
1031
+ }
1032
+ // Already-formed frames, carrying their original seq — resent verbatim so the server's
1033
+ // de-duplication does the deciding.
1034
+ for (const entry of entries)
1035
+ this.emit(entry.frame);
1036
+ });
1037
+ this.emit({
1038
+ t: 'agent_opened',
1039
+ id: frame.id,
1040
+ ok: true,
1041
+ nativeSessionId: live.nativeSessionId,
1042
+ // Whether THIS open actually performed `session/load` or `session/new`, as the caller told
1043
+ // us — see the parameter doc on `resumeAndReply` for why that has to come from the caller.
1044
+ // Emitted only when true, the same `supportsSteering` convention as below: absent is the
1045
+ // field's only "no", and it is also what an older peer sends.
1046
+ ...(reloaded ? { reloaded: true } : {}),
1047
+ // The server learns this here and nowhere else. It used to be published only on the roster in
1048
+ // the `agent_hello` handshake, which is a snapshot taken *before* any agent was connected to —
1049
+ // and the capability can only be observed on a live `initialize`, whose record
1050
+ // (`AcpAgents.observed`) does not outlive this process. So the roster could never carry it,
1051
+ // and a workstation whose adapter genuinely steers reported `false` forever. Read off the
1052
+ // session rather than copied onto {@link LiveSession}: it is fixed for that session's life, so
1053
+ // a second copy could only ever disagree with the first.
1054
+ //
1055
+ // Emitted only when true, like `BridgeInfo.supportsSteering`: absent is the field's only "no",
1056
+ // and it is also what a peer too old to answer sends.
1057
+ ...(live.session.supportsSteering ? { supportsSteering: true } : {}),
1058
+ // Which delivery timings that same `initialize` declared. Emitted only when non-empty, for
1059
+ // `supportsSteering`'s reason: absent and `[]` say the same thing — the agent named no
1060
+ // timing, so the server may not name one either (ruling N4) — and absent is also what a peer
1061
+ // too old to answer sends. Read off the session for the same session-not-roster reason.
1062
+ // Read defensively, exactly as `supportsSteering` above already is — **not every driver of
1063
+ // this manager is an object TypeScript ever checked.** `tsconfig.json` excludes
1064
+ // `src/**/*.test.ts`, and the server's end-to-end replay harness is plain `.mjs`
1065
+ // (`axom-server/src/test/resources/full-replay-agent-harness.mjs`); both hand this method a
1066
+ // session carrying only the members they exercise, and neither declares this one. A bare
1067
+ // `.length` on it threw `TypeError: Cannot read properties of undefined` *inside the open
1068
+ // reply*, so `agent_opened` was never sent at all and the session stranded until the server
1069
+ // timed out a minute later. Absent has to degrade to "the agent named no timing" — ruling
1070
+ // N4's safe direction, never specify one — and not to a crash that costs the whole session.
1071
+ ...((live.session.steeringTimings?.length ?? 0) > 0
1072
+ ? { steeringTimings: [...live.session.steeringTimings] }
1073
+ : {}),
1074
+ // `InitializeResponse.agentInfo` verbatim, read off the session for the same reason
1075
+ // `supportsSteering` is (see the comment above): fixed for the session's life, and only
1076
+ // observable on a live `initialize`. Absent — never a fabricated placeholder — when the
1077
+ // agent's own `initialize` never sent one; this does NOT go through `BridgeInfo` (pre-flight
1078
+ // ruling R1: `agent_opened` is assembled from `live.session.*`, not from the roster, so a
1079
+ // field added to `BridgeInfo` would have no reader here).
1080
+ ...(live.session.agentInfo ? { agentInfo: live.session.agentInfo } : {}),
1081
+ // ACP `PromptCapabilities.image`, learned at the same `initialize` and for the same
1082
+ // session-not-roster reason as `supportsSteering` above: `true`-or-absent is this field's
1083
+ // whole vocabulary, so an agent that said nothing (or a peer too old to answer) reads the
1084
+ // same as one that explicitly said no.
1085
+ ...(live.session.supportsImageInput ? { supportsImageInput: true } : {}),
1086
+ // The session's own config option snapshot — model, reasoning effort, whatever else the
1087
+ // agent declared — read off the session for the same reason `supportsSteering` is: it is
1088
+ // observable only on this session's own `initialize`/`session/new`, never on the roster.
1089
+ //
1090
+ // Unlike `supportsSteering`, this one is **not** fixed for the session's life: every
1091
+ // `agent_set_config_option` replaces it. That is why it is read through a getter here rather
1092
+ // than copied onto {@link LiveSession} at open time — this line runs again on every reuse
1093
+ // `agent_opened`, which is what a tunnel reconnect produces, and a snapshot frozen at open
1094
+ // would report the pre-change values to the server after every config change. See
1095
+ // {@link AcpAgentSession.configOptions} for what that costs.
1096
+ configOptions: live.session.configOptions,
1097
+ });
1098
+ }
1099
+ /**
1100
+ * Whether the overflow drop actually took something the server still needs. `droppedOldest` alone
1101
+ * is not enough: by the time of this reconnect the server may have acked past the hole, and
1102
+ * announcing a gap that no longer exists would both mislead the user and collide with the seq of
1103
+ * the first replayed entry.
1104
+ */
1105
+ hasGap(live, entries, resumeFrom) {
1106
+ if (!live.journal.droppedOldest)
1107
+ return false;
1108
+ const first = entries[0];
1109
+ return first ? first.seq > resumeFrom + 1 : live.journal.lastSeq > resumeFrom;
1110
+ }
1111
+ async openSession(frame) {
1112
+ // A previous incarnation of this ref may still be deleting its journal; two SessionJournal
1113
+ // instances must never share a path (see {@link retiring}). This is the first statement on
1114
+ // purpose — before init, before the agent spawns, before anything touches the filesystem.
1115
+ await this.retiring.get(frame.sessionRef);
1116
+ // spec.env is an OVERLAY merged onto process.env when the agent is spawned (see
1117
+ // AcpOpenSpec.env), so it is built from frame-supplied values only. The single exception is
1118
+ // PATH: when pathPrepend is set we resolve the whole variable here — prepends first, then the
1119
+ // frame's PATH or, failing that, the daemon's — so the launcher uses spec.env.PATH verbatim
1120
+ // instead of re-deriving it.
1121
+ const env = { ...(frame.env ?? {}) };
1122
+ const pathPrepend = frame.pathPrepend ?? [];
1123
+ if (pathPrepend.length > 0) {
1124
+ env.PATH = [...pathPrepend, env.PATH ?? process.env.PATH ?? ''].filter(Boolean).join(path.delimiter);
1125
+ }
1126
+ // Recovered before the agent spawns, because nextSeq() is synchronous and the first event may
1127
+ // arrive the instant onEvent is registered: the high-water mark has to be known by then or a
1128
+ // restarted daemon would restart seq at 1 and the server would swallow the next turn as
1129
+ // already-applied. The frame's watermark is passed in as a floor under whatever the file
1130
+ // yields, because "the file is empty" is the *normal* state between turns — everything in it
1131
+ // was acked and truncated — so the file alone would say 0 while the server sits at N.
1132
+ // init() only reads — the file appears on the first append — so an open that fails here leaves
1133
+ // nothing behind.
1134
+ const journal = new SessionJournal(this.sessionsDir, frame.sessionRef, this.maxJournalBytes);
1135
+ try {
1136
+ await journal.init(resumeFromSeqOf(frame));
1137
+ }
1138
+ catch (err) {
1139
+ return { live: null, error: `journal init failed: ${errorMessage(err)}` };
1140
+ }
1141
+ // Both journal recovery and a retiring prior incarnation can await before this point. Only a
1142
+ // force-stop may cancel an open admitted before that await; graceful drain owns its reservation
1143
+ // and must let it settle without starting a second admission decision halfway through.
1144
+ if (this.tasks?.isForceStopping()) {
1145
+ await journal.destroy();
1146
+ return { live: null, error: 'Workstation is not accepting new work' };
1147
+ }
1148
+ let session;
1149
+ let nativeSessionId;
1150
+ try {
1151
+ const result = await this.agents.open({
1152
+ // `frame.bridge` is the registry agent id now — the wire field keeps its name because Plan A
1153
+ // renames nothing (Global Constraint 3); ruling R2 replaced what it *means*.
1154
+ agentId: frame.bridge,
1155
+ cwd: frame.cwd,
1156
+ env,
1157
+ agentEnv: frame.agentEnv ?? {},
1158
+ ...(frame.command ? { command: frame.command } : {}),
1159
+ args: frame.args ?? [],
1160
+ ...(frame.nativeSessionId ? { nativeSessionId: frame.nativeSessionId } : {}),
1161
+ // Retaining a whole adopted history costs memory for the session's lifetime, so it is only
1162
+ // kept when this open is the one that will stream it back.
1163
+ backfill: frame.backfill === true,
1164
+ });
1165
+ session = result.session;
1166
+ nativeSessionId = result.nativeSessionId;
1167
+ }
1168
+ catch (err) {
1169
+ // `open` throws on both sides of the agent's `initialize` — nothing to launch and a spawn
1170
+ // that failed look identical from here — so the marker is asked for rather than assumed. Only
1171
+ // an {@link AgentRefusedSessionError} is the agent's own no, which is what an `auth_required`
1172
+ // on `session/new` arrives as; see {@link OpenOutcome.agentRefused}.
1173
+ return {
1174
+ live: null,
1175
+ error: errorMessage(err),
1176
+ // The code comes off the marker, not off `errorMessage(err)` above: the wrapper captured it
1177
+ // from the original rejection at the moment that object still existed.
1178
+ ...(err instanceof AgentRefusedSessionError
1179
+ ? { agentRefused: { agentId: frame.bridge, ...(err.errorCode !== undefined ? { errorCode: err.errorCode } : {}) } }
1180
+ : {}),
1181
+ };
1182
+ }
1183
+ // An accepted open is counted while startup is asynchronous. A graceful gate change therefore
1184
+ // waits for it and leaves it alive; only force-stop authorizes destructive late completion.
1185
+ if (this.tasks?.isForceStopping()) {
1186
+ try {
1187
+ await session.close();
1188
+ }
1189
+ catch (err) {
1190
+ console.error(`[agent] close of force-stopped open failed for ${frame.sessionRef}: ${errorMessage(err)}`);
1191
+ }
1192
+ await journal.destroy();
1193
+ return { live: null, error: 'Workstation is not accepting new work' };
1194
+ }
1195
+ // The session became pointless while it was spawning (agent_close, or the socket dropped and the
1196
+ // server abandoned its pending open). Shut the agent down instead of registering an orphan
1197
+ // nobody will ever close.
1198
+ const abandonReason = this.closedDuringOpen.get(frame.sessionRef);
1199
+ if (abandonReason) {
1200
+ this.closedDuringOpen.delete(frame.sessionRef);
1201
+ try {
1202
+ await session.close();
1203
+ }
1204
+ catch (err) {
1205
+ console.error(`[agent] close of abandoned open failed for ${frame.sessionRef}: ${errorMessage(err)}`);
1206
+ }
1207
+ // Only an explicit agent_close retires the ref, and only then does its journal go. A socket
1208
+ // drop must leave the file alone: it may hold entries this daemon recovered from a previous
1209
+ // life, and the server will re-open the ref and ask for exactly those.
1210
+ if (abandonReason === 'closed')
1211
+ await journal.destroy();
1212
+ return { live: null, error: 'session closed during open' };
1213
+ }
1214
+ const live = {
1215
+ sessionRef: frame.sessionRef,
1216
+ agentId: frame.bridge,
1217
+ agentKey: frame.agentKey,
1218
+ session,
1219
+ nativeSessionId,
1220
+ journal,
1221
+ writes: Promise.resolve(),
1222
+ closed: false,
1223
+ lastActivityAt: Date.now(),
1224
+ };
1225
+ this.sessions.set(frame.sessionRef, live);
1226
+ this.tasks?.registerAgentSession(frame.sessionRef, () => this.retire(live, 'destroy'));
1227
+ session.onEvent((e) => this.onAgentEvent(live, e));
1228
+ session.onTurnDone((r) => this.onAgentTurnDone(live, r));
1229
+ return { live };
1230
+ }
1231
+ /**
1232
+ * Answers `agent_list_native` by asking the agent itself, over a throwaway connection this manager
1233
+ * never sees. Deliberately involves no `LiveSession`: listing is what a user does *before* deciding
1234
+ * to adopt a session, so there is nothing session-shaped to look up yet and inventing one would be
1235
+ * pure ceremony. Do not add a `sessions`/`sessionRef` lookup here later; listing precedes opening
1236
+ * by design.
1237
+ *
1238
+ * An agent that does not advertise `sessionCapabilities.list` answers `ok:true` with an empty list,
1239
+ * not an error — `adoption.ts` gates on the capability and the directory hides the "not yet
1240
+ * adopted" grouping accordingly. `ok:false` is reserved for a genuine failure (an agent id nothing
1241
+ * can launch, a subprocess that will not start), because "cannot reach this agent" and "this agent
1242
+ * has no sessions" must not look alike to the user.
1243
+ *
1244
+ * None of its exits may throw: `handleFrame`'s caller is the frame-ingestion path, and an
1245
+ * exception there damages the whole tunnel, not just this request.
1246
+ */
1247
+ async onListNative(frame) {
1248
+ const spec = sanitizeNativeSessionQuery(frame.bridge, frame.query);
1249
+ if (!spec) {
1250
+ this.emit({ t: 'agent_native_sessions', id: frame.id, ok: false, error: 'native session query is missing cwd' });
1251
+ return;
1252
+ }
1253
+ try {
1254
+ const sessions = await this.agents.listNativeSessions({ ...spec, filterCwd: frame.query.filterCwd ?? undefined });
1255
+ // `updatedAtEpochMs` crosses to a Kotlin `Long`, and a fractional value does not degrade —
1256
+ // the whole frame fails to decode, and the gateway answers an undecodable frame by closing
1257
+ // the agent channel, which takes down every session on that workstation with it. Coerced
1258
+ // here rather than trusted upstream: the deleted claude bridge reached the wire with a raw
1259
+ // `stat().mtimeMs`, which is fractional on macOS, and a third-party agent can do the same.
1260
+ this.emit({
1261
+ t: 'agent_native_sessions',
1262
+ id: frame.id,
1263
+ ok: true,
1264
+ sessions: sessions.map((session) => ({ ...session, updatedAtEpochMs: Math.round(session.updatedAtEpochMs) })),
1265
+ });
1266
+ }
1267
+ catch (err) {
1268
+ this.emit({ t: 'agent_native_sessions', id: frame.id, ok: false, error: errorMessage(err) });
1269
+ }
1270
+ }
1271
+ /**
1272
+ * Answers `agent_verify` by actually starting the agent and seeing what it says — the only way the
1273
+ * roster's auth fields can be populated for an agent no session has ever run (see
1274
+ * {@link AcpAgents.verify}).
1275
+ *
1276
+ * `ok: false` means the verification itself could not be carried out — an agent id nothing can
1277
+ * launch, a subprocess that would not start. An agent that started but refused a session is
1278
+ * `ok: true` with a `sessionError`: the verification succeeded, its answer is just
1279
+ * "inconclusive". Collapsing the two would leave a settings page unable to tell "not
1280
+ * installed" from "installed, and something is wrong with it".
1281
+ *
1282
+ * **`authenticated` is spread in, never assigned.** An explicit `authenticated: undefined` is
1283
+ * still an own property, and the whole contract of this field is that a missing key means unknown.
1284
+ * There is no `false` to send — the frame's own outbound check refuses one
1285
+ * (`protocol/agent.ts`'s `authenticatedViolation`).
1286
+ *
1287
+ * None of its exits may throw, for the same reason {@link onListNative} says so: this runs on the
1288
+ * frame-ingestion path, and an exception there damages the whole tunnel.
1289
+ */
1290
+ async onVerify(frame) {
1291
+ const spec = sanitizeNativeSessionQuery(frame.bridge, frame.query);
1292
+ if (!spec) {
1293
+ this.emit({ t: 'agent_verified', id: frame.id, ok: false, error: 'agent verification query is missing cwd' });
1294
+ return;
1295
+ }
1296
+ try {
1297
+ // 预算跟着请求走:发起方说多久就多久(夹紧之后),没说就还是本地的
1298
+ // AGENT_PROBE_TIMEOUT_MS。这一行是「一个超时贯通两端」缺的那一环 —— 在它之前,服务端
1299
+ // 把后台预热放宽到 180s 而这里雷打不动 20s,于是先放弃的永远是 daemon,那 180s 一秒也
1300
+ // 用不上(真机日志:`did not respond to initialize within 20000ms` / 服务端量到 20021ms)。
1301
+ const verification = await this.agents.verify(spec, clampProbeTimeout(frame.timeoutMs));
1302
+ this.emit({
1303
+ t: 'agent_verified',
1304
+ id: frame.id,
1305
+ ok: true,
1306
+ authMethods: verification.authMethods,
1307
+ ...(verification.authenticated ? { authenticated: verification.authenticated } : {}),
1308
+ ...(verification.sessionError ? { sessionError: verification.sessionError } : {}),
1309
+ // Spread, never assigned — same rule as `authenticated` two lines up, and for the same
1310
+ // reason: an explicit `undefined` is still an own property, and a missing key is this
1311
+ // field's whole contract ("nobody looked"). `[]` is a different answer and must survive,
1312
+ // which is why the test is `!== undefined` rather than a truthiness check.
1313
+ ...(verification.availableCommands !== undefined
1314
+ ? { availableCommands: verification.availableCommands }
1315
+ : {}),
1316
+ // Same absent-vs-`[]` convention as `availableCommands` above: absent means nobody looked
1317
+ // (the probe never opened a session), `[]` means the agent declared zero config options.
1318
+ ...(verification.configOptions !== undefined ? { configOptions: verification.configOptions } : {}),
1319
+ });
1320
+ }
1321
+ catch (err) {
1322
+ this.emit({ t: 'agent_verified', id: frame.id, ok: false, error: errorMessage(err) });
1323
+ }
1324
+ }
1325
+ /**
1326
+ * Answers `agent_authenticate` by running the agent's own `authenticate` for a method **it
1327
+ * advertised**. A `methodId` it did not advertise is refused by `AcpAuth` without anything being
1328
+ * written to the agent, and arrives here as `ok: false` with that reason.
1329
+ *
1330
+ * Reports no auth state: the reply says whether the call went through, and the caller asks
1331
+ * `agent_verify` for an observation. Same no-throw rule as {@link onVerify}.
1332
+ *
1333
+ * ## Why the reply comes back before the login does
1334
+ *
1335
+ * A login is a round trip through a *person*, in a browser, on some machine. Awaiting it before
1336
+ * replying parks the tunnel request for its whole duration, and the server's own deadline then
1337
+ * ends it — spec §6.2 measured an agent whose `authenticate` had not returned after 25 seconds
1338
+ * because it was still waiting on a browser callback. So this acknowledges the request
1339
+ * immediately (`pending: true`) and sends the outcome as a second `agent_authenticated` when the
1340
+ * login actually settles. Between the two, the URL the reader has to open travels as
1341
+ * `agent_auth_url`, and its retraction as `agent_auth_url_done`.
1342
+ *
1343
+ * Below {@link AGENT_WIRE_AUTH_URL_VERSION} none of that exists on the peer, and the fallback is
1344
+ * not a degraded version of it but literally the previous behaviour: await, then reply once. An
1345
+ * old server cannot decode either new frame — it would count them as undecodable and close the
1346
+ * whole agent channel — and it has no branch that would keep waiting after a `pending` reply it
1347
+ * cannot see, so it must keep getting the shape it knows.
1348
+ */
1349
+ async onAuthenticate(frame) {
1350
+ const spec = sanitizeNativeSessionQuery(frame.bridge, frame.query);
1351
+ if (!spec) {
1352
+ this.emit({ t: 'agent_authenticated', id: frame.id, ok: false, error: 'agent authentication query is missing cwd' });
1353
+ return;
1354
+ }
1355
+ if (this.serverAgentWire < AGENT_WIRE_AUTH_URL_VERSION) {
1356
+ await this.runLogin(frame, spec, {});
1357
+ return;
1358
+ }
1359
+ this.emit({ t: 'agent_authenticated', id: frame.id, ok: true, pending: true });
1360
+ const cancel = new AbortController();
1361
+ this.loginsInFlight.add(cancel);
1362
+ void this.runLogin(frame, spec, {
1363
+ signal: cancel.signal,
1364
+ // Gated here, inside the callbacks, rather than only at the top of this method: the sender is
1365
+ // rebound on every reconnect and carries the *new* peer's version with it, so a login that
1366
+ // outlives a reconnect to an older server must not keep sending frames that would close the
1367
+ // channel it just came back on.
1368
+ present: (elicitationId, url) => {
1369
+ if (this.serverAgentWire < AGENT_WIRE_AUTH_URL_VERSION)
1370
+ return;
1371
+ this.emit({ t: 'agent_auth_url', id: frame.id, elicitationId, url });
1372
+ },
1373
+ dismiss: (elicitationId) => {
1374
+ if (this.serverAgentWire < AGENT_WIRE_AUTH_URL_VERSION)
1375
+ return;
1376
+ this.emit({ t: 'agent_auth_url_done', id: frame.id, elicitationId });
1377
+ },
1378
+ }).finally(() => this.loginsInFlight.delete(cancel)).catch((err) => {
1379
+ // Unreachable by construction ({@link runLogin} settles every path itself), and logged rather
1380
+ // than left implicit because this promise is deliberately not awaited: an escaping rejection
1381
+ // here would be an `unhandledRejection`, which this daemon treats as fatal.
1382
+ console.error(`[agent] login for '${frame.bridge}' left an unhandled failure: ${errorMessage(err)}`);
1383
+ });
1384
+ }
1385
+ /**
1386
+ * Run one login to its end and report the outcome — the *final* `agent_authenticated`, the one
1387
+ * without `pending`, which is the only one that carries an error.
1388
+ *
1389
+ * Called either awaited (an old peer, where it is the whole exchange) or detached (a new peer,
1390
+ * after the acknowledgement), so it must never throw: it is the tail of the frame-ingestion path
1391
+ * in the first case and an unowned promise in the second.
1392
+ */
1393
+ async runLogin(frame, spec, login) {
1394
+ try {
1395
+ await this.agents.authenticate(spec, frame.methodId, login);
1396
+ this.emit({ t: 'agent_authenticated', id: frame.id, ok: true });
1397
+ }
1398
+ catch (err) {
1399
+ this.emit({ t: 'agent_authenticated', id: frame.id, ok: false, error: errorMessage(err) });
1400
+ }
1401
+ }
1402
+ /**
1403
+ * Answers `agent_install` by fetching the agent's adapter — and **only** the adapter. Nothing is
1404
+ * launched here, which is what separates it from `agent_verify`: this is the work the first
1405
+ * session would otherwise do invisibly, brought forward to a button.
1406
+ *
1407
+ * `unsupported` travels as its own field rather than folded into `error`, because "this build
1408
+ * cannot fetch this kind of adapter yet" is a permanent property of the build that the settings
1409
+ * page renders as *coming soon*, while an `error` is a download the reader can retry.
1410
+ *
1411
+ * `adapterInstalled` is spread in, never assigned, for the reason `onVerify` gives about
1412
+ * `authenticated`: an explicit `undefined` is still an own property, and the frame's own outbound
1413
+ * check refuses anything but `true` or absent.
1414
+ *
1415
+ * None of its exits may throw — same rule as {@link onListNative}, same reason.
1416
+ */
1417
+ async onInstall(frame) {
1418
+ try {
1419
+ const result = await this.agents.install(frame.bridge);
1420
+ if (result.ok) {
1421
+ this.emit({
1422
+ t: 'agent_installed',
1423
+ id: frame.id,
1424
+ ok: true,
1425
+ adapterInstalled: result.adapterInstalled,
1426
+ alreadyInstalled: result.alreadyInstalled,
1427
+ ...(result.pinnedVersionUnavailable ? { pinnedVersionUnavailable: result.pinnedVersionUnavailable } : {}),
1428
+ });
1429
+ return;
1430
+ }
1431
+ this.emit({
1432
+ t: 'agent_installed',
1433
+ id: frame.id,
1434
+ ok: false,
1435
+ ...(result.unsupported ? { unsupported: true } : {}),
1436
+ error: result.error,
1437
+ });
1438
+ }
1439
+ catch (err) {
1440
+ this.emit({ t: 'agent_installed', id: frame.id, ok: false, error: errorMessage(err) });
1441
+ }
1442
+ }
1443
+ onAgentEvent(live, e) {
1444
+ if (live.closed)
1445
+ return;
1446
+ // An agent can resume an existing native turn after a client restart, so an inbound prompt is
1447
+ // not the only reliable start marker. These are the two wire events that represent work which
1448
+ // may still require user action or native execution; turn_done below releases the mark.
1449
+ if (e.eventName === 'tool_call' || e.eventName === 'permission_request')
1450
+ this.tasks?.agentTurnStarted(live.sessionRef);
1451
+ // Before the contract check: an agent emitting even non-conforming events is a live process,
1452
+ // and the idle sweep is about liveness, not correctness.
1453
+ this.touch(live);
1454
+ // Contract check BEFORE record(): record() takes the next seq synchronously, so a violating
1455
+ // event that got numbered and then dropped would leave a hole in the sequence — the server's
1456
+ // watermark reads a hole as lost data. Drop, don't throw: one bad event must not poison the
1457
+ // session, and the diagnostic (agent, session, event) is what makes the violation debuggable.
1458
+ const rejection = validateWireEvent(e);
1459
+ if (rejection) {
1460
+ console.warn(`[acp:${live.agentId} agent:${live.agentKey}] dropped event '${wireEventName(e)}' for ${live.sessionRef}: ` +
1461
+ `${rejection.reason}${rejection.detail ? ` (${rejection.detail})` : ''}`);
1462
+ return;
1463
+ }
1464
+ this.record(live, (seq) => ({
1465
+ t: 'agent_event',
1466
+ sessionRef: live.sessionRef,
1467
+ seq,
1468
+ eventName: e.eventName,
1469
+ payload: e.payload,
1470
+ // Present only for a subagent's own output. The frame still rides this session's ref, journal
1471
+ // and seq — one watermark per ref (see `WireEvent.acpSessionId`); this field only says which
1472
+ // session on the far side the event belongs to.
1473
+ ...(e.acpSessionId ? { acpSessionId: e.acpSessionId } : {}),
1474
+ }));
1475
+ }
1476
+ onAgentTurnDone(live, r) {
1477
+ if (live.closed)
1478
+ return;
1479
+ this.tasks?.agentTurnFinished(live.sessionRef);
1480
+ this.touch(live);
1481
+ if (r.nativeSessionId)
1482
+ live.nativeSessionId = r.nativeSessionId;
1483
+ this.record(live, (seq) => ({
1484
+ t: 'agent_turn_done',
1485
+ sessionRef: live.sessionRef,
1486
+ seq,
1487
+ status: r.status,
1488
+ stopReason: r.stopReason,
1489
+ nativeSessionId: r.nativeSessionId ?? null,
1490
+ error: r.error ?? null,
1491
+ // Only on a failure the agent itself answered. This is the frame credentials that expired
1492
+ // mid-conversation arrive on — the agent answers the next `session/prompt` with
1493
+ // `auth_required` rather than ending the session — and it is also the busiest frame on this
1494
+ // channel, one per turn for the life of every session, which is why a completed turn carries
1495
+ // no table nothing reads.
1496
+ //
1497
+ // `status === 'failed'` on its own would be wrong, and wrong in the direction that hurts:
1498
+ // `AcpSession` synthesises that status on three paths the agent never answered (a prompt
1499
+ // after the read loop died, a connection that aborted mid-turn, an unmappable update), all of
1500
+ // them on a *live* session whose method table this daemon certainly holds. An agent process
1501
+ // crashing mid-turn is plausibly more common than a token expiring mid-turn, so pairing those
1502
+ // with `authMethods` would put more false login cards on screen than real ones. See
1503
+ // {@link TurnResult.agentRefused}.
1504
+ //
1505
+ // And `agentRefused` on its own is not enough either, which is what `errorCode` is here for:
1506
+ // an exceeded context window is a refusal the agent genuinely answered, and it is common.
1507
+ ...(r.status === 'failed' && r.agentRefused ? this.refusalFields(live.agentId, r.errorCode) : {}),
1508
+ }));
1509
+ }
1510
+ /**
1511
+ * Numbers a frame, writes it, then sends it. The seq is taken synchronously so callbacks are
1512
+ * numbered in arrival order; the write and the send ride the session's queue so they stay in that
1513
+ * order on the wire.
1514
+ */
1515
+ record(live, build) {
1516
+ const seq = live.journal.nextSeq();
1517
+ const frame = build(seq);
1518
+ void this.enqueue(live, async () => {
1519
+ try {
1520
+ await live.journal.append({ seq, frame });
1521
+ }
1522
+ catch (err) {
1523
+ // Losing durability is bad; losing the event outright is worse. Send it anyway — it just
1524
+ // will not survive a reconnect.
1525
+ console.error(`[agent] journal append failed for ${live.sessionRef}: ${errorMessage(err)}`);
1526
+ }
1527
+ this.emit(frame);
1528
+ });
1529
+ }
1530
+ /** Appends `op` to the session's serialized queue. The returned promise never rejects. */
1531
+ enqueue(live, op) {
1532
+ const next = live.writes.then(op).catch((err) => {
1533
+ console.error(`[agent] journal operation failed for ${live.sessionRef}: ${errorMessage(err)}`);
1534
+ });
1535
+ live.writes = next;
1536
+ return next;
1537
+ }
1538
+ async onClose(sessionRef) {
1539
+ // Closing a ref that is still spawning: nothing to close yet, so tombstone it — openSession
1540
+ // closes the session (and destroys its journal) the moment the bridge hands it over.
1541
+ //
1542
+ // Checking `opening` before `sessions` is only correct because nothing awaits between
1543
+ // openSession's `sessions.set(...)` and onOpen's finally clearing `opening`: a ref is never in
1544
+ // both maps at a point where an inbound frame could observe it. The journal replay onOpen added
1545
+ // — which does await — deliberately runs *after* that finally for exactly this reason.
1546
+ if (this.opening.has(sessionRef)) {
1547
+ this.closedDuringOpen.set(sessionRef, 'closed');
1548
+ return;
1549
+ }
1550
+ const live = this.sessions.get(sessionRef);
1551
+ if (!live)
1552
+ return;
1553
+ await this.retire(live, 'destroy');
1554
+ }
1555
+ /**
1556
+ * The teardown behind `agent_close`: stop emitting, unregister, kill the agent subprocess, and —
1557
+ * when
1558
+ * `journal` says so — delete the journal. Shared with {@link sweepIdle} so an orphan swept on the
1559
+ * timer leaves exactly as little behind as one the server closed properly, except for the file
1560
+ * itself (see {@link sweepIdle} for why that one differs).
1561
+ *
1562
+ * `journal: 'keep'` still rides the same queue and still publishes on {@link retiring}. The wait
1563
+ * is not about the unlink: the point is that this session's pending appends must have landed
1564
+ * before a reopen constructs a second {@link SessionJournal} on the same path, or the new
1565
+ * instance recovers its seq counter from a file that is still being written behind it.
1566
+ */
1567
+ async retire(live, journal) {
1568
+ const sessionRef = live.sessionRef;
1569
+ live.closed = true;
1570
+ this.sessions.delete(sessionRef);
1571
+ this.tasks?.unregisterAgentSession(sessionRef);
1572
+ // Queued behind the pending writes: an append that landed after the file was removed would
1573
+ // resurrect a journal for a session that no longer exists. Published on `retiring` for the whole
1574
+ // teardown, so a reopen of this ref parks until the file is actually gone.
1575
+ const journalDone = this.enqueue(live, async () => {
1576
+ if (journal === 'destroy')
1577
+ await live.journal.destroy();
1578
+ });
1579
+ const retired = journalDone.finally(() => {
1580
+ // Only clear our own entry: a later close of the same ref may already have replaced it.
1581
+ if (this.retiring.get(sessionRef) === retired)
1582
+ this.retiring.delete(sessionRef);
1583
+ });
1584
+ this.retiring.set(sessionRef, retired);
1585
+ try {
1586
+ await live.session.close();
1587
+ }
1588
+ catch (err) {
1589
+ console.error(`[agent] close failed for ${sessionRef}: ${errorMessage(err)}`);
1590
+ }
1591
+ await journalDone;
1592
+ }
1593
+ /**
1594
+ * The one place every outbound agent frame passes, and so the one place the wire contract can be
1595
+ * enforced for all of them at once.
1596
+ *
1597
+ * A frame the server cannot decode is not a lost frame — the server has no way to tell which
1598
+ * session or request the wreckage belonged to, so the damage lands wherever it falls. Per-agent
1599
+ * care at each field is what failed twice already: the deleted codex bridge was fixed for exactly
1600
+ * this and the deleted claude bridge shipped the same defect months later. Checking here covers
1601
+ * every agent, including the ones nobody has written an adapter for yet.
1602
+ *
1603
+ * The cost is one serialize-and-parse per frame, which streaming events pay too. That is the
1604
+ * price of the check being faithful to what the peer reads rather than to what this process
1605
+ * believes it built.
1606
+ */
1607
+ emit(frame) {
1608
+ const rejection = outboundAgentFrameRejection(frame);
1609
+ if (rejection) {
1610
+ console.error(`[agent] refusing to send a malformed ${frame.t}: ${rejection}`);
1611
+ // Nothing is sent when the frame answered no request: an unreadable frame is worse than a
1612
+ // missing one, and the server's own scoping decides what a gap costs.
1613
+ const reply = outboundFailureReply(frame, rejection);
1614
+ if (reply)
1615
+ this.send?.(reply);
1616
+ return;
1617
+ }
1618
+ if (this.send === null) {
1619
+ if (this.mutedDrops === 0) {
1620
+ this.mutedSinceMs = Date.now();
1621
+ console.warn(`[agent] sender is unbound; dropping ${frame.t} and everything after it until a reconnect rebinds it`);
1622
+ }
1623
+ this.mutedDrops++;
1624
+ return;
1625
+ }
1626
+ this.send(frame);
1627
+ }
1628
+ }