@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.
- package/README.md +1 -1
- package/dist/authScopes.js +19 -0
- package/dist/axom-patch/cli/README.md +110 -0
- package/dist/axom-patch/cli/pi-steering.json +37 -0
- package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +34 -0
- package/dist/axom-patch/cli/probes/pi-steering.mjs +80 -0
- package/dist/axom-patch/cli/probes/qoder-steering.mjs +89 -0
- package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +49 -0
- package/dist/axom-patch/cli/qoder-background-terminal.md +81 -0
- package/dist/axom-patch/cli/qoder-foreground-output.md +41 -0
- package/dist/axom-patch/cli/qoder-steering.json +52 -0
- package/dist/axom-patch/cli/qoder-steering.md +30 -0
- package/dist/axom-patch/cli/qoder-subagent-origin.json +73 -0
- package/dist/axom-patch/cli/qoder-subagent-subwork.md +73 -0
- package/dist/axom-patch/cli/runtime.json +37 -0
- package/dist/axom-patch/cli/steering/claude-steering-js.patch +89 -0
- package/dist/axom-patch/cli/steering/claude-steering-types.patch +28 -0
- package/dist/axom-patch/cli/steering/codex-steering.patch +57 -0
- package/dist/axom-patch/cli/steering/pi-steering.js +79 -0
- package/dist/cli.js +9 -1
- package/dist/commands/auth.js +4 -13
- package/dist/commands/execute.js +4 -4
- package/dist/commands/record.js +6 -6
- package/dist/commands/struct.js +4 -4
- package/dist/commands/workstation.js +2 -2
- package/dist/credentials.js +2 -2
- package/dist/install/claude/commands.js +13 -13
- package/dist/mcp.js +32 -14
- package/dist/workstation/acp/acpAgents.js +447 -166
- package/dist/workstation/acp/acpClient.js +232 -18
- package/dist/workstation/acp/acpSession.js +502 -74
- package/dist/workstation/acp/activityBusy.js +43 -0
- package/dist/workstation/acp/activityReporting.js +54 -0
- package/dist/workstation/acp/adoption.js +107 -49
- package/dist/workstation/acp/authUrlSniffer.js +36 -0
- package/dist/workstation/acp/contract.js +11 -6
- package/dist/workstation/acp/delegationMcp.js +24 -0
- package/dist/workstation/acp/draftInterceptor.js +9 -0
- package/dist/workstation/acp/managedPatches.js +228 -0
- package/dist/workstation/acp/patchAssets.js +63 -0
- package/dist/workstation/acp/permission.js +300 -34
- package/dist/workstation/acp/qoderStartup.js +45 -0
- package/dist/workstation/acp/registry/catalog.js +263 -4
- package/dist/workstation/acp/registry/detect.js +55 -0
- package/dist/workstation/acp/sessionScope.js +19 -0
- package/dist/workstation/acp/sessionUpdateMapper.js +39 -21
- package/dist/workstation/acp/terminalLogin.js +197 -0
- package/dist/workstation/acp/test/fakeAgent.js +185 -9
- package/dist/workstation/acp/validate.js +33 -5
- package/dist/workstation/agentSessions.js +532 -44
- package/dist/workstation/channels/agent.js +7 -1
- package/dist/workstation/channels/computer.js +152 -4
- package/dist/workstation/channels/management.js +3 -3
- package/dist/workstation/config.js +3 -1
- package/dist/workstation/connection/encryptedChannel.js +88 -21
- package/dist/workstation/daemon.js +25 -3
- package/dist/workstation/desktop/binding.js +36 -0
- package/dist/workstation/desktop/bridge.js +212 -0
- package/dist/workstation/desktop/flow-control.js +52 -0
- package/dist/workstation/desktop/focused-text.js +130 -0
- package/dist/workstation/desktop/protocol.js +35 -0
- package/dist/workstation/fileOps.js +277 -0
- package/dist/workstation/filePaths.js +174 -0
- package/dist/workstation/fileWorker.js +81 -0
- package/dist/workstation/fileWorkerClient.js +121 -0
- package/dist/workstation/oauth/deviceAuthorization.js +21 -12
- package/dist/workstation/protocol/agent.js +154 -5
- package/dist/workstation/protocol/common.js +6 -0
- package/dist/workstation/protocol/computer.js +92 -3
- package/dist/workstation/qoderAdapter/backgroundProcess.js +106 -0
- package/dist/workstation/qoderAdapter/backgroundTerminal.js +262 -0
- package/dist/workstation/qoderAdapter/completedContinuation.js +139 -0
- package/dist/workstation/qoderAdapter/contract.js +78 -0
- package/dist/workstation/qoderAdapter/foregroundOutput.js +30 -0
- package/dist/workstation/qoderAdapter/history.js +87 -0
- package/dist/workstation/qoderAdapter/identityIndex.js +191 -0
- package/dist/workstation/qoderAdapter/inputQueue.js +42 -0
- package/dist/workstation/qoderAdapter/main.js +93 -0
- package/dist/workstation/qoderAdapter/mapper.js +117 -0
- package/dist/workstation/qoderAdapter/observerPlugin.js +245 -0
- package/dist/workstation/qoderAdapter/observerReceiver.js +187 -0
- package/dist/workstation/qoderAdapter/permissions.js +61 -0
- package/dist/workstation/qoderAdapter/profile.js +93 -0
- package/dist/workstation/qoderAdapter/sdkDriver.js +49 -0
- package/dist/workstation/qoderAdapter/session.js +319 -0
- package/dist/workstation/qoderAdapter/subagentMeta.js +28 -0
- package/dist/workstation/qoderAdapter/subwork.js +77 -0
- package/dist/workstation/qoderAdapter/taskControl.js +38 -0
- package/dist/workstation/qoderAdapter/taskEvent.js +26 -0
- package/dist/workstation/qoderAdapter/taskLedger.js +163 -0
- package/dist/workstation/qoderAdapter/types.js +1 -0
- package/dist/workstation/qoderAdapter/usage.js +37 -0
- package/dist/workstation/register.js +4 -0
- package/dist/workstation/tools/browser.js +394 -0
- package/dist/workstation/tools/client.js +269 -0
- package/dist/workstation/tools/core.js +50 -0
- package/dist/workstation/tools/files.js +525 -0
- package/dist/workstation/tools/keeperProcess.js +114 -0
- package/dist/workstation/tools/terminal.js +214 -0
- package/dist/workstation/tools/validation.js +50 -0
- package/dist/workstation/update/coordinator.js +29 -0
- package/dist/workstation/update/npmInstaller.js +2 -0
- package/dist/workstation/update/ptyPreflight.js +24 -0
- package/dist/workstation/update/taskRegistry.js +44 -20
- package/dist/workstation/update/watchdog.js +66 -0
- package/package.json +14 -4
- package/skills/axom/SKILL.md +55 -17
- package/skills/axom-definition/SKILL.md +32 -32
- 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
|
|
183
|
-
// // request — lets a test
|
|
184
|
-
// // sessionId/cwd were sent,
|
|
185
|
-
// // this and asserting the
|
|
186
|
-
// // adoption.ts never calls
|
|
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.
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
}
|