@platless/axom-cli 0.1.0 → 0.3.13

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 (109) hide show
  1. package/README.md +1 -1
  2. package/dist/authScopes.js +19 -0
  3. package/dist/axom-patch/cli/README.md +110 -0
  4. package/dist/axom-patch/cli/pi-steering.json +37 -0
  5. package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +34 -0
  6. package/dist/axom-patch/cli/probes/pi-steering.mjs +80 -0
  7. package/dist/axom-patch/cli/probes/qoder-steering.mjs +89 -0
  8. package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +49 -0
  9. package/dist/axom-patch/cli/qoder-background-terminal.md +81 -0
  10. package/dist/axom-patch/cli/qoder-foreground-output.md +41 -0
  11. package/dist/axom-patch/cli/qoder-steering.json +52 -0
  12. package/dist/axom-patch/cli/qoder-steering.md +30 -0
  13. package/dist/axom-patch/cli/qoder-subagent-origin.json +73 -0
  14. package/dist/axom-patch/cli/qoder-subagent-subwork.md +73 -0
  15. package/dist/axom-patch/cli/runtime.json +37 -0
  16. package/dist/axom-patch/cli/steering/claude-steering-js.patch +89 -0
  17. package/dist/axom-patch/cli/steering/claude-steering-types.patch +28 -0
  18. package/dist/axom-patch/cli/steering/codex-steering.patch +57 -0
  19. package/dist/axom-patch/cli/steering/pi-steering.js +79 -0
  20. package/dist/cli.js +9 -1
  21. package/dist/commands/auth.js +4 -13
  22. package/dist/commands/execute.js +4 -4
  23. package/dist/commands/record.js +6 -6
  24. package/dist/commands/struct.js +4 -4
  25. package/dist/commands/workstation.js +2 -2
  26. package/dist/credentials.js +2 -2
  27. package/dist/install/claude/commands.js +13 -13
  28. package/dist/mcp.js +32 -14
  29. package/dist/workstation/acp/acpAgents.js +447 -166
  30. package/dist/workstation/acp/acpClient.js +232 -18
  31. package/dist/workstation/acp/acpSession.js +502 -74
  32. package/dist/workstation/acp/activityBusy.js +43 -0
  33. package/dist/workstation/acp/activityReporting.js +54 -0
  34. package/dist/workstation/acp/adoption.js +107 -49
  35. package/dist/workstation/acp/authUrlSniffer.js +36 -0
  36. package/dist/workstation/acp/contract.js +11 -6
  37. package/dist/workstation/acp/delegationMcp.js +24 -0
  38. package/dist/workstation/acp/draftInterceptor.js +9 -0
  39. package/dist/workstation/acp/managedPatches.js +228 -0
  40. package/dist/workstation/acp/patchAssets.js +63 -0
  41. package/dist/workstation/acp/permission.js +300 -34
  42. package/dist/workstation/acp/qoderStartup.js +45 -0
  43. package/dist/workstation/acp/registry/catalog.js +263 -4
  44. package/dist/workstation/acp/registry/detect.js +55 -0
  45. package/dist/workstation/acp/sessionScope.js +19 -0
  46. package/dist/workstation/acp/sessionUpdateMapper.js +39 -21
  47. package/dist/workstation/acp/terminalLogin.js +197 -0
  48. package/dist/workstation/acp/test/fakeAgent.js +185 -9
  49. package/dist/workstation/acp/validate.js +33 -5
  50. package/dist/workstation/agentSessions.js +532 -44
  51. package/dist/workstation/channels/agent.js +7 -1
  52. package/dist/workstation/channels/computer.js +152 -4
  53. package/dist/workstation/channels/management.js +3 -3
  54. package/dist/workstation/config.js +3 -1
  55. package/dist/workstation/connection/encryptedChannel.js +88 -21
  56. package/dist/workstation/daemon.js +25 -3
  57. package/dist/workstation/desktop/binding.js +36 -0
  58. package/dist/workstation/desktop/bridge.js +212 -0
  59. package/dist/workstation/desktop/flow-control.js +52 -0
  60. package/dist/workstation/desktop/focused-text.js +130 -0
  61. package/dist/workstation/desktop/protocol.js +35 -0
  62. package/dist/workstation/fileOps.js +277 -0
  63. package/dist/workstation/filePaths.js +174 -0
  64. package/dist/workstation/fileWorker.js +81 -0
  65. package/dist/workstation/fileWorkerClient.js +121 -0
  66. package/dist/workstation/oauth/deviceAuthorization.js +21 -12
  67. package/dist/workstation/protocol/agent.js +154 -5
  68. package/dist/workstation/protocol/common.js +6 -0
  69. package/dist/workstation/protocol/computer.js +92 -3
  70. package/dist/workstation/qoderAdapter/backgroundProcess.js +106 -0
  71. package/dist/workstation/qoderAdapter/backgroundTerminal.js +262 -0
  72. package/dist/workstation/qoderAdapter/completedContinuation.js +139 -0
  73. package/dist/workstation/qoderAdapter/contract.js +78 -0
  74. package/dist/workstation/qoderAdapter/foregroundOutput.js +30 -0
  75. package/dist/workstation/qoderAdapter/history.js +87 -0
  76. package/dist/workstation/qoderAdapter/identityIndex.js +191 -0
  77. package/dist/workstation/qoderAdapter/inputQueue.js +42 -0
  78. package/dist/workstation/qoderAdapter/main.js +93 -0
  79. package/dist/workstation/qoderAdapter/mapper.js +117 -0
  80. package/dist/workstation/qoderAdapter/observerPlugin.js +245 -0
  81. package/dist/workstation/qoderAdapter/observerReceiver.js +187 -0
  82. package/dist/workstation/qoderAdapter/permissions.js +61 -0
  83. package/dist/workstation/qoderAdapter/profile.js +93 -0
  84. package/dist/workstation/qoderAdapter/sdkDriver.js +49 -0
  85. package/dist/workstation/qoderAdapter/session.js +319 -0
  86. package/dist/workstation/qoderAdapter/subagentMeta.js +28 -0
  87. package/dist/workstation/qoderAdapter/subwork.js +77 -0
  88. package/dist/workstation/qoderAdapter/taskControl.js +38 -0
  89. package/dist/workstation/qoderAdapter/taskEvent.js +26 -0
  90. package/dist/workstation/qoderAdapter/taskLedger.js +163 -0
  91. package/dist/workstation/qoderAdapter/types.js +1 -0
  92. package/dist/workstation/qoderAdapter/usage.js +37 -0
  93. package/dist/workstation/register.js +4 -0
  94. package/dist/workstation/tools/browser.js +394 -0
  95. package/dist/workstation/tools/client.js +269 -0
  96. package/dist/workstation/tools/core.js +50 -0
  97. package/dist/workstation/tools/files.js +525 -0
  98. package/dist/workstation/tools/keeperProcess.js +114 -0
  99. package/dist/workstation/tools/terminal.js +214 -0
  100. package/dist/workstation/tools/validation.js +50 -0
  101. package/dist/workstation/update/coordinator.js +29 -0
  102. package/dist/workstation/update/npmInstaller.js +2 -0
  103. package/dist/workstation/update/ptyPreflight.js +24 -0
  104. package/dist/workstation/update/taskRegistry.js +44 -20
  105. package/dist/workstation/update/watchdog.js +66 -0
  106. package/package.json +14 -4
  107. package/skills/axom/SKILL.md +55 -17
  108. package/skills/axom-definition/SKILL.md +32 -32
  109. package/skills/axom-platform/SKILL.md +39 -39
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Hosting an ACP `terminal` sign-in on the workstation, so the reader gets a link instead of an
3
+ * instruction to go find a terminal (spec 2026-09-24 D6, D7, D9).
4
+ *
5
+ * ## Which command
6
+ *
7
+ * `AuthMethodTerminal` (`@agentclientprotocol/sdk` 1.4.0, `types.gen.d.ts:2237`) says its `args`
8
+ * are appended to "the configured agent invocation". That is wrong for at least one agent in the
9
+ * wild: measured, appending `login` to a binary agent's `acp` launch fails with "unexpected extra
10
+ * argument", while the same agent's `_meta["terminal-auth"]` names the invocation that works. So a
11
+ * `_meta["terminal-auth"]` that carries a `command` is taken as the whole invocation, and only
12
+ * without one does the standard append rule apply. That key appears nowhere in the SDK; it is a
13
+ * convention agents use, and it is matched by **shape**, never by which agent sent it.
14
+ *
15
+ * Trusting the agent's own command adds nothing it could not already do: it is a program this
16
+ * daemon already runs, as the same user, on the same machine. Nothing here composes, quotes or
17
+ * guesses a command, and nothing goes through a shell.
18
+ *
19
+ * ## How it runs
20
+ *
21
+ * Pipes, not a terminal: this client cannot reproduce an interactive terminal, which is also why it
22
+ * does not declare `clientCapabilities.auth.terminal` (`:4399`). What it can host is the common
23
+ * shape — print a link, wait for the reader to approve it somewhere, exit 0 — and that is every
24
+ * device-code login. `PYTHONUNBUFFERED=1` is set because a Python-built CLI block-buffers a pipe
25
+ * and was measured holding its link back for over 40 seconds; it names no agent and is inert for
26
+ * everything else.
27
+ *
28
+ * Every exit ends the **process group**, not just the child. A single-file bundle runs its real work
29
+ * in a grandchild; killing only the bundle's bootstrap was measured leaving that grandchild alive
30
+ * for 23 minutes.
31
+ */
32
+ import { spawn } from 'node:child_process';
33
+ import { randomUUID } from 'node:crypto';
34
+ import { createAuthUrlScanner } from './authUrlSniffer.js';
35
+ export class TerminalLoginFailedError extends Error {
36
+ }
37
+ export class TerminalLoginTimeoutError extends Error {
38
+ }
39
+ export class TerminalLoginCancelledError extends Error {
40
+ }
41
+ /** How many of the process's last output lines a failure quotes. */
42
+ const FAILURE_TAIL_LINES = 20;
43
+ const DEFAULT_KILL_GRACE_MS = 2_000;
44
+ function isRecord(value) {
45
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
46
+ }
47
+ function stringArray(value) {
48
+ return Array.isArray(value) && value.every((item) => typeof item === 'string') ? [...value] : undefined;
49
+ }
50
+ function stringRecord(value) {
51
+ if (!isRecord(value))
52
+ return {};
53
+ return Object.fromEntries(Object.entries(value).filter((entry) => typeof entry[1] === 'string'));
54
+ }
55
+ function metaTerminalAuth(method) {
56
+ if (!isRecord(method) || !isRecord(method._meta))
57
+ return undefined;
58
+ const entry = method._meta['terminal-auth'];
59
+ return isRecord(entry) ? entry : undefined;
60
+ }
61
+ /** The `args` a `_meta["terminal-auth"]` entry carries, for display where the top-level `args` is absent. */
62
+ export function metaTerminalAuthArgs(method) {
63
+ return stringArray(metaTerminalAuth(method)?.args);
64
+ }
65
+ /**
66
+ * How to run this advertised method as a terminal login, or `undefined` when it is not one (spec
67
+ * D6: top-level `type: "terminal"`, or a `_meta["terminal-auth"]` object).
68
+ */
69
+ export function terminalLoginSpecOf(method) {
70
+ if (!isRecord(method))
71
+ return undefined;
72
+ const meta = metaTerminalAuth(method);
73
+ if (meta && typeof meta.command === 'string' && meta.command.trim() !== '') {
74
+ return { kind: 'meta', command: meta.command, args: stringArray(meta.args) ?? [], env: stringRecord(meta.env) };
75
+ }
76
+ if (meta || method.type === 'terminal') {
77
+ return {
78
+ kind: 'append',
79
+ args: stringArray(method.args) ?? stringArray(meta?.args) ?? [],
80
+ env: { ...stringRecord(meta?.env), ...stringRecord(method.env) },
81
+ };
82
+ }
83
+ return undefined;
84
+ }
85
+ /**
86
+ * The concrete command line. `baseEnv` is the workstation's and the agent's configured environment
87
+ * (`AcpListSpec.env` then `.agentEnv`), layered the way `connectAgent` layers a session's; the
88
+ * method's own `env` goes last because ACP says it overrides the base launch configuration.
89
+ */
90
+ export function terminalInvocationFor(spec, launch, baseEnv) {
91
+ const env = { ...launch.env, ...baseEnv, ...spec.env };
92
+ if (spec.kind === 'meta')
93
+ return { command: spec.command, args: [...spec.args], env };
94
+ return { command: launch.command, args: [...launch.args, ...spec.args], env };
95
+ }
96
+ function signalGroup(child, signal) {
97
+ if (child.pid === undefined)
98
+ return;
99
+ try {
100
+ // A negative pid addresses the whole group `detached: true` created. Windows has no process
101
+ // groups; there this reaches the direct child only.
102
+ process.kill(process.platform === 'win32' ? child.pid : -child.pid, signal);
103
+ }
104
+ catch {
105
+ // Best effort, and it must stay that way: this runs inside child-process event handlers, where
106
+ // a throw is an uncaught exception that takes the whole daemon down. ESRCH (the group is already
107
+ // gone) is the common case; anything else (EPERM) still leaves nothing this code could do.
108
+ }
109
+ }
110
+ /**
111
+ * Run one terminal sign-in to completion. Resolves on exit 0; rejects with
112
+ * {@link TerminalLoginFailedError} (non-zero exit, signal, or spawn failure — quoting the process's
113
+ * own last lines), {@link TerminalLoginTimeoutError} or {@link TerminalLoginCancelledError}. Every
114
+ * exit ends the process group first.
115
+ */
116
+ export function runTerminalLogin(invocation, options) {
117
+ return new Promise((resolve, reject) => {
118
+ const killGraceMs = options.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
119
+ if (options.signal?.aborted) {
120
+ reject(new TerminalLoginCancelledError('Sign-in was cancelled before it started.'));
121
+ return;
122
+ }
123
+ const child = spawn(invocation.command, invocation.args, {
124
+ stdio: ['ignore', 'pipe', 'pipe'],
125
+ detached: process.platform !== 'win32',
126
+ env: { ...process.env, ...invocation.env, PYTHONUNBUFFERED: '1' },
127
+ });
128
+ const scan = createAuthUrlScanner();
129
+ const presented = new Set();
130
+ const tail = [];
131
+ let ending;
132
+ let killTimer;
133
+ const onLine = (raw) => {
134
+ const line = raw.replace(/\r$/, '');
135
+ if (line.trim() !== '') {
136
+ // eslint-disable-next-line no-control-regex -- stripping the literal ESC (0x1b) is the point
137
+ tail.push(line.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ''));
138
+ if (tail.length > FAILURE_TAIL_LINES)
139
+ tail.shift();
140
+ }
141
+ const url = scan(line);
142
+ if (url && !presented.has(url)) {
143
+ presented.add(url);
144
+ options.present?.(`terminal-auth-url-${randomUUID()}`, url);
145
+ }
146
+ };
147
+ for (const stream of [child.stdout, child.stderr]) {
148
+ let buffer = '';
149
+ stream?.setEncoding('utf8');
150
+ stream?.on('data', (chunk) => {
151
+ buffer += chunk;
152
+ const lines = buffer.split('\n');
153
+ buffer = lines.pop() ?? '';
154
+ lines.forEach(onLine);
155
+ });
156
+ stream?.on('end', () => { if (buffer !== '')
157
+ onLine(buffer); buffer = ''; });
158
+ }
159
+ const end = (reason) => {
160
+ if (ending)
161
+ return;
162
+ ending = reason;
163
+ signalGroup(child, 'SIGTERM');
164
+ killTimer = setTimeout(() => signalGroup(child, 'SIGKILL'), killGraceMs);
165
+ killTimer.unref();
166
+ };
167
+ const deadline = setTimeout(() => end(new TerminalLoginTimeoutError(`Sign-in did not finish within ${Math.round(options.deadlineMs / 1000)} seconds.`)), options.deadlineMs);
168
+ const onAbort = () => end(new TerminalLoginCancelledError('Sign-in was cancelled.'));
169
+ options.signal?.addEventListener('abort', onAbort, { once: true });
170
+ const settle = (outcome) => {
171
+ clearTimeout(deadline);
172
+ if (killTimer)
173
+ clearTimeout(killTimer);
174
+ options.signal?.removeEventListener('abort', onAbort);
175
+ if (outcome)
176
+ reject(outcome);
177
+ else
178
+ resolve();
179
+ };
180
+ child.once('error', (error) => {
181
+ settle(new TerminalLoginFailedError(`Could not start the sign-in command: ${error.message}`));
182
+ });
183
+ child.once('exit', () => {
184
+ // Whatever the login left behind in its group must not outlive it — see the file comment.
185
+ signalGroup(child, 'SIGKILL');
186
+ });
187
+ child.once('close', (code, signal) => {
188
+ if (ending)
189
+ return settle(ending);
190
+ if (code === 0)
191
+ return settle(undefined);
192
+ const how = code !== null ? `exited with code ${code}` : `was ended by ${signal}`;
193
+ const said = tail.length > 0 ? `: ${tail.join('\n')}` : '';
194
+ return settle(new TerminalLoginFailedError(`The sign-in command ${how}${said}`));
195
+ });
196
+ });
197
+ }
@@ -72,7 +72,7 @@
72
72
  // // before `.connect()`, since it only works if the
73
73
  // // handler exists in time to answer this call
74
74
  // pingResultSink?: string,
75
- // requestPermissionDuringPrompt?: { toolCall: ToolCallUpdate, options: PermissionOption[] },
75
+ // requestPermissionDuringPrompt?: { sessionId?: string, toolCall: ToolCallUpdate, options: PermissionOption[] },
76
76
  // // if set, session/prompt calls the client's
77
77
  // // session/request_permission with this payload
78
78
  // // before returning, and writes the raw response to
@@ -85,6 +85,24 @@
85
85
  // // before returning, and writes the raw response to
86
86
  // // createElicitationResultSink.
87
87
  // createElicitationResultSink?: string,
88
+ // createElicitationsDuringPrompt?: object[],
89
+ // // the plural of the above: session/prompt calls the
90
+ // // client's elicitation/create once per element, IN
91
+ // // ORDER and ONE AT A TIME — each call is awaited
92
+ // // before the next is sent, so exactly one card is
93
+ // // ever open. That sequencing is the point: it is how
94
+ // // a real agent asks a follow-up question, and it is
95
+ // // the only way to observe one property kind's answer
96
+ // // without the others' cards confusing the timeline.
97
+ // // Each raw response is appended to the JSON array at
98
+ // // createElicitationResultsSink, which is REWRITTEN
99
+ // // after every answer rather than once at the end —
100
+ // // a driver (a test, or a human clicking through a
101
+ // // browser) has to be able to read the answer to card
102
+ // // N while card N+1 is still open, and a single
103
+ // // write at the end of the turn would only be
104
+ // // readable after the last card was answered.
105
+ // createElicitationResultsSink?: string,
88
106
  // authenticateSink?: string, // if set, a file this process writes (the raw JSON
89
107
  // // `authenticate` params, i.e. `{ methodId }`) the
90
108
  // // moment it receives that request — lets a test
@@ -124,6 +142,8 @@
124
142
  // // stderr-only login shape (antigravity), and it
125
143
  // // starts at spawn on purpose, which is exactly the
126
144
  // // window `ConnectAgentOptions.present` warns about.
145
+ // newSessionDelayMs?: number, // delays the session/new response
146
+ // hangOnSessionLoad?: boolean, // session/load never resolves, for startup cleanup tests
127
147
  // hangOnSessionNew?: boolean, // if true, session/new never resolves — the agent
128
148
  // // answered initialize and then went quiet on the
129
149
  // // very next request. Distinct from hangOnInitialize:
@@ -145,14 +165,41 @@
145
165
  // // 这么干的:setTimeout 0,见 acp-agent.js:756)。
146
166
  // // 用数组的数组是因为「推几次」本身就是被测行为:
147
167
  // // claude 会推两次(70 → 68),codex 只推一次。
168
+ // rejectLaterSessionNew?: string, // p3-fix7c 3: a marker path; the first session/new (across
169
+ // // processes) succeeds and creates it, every later one throws —
170
+ // // a probe retry that fails.
171
+ // commandsBeforeLaterSessionReply?: { commands: AvailableCommand[], markerPath: string }, // p3-fix7b M-4:
172
+ // // like commandsBeforeNewSessionReply, but only from the second
173
+ // // session/new on (counted across processes through markerPath) —
174
+ // // a first probe that saw nothing, then a retry the table races.
175
+ // commandsBeforeNewSessionReply?: AvailableCommand[], // p3-fix7 N9: one available_commands_update
176
+ // // sent INSIDE the session/new handler, before its reply —
177
+ // // the order a fast Claude adapter produced in run 5, which
178
+ // // reached the client before it knew its own session id.
148
179
  // newSessionCommandsDelayMs?: number, // 每次推送之间的间隔,默认 0。让测试能把一条晚到的
149
180
  // // 推送放在 settle 窗口的里面或外面。
181
+ // newSessionSink?: string, // if set, a file this process writes (the raw JSON
182
+ // // session/new params, including `_meta`) the moment
183
+ // // it receives that request — Task 5's proof that
184
+ // // `AcpSession.open` actually sent
185
+ // // `_meta.claudeCode.emitRawSDKMessages` (or did not,
186
+ // // for an agent that is not claude)
187
+ // extNotifications?: { method: string, params: unknown }[], // sent, in order, as raw
188
+ // // extNotification calls after session/new answers —
189
+ // // same "after the reply" timing as
190
+ // // `newSessionCommands` above. Task 5 uses this to
191
+ // // model claude-agent-acp's
192
+ // // `extNotification("_claude/sdkMessage", …)`.
193
+ // extNotificationDelayMs?: number, // delay between each of the above; default 0.
150
194
  // sessions?: SessionInfo[], // session/list's response `sessions` array; default []
151
195
  // nextCursor?: string, // session/list's response `nextCursor`
152
196
  // sessionListSink?: string, // if set, a file this process writes (the raw JSON
153
197
  // // session/list params, i.e. `{cwd, cursor}`) the
154
198
  // // moment it receives that request — Task 7's proof
155
199
  // // that listNativeSessions really sent cwd/cursor
200
+ // deleteSessionSink?: string, // if set, a file each session/delete request's params
201
+ // // are appended to as a JSON line
202
+ // deleteSessionFailures?: string[], // session ids whose session/delete throws internalError
156
203
  // loadUpdates?: SessionUpdate[], // Task 7 (adoption.ts): sent as session/update
157
204
  // // notifications during session/load, before it
158
205
  // // returns — this is the replay mechanism ACP v1
@@ -172,6 +219,9 @@
172
219
  // // the shape credentials expiring mid-conversation
173
220
  // // actually takes, and the only failure the login
174
221
  // // card may be drawn for
222
+ // exitOnPrompt?: boolean, // simulate transport loss after accepting a prompt
223
+ // promptRpcError?: { code: number, message: string }, // explicit remote JSON-RPC failure
224
+ // promptCallsSink?: string, // append-only request log for no-replay assertions
175
225
  // promptSink?: string, // if set, a file this process overwrites with the raw
176
226
  // // JSON session/prompt params on every prompt — the
177
227
  // // only way to observe WHICH sessionId a prompt was
@@ -179,12 +229,18 @@
179
229
  // // to a loaded session rather than opening a new one
180
230
  // loadSessionResponse?: LoadSessionResponse, // session/load's response; default {}
181
231
  // 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
232
+ // // session/load params, including `_meta`) the
233
+ // // moment it receives that request — lets a test
234
+ // // prove the right sessionId/cwd/`_meta` were sent,
235
+ // // and (by NOT configuring this and asserting the
236
+ // // file never appears) that adoption.ts never calls
237
+ // // session/load when the capability isn't advertised
238
+ // loadExtNotifications?: { method: string, params: unknown }[], // Task 9's equivalent of
239
+ // // `extNotifications` above, but pushed after
240
+ // // session/load answers instead of session/new — the
241
+ // // shape claude-agent-acp's activity push actually
242
+ // // takes on the adoption/reconnect path, where no
243
+ // // session/new is ever sent
188
244
  // resumeSessionResponse?: ResumeSessionResponse, // session/resume's response; default {}
189
245
  // resumeSessionSink?: string, // same idea as loadSessionSink, for session/resume
190
246
  // // — Task 7's proof that adoption.ts NEVER calls
@@ -206,6 +262,16 @@
206
262
  // // `promptRequired`, so seeing it back means the
207
263
  // // agent ignored the opt-in we always send
208
264
  // steerRejectsWith?: string, // if set, the handler throws with this message
265
+ // asyncTaskStopSink?: string, // if set, a file this process writes (the raw JSON
266
+ // // `_session/async_task/stop` params) the moment it
267
+ // // receives that request. Also an EXTENSION, not ACP
268
+ // // (`claude-agent-acp` `acp-agent.js@0.79.0:165`),
269
+ // // routed by its literal method name for the same
270
+ // // reason `_session/steering` is
271
+ // asyncTaskStopStopped?: boolean, // what `{ stopped }` the handler answers with;
272
+ // // default true. `false` is what the adapter answers
273
+ // // for an id it has no live task under
274
+ // // (`acp-agent.js@0.79.0:1788`)
209
275
  // newSessionConfigOptions?: SessionConfigOption[], // returned verbatim on session/new (types.gen.d.ts:2546)
210
276
  // newSessionModes?: SessionModeState, // returned verbatim on session/new (:2542)
211
277
  // setConfigOptionSink?: string, // file receiving the JSON params of each session/set_config_option
@@ -225,7 +291,7 @@
225
291
  // fixture that can prove "honour the advertised capability, not the router" end to end, not just
226
292
  // assert a boolean.
227
293
  async function main() {
228
- const { writeFileSync } = require('node:fs');
294
+ const { writeFileSync, appendFileSync, existsSync } = require('node:fs');
229
295
  // Parsed and wired up before the (slow, cold-start) dynamic import below, so a caller that kills
230
296
  // this process almost immediately after spawning it (exercising connectAgent's initialize
231
297
  // timeout with a short deadline) still gets a SIGTERM handler in place. Registering this only
@@ -307,16 +373,56 @@ async function main() {
307
373
  return {};
308
374
  })
309
375
  .onRequest(methods.agent.session.new, async (ctx) => {
376
+ if (config.echoMcpAuthorizationToStderr) {
377
+ const authorization = ctx.params.mcpServers.find((server) => server.name === 'axom-delegation')?.headers?.[0]?.value;
378
+ process.stderr.write('adapter diagnostic: started\n');
379
+ process.stderr.write(`configured Authorization: ${authorization?.slice(0, 9) ?? ''}`);
380
+ await new Promise((resolve) => setTimeout(resolve, 20));
381
+ process.stderr.write(`${authorization?.slice(9) ?? ''}\n`);
382
+ process.stderr.write(`configured token: ${authorization?.slice(7) ?? ''}\n`);
383
+ process.stderr.write(`Open https://auth.example.test/device?token=${authorization?.slice(7) ?? ''} to continue\n`);
384
+ process.stderr.write('adapter diagnostic: finished\n');
385
+ }
310
386
  if (config.newSessionRejectsWith)
311
387
  throw new Error(config.newSessionRejectsWith);
312
388
  // Checked after the rejection so the two flags stay independently exercisable; a promise that
313
389
  // never settles is the only faithful way to model an agent that simply stops answering.
314
390
  if (config.hangOnSessionNew)
315
391
  return new Promise(() => { });
392
+ if (config.newSessionDelayMs)
393
+ await new Promise((resolve) => setTimeout(resolve, config.newSessionDelayMs));
394
+ if (config.newSessionSink)
395
+ writeFileSync(config.newSessionSink, JSON.stringify(ctx.params));
396
+ for (const { method, params } of config.beforeSessionReplyNotifications ?? []) {
397
+ await ctx.client.notify(method, params);
398
+ }
316
399
  // Pushed AFTER this handler returns, not before: the real adapter schedules its first
317
400
  // `available_commands_update` on a `setTimeout(…, 0)` that runs once `session/new` has been
318
401
  // answered (`acp-agent.js:752-757`). Sending them inline would let a client that only reads
319
402
  // updates *before* the reply still pass, which is the bug this fixture exists to catch.
403
+ if (config.rejectLaterSessionNew) {
404
+ if (existsSync(config.rejectLaterSessionNew))
405
+ throw new Error('second session refused');
406
+ writeFileSync(config.rejectLaterSessionNew, 'seen');
407
+ }
408
+ if (config.commandsBeforeLaterSessionReply) {
409
+ const { commands, markerPath } = config.commandsBeforeLaterSessionReply;
410
+ if (existsSync(markerPath)) {
411
+ await ctx.client.notify(methods.client.session.update, {
412
+ sessionId,
413
+ update: { sessionUpdate: 'available_commands_update', availableCommands: commands },
414
+ });
415
+ }
416
+ else {
417
+ writeFileSync(markerPath, 'seen');
418
+ }
419
+ }
420
+ if (config.commandsBeforeNewSessionReply) {
421
+ await ctx.client.notify(methods.client.session.update, {
422
+ sessionId,
423
+ update: { sessionUpdate: 'available_commands_update', availableCommands: config.commandsBeforeNewSessionReply },
424
+ });
425
+ }
320
426
  const pushes = config.newSessionCommands ?? [];
321
427
  if (pushes.length > 0) {
322
428
  void (async () => {
@@ -329,6 +435,18 @@ async function main() {
329
435
  }
330
436
  })();
331
437
  }
438
+ // Same "after the reply, not before" reasoning as `newSessionCommands` above — Task 5
439
+ // (agent-reported activity) uses this to model claude-agent-acp's
440
+ // `extNotification("_claude/sdkMessage", …)`, which only ever arrives once a session exists.
441
+ const extNotifications = config.extNotifications ?? [];
442
+ if (extNotifications.length > 0) {
443
+ void (async () => {
444
+ for (const { method, params } of extNotifications) {
445
+ await new Promise((r) => setTimeout(r, config.extNotificationDelayMs ?? 0));
446
+ await ctx.client.notify(method, params);
447
+ }
448
+ })();
449
+ }
332
450
  return {
333
451
  sessionId,
334
452
  ...(config.newSessionConfigOptions ? { configOptions: config.newSessionConfigOptions } : {}),
@@ -339,20 +457,49 @@ async function main() {
339
457
  if (config.sessionListSink)
340
458
  writeFileSync(config.sessionListSink, JSON.stringify(ctx.params));
341
459
  return { sessions: config.sessions ?? [], ...(config.nextCursor ? { nextCursor: config.nextCursor } : {}) };
460
+ })
461
+ .onRequest(methods.agent.session.delete, (ctx) => {
462
+ if (config.deleteSessionSink)
463
+ appendFileSync(config.deleteSessionSink, JSON.stringify(ctx.params) + '\n');
464
+ if (config.deleteSessionFailures?.includes(ctx.params.sessionId))
465
+ throw RequestError.internalError(undefined, 'boom');
466
+ return {};
342
467
  })
343
468
  .onRequest(methods.agent.session.load, async (ctx) => {
344
469
  if (config.loadSessionSink)
345
470
  writeFileSync(config.loadSessionSink, JSON.stringify(ctx.params));
346
471
  if (config.loadSessionRejectsWith)
347
472
  throw new Error(config.loadSessionRejectsWith);
473
+ if (config.hangOnSessionLoad)
474
+ return new Promise(() => { });
475
+ for (const { method, params } of config.beforeSessionReplyNotifications ?? []) {
476
+ await ctx.client.notify(method, params);
477
+ }
478
+ for (const notification of config.scopedLoadUpdates ?? []) {
479
+ await ctx.client.notify(methods.client.session.update, notification);
480
+ }
348
481
  for (const update of config.loadUpdates ?? []) {
349
482
  await ctx.client.notify(methods.client.session.update, { sessionId: ctx.params.sessionId, update });
350
483
  }
484
+ // Same "after the reply, not before" reasoning as `extNotifications` on session/new above —
485
+ // Task 9 uses this to model claude-agent-acp's `extNotification("_claude/sdkMessage", …)`
486
+ // arriving on the session/load (adoption/reconnect) path, where no session/new is ever sent.
487
+ const loadExtNotifications = config.loadExtNotifications ?? [];
488
+ if (loadExtNotifications.length > 0) {
489
+ void (async () => {
490
+ for (const { method, params } of loadExtNotifications) {
491
+ await new Promise((r) => setTimeout(r, config.extNotificationDelayMs ?? 0));
492
+ await ctx.client.notify(method, params);
493
+ }
494
+ })();
495
+ }
351
496
  return config.loadSessionResponse ?? {};
352
497
  })
353
498
  .onRequest(methods.agent.session.resume, async (ctx) => {
354
499
  if (config.resumeSessionSink)
355
500
  writeFileSync(config.resumeSessionSink, JSON.stringify(ctx.params));
501
+ if (config.resumeSessionRejectsWith)
502
+ throw new Error(config.resumeSessionRejectsWith);
356
503
  return config.resumeSessionResponse ?? {};
357
504
  })
358
505
  .onRequest(methods.agent.session.setConfigOption, (ctx) => {
@@ -384,10 +531,21 @@ async function main() {
384
531
  throw new Error(config.steerRejectsWith);
385
532
  const outcome = config.steerOutcome ?? 'injected';
386
533
  return outcome === 'promptRequired' ? { outcome, reason: 'noRunningTurn' } : { outcome };
534
+ })
535
+ .onRequest('_session/async_task/stop', (params) => params, (ctx) => {
536
+ if (config.asyncTaskStopSink)
537
+ writeFileSync(config.asyncTaskStopSink, JSON.stringify(ctx.params));
538
+ return { stopped: config.asyncTaskStopStopped ?? true };
387
539
  })
388
540
  .onRequest(methods.agent.session.prompt, async (ctx) => {
541
+ if (config.promptCallsSink)
542
+ appendFileSync(config.promptCallsSink, JSON.stringify(ctx.params) + '\n');
389
543
  if (config.promptSink)
390
544
  writeFileSync(config.promptSink, JSON.stringify(ctx.params));
545
+ if (config.exitOnPrompt)
546
+ process.exit(1);
547
+ if (config.promptRpcError)
548
+ throw new RequestError(config.promptRpcError.code, config.promptRpcError.message);
391
549
  // A plain Error is masked by the SDK as JSON-RPC -32603; `RequestError.authRequired()` is
392
550
  // the real thing an agent whose credentials expired answers with (-32000). The two shapes are
393
551
  // what the login card has to tell apart, and only a real agent process on a real wire proves
@@ -396,6 +554,11 @@ async function main() {
396
554
  throw RequestError.authRequired();
397
555
  if (config.promptRejectsWith)
398
556
  throw new Error(config.promptRejectsWith);
557
+ if (config.foregroundWriteSink && ctx.params.prompt.some(block => block.type === 'text' && block.text === 'partial-write')) {
558
+ appendFileSync(config.foregroundWriteSink, 'partial\n');
559
+ await cancelled;
560
+ return { stopReason: 'cancelled' };
561
+ }
399
562
  for (const update of updates) {
400
563
  await ctx.client.notify(methods.client.session.update, { sessionId: ctx.params.sessionId, update });
401
564
  }
@@ -405,7 +568,7 @@ async function main() {
405
568
  // OS process speaking real ACP, not just callable in-process.
406
569
  if (config.requestPermissionDuringPrompt) {
407
570
  const result = await ctx.client.request(methods.client.session.requestPermission, {
408
- sessionId: ctx.params.sessionId,
571
+ sessionId: config.requestPermissionDuringPrompt.sessionId ?? ctx.params.sessionId,
409
572
  toolCall: config.requestPermissionDuringPrompt.toolCall,
410
573
  options: config.requestPermissionDuringPrompt.options,
411
574
  });
@@ -416,6 +579,19 @@ async function main() {
416
579
  const result = await ctx.client.request(methods.client.elicitation.create, config.createElicitationDuringPrompt);
417
580
  writeFileSync(config.createElicitationResultSink, JSON.stringify(result));
418
581
  }
582
+ // The plural form. `await` inside the loop is load-bearing, not incidental: the whole point is
583
+ // that card N is answered before card N+1 is raised. The sink is rewritten on every iteration
584
+ // so the answer to card N is readable while card N+1 is still open.
585
+ if (Array.isArray(config.createElicitationsDuringPrompt)) {
586
+ const results = [];
587
+ for (const payload of config.createElicitationsDuringPrompt) {
588
+ const result = await ctx.client.request(methods.client.elicitation.create, payload);
589
+ results.push(result);
590
+ if (config.createElicitationResultsSink) {
591
+ writeFileSync(config.createElicitationResultsSink, JSON.stringify(results));
592
+ }
593
+ }
594
+ }
419
595
  if (config.hangUntilCancel) {
420
596
  await cancelled;
421
597
  return { stopReason: config.stopReasonAfterCancel ?? 'end_turn' };
@@ -18,10 +18,13 @@
18
18
  * question. Its two wire names behave differently here, and the difference is the suffix, not the
19
19
  * family:
20
20
  *
21
- * - `elicitation_request` (`TimelineEvent.InteractionRequested`) is request-shaped and absent from
22
- * the set, so a bridge emitting it is rejected. That stays true after Plan B minted the name:
23
- * the platform now *has* the vocabulary but still has no bridge decode branch for it, so it still
24
- * cannot answer one. Adding it here would exempt it into a generic item — a card with no button.
21
+ * - `elicitation_request` (`TimelineEvent.ElicitationRequested`) is request-shaped and, since the
22
+ * platform grew a named decode branch for it (`TimelineEventWire.decodeElicitationRequested`,
23
+ * landing a `WorkItemKind.USER_INTERACTION` item that parks the run), is now a member of this
24
+ * set: a bridge emitting it is answerable, not rejected. The reply that unblocks the agent's
25
+ * `elicitation/create` travels back on its own frame — `AgentFrame.AgentElicitationAnswer` — not
26
+ * through `permission_request`'s `agent_answer`, because ACP's `CreateElicitationResponse.content`
27
+ * is a structured object `agent_answer`'s single-string `outcome` cannot carry.
25
28
  * - `elicitation` (`TimelineEvent.UserInteractionRequested`) carries no request suffix, so this gate
26
29
  * never looks at it and a bridge emitting it passes through. That is now correct rather than
27
30
  * merely harmless: `TimelineEventWire.decode` has a named `elicitation` branch that rebuilds the
@@ -32,7 +35,7 @@
32
35
  * run never parks on. Well-formedness is defined in exactly one place, that decoder; this gate
33
36
  * deliberately does not carry a second copy of the shape rules to drift from it.
34
37
  */
35
- export const KNOWN_REQUEST_EVENT_NAMES = new Set(['permission_request']);
38
+ export const KNOWN_REQUEST_EVENT_NAMES = new Set(['permission_request', 'elicitation_request']);
36
39
  /**
37
40
  * Does this name carry the "answer me exactly once" promise?
38
41
  *
@@ -130,5 +133,30 @@ function inspect(event) {
130
133
  }
131
134
  }
132
135
  }
136
+ // `elicitation_request`'s counterpart to the `permission_request` checks above — added by Task 18,
137
+ // which is also this event's first real producer (`permission.ts`'s `handleFormElicitation`).
138
+ // `validate.test.ts` used to pin the opposite — `{requestId: 'r1'}` alone passing — on the explicit
139
+ // reasoning that a shape rule with no producer to catch would only be guarding against a caller
140
+ // that did not exist; that premise no longer holds, so the rule now exists too. `mode`/`message` are
141
+ // the two fields `elicitationRequestFromPayload` (`ElicitationRequest.kt`) requires no matter which
142
+ // mode arrives — a payload missing either is dropped silently server-side (`runCatching { ... }
143
+ // .getOrNull()`), the same "advisory here, silent there" gap the `permission_request` checks above
144
+ // exist to close.
145
+ if (event.eventName === 'elicitation_request') {
146
+ if (!nonEmptyString(payload.requestId)) {
147
+ return { reason: 'invalid elicitation_request', detail: 'requestId must be a non-empty string' };
148
+ }
149
+ const request = payload.request;
150
+ if (request === null || typeof request !== 'object' || Array.isArray(request)) {
151
+ return { reason: 'invalid elicitation_request', detail: 'request must be an object' };
152
+ }
153
+ const requestPayload = request;
154
+ if (!nonEmptyString(requestPayload.mode)) {
155
+ return { reason: 'invalid elicitation_request', detail: 'request.mode must be a non-empty string' };
156
+ }
157
+ if (!nonEmptyString(requestPayload.message)) {
158
+ return { reason: 'invalid elicitation_request', detail: 'request.message must be a non-empty string' };
159
+ }
160
+ }
133
161
  return null;
134
162
  }