@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,602 @@
1
+ /**
2
+ * One session's memory, and the reason this file is no longer stateless: **what this session has
3
+ * already been told**, for the two branches that cannot answer a frame from the frame alone.
4
+ *
5
+ * It holds two unrelated facts, and they are together only because they have the same lifetime and
6
+ * the same owner (`AcpSession`, one per session, handed to every `mapSessionUpdate` call):
7
+ *
8
+ * 1. `toolCallId → name`, because ACP's `tool_call_update` is a delta (below);
9
+ * 2. the last agent queue depth reported to the server, because `pi-acp` re-states its depth on
10
+ * frames that are not queue events at all (see {@link SessionMapperState.reportsQueueDepth}).
11
+ *
12
+ * ## 1. `toolCallId → name`
13
+ *
14
+ * ACP's `tool_call_update` is a **delta**, keyed by `toolCallId`. Its `name` is optional, and the
15
+ * schema says exactly what leaving it out means (`types.gen.d.ts:164-165`):
16
+ *
17
+ * > "This field is optional. Omitting it or sending `null` both mean that the existing name is
18
+ * > left unchanged."
19
+ *
20
+ * "Unchanged" is a statement about a name the consumer is expected to still be holding — so a
21
+ * consumer that holds nothing has no way to honour it. The previous version of this file wrote the
22
+ * literal `'unknown'` there instead, which does not mean "unchanged": it is a different, false
23
+ * claim, and it went into the durable `item` row for every terminal update a bridged agent ever
24
+ * produced. The settings page's "Ready to launch" defect was the same shape — a value nobody had
25
+ * established, rendered as one that had been.
26
+ *
27
+ * Held per session and passed in, rather than kept in module state, so two concurrent sessions
28
+ * cannot see each other's tool calls. It grows by one short string pair per tool call for the
29
+ * session's lifetime; a session long enough for that to matter has far larger per-call costs
30
+ * already (every call's `rawInput`/`rawOutput` is journaled), so there is no eviction here.
31
+ */
32
+ export class SessionMapperState {
33
+ byId = new Map();
34
+ /**
35
+ * The agent queue depth this session has already reported to the server.
36
+ *
37
+ * **Starts at 0, and that initial value is load-bearing, not a placeholder**: a session begins with
38
+ * an empty agent queue, so a first frame saying "0 waiting" is not news — it is the state the
39
+ * reader already assumes. Seeding it to `-1` (or `null`) would let the very first frame through,
40
+ * which on `pi-acp` is `startTurn`'s, i.e. the defect this field exists to prevent.
41
+ */
42
+ lastReportedQueueDepth = 0;
43
+ /**
44
+ * Is `depth` news? Records it if so. **This is the whole fix for a defect that shipped in the
45
+ * previous commit**, so the reasoning is written out rather than summarised.
46
+ *
47
+ * `pi-acp@0.0.33` emits `_meta.piAcp.{queueDepth,running}` from **five** sites (read first-hand
48
+ * from the package pinned on this machine, `pi-acp/dist/index.js`, `package.json` version
49
+ * `0.0.33`), and **the two that fire most are not queue events**:
50
+ *
51
+ * | line | when | payload with nothing queued |
52
+ * |---|---|---|
53
+ * | `:845` | `prompt()` while a turn is running — a real enqueue | `{n≥1, true}` |
54
+ * | `:864` | `cancel()` cleared a non-empty queue — a real drain | `{0, …}` |
55
+ * | **`:925`** | **`startTurn()` — every turn, queue or no queue** | **`{0, true}`** |
56
+ * | `:940` | the turn's prompt rejected | `{depth, false}` |
57
+ * | **`:1228`** | **`agent_settled` with an empty queue — every turn end** | **`{0, false}`** |
58
+ *
59
+ * (`:1267` carries `piAcp.notify` and no depth; `agentQueueNotice`'s shape check drops it.)
60
+ *
61
+ * Because a `step` is a durable item and `foldStep` never merges adjacent ones, emitting on every
62
+ * frame gave **every pi session two permanent "0 messages waiting" cards per turn, from its first
63
+ * turn**. The previous commit's premise — that pi publishes on queue changes — was simply wrong,
64
+ * and nobody had read the package.
65
+ *
66
+ * ## Why "the depth changed", and not the two alternatives
67
+ *
68
+ * **De-duplicating consecutive identical `{depth, running}` pairs does not fix it.** The two
69
+ * every-turn frames are `{0,true}` and `{0,false}`: they differ in `running` and they *alternate*,
70
+ * so they are never consecutive-identical and every single one would still be published. A test
71
+ * below is written to fail under exactly that implementation.
72
+ *
73
+ * **Suppressing `queueDepth === 0` outright eats the news the reader most wants**: the drain. A
74
+ * queue going 2 → 1 → 0 ends on a zero, and that zero is the whole point.
75
+ *
76
+ * Comparing against the last depth *we reported* keeps the drain (`0 ≠ 2`), kills the every-turn
77
+ * noise (`0 === 0`), and self-heals: once a queue has drained, the session goes quiet again.
78
+ *
79
+ * ## Why `running` does not take part in the decision
80
+ *
81
+ * **The decisive reason is the measurement above, not a principle**: `running` is exactly the field
82
+ * that *alternates* across the two every-turn frames (`{0,true}` then `{0,false}`). Any 判据 that
83
+ * reads it therefore publishes on every turn boundary — which is the defect. It is disqualified by
84
+ * what it does, and that is checkable: the pair-dedup test below fails if this ever changes.
85
+ *
86
+ * A secondary reason to be comfortable with that: the subject of this observation is the depth, and
87
+ * "is the agent running" is a fact Axom already owns from its own turn state, which invariant II
88
+ * would rather not have a second independent writer for. That argument is real but it is not what
89
+ * settles it — a field could be redundant with Axom's own state and still be worth reacting to.
90
+ *
91
+ * `running` still rides along inside the sentence, where it is an attribution ("the agent
92
+ * reports…") rather than a second account of the same fact.
93
+ */
94
+ reportsQueueDepth(depth) {
95
+ if (depth === this.lastReportedQueueDepth)
96
+ return false;
97
+ this.lastReportedQueueDepth = depth;
98
+ return true;
99
+ }
100
+ /** Remember what this call is called. Later updates that carry no `name` fall back to it. */
101
+ record(toolCallId, name) {
102
+ if (name)
103
+ this.byId.set(toolCallId, name);
104
+ }
105
+ /**
106
+ * The name to report for one update, in ACP's own order of authority: an explicit `name` on the
107
+ * update *is* a rename and wins (and is remembered); otherwise the remembered name is what
108
+ * "unchanged" refers to; `title` is the last resort, matching what `toolCallCreatedEvent` would
109
+ * have used had `name` been absent at creation. `'unknown'` survives only for a call this mapper
110
+ * genuinely never saw created — a mid-stream reconnect, or an agent updating an id it never
111
+ * opened.
112
+ */
113
+ resolve(toolCallId, name, title) {
114
+ if (name) {
115
+ this.byId.set(toolCallId, name);
116
+ return name;
117
+ }
118
+ return this.byId.get(toolCallId) ?? title ?? 'unknown';
119
+ }
120
+ }
121
+ /**
122
+ * Identity, by construction: every ACP `ToolKind` member is a wire value. `delete`, `move` and
123
+ * `switch_mode` used to be flattened to `'other'` here, which destroyed the only signal the card
124
+ * renderer has for telling "removed a file" apart from "did something unclassified" — and the
125
+ * flattening was lossy at the boundary, so no consumer could recover it.
126
+ *
127
+ * The only non-identity case left is ACP reporting no kind at all: `ToolCall.kind` is
128
+ * `ToolKind | null | undefined`, and absent is not a tenth kind — it means "unclassified", which is
129
+ * exactly what `'other'` already spells.
130
+ */
131
+ function mapToolKind(kind) {
132
+ return kind ?? 'other';
133
+ }
134
+ /** Drop `undefined`-valued keys so optional wire fields are genuinely absent, not `null`/`undefined` literals. */
135
+ function compact(value) {
136
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined));
137
+ }
138
+ /**
139
+ * `ToolCall.locations` → the wire `tool_call.locations` shape; omitted entirely when there are none.
140
+ *
141
+ * An identity copy of ACP's `ToolCallLocation` (`types.gen.d.ts:581`), which is `{path, line?}` —
142
+ * `line` is `number | null | undefined` (`:586-589`) and used to be dropped here, which cost the
143
+ * "follow along" feature the only signal telling *where* in the file the agent is working. Absent
144
+ * stays absent rather than becoming a `null`: `compact` is applied per location, not just to the
145
+ * enclosing payload.
146
+ */
147
+ function mapLocations(locations) {
148
+ if (!locations || locations.length === 0)
149
+ return undefined;
150
+ return locations.map((location) => compact({ path: location.path, line: location.line ?? undefined }));
151
+ }
152
+ function toolCallCreatedEvent(update, state) {
153
+ // Recorded here rather than in `mapToolCallEvents` so the one place that decides what a call is
154
+ // called is also the one place that remembers it: the two can never drift apart.
155
+ state.record(update.toolCallId, update.name ?? update.title);
156
+ return {
157
+ eventName: 'tool_call',
158
+ // Every field is now an identity copy of ACP's own `ToolCall` (`types.gen.d.ts:3479`): the wire
159
+ // vocabulary took ACP's `toolCallId`/`name`/`title`/`rawInput`/`kind`/`locations`/`_meta` names
160
+ // verbatim.
161
+ payload: compact({
162
+ // ACP's `ToolCall.name` is the programmatic name and is optional and UNSTABLE (`:3488-3500`);
163
+ // on a *creation* its absence means "no tool name is available" (`:3495-3496`) — not the
164
+ // "left unchanged" that the same-named field on `ToolCallUpdate` means (`:164-165`) — so
165
+ // there is nothing earlier to fall back to and `title` (always present, `:3484-3487`) is the
166
+ // only field guaranteed to give the card a name.
167
+ name: update.name ?? update.title,
168
+ toolCallId: update.toolCallId,
169
+ rawInput: update.rawInput,
170
+ kind: mapToolKind(update.kind),
171
+ title: update.title,
172
+ locations: mapLocations(update.locations),
173
+ // `_meta` is ACP's own extension slot, present on every request/notification shape
174
+ // (`types.gen.d.ts`). This mapper copies it verbatim and reads nothing inside it — Global
175
+ // Constraint 3 ("transport, not translation"): claude-agent-acp puts `parentToolUseId` here to
176
+ // thread a subagent's tool calls back to the `Task` call that spawned it, and other agents put
177
+ // other vendor-private shapes there; this file has no business knowing either.
178
+ _meta: update._meta ?? undefined,
179
+ }),
180
+ };
181
+ }
182
+ /**
183
+ * ACP `tool_call_update` (`types.gen.d.ts:140`) — a tool call's **entire** remaining life under one
184
+ * event name, increments and answer alike, with ACP's own `status` (`ToolCallStatus`, `:217`:
185
+ * `pending | in_progress | completed | failed`) saying which this one is.
186
+ *
187
+ * There used to be two events here. `tool_call_progress` carried the increments and `tool_call_result`
188
+ * the terminal ones — but `tool_call_progress` *also* carried `status`, so the event name and the field
189
+ * were two spellings of one fact, and ACP has only the field. The two names cost real data on the way
190
+ * out of this file: `extractDiff` kept `content.find(type === 'diff')`, the **first** diff and no other,
191
+ * so a multi-file edit silently lost every diff after the first; `extractOutputChunk`/
192
+ * `flattenContentForResult` flattened content blocks to free text, so a `terminal` reference became a
193
+ * string no consumer could resolve back to a terminal. Carrying ACP's `content` array verbatim ends
194
+ * both losses, and `ToolCallContent` (`:226`, the three-member `content`/`diff`/`terminal` union) is
195
+ * already the wire shape the server decodes (`AcpToolCallContent.fromWire`).
196
+ *
197
+ * `executeStatus` went with the event name it belonged to. It was Axom's `success | failure | running`
198
+ * riding alongside ACP's `status` — same question, two fields, disjoint value domains, and a consumer
199
+ * reading one under the other's meaning would call every failure a success. The server maps
200
+ * `ExecuteResult`'s values into ACP's four rather than keeping a second field.
201
+ *
202
+ * Every key emitted here is a key the server's `decodeToolUpdate` reads (`TimelineEventWire.kt`:
203
+ * name/toolCallId/kind/title/status/content/locations/rawInput/rawOutput/`_meta`): a key this
204
+ * function does not send has no other source, so it simply is not on the timeline. `name` is the one
205
+ * whose absence is fatal rather than lossy — the server's branch is `?: return null`, dropping the
206
+ * whole update silently.
207
+ *
208
+ * `_meta` is forwarded verbatim: it is ACP's own extension slot, and this mapper copies the object
209
+ * without reading anything inside it (Global Constraint 3, "transport, not translation").
210
+ * `decodeToolUpdate` reads it too — also verbatim, straight onto the event payload — so the slot is
211
+ * a live end-to-end channel now, not an additive one waiting for a consumer.
212
+ *
213
+ * **`stream` is deliberately absent, and it is no longer a concept without a home.** `stdout` vs
214
+ * `stderr` has nowhere to live in ACP's typed `content`, and the card renders the two separately.
215
+ * The marker now travels as `_meta.axom.stream` (alongside `_meta.axom.delta`) — `_meta.axom` is the
216
+ * one namespace the platform writes into ACP's extension slot, and `WorkTimelineTools`'
217
+ * `effectiveStream()` reads it first, falling back to the old top-level `stream` only for rows
218
+ * written before that move. What this mapper still does not do is *invent* one: an ACP agent never
219
+ * says which stream a content block came from, and `_meta.axom` is the platform's namespace, not
220
+ * something to synthesize on an agent's behalf. So the key stays omitted — ACP's "unchanged", which
221
+ * is the truthful thing to say when the agent never told us.
222
+ *
223
+ * Everything but the id is optional on `ToolCallUpdate` and absent means "leave it unchanged"
224
+ * (`:140-143`), so `compact` dropping the `undefined`s is load-bearing rather than cosmetic. `kind`
225
+ * in particular does **not** get `mapToolKind`'s `?? 'other'` fallback: that fallback is right on a
226
+ * *creation*, where absent means "unclassified", and wrong here, where it would overwrite the kind
227
+ * the `tool_call` established with a claim the agent never made.
228
+ */
229
+ function toolCallUpdateEvent(update, state) {
230
+ return {
231
+ eventName: 'tool_call_update',
232
+ payload: compact({
233
+ // Neither `name` nor `title` is guaranteed on an update that only changed `status`, and ACP
234
+ // defines that absence as "unchanged" (`types.gen.d.ts:164-165`) rather than "unknown" — see
235
+ // {@link SessionMapperState}. The emitted key is ACP's own `name` (`ToolCallUpdate.name`, `:169`);
236
+ // `toolName` was a synonym of it.
237
+ name: state.resolve(update.toolCallId, update.name, update.title),
238
+ toolCallId: update.toolCallId,
239
+ title: update.title ?? undefined,
240
+ kind: update.kind ?? undefined,
241
+ status: update.status ?? undefined,
242
+ // An empty array is omitted rather than sent, and the behaviour is deliberate now that the
243
+ // card's fold *does* honour ACP's "replace the content collection": `applyChunks` treats an
244
+ // empty chunk list as "unchanged" at every call site, never as a clear. So an empty array
245
+ // carries nothing either way — and the server drops it on the way back out anyway
246
+ // (`TimelineEvent.ToolCallUpdated.payload` only writes `content` when it is non-empty), which
247
+ // is why sending one could not express a clear even if a consumer wanted to act on it.
248
+ content: update.content && update.content.length > 0 ? update.content : undefined,
249
+ locations: mapLocations(update.locations),
250
+ rawInput: update.rawInput,
251
+ rawOutput: update.rawOutput,
252
+ // See the doc comment above: ACP's extension slot, copied verbatim and read by nothing *here*
253
+ // (the server's `decodeToolUpdate` does read it, also verbatim).
254
+ _meta: update._meta ?? undefined,
255
+ }),
256
+ };
257
+ }
258
+ function mapToolCallEvents(update, state) {
259
+ const events = [toolCallCreatedEvent(update, state)];
260
+ // ACP permits a `tool_call` to arrive already finished — the agent reporting a call it both
261
+ // started and completed in one notification, with no separate `tool_call_update` at all. A card
262
+ // only ever leaves "Running" on an update reporting a non-pending status
263
+ // (`WorkTimelineTools.foldToolUpdate`'s outcome branch), and the creation event carries no status,
264
+ // so without this second event an already-terminal call would render as permanently "Running".
265
+ //
266
+ // `ToolCall` is structurally a `ToolCallUpdate` (every field of the latter, with `title` and `kind`
267
+ // merely narrower), so the same function emits both — the two cannot drift into disagreeing about
268
+ // what a terminal update looks like.
269
+ if (update.status === 'completed' || update.status === 'failed') {
270
+ events.push(toolCallUpdateEvent(update, state));
271
+ }
272
+ return events;
273
+ }
274
+ function summarizePlanEntries(entries) {
275
+ if (entries.length === 0)
276
+ return 'No plan entries.';
277
+ return entries.map((entry) => `[${entry.status}] ${entry.content}`).join('\n');
278
+ }
279
+ function summarizePlanUpdateContent(plan) {
280
+ switch (plan.type) {
281
+ case 'items':
282
+ return summarizePlanEntries(plan.entries);
283
+ case 'file':
284
+ return `Plan file: ${plan.uri}`;
285
+ case 'markdown':
286
+ return plan.content;
287
+ default:
288
+ // `PlanUpdateContent` is marked UNSTABLE in the SDK itself, unlike the outer `SessionUpdate`
289
+ // union this file's exhaustiveness check guards — tolerating a shape this switch doesn't
290
+ // recognize (rather than failing the build over an experimental type) matches how the deleted
291
+ // bridges already treat everything the spec marks unstable.
292
+ return 'Plan updated.';
293
+ }
294
+ }
295
+ /**
296
+ * Generic catch-all wire event — the same `{title, message}` shape the deleted `codex/events.ts`
297
+ * used for its own "no dedicated card" `ThreadItem` variants.
298
+ *
299
+ * **Exactly one branch below still calls this, and that is now the whole story of `step` in this
300
+ * file.** `summarizeAvailableCommands` / `summarizeConfigOptions` / `summarizeCompaction` and the
301
+ * `blockText` flattening for `compaction_summary_chunk` are gone with the branches that used them:
302
+ * each of those five variants publishes ACP's own structure under its own event name. What is left
303
+ * is `plan_update`, and its reason is written at that branch.
304
+ */
305
+ function stepEvent(title, message) {
306
+ return { eventName: 'step', payload: { title, message } };
307
+ }
308
+ /** The `step` line's title, exported so the test asserts the same string the mapper writes. */
309
+ export const AGENT_QUEUE_NOTICE_TITLE = 'Agent queue';
310
+ /**
311
+ * `session_info_update._meta.piAcp.{queueDepth,running}` → a `step` line — ruling N11.
312
+ *
313
+ * `pi-acp@0.0.33` publishes its own queue depth under `_meta.piAcp`, and this branch used to read
314
+ * only `title`/`updatedAt`, so the numbers were dropped on the floor. They are worth showing: on
315
+ * that adapter a mid-turn `session/prompt` waits for `agent_settled`, and until now nothing on the
316
+ * reader's screen said the agent was holding anything at all.
317
+ *
318
+ * **Most of those frames say nothing, and skipping them is not an optimisation — it is the
319
+ * correctness fix for a defect the previous commit shipped.** `pi-acp` re-states the depth when a
320
+ * turn *starts* and when it *settles*, not only when the queue moves; the full site table, the
321
+ * measurement it came from, and why the two obvious alternatives are wrong are at
322
+ * {@link SessionMapperState.reportsQueueDepth}, which owns that decision.
323
+ *
324
+ * **They describe a different set from Axom's queue, and the normalisation is presentational on
325
+ * purpose (ruling N11: "相加即错").** `pi-acp`'s queue holds prompts *that adapter* has accepted and
326
+ * not yet run; Axom's `session_message_queue` holds messages the *server* decided to hold
327
+ * (spec §4.1) — and today Axom never sends a second `session/prompt` into a running turn at all
328
+ * (ruling N1), so the two can never be summed into anything meaningful. That is enforced by shape,
329
+ * not by discipline: `step` is the generic notice event whose payload is four strings
330
+ * (`WorkTimelinePayload.Step`: `message`/`title`/`detail`/`tone`). The depth survives only as a word
331
+ * inside a sentence, so no future reader can add it to a queue length, gate a delivery on it, or
332
+ * render the queue bar from it without first re-parsing prose — which is a thing nobody does by
333
+ * accident. Publishing `{queueDepth, running}` as structured fields under a name of its own would
334
+ * have left exactly that door open.
335
+ *
336
+ * **Reading a vendor key is not vendor branching.** `_meta` is ACP's extension slot, and `piAcp` is
337
+ * where this agent puts the observation; there is no vendor-neutral spelling to read instead, and
338
+ * nothing here changes *behaviour* based on who the agent is (ruling L2's actual prohibition) — a
339
+ * frame without the key simply says nothing. Shape checks are strict for the same reason every
340
+ * other branch here checks: `_meta` is `{[key: string]: unknown}` in the SDK (`types.gen.d.ts:3912`),
341
+ * so anything at all can arrive under it.
342
+ *
343
+ * **`queueDepth` is required, `running` is not.** A frame carrying only `running` would be a second
344
+ * spelling of a fact Axom already owns from its own turn state, which invariant II forbids having
345
+ * two writers for; as part of a sentence *about the agent's own queue* it is an attribution
346
+ * ("the agent reports"), which is a different claim and one only the agent can make.
347
+ */
348
+ function agentQueueNotice(meta, state) {
349
+ const piAcp = meta?.piAcp;
350
+ if (typeof piAcp !== 'object' || piAcp === null)
351
+ return null;
352
+ const { queueDepth, running } = piAcp;
353
+ if (typeof queueDepth !== 'number' || !Number.isInteger(queueDepth) || queueDepth < 0)
354
+ return null;
355
+ // The shape check comes first on purpose: a malformed frame must not be able to move the
356
+ // watermark this session compares against, or one bad frame silences the next real change.
357
+ if (!state.reportsQueueDepth(queueDepth))
358
+ return null;
359
+ const waiting = queueDepth === 1 ? '1 message waiting' : `${queueDepth} messages waiting`;
360
+ const runState = typeof running === 'boolean'
361
+ ? `, and that it is ${running ? 'running' : 'idle'}`
362
+ : '';
363
+ return stepEvent(AGENT_QUEUE_NOTICE_TITLE, `The agent reports ${waiting} in its own queue${runState}. ` +
364
+ `This is the agent's queue, not Axom's.`);
365
+ }
366
+ /**
367
+ * Map one ACP `session/update` payload to zero or more wire events.
368
+ *
369
+ * Carries no state of its own: everything it needs to remember across updates lives in the
370
+ * {@link SessionMapperState} its caller owns and passes in. That parameter is **required**, not
371
+ * optional, on purpose — an optional one would let a future call site forget it and silently
372
+ * reintroduce *both* failures that class exists to prevent (`name: 'unknown'`, and a queue notice on
373
+ * every turn boundary), which is exactly the shape of failure a default value hides. `AcpSession` is
374
+ * the caller with a session's lifetime, and it is where the rest of the cross-update state already
375
+ * lives: `agent_message` synthesis and `user_message_chunk` folding both stay there rather than here
376
+ * (see the file doc comment).
377
+ */
378
+ export function mapSessionUpdate(update, state) {
379
+ switch (update.sessionUpdate) {
380
+ case 'user_message_chunk':
381
+ // identity mapping, same as the two assistant chunk kinds below: `messageId` is the grouping
382
+ // key ACP defines on `ContentChunk` (`types.gen.d.ts:3456`) and the server folds on it, so it
383
+ // must survive the hop. Dropping it here would make every chunk look like a separate message.
384
+ // `_meta` (ACP's own extension slot, `ContentChunk`, `types.gen.d.ts:3460`) is forwarded
385
+ // verbatim, same as on `tool_call`/`tool_call_update` above and for the same reason (Task 8):
386
+ // claude-agent-acp stamps `_meta.claudeCode.parentToolUseId` on chunks belonging to a
387
+ // subagent (`acp-agent.js:6392-6404`; its own comment at `:1271-1274` says subagent-attributed
388
+ // chunks are recognizable by exactly that stamp), and P2's routing needs it to reach a chunk,
389
+ // not just a tool call. This mapper reads nothing inside it — Global Constraint
390
+ // "transport, not translation".
391
+ return [{ eventName: 'user_message_chunk', payload: compact({ content: update.content, messageId: update.messageId ?? undefined, _meta: update._meta ?? undefined }) }];
392
+ case 'agent_message_chunk':
393
+ // identity mapping since the ACP rename: the server event is now called `agent_message_chunk` too
394
+ return [{ eventName: 'agent_message_chunk', payload: compact({ content: update.content, messageId: update.messageId ?? undefined, _meta: update._meta ?? undefined }) }];
395
+ case 'agent_thought_chunk':
396
+ // identity mapping since the ACP rename: the server event is now called `agent_thought_chunk` too
397
+ return [{ eventName: 'agent_thought_chunk', payload: compact({ content: update.content, messageId: update.messageId ?? undefined, _meta: update._meta ?? undefined }) }];
398
+ case 'tool_call':
399
+ // existing events: tool_call, plus a tool_call_update when ACP reports it already finished — see mapToolCallEvents
400
+ return mapToolCallEvents(update, state);
401
+ case 'tool_call_update':
402
+ // identity mapping since the merge: one wire event per ACP update, whatever its status — see toolCallUpdateEvent
403
+ return [toolCallUpdateEvent(update, state)];
404
+ case 'plan':
405
+ // identity mapping: the server event is now called `plan` too, and it carries ACP's own
406
+ // `entries` (`types.gen.d.ts:3594`) rather than a summary of them.
407
+ //
408
+ // This used to be `stepEvent('Plan', summarizePlanEntries(update.entries))` — a free-text
409
+ // line per entry, joined into one string. Measured on real hardware, claude-agent-acp emits
410
+ // fully structured plans (`dist/acp-agent.js:6489` from Claude Code's TodoWrite, `:6678` from
411
+ // its own Task subagent tools), each entry `{content, status, priority}`. The structure was
412
+ // being destroyed *here*, in Axom's own code, not withheld by the adapter — which is why an
413
+ // external session's plan card was always empty while the built-in agent had one.
414
+ //
415
+ // The entries go over the wire verbatim, `_meta` and all: `TimelineEventWire`'s `plan`
416
+ // decoder rebuilds a typed `AcpPlanEntry` and keeps only ACP's three fields, so nothing
417
+ // unrecognized reaches the database. An empty `entries` is still emitted rather than
418
+ // swallowed — ACP allows it (TodoWrite clearing its list), and the *server* owns the ruling
419
+ // that an empty plan writes no plan; dropping it here would hide that the agent sent one.
420
+ return [{ eventName: 'plan', payload: { entries: update.entries } }];
421
+ case 'plan_update':
422
+ // existing event: step. Deliberately NOT mapped onto `plan`: `PlanUpdate` (`:3738`) is the
423
+ // id-addressed family the SDK marks UNSTABLE/@experimental, its content is a three-way union
424
+ // (`items` | `file` | `markdown`), and it is gated by a client capability Axom does not
425
+ // advertise. `summarizePlanEntries` therefore survives for this branch alone.
426
+ return [stepEvent('Plan updated', summarizePlanUpdateContent(update.plan))];
427
+ case 'plan_removed':
428
+ // identity mapping: the server event is now called `plan_removed` too, carrying ACP's own
429
+ // `planId` (`:3763`). Also UNSTABLE in the SDK, and also id-addressed — but unlike
430
+ // `plan_update` the platform has a real action for it (cancel the session's live plan), and
431
+ // dropping the id into a `step` line left that action impossible to take.
432
+ return [{ eventName: 'plan_removed', payload: { planId: update.planId } }];
433
+ case 'available_commands_update':
434
+ // identity mapping: the server event is now called `available_commands_update` too, and it
435
+ // carries ACP's own `AvailableCommand[]` (`types.gen.d.ts:3782`) rather than a comma-joined
436
+ // list of names. Each command's `description` and `input.hint` (`:3813`) were being destroyed
437
+ // *here* — the adapter sends them; nothing downstream ever saw them.
438
+ //
439
+ // An empty list is still emitted rather than swallowed: "the agent now offers no commands" is
440
+ // a real transition, and dropping it would hide that the agent said anything at all.
441
+ return [{ eventName: 'available_commands_update', payload: { availableCommands: update.availableCommands } }];
442
+ case 'current_mode_update':
443
+ // identity mapping: the server event is now called `current_mode_update` too, carrying ACP's
444
+ // own `currentModeId` (`SessionModeId`, `types.gen.d.ts:3855`) instead of a prose line that
445
+ // spelled the id inside a sentence no consumer could parse back out.
446
+ return [{ eventName: 'current_mode_update', payload: { currentModeId: update.currentModeId } }];
447
+ case 'config_option_update':
448
+ // identity mapping: the server event is now called `config_option_update` too, carrying ACP's
449
+ // own `SessionConfigOption[]` (`:2617`) verbatim — union member, `currentValue` and all. The
450
+ // old `options.map(o => o.name).join(', ')` made "configuration updated" a notice that could
451
+ // not say what changed or what it changed to.
452
+ return [{ eventName: 'config_option_update', payload: { configOptions: update.configOptions } }];
453
+ case 'session_info_update': {
454
+ // identity mapping since the ACP rename: the server event is now called `session_info_update`
455
+ // too (it was `title_suggested`), reusing the bridge-title-sync path the deleted claudeCode
456
+ // bridge used for its own native session titles.
457
+ //
458
+ // **The shape is no longer narrowed.** `SessionInfoUpdate` (`types.gen.d.ts:3896`) is
459
+ // `{title?, updatedAt?}` with both fields `string | null`, and the doc comment says a null
460
+ // clears. That is three states, and this branch used to collapse them to one: anything but a
461
+ // non-empty string emitted nothing, so an explicit clear and a title-less "the session moved"
462
+ // both vanished here. `'title' in update` is what tells absent from null — the server's
463
+ // decoder makes the same distinction with `containsKey`.
464
+ //
465
+ // A wholly empty update is still dropped: the server drops it too, and emitting it would only
466
+ // push the bridge watermark past a frame nobody can act on. "Wholly empty" is about ACP's own
467
+ // two fields; `_meta` is read separately below and can carry an update of its own.
468
+ const payload = {};
469
+ if ('title' in update)
470
+ payload.title = update.title ?? null;
471
+ if (update.updatedAt != null)
472
+ payload.updatedAt = update.updatedAt;
473
+ const events = Object.keys(payload).length > 0
474
+ ? [{ eventName: 'session_info_update', payload }]
475
+ : [];
476
+ const queueNotice = agentQueueNotice(update._meta, state);
477
+ if (queueNotice)
478
+ events.push(queueNotice);
479
+ return events;
480
+ }
481
+ case 'usage_update':
482
+ // existing event: usage_update — context occupancy/cost, same wire shape the deleted bridges already emit
483
+ // An identity copy of ACP's `UsageUpdate` (`types.gen.d.ts:3942`): `used` (`:3946`), `size`
484
+ // (`:3950`) and `cost` (`:3954`, a `Cost` of `{amount, currency}` — `:3919-3927`). The wire
485
+ // used to spell these `contextTokens`/`totalCostUsd`, and the latter's baked-in currency is
486
+ // why a non-USD cost had to be dropped rather than mislabeled. Carrying ACP's own `cost`
487
+ // object carries the currency with it, so there is nothing left to drop.
488
+ return [{ eventName: 'usage_update', payload: compact({ used: update.used, size: update.size, cost: update.cost ?? undefined }) }];
489
+ case 'compaction_update':
490
+ // identity mapping: the server event is now called `compaction_update` too, carrying ACP's own
491
+ // `{compactionId, status, summary?, error?}` (`types.gen.d.ts:4003`, status at `:3985`). The
492
+ // server's own compaction audit record moved onto this same name and shape — one concept, one
493
+ // name across both agents, with Axom's engine watermarks riding along as extensions ACP has no
494
+ // field for.
495
+ //
496
+ // `summary` and `error` have **patch semantics** (`:4003`: omission leaves the stored value
497
+ // unchanged, null clears it), so `compact` dropping the undefineds is load-bearing: an omitted
498
+ // key is "unchanged", which is the truthful thing to say when the agent said nothing. A
499
+ // literal null is a different claim and is not one absence is entitled to make.
500
+ return [{
501
+ eventName: 'compaction_update',
502
+ payload: compact({
503
+ compactionId: update.compactionId,
504
+ status: update.status,
505
+ summary: update.summary ?? undefined,
506
+ error: update.error ?? undefined,
507
+ }),
508
+ }];
509
+ case 'compaction_summary_chunk':
510
+ // identity mapping: the server event is now called `compaction_summary_chunk` too, carrying
511
+ // ACP's `{compactionId, content}` (`:4039`). This used to flatten the block to free text and
512
+ // drop the id entirely, so a non-text block became `[image]` and no consumer could attach the
513
+ // chunk back to the compaction it belongs to.
514
+ return [{
515
+ eventName: 'compaction_summary_chunk',
516
+ payload: { compactionId: update.compactionId, content: update.content },
517
+ }];
518
+ // --- draft ACP (RFD #1992) and the AIR async-task extension; shapes in `draftSubagents.ts` ---
519
+ //
520
+ // All five are identity mappings, for the same reason every other branch here is transport
521
+ // rather than translation: the normalisation point is server ingest, one place, so that a
522
+ // workstation running a version behind cannot put a second shape in the database. The names are
523
+ // ACP's (or, for the async-task trio, the ecosystem's), so there is nothing to rename.
524
+ case 'subagent_spawned':
525
+ return [{
526
+ eventName: 'subagent_spawned',
527
+ payload: compact({
528
+ subagentSessionId: update.subagentSessionId,
529
+ name: update.name,
530
+ task: update.task,
531
+ capabilities: update.capabilities,
532
+ _meta: update._meta ?? undefined,
533
+ }),
534
+ }];
535
+ case 'subagent_state_update':
536
+ return [{
537
+ eventName: 'subagent_state_update',
538
+ payload: compact({
539
+ subagentSessionId: update.subagentSessionId,
540
+ state: update.state,
541
+ _meta: update._meta ?? undefined,
542
+ }),
543
+ }];
544
+ case 'async_task_spawned':
545
+ // `toolCallId` is optional here **because it is genuinely absent** on this frame in practice —
546
+ // it arrives on the next `async_task_progress`. `compact` dropping it is what lets the server
547
+ // treat "not said yet" as different from "said to be nothing".
548
+ return [{
549
+ eventName: 'async_task_spawned',
550
+ payload: compact({
551
+ asyncTaskId: update.asyncTaskId,
552
+ name: update.name,
553
+ taskType: update.taskType,
554
+ description: update.description,
555
+ showInTranscript: update.showInTranscript,
556
+ canStop: update.canStop,
557
+ outputFilePath: update.outputFilePath ?? undefined,
558
+ toolCallId: update.toolCallId ?? undefined,
559
+ _meta: update._meta ?? undefined,
560
+ }),
561
+ }];
562
+ case 'async_task_progress':
563
+ return [{
564
+ eventName: 'async_task_progress',
565
+ payload: compact({
566
+ asyncTaskId: update.asyncTaskId,
567
+ description: update.description ?? undefined,
568
+ summary: update.summary ?? undefined,
569
+ lastToolName: update.lastToolName ?? undefined,
570
+ usage: update.usage ?? undefined,
571
+ outputFilePath: update.outputFilePath ?? undefined,
572
+ toolCallId: update.toolCallId ?? undefined,
573
+ _meta: update._meta ?? undefined,
574
+ }),
575
+ }];
576
+ case 'async_task_state_update':
577
+ return [{
578
+ eventName: 'async_task_state_update',
579
+ payload: compact({
580
+ asyncTaskId: update.asyncTaskId,
581
+ state: update.state,
582
+ summary: update.summary ?? undefined,
583
+ outputFilePath: update.outputFilePath ?? undefined,
584
+ toolCallId: update.toolCallId ?? undefined,
585
+ _meta: update._meta ?? undefined,
586
+ }),
587
+ }];
588
+ default: {
589
+ // Exhaustiveness guard (plan requirement): if the SDK ever adds a 16th `SessionUpdate` variant,
590
+ // `update` stops being assignable to `never` here and this file fails to *compile* — not just a
591
+ // test going red. The hardcoded value list in the test file is a second, weaker net.
592
+ //
593
+ // Widening the parameter to `IncomingSessionUpdate` does **not** weaken this: the five draft
594
+ // variants are named cases above, so they are subtracted from the union before it reaches
595
+ // here, and anything genuinely unknown still fails the assignment. Adding a draft variant to
596
+ // `draftSubagents.ts` without a branch here is therefore also a compile error, which is the
597
+ // property that keeps the two files from drifting apart.
598
+ const _exhaustive = update;
599
+ throw new Error(`unmapped sessionUpdate variant: ${JSON.stringify(_exhaustive)}`);
600
+ }
601
+ }
602
+ }