@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,435 @@
1
+ // @ts-nocheck
2
+ // Deliberately valid JavaScript, not a module TypeScript needs to type-check: `acpClient.test.ts`
3
+ // runs this fixture as a real, separate OS process that speaks ACP over ndjson stdio, through a
4
+ // Node 20-safe `node -e` bootstrap: `eval(readFileSync(process.argv[1], 'utf8'))`. (The trick was
5
+ // borrowed from the Codex app-server fake, `src/test/fakeAppServer.ts`, which Task 8 deleted along
6
+ // with the native bridges — the path is history, not somewhere to look.) That bootstrap evaluates
7
+ // this file's raw source as plain JavaScript — it never goes through `tsc`, so top-level `import`/
8
+ // `export` syntax would be a SyntaxError here. `require()` reaches Node builtins; the ACP SDK is
9
+ // ESM-only (no CJS build), so it is reached with a dynamic `import()`, which works from CJS too.
10
+ //
11
+ // This is a REAL ACP agent built on the SDK's own `agent()` builder — not a hand-rolled stand-in
12
+ // for our own client. The contract suite (a later task) depends on that being true, and depends on
13
+ // this fixture being able to present different capability sets so tests can cover both "advertises
14
+ // X" and "does not advertise X" without forking the file. Everything an agent would normally decide
15
+ // for itself (capabilities, authMethods, the session id `session/new` hands back, and the
16
+ // `session/update` notifications `session/prompt` sends before it stops) is instead read from a
17
+ // JSON config on argv[2], so callers configure behavior without touching this file.
18
+ //
19
+ // Config shape (all fields optional):
20
+ // {
21
+ // agentCapabilities?: AgentCapabilities, // passed through verbatim into InitializeResponse
22
+ // authMethods?: AuthMethod[], // passed through verbatim into InitializeResponse
23
+ // agentInfo?: Implementation,
24
+ // initializeMeta?: object, // passed through verbatim as InitializeResponse._meta
25
+ // // — the TOP-LEVEL one, a sibling of
26
+ // // agentCapabilities. That is where the steering
27
+ // // extension advertises itself
28
+ // // (`claude-agent-acp` `acp-agent.js@0.76.0:944-950`),
29
+ // // so a test can present `{steering:{supported:true}}`
30
+ // // and, just as importantly, an agent that says
31
+ // // nothing at all
32
+ // sessionId?: string, // default 'fake-session'
33
+ // updates?: SessionUpdate[], // sent as session/update notifications during prompt
34
+ // stopReason?: StopReason, // default 'end_turn'
35
+ // protocolVersionSink?: string, // if set, the received initialize().protocolVersion
36
+ // // is written to this file path (test-only escape
37
+ // // hatch: InitializeResponse doesn't have to echo it,
38
+ // // so this is how a test observes what was sent)
39
+ // clientCapabilitiesSink?: string, // if set, the received initialize().clientCapabilities
40
+ // // is written to this file path as JSON (same escape
41
+ // // hatch as protocolVersionSink — InitializeResponse
42
+ // // has nowhere to echo what the client sent)
43
+ // protocolVersionOverride?: number, // if set, returned as initialize().protocolVersion
44
+ // // instead of the SDK's real PROTOCOL_VERSION —
45
+ // // lets a test simulate an agent that negotiated a
46
+ // // version this client should refuse
47
+ // hangOnInitialize?: boolean, // if true, initialize() never resolves — simulates
48
+ // // an agent that opened stdio but never speaks, for
49
+ // // exercising connectAgent's initialize timeout
50
+ // exitMarkerPath?: string, // if set, a file this process writes right before
51
+ // // exiting on SIGTERM — lets a test confirm the
52
+ // // process actually died after `child.kill()`
53
+ // cancelSink?: string, // if set, a file this process writes (the JSON
54
+ // // `session/cancel` params) the moment it receives
55
+ // // that notification — lets a test prove
56
+ // // `AcpSession.interrupt()` really sent
57
+ // // `session/cancel` and not, say, an aborted request
58
+ // hangUntilCancel?: boolean, // if true, session/prompt sends its `updates` and
59
+ // // then waits for a `session/cancel` notification
60
+ // // before returning, instead of returning right away
61
+ // // — gives a test a real in-flight turn to interrupt
62
+ // stopReasonAfterCancel?: StopReason, // stopReason returned once hangUntilCancel's wait is
63
+ // // over; default 'end_turn' — deliberately NOT
64
+ // // 'cancelled', so a test can prove AcpSession forces
65
+ // // status 'interrupted' off its own interrupted flag
66
+ // // rather than merely relaying the agent's stopReason
67
+ // pingDuringInitialize?: boolean, // if true, initialize() first calls the client-side
68
+ // // custom method `_test/ping` and writes the
69
+ // // response to pingResultSink before returning —
70
+ // // this is how a test proves a `connectAgent`
71
+ // // `configure` hook actually registered a handler
72
+ // // before `.connect()`, since it only works if the
73
+ // // handler exists in time to answer this call
74
+ // pingResultSink?: string,
75
+ // requestPermissionDuringPrompt?: { toolCall: ToolCallUpdate, options: PermissionOption[] },
76
+ // // if set, session/prompt calls the client's
77
+ // // session/request_permission with this payload
78
+ // // before returning, and writes the raw response to
79
+ // // requestPermissionResultSink — Task 5's proof that
80
+ // // its handler is reachable through connectAgent's
81
+ // // `configure` hook from a real separate process.
82
+ // requestPermissionResultSink?: string,
83
+ // createElicitationDuringPrompt?: object, // if set, session/prompt calls the client's
84
+ // // elicitation/create with this payload verbatim
85
+ // // before returning, and writes the raw response to
86
+ // // createElicitationResultSink.
87
+ // createElicitationResultSink?: string,
88
+ // authenticateSink?: string, // if set, a file this process writes (the raw JSON
89
+ // // `authenticate` params, i.e. `{ methodId }`) the
90
+ // // moment it receives that request — lets a test
91
+ // // prove `AcpAuth.authenticate` really sent the
92
+ // // request (vs. refusing before ever reaching the
93
+ // // agent for an unadvertised or terminal methodId)
94
+ // authenticateRejectsWith?: string, // if set, the authenticate handler throws an Error
95
+ // // with this message instead of succeeding — lets a
96
+ // // test prove a real agent-side rejection surfaces as
97
+ // // a rejected promise
98
+ // hangOnAuthenticate?: boolean, // if true, `authenticate` never resolves — the shape
99
+ // // measured on a real agent that opens a browser and
100
+ // // waits for its callback (spec §6.2: antigravity's
101
+ // // authenticate had not returned after 25s). Lets a
102
+ // // test prove a login is NOT cut off at the probe
103
+ // // budget the way `hangOnSessionNew` proves a probe is
104
+ // createElicitationDuringAuthenticate?: object,
105
+ // // if set, the `authenticate` handler calls the
106
+ // // client's `elicitation/create` with this payload
107
+ // // verbatim before returning, and writes the raw
108
+ // // response to createElicitationDuringAuthenticateResultSink.
109
+ // // This is the device-code login shape (Codex), and
110
+ // // the only way to prove the client registered its
111
+ // // elicitation handlers on the connection
112
+ // // `authenticate` actually runs on — a session
113
+ // // connection's handlers are unreachable from here.
114
+ // createElicitationDuringAuthenticateResultSink?: string,
115
+ // completeElicitationAfterAuthenticate?: string,
116
+ // // if set, the `authenticate` handler sends
117
+ // // `elicitation/complete` for this elicitationId
118
+ // // right after the elicitation above is answered —
119
+ // // the agent retiring its own URL, which is the only
120
+ // // provenance that can ever be retracted by the
121
+ // // protocol itself.
122
+ // stderrLines?: string[], // written to this process's stderr (one line each)
123
+ // // before it speaks any protocol at all — the
124
+ // // stderr-only login shape (antigravity), and it
125
+ // // starts at spawn on purpose, which is exactly the
126
+ // // window `ConnectAgentOptions.present` warns about.
127
+ // hangOnSessionNew?: boolean, // if true, session/new never resolves — the agent
128
+ // // answered initialize and then went quiet on the
129
+ // // very next request. Distinct from hangOnInitialize:
130
+ // // that one is caught by connectAgent's own
131
+ // // initializeTimeoutMs, this one is past it, so only
132
+ // // a deadline the CALLER imposes (AcpAgents.verify's
133
+ // // withDeadline) can end it. Task 10a's review
134
+ // // measured this path with a temporary flag and then
135
+ // // reverted it; Task 11 made it permanent.
136
+ // newSessionRejectsWith?: string, // if set, the session/new handler throws an Error with
137
+ // // this message instead of returning a sessionId —
138
+ // // the shape a real agent takes when it needs
139
+ // // credentials it does not have. Task 10a uses it to
140
+ // // prove a failed verification reports what happened
141
+ // // WITHOUT claiming the agent is unauthenticated,
142
+ // // which ACP v1 gives nobody the means to know.
143
+ // newSessionCommands?: AvailableCommand[][], // 每个元素是一次 available_commands_update 推送,
144
+ // // 在 session/new 回包之后按序发出(真适配器就是
145
+ // // 这么干的:setTimeout 0,见 acp-agent.js:756)。
146
+ // // 用数组的数组是因为「推几次」本身就是被测行为:
147
+ // // claude 会推两次(70 → 68),codex 只推一次。
148
+ // newSessionCommandsDelayMs?: number, // 每次推送之间的间隔,默认 0。让测试能把一条晚到的
149
+ // // 推送放在 settle 窗口的里面或外面。
150
+ // sessions?: SessionInfo[], // session/list's response `sessions` array; default []
151
+ // nextCursor?: string, // session/list's response `nextCursor`
152
+ // sessionListSink?: string, // if set, a file this process writes (the raw JSON
153
+ // // session/list params, i.e. `{cwd, cursor}`) the
154
+ // // moment it receives that request — Task 7's proof
155
+ // // that listNativeSessions really sent cwd/cursor
156
+ // loadUpdates?: SessionUpdate[], // Task 7 (adoption.ts): sent as session/update
157
+ // // notifications during session/load, before it
158
+ // // returns — this is the replay mechanism ACP v1
159
+ // // actually uses (session/resume does NOT replay
160
+ // // history; see adoption.ts's file doc comment)
161
+ // loadSessionRejectsWith?: string, // if set, the session/load handler throws an Error with
162
+ // // this message instead of replaying and succeeding —
163
+ // // lets a test prove a client falls back to session/new
164
+ // // when an advertised load is refused (a stale id)
165
+ // promptRejectsWith?: string, // if set, the session/prompt handler throws an Error with
166
+ // // this message instead of returning a stopReason — the
167
+ // // only way to exercise a rejected prompt against a real
168
+ // // agent (the SDK masks the text as "Internal error",
169
+ // // and the code as JSON-RPC -32603)
170
+ // promptRejectsWithAuthRequired?: boolean, // if set, session/prompt answers with ACP's own
171
+ // // `RequestError.authRequired()` (-32000) instead —
172
+ // // the shape credentials expiring mid-conversation
173
+ // // actually takes, and the only failure the login
174
+ // // card may be drawn for
175
+ // promptSink?: string, // if set, a file this process overwrites with the raw
176
+ // // JSON session/prompt params on every prompt — the
177
+ // // only way to observe WHICH sessionId a prompt was
178
+ // // addressed to, which is the whole point of attaching
179
+ // // to a loaded session rather than opening a new one
180
+ // loadSessionResponse?: LoadSessionResponse, // session/load's response; default {}
181
+ // loadSessionSink?: string, // if set, a file this process writes (the raw JSON
182
+ // // session/load params) the moment it receives that
183
+ // // request — lets a test prove the right
184
+ // // sessionId/cwd were sent, and (by NOT configuring
185
+ // // this and asserting the file never appears) that
186
+ // // adoption.ts never calls session/load when the
187
+ // // capability isn't advertised
188
+ // resumeSessionResponse?: ResumeSessionResponse, // session/resume's response; default {}
189
+ // resumeSessionSink?: string, // same idea as loadSessionSink, for session/resume
190
+ // // — Task 7's proof that adoption.ts NEVER calls
191
+ // // session/resume for replay (see adoption.ts's file
192
+ // // doc comment for why), even when the agent
193
+ // // advertises and answers it
194
+ // steerSink?: string, // if set, a file this process writes (the raw JSON
195
+ // // `_session/steering` params) the moment it receives
196
+ // // that request. `_session/steering` is an EXTENSION,
197
+ // // not ACP (`claude-agent-acp`
198
+ // // `acp-agent.js@0.76.0:164`),
199
+ // // which is why it is routed here by its literal
200
+ // // method name rather than through `methods.agent.*`
201
+ // steerOutcome?: 'injected'|'startedNewTurn'|'promptRequired',
202
+ // // the outcome `_session/steering` answers with;
203
+ // // default 'injected'. `startedNewTurn` is the shape
204
+ // // a contract-breaking agent takes — it is what an
205
+ // // agent returns to a client that did NOT opt into
206
+ // // `promptRequired`, so seeing it back means the
207
+ // // agent ignored the opt-in we always send
208
+ // steerRejectsWith?: string, // if set, the handler throws with this message
209
+ // newSessionConfigOptions?: SessionConfigOption[], // returned verbatim on session/new (types.gen.d.ts:2546)
210
+ // newSessionModes?: SessionModeState, // returned verbatim on session/new (:2542)
211
+ // setConfigOptionSink?: string, // file receiving the JSON params of each session/set_config_option
212
+ // setConfigOptionRejectsWith?: string, // if set, session/set_config_option throws
213
+ // setConfigOptionConfigOptions?: SessionConfigOption[], // if set, session/set_config_option answers with
214
+ // // exactly this list instead of echoing
215
+ // // `newSessionConfigOptions` with the value applied —
216
+ // // for the case where the agent's reply carries a
217
+ // // *different* set of options than the open did
218
+ // setModeSink?: string, // file receiving the JSON params of each session/set_mode
219
+ // }
220
+ //
221
+ // session/list, session/load and session/resume are ALWAYS routed (see the unconditional onRequest
222
+ // calls below), independent of whether `agentCapabilities.sessionCapabilities.list`/`.resume` or
223
+ // `agentCapabilities.loadSession` are advertised. That mirrors the real-world case this task exists
224
+ // to guard against — codex-acp routes session/fork without declaring it — and gives callers a
225
+ // fixture that can prove "honour the advertised capability, not the router" end to end, not just
226
+ // assert a boolean.
227
+ async function main() {
228
+ const { writeFileSync } = require('node:fs');
229
+ // Parsed and wired up before the (slow, cold-start) dynamic import below, so a caller that kills
230
+ // this process almost immediately after spawning it (exercising connectAgent's initialize
231
+ // timeout with a short deadline) still gets a SIGTERM handler in place. Registering this only
232
+ // after the SDK import resolved raced the timeout in practice.
233
+ const config = JSON.parse(process.argv[2] ?? '{}');
234
+ // Before the SDK import, deliberately: an agent that only ever prints its login URL does it while
235
+ // it is still starting up, and the client's sniffer is armed from `spawn()` for that reason.
236
+ for (const line of config.stderrLines ?? [])
237
+ process.stderr.write(`${line}\n`);
238
+ if (config.exitMarkerPath) {
239
+ process.on('SIGTERM', () => {
240
+ try {
241
+ writeFileSync(config.exitMarkerPath, 'killed');
242
+ }
243
+ finally {
244
+ process.exit(1);
245
+ }
246
+ });
247
+ }
248
+ const { agent, methods, PROTOCOL_VERSION, ndJsonStream, RequestError } = await import('@agentclientprotocol/sdk');
249
+ const { Readable, Writable } = require('node:stream');
250
+ const sessionId = config.sessionId ?? 'fake-session';
251
+ const updates = config.updates ?? [];
252
+ const stopReason = config.stopReason ?? 'end_turn';
253
+ let resolveCancelled;
254
+ const cancelled = new Promise((resolve) => {
255
+ resolveCancelled = resolve;
256
+ });
257
+ const app = agent({ name: 'fake-agent' })
258
+ .onRequest(methods.agent.initialize, async (ctx) => {
259
+ if (config.protocolVersionSink) {
260
+ writeFileSync(config.protocolVersionSink, String(ctx.params.protocolVersion));
261
+ }
262
+ if (config.clientCapabilitiesSink) {
263
+ writeFileSync(config.clientCapabilitiesSink, JSON.stringify(ctx.params.clientCapabilities ?? {}));
264
+ }
265
+ if (config.pingDuringInitialize) {
266
+ const pong = await ctx.client.request('_test/ping', { echo: 'hi' });
267
+ writeFileSync(config.pingResultSink, JSON.stringify(pong));
268
+ }
269
+ if (config.hangOnInitialize) {
270
+ return new Promise(() => { }); // never resolves
271
+ }
272
+ return {
273
+ protocolVersion: config.protocolVersionOverride ?? PROTOCOL_VERSION,
274
+ agentCapabilities: config.agentCapabilities ?? {},
275
+ authMethods: config.authMethods ?? [],
276
+ ...(config.agentInfo ? { agentInfo: config.agentInfo } : {}),
277
+ ...(config.initializeMeta ? { _meta: config.initializeMeta } : {}),
278
+ };
279
+ })
280
+ .onRequest(methods.agent.authenticate, async (ctx) => {
281
+ if (config.authenticateSink)
282
+ writeFileSync(config.authenticateSink, JSON.stringify(ctx.params));
283
+ if (config.authenticateRejectsWith)
284
+ throw new Error(config.authenticateRejectsWith);
285
+ if (config.createElicitationDuringAuthenticate) {
286
+ // `requestId` is filled in here rather than in the config: a login elicitation is
287
+ // request-scoped (ACP's `ElicitationRequestScope` — "tied to a specific JSON-RPC request
288
+ // outside of a session, e.g. during auth phases before any session is started"), and only
289
+ // this handler knows the id of the `authenticate` it is answering.
290
+ const result = await ctx.client.request(methods.client.elicitation.create, {
291
+ requestId: ctx.requestId,
292
+ ...config.createElicitationDuringAuthenticate,
293
+ });
294
+ if (config.createElicitationDuringAuthenticateResultSink) {
295
+ writeFileSync(config.createElicitationDuringAuthenticateResultSink, JSON.stringify(result));
296
+ }
297
+ if (config.completeElicitationAfterAuthenticate) {
298
+ await ctx.client.notify(methods.client.elicitation.complete, {
299
+ elicitationId: config.completeElicitationAfterAuthenticate,
300
+ });
301
+ }
302
+ }
303
+ // Last, so the flags above are independently exercisable: a login that hangs may well have
304
+ // handed out its URL first, which is precisely the case this fixture has to be able to model.
305
+ if (config.hangOnAuthenticate)
306
+ return new Promise(() => { });
307
+ return {};
308
+ })
309
+ .onRequest(methods.agent.session.new, async (ctx) => {
310
+ if (config.newSessionRejectsWith)
311
+ throw new Error(config.newSessionRejectsWith);
312
+ // Checked after the rejection so the two flags stay independently exercisable; a promise that
313
+ // never settles is the only faithful way to model an agent that simply stops answering.
314
+ if (config.hangOnSessionNew)
315
+ return new Promise(() => { });
316
+ // Pushed AFTER this handler returns, not before: the real adapter schedules its first
317
+ // `available_commands_update` on a `setTimeout(…, 0)` that runs once `session/new` has been
318
+ // answered (`acp-agent.js:752-757`). Sending them inline would let a client that only reads
319
+ // updates *before* the reply still pass, which is the bug this fixture exists to catch.
320
+ const pushes = config.newSessionCommands ?? [];
321
+ if (pushes.length > 0) {
322
+ void (async () => {
323
+ for (const availableCommands of pushes) {
324
+ await new Promise((r) => setTimeout(r, config.newSessionCommandsDelayMs ?? 0));
325
+ await ctx.client.notify(methods.client.session.update, {
326
+ sessionId,
327
+ update: { sessionUpdate: 'available_commands_update', availableCommands },
328
+ });
329
+ }
330
+ })();
331
+ }
332
+ return {
333
+ sessionId,
334
+ ...(config.newSessionConfigOptions ? { configOptions: config.newSessionConfigOptions } : {}),
335
+ ...(config.newSessionModes ? { modes: config.newSessionModes } : {}),
336
+ };
337
+ })
338
+ .onRequest(methods.agent.session.list, (ctx) => {
339
+ if (config.sessionListSink)
340
+ writeFileSync(config.sessionListSink, JSON.stringify(ctx.params));
341
+ return { sessions: config.sessions ?? [], ...(config.nextCursor ? { nextCursor: config.nextCursor } : {}) };
342
+ })
343
+ .onRequest(methods.agent.session.load, async (ctx) => {
344
+ if (config.loadSessionSink)
345
+ writeFileSync(config.loadSessionSink, JSON.stringify(ctx.params));
346
+ if (config.loadSessionRejectsWith)
347
+ throw new Error(config.loadSessionRejectsWith);
348
+ for (const update of config.loadUpdates ?? []) {
349
+ await ctx.client.notify(methods.client.session.update, { sessionId: ctx.params.sessionId, update });
350
+ }
351
+ return config.loadSessionResponse ?? {};
352
+ })
353
+ .onRequest(methods.agent.session.resume, async (ctx) => {
354
+ if (config.resumeSessionSink)
355
+ writeFileSync(config.resumeSessionSink, JSON.stringify(ctx.params));
356
+ return config.resumeSessionResponse ?? {};
357
+ })
358
+ .onRequest(methods.agent.session.setConfigOption, (ctx) => {
359
+ if (config.setConfigOptionSink)
360
+ writeFileSync(config.setConfigOptionSink, JSON.stringify(ctx.params));
361
+ if (config.setConfigOptionRejectsWith)
362
+ throw new Error(config.setConfigOptionRejectsWith);
363
+ if (config.setConfigOptionConfigOptions)
364
+ return { configOptions: config.setConfigOptionConfigOptions };
365
+ // Echo the snapshot with the requested value applied — the real adapters answer the full set
366
+ // (SetSessionConfigOptionResponse.configOptions, types.gen.d.ts:2953).
367
+ const next = (config.newSessionConfigOptions ?? []).map((o) => o.id === ctx.params.configId ? { ...o, currentValue: ctx.params.value } : o);
368
+ return { configOptions: next };
369
+ })
370
+ .onRequest(methods.agent.session.setMode, (ctx) => {
371
+ if (config.setModeSink)
372
+ writeFileSync(config.setModeSink, JSON.stringify(ctx.params));
373
+ return {};
374
+ })
375
+ .onNotification(methods.agent.session.cancel, (ctx) => {
376
+ if (config.cancelSink)
377
+ writeFileSync(config.cancelSink, JSON.stringify(ctx.params));
378
+ resolveCancelled();
379
+ })
380
+ .onRequest('_session/steering', (params) => params, (ctx) => {
381
+ if (config.steerSink)
382
+ writeFileSync(config.steerSink, JSON.stringify(ctx.params));
383
+ if (config.steerRejectsWith)
384
+ throw new Error(config.steerRejectsWith);
385
+ const outcome = config.steerOutcome ?? 'injected';
386
+ return outcome === 'promptRequired' ? { outcome, reason: 'noRunningTurn' } : { outcome };
387
+ })
388
+ .onRequest(methods.agent.session.prompt, async (ctx) => {
389
+ if (config.promptSink)
390
+ writeFileSync(config.promptSink, JSON.stringify(ctx.params));
391
+ // A plain Error is masked by the SDK as JSON-RPC -32603; `RequestError.authRequired()` is
392
+ // the real thing an agent whose credentials expired answers with (-32000). The two shapes are
393
+ // what the login card has to tell apart, and only a real agent process on a real wire proves
394
+ // the code survives the trip.
395
+ if (config.promptRejectsWithAuthRequired)
396
+ throw RequestError.authRequired();
397
+ if (config.promptRejectsWith)
398
+ throw new Error(config.promptRejectsWith);
399
+ for (const update of updates) {
400
+ await ctx.client.notify(methods.client.session.update, { sessionId: ctx.params.sessionId, update });
401
+ }
402
+ // Task 5 (permission.ts): if configured, calls the client's `session/request_permission`
403
+ // handler mid-turn and writes the raw response to a sink file — this is how a test proves the
404
+ // handler registered via `connectAgent`'s `configure` hook is really reachable from a separate
405
+ // OS process speaking real ACP, not just callable in-process.
406
+ if (config.requestPermissionDuringPrompt) {
407
+ const result = await ctx.client.request(methods.client.session.requestPermission, {
408
+ sessionId: ctx.params.sessionId,
409
+ toolCall: config.requestPermissionDuringPrompt.toolCall,
410
+ options: config.requestPermissionDuringPrompt.options,
411
+ });
412
+ writeFileSync(config.requestPermissionResultSink, JSON.stringify(result));
413
+ }
414
+ // Same idea for `elicitation/create`.
415
+ if (config.createElicitationDuringPrompt) {
416
+ const result = await ctx.client.request(methods.client.elicitation.create, config.createElicitationDuringPrompt);
417
+ writeFileSync(config.createElicitationResultSink, JSON.stringify(result));
418
+ }
419
+ if (config.hangUntilCancel) {
420
+ await cancelled;
421
+ return { stopReason: config.stopReasonAfterCancel ?? 'end_turn' };
422
+ }
423
+ return { stopReason };
424
+ });
425
+ // Mirrors the SDK's own `dist/examples/agent.js`: the agent's outgoing JSON-RPC traffic goes out
426
+ // over its own stdout, and it reads incoming traffic from its own stdin — the opposite way round
427
+ // from the client side of the same pipe pair.
428
+ const stream = ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
429
+ app.connect(stream);
430
+ }
431
+ main().catch((err) => {
432
+ process.stderr.write(`fake-agent crashed: ${(err && err.stack) || err}\n`);
433
+ process.exit(1);
434
+ });
435
+ export {};
@@ -0,0 +1,165 @@
1
+ /**
2
+ * ACP's fifteen `SessionUpdate` variants, and one minimal (plus one "rich") input per variant.
3
+ *
4
+ * **This file exists because a hand-copied list of variants is exactly the guard that cannot
5
+ * fail.** The list used to live inline in `sessionUpdateMapper.test.ts` as a `const [...] as const`
6
+ * — its own comment said it was "the second, weaker net" — and `adoption.test.ts` shipped a stale
7
+ * copy of a different vocabulary list for a whole phase without anything noticing. So the list here
8
+ * is checked against the SDK's own type declaration ({@link acpVariantsFromSdk}) by
9
+ * `acpVocabulary.test.ts`, and its shape is pinned by the compiler as well:
10
+ * {@link MINIMAL} is a `Record` keyed on the SDK's own union, so adding, removing or misspelling a
11
+ * variant is a **compile** error before it is ever a test failure.
12
+ *
13
+ * Three suites share it — `sessionUpdateMapper.test.ts`, `acpVocabulary.test.ts` and
14
+ * `wireContract.test.ts` — so there is exactly one place where "what does ACP send" is written
15
+ * down, and it is the place the SDK is diffed against.
16
+ */
17
+ import { readFileSync } from 'node:fs';
18
+ import { dirname, resolve } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ export const TEXT_CONTENT = { type: 'text', text: 'hello' };
21
+ /**
22
+ * The minimal fields each variant's own type requires.
23
+ *
24
+ * A `Record` over the SDK's union rather than a `switch`: a `switch` with a missing case is only a
25
+ * problem if some caller happens to hit it, while a `Record` with a missing key does not compile.
26
+ */
27
+ const MINIMAL = {
28
+ user_message_chunk: { content: TEXT_CONTENT },
29
+ agent_message_chunk: { content: TEXT_CONTENT },
30
+ agent_thought_chunk: { content: TEXT_CONTENT },
31
+ tool_call: { toolCallId: 'call-1', title: 'Reading a file' },
32
+ tool_call_update: { toolCallId: 'call-1' },
33
+ plan: { entries: [] },
34
+ plan_update: { plan: { type: 'markdown', planId: 'plan-1', content: '- step one' } },
35
+ plan_removed: { planId: 'plan-1' },
36
+ available_commands_update: { availableCommands: [] },
37
+ current_mode_update: { currentModeId: 'default' },
38
+ config_option_update: { configOptions: [] },
39
+ session_info_update: {},
40
+ usage_update: { used: 100, size: 1000 },
41
+ compaction_update: { compactionId: 'compaction-1', status: 'in_progress' },
42
+ compaction_summary_chunk: { compactionId: 'compaction-1', content: TEXT_CONTENT },
43
+ };
44
+ /** Every ACP `SessionUpdate` variant, in the SDK's own declaration order. */
45
+ export const ALL_SESSION_UPDATE_VARIANTS = Object.keys(MINIMAL);
46
+ export function minimalFor(variant) {
47
+ return MINIMAL[variant];
48
+ }
49
+ /**
50
+ * "Rich" inputs for the two tool variants, whose mapping is the one place a `status` changes how
51
+ * many events come out: an already-terminal `tool_call` emits a second `tool_call_update` beside
52
+ * the creation, while a `tool_call_update` is always exactly one event whatever its status says
53
+ * (see `sessionUpdateMapper.ts`'s `toolCallUpdateEvent`). Kept separate from {@link minimalFor} so
54
+ * a sweep exercises both the pending/in_progress path and the completed/failed path for those two.
55
+ */
56
+ export function richFor(variant) {
57
+ if (variant === 'tool_call') {
58
+ return [
59
+ { toolCallId: 'call-1', title: 'Reading a file', status: 'completed', content: [{ type: 'content', content: TEXT_CONTENT }] },
60
+ { toolCallId: 'call-1', title: 'Reading a file', status: 'failed', content: [{ type: 'content', content: TEXT_CONTENT }] },
61
+ ];
62
+ }
63
+ if (variant === 'tool_call_update') {
64
+ return [
65
+ { toolCallId: 'call-1', status: 'completed', content: [{ type: 'diff', path: '/repo/a.ts', newText: 'new' }] },
66
+ { toolCallId: 'call-1', status: 'failed', content: [{ type: 'content', content: TEXT_CONTENT }] },
67
+ ];
68
+ }
69
+ return [minimalFor(variant)];
70
+ }
71
+ /**
72
+ * Every field the SDK declares on each variant, filled in — used by the wire-contract guard, whose
73
+ * question is "which keys can the mapper ever emit for this variant", not "does the minimal input
74
+ * work". A minimal input answers the second question only, and every optional field it leaves out
75
+ * is a key the guard would then never see.
76
+ *
77
+ * Hand-written rather than derived: the SDK ships `.d.ts`, not a runtime schema, so there is
78
+ * nothing to enumerate at runtime. What keeps it honest is the guard that consumes it — a key the
79
+ * mapper emits from a field missing here simply never appears, and the *other* direction (a Kotlin
80
+ * decoder reading a key nothing here produces) is what that guard reports.
81
+ */
82
+ export function saturatedFor(variant) {
83
+ switch (variant) {
84
+ case 'user_message_chunk':
85
+ case 'agent_message_chunk':
86
+ case 'agent_thought_chunk':
87
+ // Task 8: `_meta` now rides all three chunk kinds too (`sessionUpdateMapper.ts`'s
88
+ // `mapSessionUpdate`), so the wire-contract guard (`wireContract.test.ts`) needs to see it
89
+ // emitted here or it flags `<event>._meta` as a key the server reads that the CLI never
90
+ // sends — a false "silent frame drop" for a key that is, in fact, sent.
91
+ return { content: TEXT_CONTENT, messageId: 'm-1', _meta: { claudeCode: { parentToolUseId: 'toolu_01' } } };
92
+ case 'tool_call':
93
+ // `tool_call` now has a named `TimelineEventWire.decodeToolCall` branch and its typed
94
+ // `WorkTimelinePayload.ToolCall` consumer preserves ACP's extension slot. Include `_meta`
95
+ // here so the cross-language guard exercises the actual transport contract rather than a
96
+ // pre-decoder fallback shape.
97
+ return {
98
+ toolCallId: 'call-1',
99
+ title: 'Reading a file',
100
+ name: 'read_file',
101
+ kind: 'read',
102
+ status: 'completed',
103
+ content: [{ type: 'content', content: TEXT_CONTENT }],
104
+ locations: [{ path: '/repo/a.ts', line: 3 }],
105
+ rawInput: { path: '/repo/a.ts' },
106
+ rawOutput: 'contents',
107
+ _meta: { claudeCode: { parentToolUseId: 'toolu_01' } },
108
+ };
109
+ case 'tool_call_update':
110
+ return {
111
+ toolCallId: 'call-1',
112
+ title: 'Reading a file',
113
+ name: 'read_file',
114
+ kind: 'read',
115
+ status: 'completed',
116
+ content: [{ type: 'content', content: TEXT_CONTENT }],
117
+ locations: [{ path: '/repo/a.ts', line: 3 }],
118
+ rawInput: { path: '/repo/a.ts' },
119
+ rawOutput: 'contents',
120
+ _meta: { claudeCode: { parentToolUseId: 'toolu_01' } },
121
+ };
122
+ case 'plan':
123
+ return { entries: [{ content: 'write the test', priority: 'medium', status: 'pending' }] };
124
+ case 'plan_update':
125
+ return { planId: 'plan-1', plan: { type: 'markdown', planId: 'plan-1', content: '- step one' } };
126
+ case 'plan_removed':
127
+ return { planId: 'plan-1' };
128
+ case 'available_commands_update':
129
+ return { availableCommands: [{ name: 'plan', description: 'Draft a plan', input: { hint: 'goal' } }] };
130
+ case 'current_mode_update':
131
+ return { currentModeId: 'default' };
132
+ case 'config_option_update':
133
+ return { configOptions: [{ id: 'model', name: 'Model', type: 'select', currentValue: 'opus', options: [] }] };
134
+ case 'session_info_update':
135
+ return { title: 'A session', updatedAt: '2026-08-24T00:00:00Z' };
136
+ case 'usage_update':
137
+ return { used: 100, size: 1000, cost: { amount: 0.5, currency: 'USD' } };
138
+ case 'compaction_update':
139
+ return { compactionId: 'compaction-1', status: 'failed', summary: [TEXT_CONTENT], error: 'out of budget' };
140
+ case 'compaction_summary_chunk':
141
+ return { compactionId: 'compaction-1', content: TEXT_CONTENT };
142
+ }
143
+ }
144
+ const SDK_TYPES = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../node_modules/@agentclientprotocol/sdk/dist/schema/types.gen.d.ts');
145
+ /**
146
+ * The variant tags the SDK itself declares, read out of `types.gen.d.ts`'s `SessionUpdate` union.
147
+ *
148
+ * Strict on purpose, in three places, because every one of them is a way for this to quietly stop
149
+ * comparing anything: the `export type SessionUpdate =` line must be found, the union must
150
+ * terminate on a `;`, and at least one tag must come out. A regex that silently matches nothing is
151
+ * precisely the unfalsifiable guard this whole file exists to replace.
152
+ */
153
+ export function acpVariantsFromSdk() {
154
+ const source = readFileSync(SDK_TYPES, 'utf8');
155
+ const start = source.indexOf('export type SessionUpdate =');
156
+ if (start < 0)
157
+ throw new Error(`no \`export type SessionUpdate =\` in ${SDK_TYPES}`);
158
+ const end = source.indexOf('\n};', start);
159
+ if (end < 0)
160
+ throw new Error(`\`SessionUpdate\` union in ${SDK_TYPES} has no terminating \`\\n};\``);
161
+ const tags = [...source.slice(start, end).matchAll(/sessionUpdate:\s*"([^"]+)"/g)].map((m) => m[1]);
162
+ if (tags.length === 0)
163
+ throw new Error(`no \`sessionUpdate: "..."\` tags in ${SDK_TYPES}'s SessionUpdate union`);
164
+ return tags;
165
+ }