@platless/axom-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -0
- package/dist/args.js +29 -0
- package/dist/cli.js +76 -0
- package/dist/commands/auth.js +52 -0
- package/dist/commands/execute.js +40 -0
- package/dist/commands/guide.js +38 -0
- package/dist/commands/record.js +99 -0
- package/dist/commands/skills.js +69 -0
- package/dist/commands/struct.js +50 -0
- package/dist/commands/update.js +88 -0
- package/dist/commands/whoami.js +8 -0
- package/dist/commands/workstation.js +171 -0
- package/dist/config.js +131 -0
- package/dist/credentialBroker.js +340 -0
- package/dist/credentials.js +211 -0
- package/dist/install/agentsDir.js +35 -0
- package/dist/install/claude/commands.js +89 -0
- package/dist/install/claude/materialize.js +42 -0
- package/dist/install/installer.js +45 -0
- package/dist/install/targets/claude.js +154 -0
- package/dist/mcp.js +152 -0
- package/dist/output.js +37 -0
- package/dist/workstation/acp/acpAgents.js +1287 -0
- package/dist/workstation/acp/acpClient.js +691 -0
- package/dist/workstation/acp/acpSession.js +1047 -0
- package/dist/workstation/acp/adoption.js +396 -0
- package/dist/workstation/acp/auth.js +80 -0
- package/dist/workstation/acp/authUrlSniffer.js +52 -0
- package/dist/workstation/acp/binaryInstall.js +411 -0
- package/dist/workstation/acp/configOptions.js +257 -0
- package/dist/workstation/acp/contract.js +494 -0
- package/dist/workstation/acp/draftInterceptor.js +154 -0
- package/dist/workstation/acp/draftSubagents.js +38 -0
- package/dist/workstation/acp/installErrors.js +89 -0
- package/dist/workstation/acp/installProbe.js +363 -0
- package/dist/workstation/acp/permission.js +327 -0
- package/dist/workstation/acp/registry/catalog.js +29 -0
- package/dist/workstation/acp/registry/launch.js +128 -0
- package/dist/workstation/acp/registry/registryClient.js +160 -0
- package/dist/workstation/acp/registry/registryTypes.js +8 -0
- package/dist/workstation/acp/sessionUpdateMapper.js +602 -0
- package/dist/workstation/acp/test/fakeAgent.js +435 -0
- package/dist/workstation/acp/test/sessionUpdateFixtures.js +165 -0
- package/dist/workstation/acp/timedCommand.js +47 -0
- package/dist/workstation/acp/uvxInstall.js +248 -0
- package/dist/workstation/acp/validate.js +134 -0
- package/dist/workstation/agentSessions.js +1628 -0
- package/dist/workstation/channels/agent.js +96 -0
- package/dist/workstation/channels/computer.js +91 -0
- package/dist/workstation/channels/management.js +231 -0
- package/dist/workstation/channels/url.js +42 -0
- package/dist/workstation/config.js +47 -0
- package/dist/workstation/connection/encryptedChannel.js +261 -0
- package/dist/workstation/connection/retryPolicy.js +14 -0
- package/dist/workstation/connection/supervisor.js +353 -0
- package/dist/workstation/crypto.js +63 -0
- package/dist/workstation/daemon.js +225 -0
- package/dist/workstation/daemonctl.js +95 -0
- package/dist/workstation/exec.js +112 -0
- package/dist/workstation/journal.js +257 -0
- package/dist/workstation/names.js +17 -0
- package/dist/workstation/oauth/browser.js +24 -0
- package/dist/workstation/oauth/deviceAuthorization.js +198 -0
- package/dist/workstation/osinfo.js +4 -0
- package/dist/workstation/protocol/agent.js +606 -0
- package/dist/workstation/protocol/common.js +136 -0
- package/dist/workstation/protocol/computer.js +31 -0
- package/dist/workstation/protocol/management.js +116 -0
- package/dist/workstation/register.js +42 -0
- package/dist/workstation/service/installer.js +362 -0
- package/dist/workstation/service/launchd.js +46 -0
- package/dist/workstation/service/loginShell.js +36 -0
- package/dist/workstation/service/systemd.js +48 -0
- package/dist/workstation/service/types.js +73 -0
- package/dist/workstation/supervision.js +52 -0
- package/dist/workstation/systemMetrics.js +271 -0
- package/dist/workstation/update/artifact.js +61 -0
- package/dist/workstation/update/coordinator.js +240 -0
- package/dist/workstation/update/npmInstaller.js +91 -0
- package/dist/workstation/update/redact.js +36 -0
- package/dist/workstation/update/runUpdate.js +215 -0
- package/dist/workstation/update/stateStore.js +87 -0
- package/dist/workstation/update/taskRegistry.js +142 -0
- package/dist/workstation/update/types.js +3 -0
- package/dist/workstation/update/watchdog.js +311 -0
- package/dist/workstation/version.js +12 -0
- package/package.json +39 -0
- package/skills/axom/SKILL.md +83 -0
- package/skills/axom-definition/SKILL.md +74 -0
- package/skills/axom-platform/SKILL.md +78 -0
|
@@ -0,0 +1,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
|
+
}
|