@platless/axom-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +64 -0
  2. package/dist/args.js +29 -0
  3. package/dist/cli.js +76 -0
  4. package/dist/commands/auth.js +52 -0
  5. package/dist/commands/execute.js +40 -0
  6. package/dist/commands/guide.js +38 -0
  7. package/dist/commands/record.js +99 -0
  8. package/dist/commands/skills.js +69 -0
  9. package/dist/commands/struct.js +50 -0
  10. package/dist/commands/update.js +88 -0
  11. package/dist/commands/whoami.js +8 -0
  12. package/dist/commands/workstation.js +171 -0
  13. package/dist/config.js +131 -0
  14. package/dist/credentialBroker.js +340 -0
  15. package/dist/credentials.js +211 -0
  16. package/dist/install/agentsDir.js +35 -0
  17. package/dist/install/claude/commands.js +89 -0
  18. package/dist/install/claude/materialize.js +42 -0
  19. package/dist/install/installer.js +45 -0
  20. package/dist/install/targets/claude.js +154 -0
  21. package/dist/mcp.js +152 -0
  22. package/dist/output.js +37 -0
  23. package/dist/workstation/acp/acpAgents.js +1287 -0
  24. package/dist/workstation/acp/acpClient.js +691 -0
  25. package/dist/workstation/acp/acpSession.js +1047 -0
  26. package/dist/workstation/acp/adoption.js +396 -0
  27. package/dist/workstation/acp/auth.js +80 -0
  28. package/dist/workstation/acp/authUrlSniffer.js +52 -0
  29. package/dist/workstation/acp/binaryInstall.js +411 -0
  30. package/dist/workstation/acp/configOptions.js +257 -0
  31. package/dist/workstation/acp/contract.js +494 -0
  32. package/dist/workstation/acp/draftInterceptor.js +154 -0
  33. package/dist/workstation/acp/draftSubagents.js +38 -0
  34. package/dist/workstation/acp/installErrors.js +89 -0
  35. package/dist/workstation/acp/installProbe.js +363 -0
  36. package/dist/workstation/acp/permission.js +327 -0
  37. package/dist/workstation/acp/registry/catalog.js +29 -0
  38. package/dist/workstation/acp/registry/launch.js +128 -0
  39. package/dist/workstation/acp/registry/registryClient.js +160 -0
  40. package/dist/workstation/acp/registry/registryTypes.js +8 -0
  41. package/dist/workstation/acp/sessionUpdateMapper.js +602 -0
  42. package/dist/workstation/acp/test/fakeAgent.js +435 -0
  43. package/dist/workstation/acp/test/sessionUpdateFixtures.js +165 -0
  44. package/dist/workstation/acp/timedCommand.js +47 -0
  45. package/dist/workstation/acp/uvxInstall.js +248 -0
  46. package/dist/workstation/acp/validate.js +134 -0
  47. package/dist/workstation/agentSessions.js +1628 -0
  48. package/dist/workstation/channels/agent.js +96 -0
  49. package/dist/workstation/channels/computer.js +91 -0
  50. package/dist/workstation/channels/management.js +231 -0
  51. package/dist/workstation/channels/url.js +42 -0
  52. package/dist/workstation/config.js +47 -0
  53. package/dist/workstation/connection/encryptedChannel.js +261 -0
  54. package/dist/workstation/connection/retryPolicy.js +14 -0
  55. package/dist/workstation/connection/supervisor.js +353 -0
  56. package/dist/workstation/crypto.js +63 -0
  57. package/dist/workstation/daemon.js +225 -0
  58. package/dist/workstation/daemonctl.js +95 -0
  59. package/dist/workstation/exec.js +112 -0
  60. package/dist/workstation/journal.js +257 -0
  61. package/dist/workstation/names.js +17 -0
  62. package/dist/workstation/oauth/browser.js +24 -0
  63. package/dist/workstation/oauth/deviceAuthorization.js +198 -0
  64. package/dist/workstation/osinfo.js +4 -0
  65. package/dist/workstation/protocol/agent.js +606 -0
  66. package/dist/workstation/protocol/common.js +136 -0
  67. package/dist/workstation/protocol/computer.js +31 -0
  68. package/dist/workstation/protocol/management.js +116 -0
  69. package/dist/workstation/register.js +42 -0
  70. package/dist/workstation/service/installer.js +362 -0
  71. package/dist/workstation/service/launchd.js +46 -0
  72. package/dist/workstation/service/loginShell.js +36 -0
  73. package/dist/workstation/service/systemd.js +48 -0
  74. package/dist/workstation/service/types.js +73 -0
  75. package/dist/workstation/supervision.js +52 -0
  76. package/dist/workstation/systemMetrics.js +271 -0
  77. package/dist/workstation/update/artifact.js +61 -0
  78. package/dist/workstation/update/coordinator.js +240 -0
  79. package/dist/workstation/update/npmInstaller.js +91 -0
  80. package/dist/workstation/update/redact.js +36 -0
  81. package/dist/workstation/update/runUpdate.js +215 -0
  82. package/dist/workstation/update/stateStore.js +87 -0
  83. package/dist/workstation/update/taskRegistry.js +142 -0
  84. package/dist/workstation/update/types.js +3 -0
  85. package/dist/workstation/update/watchdog.js +311 -0
  86. package/dist/workstation/version.js +12 -0
  87. package/package.json +39 -0
  88. package/skills/axom/SKILL.md +83 -0
  89. package/skills/axom-definition/SKILL.md +74 -0
  90. package/skills/axom-platform/SKILL.md +78 -0
@@ -0,0 +1,396 @@
1
+ /**
2
+ * "Adoption" — a user picking up a native agent session that already exists on this machine and
3
+ * having its history replayed into Axom's timeline. Two ACP methods, both **optional on the
4
+ * agent** and gated by `AcpConnection.capabilities` (Task 3): {@link listNativeSessions} calls
5
+ * `session/list`, gated on `capabilities.list`; {@link replayNativeSession} calls `session/load`,
6
+ * gated on `capabilities.loadSession`. Both degrade to "nothing to show" rather than an error when
7
+ * unsupported — see the doc comment on each for why, and the deleted `bridges/types.ts`'s
8
+ * `AgentBridge.listNativeSessions`/`BridgeSession.backfill` KDoc for the contract this carries
9
+ * forward verbatim ("the directory hides the grouping, not an error").
10
+ *
11
+ * ## `session/resume` is deliberately NOT used here — read this before "fixing" it back
12
+ *
13
+ * The plan text that spawned this task says "`session/resume(replayFrom)`". Both do not exist in
14
+ * `@agentclientprotocol/sdk@1.4.0`'s stable v1 entry point: `ResumeSessionRequest` has no
15
+ * `replayFrom` field (that name only appears under `@agentclientprotocol/sdk/experimental/v2`,
16
+ * which Global Constraint 1 forbids importing), and — more fundamentally — `resumeSession`'s own
17
+ * doc comment in `dist/acp.d.ts` says it "Resumes an existing session **without** replaying the
18
+ * message history (**unlike** `session/load`)". `session/load` (`loadSession`, gated by the
19
+ * top-level `AgentCapabilities.loadSession: boolean`, not `sessionCapabilities`) is the ACP v1
20
+ * method whose own doc comment says the agent should "Stream the entire conversation history back
21
+ * to the client via notifications" — i.e. it is the actual replay mechanism this file needs.
22
+ * `session/resume` exists for a client that already has a session's history cached elsewhere and
23
+ * wants to keep working without paying for a redundant replay; Axom has never seen an adopted
24
+ * session's history before, so that case never applies here.
25
+ *
26
+ * Verified both target adapters actually support the method this file calls: `claude-agent-acp`
27
+ * 0.70.0 and `codex-acp` 1.6.2 both declare `agentCapabilities.loadSession: true` and both
28
+ * implement `session/load` (`.superpowers/research/acp-sdk-registry.md`, "VERIFIED" section).
29
+ *
30
+ * ## Observing the replayed `session/update` notifications
31
+ *
32
+ * Unlike `session/new` (Task 4's `AcpSession`, built on the SDK's `ClientContext.buildSession(...)`
33
+ * convenience wrapper), the SDK has no `SessionBuilder`-equivalent helper for `session/load` — the
34
+ * only public, supported way to see raw `session/update` notifications outside of that helper is a
35
+ * `ClientApp`-level `onNotification(methods.client.session.update, ...)` handler (the pattern the
36
+ * SDK's own `acp.test.js` uses throughout for exactly this). That has to be registered before
37
+ * `ClientApp.connect()` copies its handler table (`acpClient.ts`'s `ConnectAgentOptions.configure`
38
+ * doc comment; `permission.ts`'s file doc comment says the same for `session/request_permission`).
39
+ * {@link createReplayCapture}/{@link ReplayCapture.register} exist for that: a caller passes
40
+ * `capture.register` into `connectAgent`'s `configure` hook — composable with `permission.ts`'s
41
+ * `registerPermissionHandlers` in the same hook, since `configure` is one function a caller can do
42
+ * several registrations inside of — and later, once the connection exists, passes the *same*
43
+ * `capture` into {@link replayNativeSession}. The registered handler sees *every* `session/update`
44
+ * notification on the connection, not just the one being replayed (verified against
45
+ * `SessionUpdateRouter.handleMessage`, which always returns `Handled.no(message)` — it never consumes
46
+ * the message, so dispatch keeps walking the handler chain to whatever else is registered, including
47
+ * the SDK's own internal `session/new`-scoped router for a live `AcpSession` on the *same* connection).
48
+ * That is exactly the axis review found this file getting wrong in fix round 1: a stray comment here
49
+ * used to say this "never interferes with a live `AcpSession` on some other connection" — true, but
50
+ * irrelevant, since the real conflict is a live `AcpSession` on the *same* connection (the shape a
51
+ * caller composing this with `registerPermissionHandlers` in one `configure` hook produces routinely).
52
+ * Without gating, the handler above would buffer *every* session's updates forever — only
53
+ * {@link ReplayCapture.take} ever releases a buffered entry, and it is only ever called for the one
54
+ * `sessionId` being replayed, so any other session's notifications arriving on this connection (most
55
+ * concretely: a live `AcpSession` opened on the same connection right after adoption, which Task 8
56
+ * needs) would accumulate unbounded for the life of the connection. {@link ReplayCapture.arm} exists
57
+ * to close that: the handler now only buffers a `sessionId` that has been armed, {@link
58
+ * replayNativeSession} arms its target *before* sending `session/load`, and `take()` disarms on its
59
+ * way out — so the buffer's upper bound is whatever is currently being replayed, never every session
60
+ * this connection has ever seen an update for.
61
+ *
62
+ * ## Replay goes through the same mapping and folding the live path uses — reimplemented, not shared
63
+ *
64
+ * {@link replayNativeSession} maps every replayed `SessionUpdate` through the exact same
65
+ * `mapSessionUpdate` (`sessionUpdateMapper.ts`) the live path (`AcpSession`) uses, and every mapped
66
+ * event through the same `validateWireEvent` (`validate.ts`) outbound check — so it can never
67
+ * emit anything the server can't decode, and (per `mapSessionUpdate`'s own contract) can never emit
68
+ * `final_result`. What it does NOT reuse is `AcpSession`'s own `agent_message` synthesis /
69
+ * synthesis: that lives in `AcpSession` as push-based, event-sink-driven instance state (see its
70
+ * class doc comment), a different execution shape than this file's pull-based `AsyncGenerator` over
71
+ * an already-complete update list. Task 4's report flagged this by name: "the replay path needs its
72
+ * own equivalent folding and message-completion logic — do not assume the live path's state machine
73
+ * is doing it for you." {@link foldReplayEvents} is that equivalent, reimplemented as a single pass
74
+ * over a finished array instead of instance fields mutated across live callbacks.
75
+ *
76
+ * **The `user_message` half of that duplication is gone.** This file used to carry its own copy of
77
+ * `AcpSession.foldUserMessageChunk`, and the two had already drifted: replay flushed a buffered user
78
+ * message on *any* intervening event, the live path only on a different `messageId` or turn end, so
79
+ * the same transcript produced a different number of cards depending on which path replayed it.
80
+ * Phase 7 moved the fold to `CoworkEngine`, which already buffered `agent_message_chunk` by exactly
81
+ * the ACP rule that covers all three chunk kinds (`ContentChunk`, `types.gen.d.ts:3456`).
82
+ * `user_message_chunk` now passes through here untouched, and `ExternalAgentEventApplier`'s
83
+ * `onBackfillDone` is what closes the last buffered message.
84
+ */
85
+ import { methods } from '@agentclientprotocol/sdk';
86
+ import { validateWireEvent } from './validate.js';
87
+ import { mapSessionUpdate, SessionMapperState } from './sessionUpdateMapper.js';
88
+ /** `NativeSessionInfo.title` is truncated to this many characters — mirrors the deleted `claudeCode/adoption.ts`'s display-safety guard (`TITLE_MAX_LENGTH`). */
89
+ const TITLE_MAX_LENGTH = 80;
90
+ /** `SessionInfo.updatedAt` is an ISO 8601 string or `null`/absent; missing or unparseable sorts as oldest (0) rather than throwing. */
91
+ function parseUpdatedAt(value) {
92
+ if (!value)
93
+ return 0;
94
+ const parsed = Date.parse(value);
95
+ return Number.isNaN(parsed) ? 0 : parsed;
96
+ }
97
+ /**
98
+ * List an agent's native sessions scoped to `query.cwd`, newest first.
99
+ *
100
+ * When the agent doesn't advertise `sessionCapabilities.list` (`connection.capabilities.list`),
101
+ * this returns an empty result **without ever calling `session/list`** — matching the deleted
102
+ * `AgentBridge.listNativeSessions?`'s contract verbatim ("the directory hides the grouping, not an
103
+ * error"). Not calling the method at all (rather than calling it and treating any error as "empty")
104
+ * matters because "honour the advertised capability, not the router" (Task 3/5's own rule) cuts both
105
+ * ways: `codex-acp` routes `session/fork` without declaring it, so a client that called unadvertised
106
+ * methods anyway would be relying on exactly the coupling that rule exists to rule out.
107
+ */
108
+ export async function listNativeSessions(connection, query) {
109
+ if (!connection.capabilities.list)
110
+ return { sessions: [] };
111
+ const response = await connection.connection.agent.request(methods.agent.session.list, {
112
+ ...(query.cwd ? { cwd: query.cwd } : {}),
113
+ ...(query.cursor ? { cursor: query.cursor } : {}),
114
+ });
115
+ const sessions = response.sessions
116
+ .map((info) => ({
117
+ nativeSessionId: info.sessionId,
118
+ title: (info.title ?? '').slice(0, TITLE_MAX_LENGTH),
119
+ updatedAtEpochMs: parseUpdatedAt(info.updatedAt),
120
+ cwd: info.cwd,
121
+ }))
122
+ .sort((a, b) => b.updatedAtEpochMs - a.updatedAtEpochMs);
123
+ return response.nextCursor != null ? { sessions, nextCursor: response.nextCursor } : { sessions };
124
+ }
125
+ export function createReplayCapture() {
126
+ const armed = new Set();
127
+ const buffered = new Map();
128
+ const listeners = new Map();
129
+ return {
130
+ register(app) {
131
+ app.onNotification(methods.client.session.update, (ctx) => {
132
+ // Armed wins over listening: while a session/load replay is in flight, its history must land
133
+ // in the buffer rather than in a live session's read loop — see `listen`'s doc comment.
134
+ if (armed.has(ctx.params.sessionId)) {
135
+ const list = buffered.get(ctx.params.sessionId) ?? [];
136
+ list.push(ctx.params.update);
137
+ buffered.set(ctx.params.sessionId, list);
138
+ return;
139
+ }
140
+ // Neither armed nor listened to: dropped, so this handler cannot accumulate every session
141
+ // the connection has ever seen an update for (see the file doc comment).
142
+ listeners.get(ctx.params.sessionId)?.(ctx.params.update);
143
+ });
144
+ },
145
+ arm(sessionId) {
146
+ armed.add(sessionId);
147
+ },
148
+ take(sessionId) {
149
+ armed.delete(sessionId);
150
+ const list = buffered.get(sessionId) ?? [];
151
+ buffered.delete(sessionId);
152
+ return list;
153
+ },
154
+ count(sessionId) {
155
+ return buffered.get(sessionId)?.length ?? 0;
156
+ },
157
+ listen(sessionId, sink) {
158
+ listeners.set(sessionId, sink);
159
+ return () => {
160
+ // Only clear our own entry: a later listen() for the same sessionId may already have
161
+ // replaced it, and unsubscribing then would silence the session that is actually running.
162
+ if (listeners.get(sessionId) === sink)
163
+ listeners.delete(sessionId);
164
+ };
165
+ },
166
+ };
167
+ }
168
+ /** `payload[key]` narrowed to `string`, or `null` — same helper `acpSession.ts` uses for the identical reason (`payload` is `Record<string, unknown>` by construction). */
169
+ function stringField(payload, key) {
170
+ const value = payload[key];
171
+ return typeof value === 'string' ? value : null;
172
+ }
173
+ /**
174
+ * Map a completed list of replayed `SessionUpdate`s to wire events, applying the same
175
+ * message-completion synthesis {@link AcpSession} applies to the live stream — see the file doc
176
+ * comment for why this is a reimplementation, not a shared call, and `acpSession.ts`'s
177
+ * `trackMessageBoundary`/`flushMessageCompleted` for the rule being mirrored:
178
+ *
179
+ * - `agent_message` is synthesized whenever the streaming `agent_message_chunk`/`agent_thought_chunk`
180
+ * `messageId` changes, and once more at the very end of the list (there is no live "turn end"
181
+ * signal to hang this off during replay — a whole adopted session's history has no `PromptResponse`
182
+ * in it at all — so "end of the replayed list" is this function's equivalent of `AcpSession.finishTurn`'s
183
+ * unconditional turn-end flush).
184
+ * - A `user_message_chunk` still forces that synthesis: a user turn beginning means whatever the
185
+ * assistant was streaming has necessarily already ended. The chunk itself is emitted verbatim —
186
+ * folding it is the server's job now (see the file doc comment).
187
+ *
188
+ * Exported directly (not just through {@link replayNativeSession}) so its synthesis behavior can be
189
+ * pinned by unit tests with no process spawning involved — the process-spawning tests are reserved
190
+ * for what only a real subprocess can prove (capability gating, the actual RPC shape, ordering
191
+ * against the real `session/load` response).
192
+ */
193
+ export function foldReplayEvents(updates) {
194
+ const events = [];
195
+ // Fresh per replay, not shared with the live session that may follow: this call reconstructs one
196
+ // adopted session's history from its own `tool_call`s, and a registry carried in from elsewhere
197
+ // could name a call this history never created.
198
+ const mapperState = new SessionMapperState();
199
+ let sawMessageContent = false;
200
+ let currentMessageId = null;
201
+ function flushMessageCompleted() {
202
+ if (!sawMessageContent)
203
+ return;
204
+ events.push({ eventName: 'agent_message', payload: { messageId: currentMessageId ?? 'replay' } });
205
+ sawMessageContent = false;
206
+ currentMessageId = null;
207
+ }
208
+ for (const update of updates) {
209
+ for (const event of mapSessionUpdate(update, mapperState)) {
210
+ if (event.eventName === 'user_message_chunk') {
211
+ // A user turn beginning means whatever the assistant was streaming has necessarily already
212
+ // ended — flush it first, mirroring AcpSession.finishTurn's ordering at a live turn
213
+ // boundary (just the mirror image here, since replay sees the *next* user turn rather than
214
+ // an explicit turn-end signal). The chunk itself then goes through verbatim: the fold that
215
+ // used to live here is the server's now.
216
+ flushMessageCompleted();
217
+ events.push(event);
218
+ continue;
219
+ }
220
+ if (event.eventName === 'agent_message_chunk' || event.eventName === 'agent_thought_chunk') {
221
+ const messageId = stringField(event.payload, 'messageId');
222
+ if (sawMessageContent && messageId !== null && currentMessageId !== null && messageId !== currentMessageId) {
223
+ flushMessageCompleted();
224
+ }
225
+ sawMessageContent = true;
226
+ if (messageId !== null)
227
+ currentMessageId = messageId;
228
+ }
229
+ events.push(event);
230
+ }
231
+ }
232
+ flushMessageCompleted();
233
+ return events;
234
+ }
235
+ /**
236
+ * Wait until the `session/update` notifications an agent sent *before* answering a request have
237
+ * actually been **dispatched**, which is **not** implied by that request having responded.
238
+ *
239
+ * The SDK handles the two message kinds on different schedules (`jsonrpc.js`'s
240
+ * `receiveWireMessage`): a response is applied **synchronously** via `handleResponse` the moment the
241
+ * reader pulls it off the stream, while a notification is handed to `processIncomingMessage`, which
242
+ * is *not* awaited by the read loop and which awaits each registered handler in turn. So even though
243
+ * the agent writes every `session/update` before its response — and the reader therefore reads them
244
+ * first — the response's `await` can resume before a single one of our handlers has run. Measured
245
+ * directly on both request paths that have this shape:
246
+ *
247
+ * - `session/load` ({@link replayNativeSession}): the order observed was `response`, then the
248
+ * updates, so `take()` on the spot returned a partial history, or none at all.
249
+ * - `session/prompt` (`acpSession.ts`'s `AcpSession.attach`): the turn's terminal `stop` message was
250
+ * enqueued **ahead of** the tail of the same turn's deltas — `agent_message` and the
251
+ * `TurnResult` landing mid-stream, with the remaining text arriving as orphans after the item the
252
+ * server had already closed.
253
+ *
254
+ * The difference between "works" and "reorders" is nothing but how many handlers happen to be
255
+ * registered on the connection — which is why the same code looked correct until the manager wired
256
+ * permission handlers onto the same `ClientApp`.
257
+ *
258
+ * The one guarantee that survives is ordering *into* the dispatcher: by the time the response is
259
+ * applied, every preceding notification's dispatch has been **started**. This waits for a macrotask
260
+ * boundary, which drains a chain of synchronous handlers' microtask continuations, and keeps waiting
261
+ * while `count()` is still growing.
262
+ *
263
+ * ## This is a heuristic. It is bounded **and** it truncates — three ways, all measured
264
+ *
265
+ * Nothing enforces the premise it rests on, so read these as live failure modes rather than as
266
+ * theoretical corners:
267
+ *
268
+ * | Condition | Measured outcome |
269
+ * |---|---|
270
+ * | Any handler on the chain awaits a **macrotask** (even `setTimeout(…, 0)`) | 6 updates → **0** replayed |
271
+ * | The agent answers **before** it has finished streaming | 6 updates → **4** replayed |
272
+ * | The stream outlasts `maxRounds` | 3000 updates → **2086** replayed |
273
+ *
274
+ * The first is why "every handler on this chain is synchronous" is a **prerequisite**, and why it is
275
+ * written down at the place that actually establishes it — `acpAgents.ts`'s `AcpAgents.open`
276
+ * `configure` hook, the one site where handlers get registered — rather than only here. A function
277
+ * cannot enforce a property of a registration it never sees.
278
+ *
279
+ * The second is outside this function's reach entirely: an agent that responds mid-stream has broken
280
+ * the ordering guarantee this is built on, and no amount of waiting recovers it.
281
+ *
282
+ * The third is `maxRounds`, the backstop against an agent that keeps streaming forever — a wait with
283
+ * no exit would hang the whole open. It is reachable in practice, so hitting it is **logged** rather
284
+ * than returned quietly: that diagnostic is the whole line between "bounded degradation" and
285
+ * "invisible data loss", and it is the only one of the three this function can see happening to
286
+ * itself.
287
+ *
288
+ * `count` is supplied by the caller rather than read off a `ReplayCapture` because the two callers
289
+ * count different things: the replay path counts buffered updates for one armed `sessionId`, the
290
+ * live path counts updates delivered into an attached session's queue.
291
+ */
292
+ export async function settleUpdates(sessionId, count, maxRounds = 10) {
293
+ let previous = -1;
294
+ for (let round = 0; round < maxRounds; round += 1) {
295
+ const before = count();
296
+ // setImmediate, not setTimeout(0): it runs in the check phase, after the poll phase has drained
297
+ // whatever the transport already had readable, so one turn covers both the microtasks and any
298
+ // further messages sitting in the socket buffer.
299
+ await new Promise((resolve) => setImmediate(resolve));
300
+ const after = count();
301
+ if (after === before && after === previous)
302
+ return;
303
+ previous = before;
304
+ }
305
+ // Falling out of the loop is not "settled" — it is "gave up while the agent was still streaming".
306
+ // Whatever arrives from here on may be reordered against the turn/replay boundary this call was
307
+ // holding, or dropped outright. Never silent: a session that quietly loses the tail of its history
308
+ // is indistinguishable from an agent that had no history to give.
309
+ console.error(`[acp] session/update stream for '${sessionId}' was still growing after ${maxRounds} settle rounds ` +
310
+ `(${count()} updates so far) — continuing anyway; later updates may be reordered or lost.`);
311
+ }
312
+ /**
313
+ * Replay one native session's history as wire events via `session/load` — see the file doc comment
314
+ * for why `session/load`, not `session/resume`.
315
+ *
316
+ * When the agent doesn't advertise `agentCapabilities.loadSession` (`connection.capabilities.loadSession`),
317
+ * this yields nothing **without ever calling `session/load`** — same "hidden grouping, not an error"
318
+ * contract as {@link listNativeSessions}, applied to the capability that actually gates replay.
319
+ *
320
+ * Every yielded event has already gone through {@link foldReplayEvents} (same mapping the live path
321
+ * uses, plus the same `agent_message` synthesis) and `validateWireEvent` (same outbound check the live path's
322
+ * `permission_request` events go through) — an event that fails validation is dropped with a
323
+ * diagnostic, never thrown, matching `validate.ts`'s own "drop + diagnostic, never an
324
+ * exception" design. `mapSessionUpdate` never produces `final_result` (Task 4's contract, unchanged
325
+ * here since this function adds no new event-producing path of its own) and `foldReplayEvents` adds
326
+ * no new event names beyond what `mapSessionUpdate` and this file's own `agent_message` synthesis
327
+ * already produce — both already appear in the server's understood vocabulary
328
+ * (`sessionUpdateMapper.test.ts`'s `SERVER_UNDERSTOOD_EVENT_NAMES`), so nothing here needs to extend
329
+ * it.
330
+ *
331
+ * ## `session/load` also activates the session, not just replays it — and Task 8 relies on that
332
+ *
333
+ * The `session/load` request below sends `mcpServers: []`. That is not incidental — `session/load`
334
+ * is not a read-only "give me the history" call: per its own spec, the agent restores this
335
+ * `sessionId` as its **current** session, the same way `session/new` would, just with history
336
+ * attached instead of starting empty.
337
+ *
338
+ * Task 8 resolved the fork this once handed forward: **Axom keeps prompting on
339
+ * `params.nativeSessionId`, the session `session/load` just activated**, rather than opening a fresh
340
+ * `session/new` and treating the replay as read-only history. `agentSessions.ts` receives a
341
+ * `nativeSessionId` on reconnect as well as on adoption (the server persists it and hands it back),
342
+ * so "open a new session and replay read-only" would silently drop the agent's own context on every
343
+ * single reconnect — a regression against the deleted Claude Code bridge, which really did resume.
344
+ * `AcpSession.attach` (`acpSession.ts`) is what that costs: the SDK's `ActiveSession` has a private
345
+ * constructor reachable only through `SessionBuilder.start()`, i.e. only through `session/new`, so
346
+ * driving an already-loaded `sessionId` means hand-building the same small `DrivenSession` surface
347
+ * around raw `session/prompt` / `session/cancel` requests and {@link ReplayCapture.listen}.
348
+ *
349
+ * `mcpServers: []` is consistent with the other path rather than a shortfall of this one:
350
+ * `AcpSession.open`'s `buildSession(cwd).start()` attaches no MCP servers either (`OpenSessionOptions`
351
+ * carries only `cwd`), because Axom does not configure MCP servers for external agents at all.
352
+ */
353
+ export async function* replayNativeSession(connection, capture, params) {
354
+ if (!connection.capabilities.loadSession)
355
+ return;
356
+ // Arm before requesting — the agent may start sending session/update notifications for this
357
+ // sessionId as soon as it receives the request, and the capture's handler drops anything for a
358
+ // sessionId that isn't armed yet (see ReplayCapture.arm's doc comment).
359
+ capture.arm(params.nativeSessionId);
360
+ // The transport diverts updates for a session it does not know is handled here, because the SDK's
361
+ // router would otherwise drop them on the floor. During a replay this capture *is* the handler,
362
+ // so the claim has to be made explicitly — and withdrawn in the same `finally` that disarms.
363
+ const unmarkSession = connection.markSessionKnown(params.nativeSessionId);
364
+ let updates;
365
+ try {
366
+ const response = await connection.connection.agent.request(methods.agent.session.load, {
367
+ sessionId: params.nativeSessionId,
368
+ cwd: params.cwd,
369
+ mcpServers: [],
370
+ });
371
+ params.onLoaded?.(response);
372
+ // The response resolving is NOT the end of the replay — see {@link settleUpdates} for the SDK
373
+ // scheduling that makes `take()` on the spot return a partial history.
374
+ await settleUpdates(params.nativeSessionId, () => capture.count(params.nativeSessionId));
375
+ }
376
+ finally {
377
+ unmarkSession();
378
+ // In a `finally` because `arm()` has no other exit. A rejected `session/load` — a stale id the
379
+ // agent has since deleted, the case `AcpAgents.startSession` catches and falls back from — used
380
+ // to throw straight past `take()`, leaving the id armed and whatever had already been buffered
381
+ // for it held until the connection died. That contradicts this file's own claim that "the
382
+ // buffer's upper bound is whatever is currently being replayed": the fallback opens a *different*
383
+ // session id, so nothing would ever come back to take this one. `take()` is both halves at once
384
+ // (release the buffer, disarm), which is why the failure path calls the same function the
385
+ // success path does rather than a disarm of its own.
386
+ updates = capture.take(params.nativeSessionId);
387
+ }
388
+ for (const event of foldReplayEvents(updates)) {
389
+ const rejection = validateWireEvent(event);
390
+ if (rejection) {
391
+ console.error(`[acp] dropping a replayed event that fails validateWireEvent: ${rejection.reason} (session ${params.nativeSessionId})`);
392
+ continue;
393
+ }
394
+ yield event;
395
+ }
396
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * ACP authentication: `authenticate({ methodId })`.
3
+ *
4
+ * The method is `authenticate`, **not** `auth/login` — that spelling appears nowhere in
5
+ * `@agentclientprotocol/sdk@1.4.0`; the spec draft this plan started from said otherwise and has
6
+ * been corrected (see the plan's "已核实的 SDK 事实").
7
+ *
8
+ * `methodId` must come from the agent's own advertised `authMethods` (surfaced on `AcpConnection` by
9
+ * Task 3): the ACP spec's `AuthMethodTerminal` doc comment says the client "MUST NOT pass this
10
+ * method to `authenticate`" — a terminal method carries its own `args`/`env` for a TUI the client
11
+ * would have to run as a *separate interactive process*, not a JSON-RPC round trip. So
12
+ * {@link AcpAuth.authenticate} refuses two shapes of bad input before ever writing to the wire: a
13
+ * `methodId` the agent never advertised, and one it advertised as `type: 'terminal'`.
14
+ *
15
+ * ## No authentication *state* lives here, deliberately
16
+ *
17
+ * There is no registry-sourced auth state (the plan's "已核实的 registry 事实" confirms the registry
18
+ * carries no auth fields at all) and ACP v1 has no "am I authenticated?" query. The only positive
19
+ * evidence anywhere in this client is a session an agent accepted, which `acpAgents.ts` records as
20
+ * `AuthObservation.authenticated?: true` — **true or absent, never `false`**.
21
+ *
22
+ * This class used to keep a local flag alongside that: it started `false`, documented as "unknown /
23
+ * not-yet-authenticated is treated the same as not authenticated", and flipped on `authenticate()`.
24
+ * That sentence is the exact conflation the rest of this branch exists to prevent —
25
+ * `ExternalAgentAuthState` has two members and neither of them is "not authenticated",
26
+ * `ExternalAgentAuthBadge` renders only those two, and a banned-word scan keeps the phrasing out of
27
+ * the UI. An agent already holding a good long-lived credential on disk read `false` there, so
28
+ * anyone who ever wired it to the roster would have shown that user a "sign in again" prompt
29
+ * indistinguishable from a real one. Nothing read it, so it is gone rather than reworded, together
30
+ * with `canLogout`/`logout()` — whose only effect beyond one RPC was flipping that same flag.
31
+ *
32
+ * `logout` itself is still visible where it is a fact rather than a state: `acpClient.ts` reduces
33
+ * `agentCapabilities.auth.logout != null` onto `AcpConnection.capabilities.logout`, which is where
34
+ * a future caller with an actual use for it should start.
35
+ */
36
+ import { methods } from '@agentclientprotocol/sdk';
37
+ /** `methodId` does not match any entry in `AcpConnection.authMethods` — the agent never advertised it. */
38
+ export class AuthMethodNotAdvertisedError extends Error {
39
+ }
40
+ /**
41
+ * `methodId` names a real advertised method, but its `type` is `'terminal'`. ACP requires the client
42
+ * to run those as a separate interactive process, never to pass them to `authenticate`.
43
+ */
44
+ export class TerminalAuthMethodError extends Error {
45
+ }
46
+ function describeAdvertised(connection) {
47
+ return connection.authMethods.length > 0 ? connection.authMethods.map((method) => method.id).join(', ') : '(none)';
48
+ }
49
+ /**
50
+ * The `authenticate` operation for one `AcpConnection`, and the two refusals that guard it. Holds no
51
+ * authentication state — see the file doc comment for why there is none to hold.
52
+ */
53
+ export class AcpAuth {
54
+ connection;
55
+ constructor(connection) {
56
+ this.connection = connection;
57
+ }
58
+ /** The agent's advertised authentication methods, unchanged from `AcpConnection.authMethods`. */
59
+ get authMethods() {
60
+ return this.connection.authMethods;
61
+ }
62
+ /**
63
+ * Sends `authenticate({ methodId })` to the agent, after refusing two shapes of bad input the
64
+ * agent could not have asked for: a `methodId` it never advertised in `initialize`, and one it
65
+ * advertised as a terminal method (which ACP forbids passing here at all). Neither refusal talks
66
+ * to the agent — see `auth.test.ts` for how that is pinned (a sink file the fake agent only writes
67
+ * once it actually receives the request).
68
+ */
69
+ async authenticate(methodId) {
70
+ const method = this.connection.authMethods.find((candidate) => candidate.id === methodId);
71
+ if (!method) {
72
+ throw new AuthMethodNotAdvertisedError(`Agent did not advertise auth method '${methodId}'. Advertised methods: ${describeAdvertised(this.connection)}.`);
73
+ }
74
+ if (method.type === 'terminal') {
75
+ throw new TerminalAuthMethodError(`Auth method '${methodId}' is a terminal method; ACP requires the client run it as a separate ` +
76
+ 'interactive process instead of passing it to authenticate().');
77
+ }
78
+ await this.connection.connection.agent.request(methods.agent.authenticate, { methodId });
79
+ }
80
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Catches a login URL an agent prints to stderr instead of delivering through
3
+ * `elicitation/create` — see this task's brief, spec §6.2. Measured on a real machine: with the
4
+ * client already declaring `elicitation.url` (`acpClient.ts`'s `clientCapabilities`), antigravity's
5
+ * `authenticate(oauth-personal)` sends no `elicitation/create` at all. It prints
6
+ * `"Open the following link to authenticate the ACP server: https://…"` to stderr and then blocks
7
+ * on a `localhost` callback. ACP does not require an agent to hand a URL to the client through
8
+ * elicitation, so this is a gap in the protocol, not a shortcut a particular agent took — and it
9
+ * means the elicitation channel alone leaves that agent's sign-in doing nothing when clicked.
10
+ *
11
+ * Global Constraint (this task's brief): the criterion may not name a vendor. No hostname, no
12
+ * domain, no agent id may appear here — a later mechanical scan checks the test suite for registry
13
+ * agent id literals. So the match is purely on the *shape* of the line: an absolute `https://` URL,
14
+ * plus wording that invites the reader to open it. Both conditions are required — an ordinary log
15
+ * line that happens to contain a URL (a settings path, a status report) must not match just because
16
+ * it has one, and a line that talks about opening something without ever naming a URL has nothing
17
+ * to hand out.
18
+ */
19
+ /** Case-insensitive: the same verbs a login prompt actually uses to invite the click. */
20
+ const INVITATION_WORDS = /\b(?:open|visit|authenticate)\b/i;
21
+ /** An absolute `https://` URL, taken up to (not including) the next whitespace — never the prose around it. */
22
+ const ABSOLUTE_URL = /https:\/\/\S+/;
23
+ /**
24
+ * ANSI CSI escape sequences (`ESC [ <params> <final-letter>`, e.g. an SGR colour code like
25
+ * `\x1b[33m`). Stripped before matching anything else: `\bopen\b` finds no word boundary between
26
+ * the "33" and the "m" in `…\x1b[33mOpen…` — the escape sequence's own trailing letter reads as
27
+ * though it were fused onto the next word, so a coloured line misses entirely rather than
28
+ * partially. Most agents run non-interactively (no TTY) and suppress colour on their own, but not
29
+ * all of them do, and this is a silent total miss rather than a degraded match.
30
+ */
31
+ // eslint-disable-next-line no-control-regex -- matching the literal ESC (0x1b) control character is the point
32
+ const ANSI_ESCAPE = /\x1b\[[0-9;]*[A-Za-z]/g;
33
+ /** Punctuation prose wraps a URL in (`<…>`, a trailing `.`, a comment in `(…)`) — never part of the URL itself. */
34
+ const TRAILING_DECORATION = /[)\]>'".,]+$/;
35
+ function stripAnsi(line) {
36
+ return line.replace(ANSI_ESCAPE, '');
37
+ }
38
+ /**
39
+ * Returns the URL if `line` both invites the reader to open something and contains an absolute
40
+ * `https://` URL, `undefined` otherwise. Takes only the URL itself, not surrounding prose on either
41
+ * side — including punctuation prose glues directly onto the end of it (`<https://x.test/a>.`
42
+ * yields `https://x.test/a`, not `https://x.test/a>.`).
43
+ */
44
+ export function sniffAuthUrl(line) {
45
+ const clean = stripAnsi(line);
46
+ if (!INVITATION_WORDS.test(clean))
47
+ return undefined;
48
+ const match = ABSOLUTE_URL.exec(clean);
49
+ if (!match)
50
+ return undefined;
51
+ return match[0].replace(TRAILING_DECORATION, '');
52
+ }