@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
|
@@ -35,20 +35,29 @@
|
|
|
35
35
|
*
|
|
36
36
|
* `elicitation/create`'s `mode` union is **open** (`"form" | "url" | string`, per
|
|
37
37
|
* `CreateElicitationRequest`'s schema comment: "Custom or future elicitation mode" — a bare `string`
|
|
38
|
-
* arm, unlike `session/update`'s closed 15-member union). Axom's wire vocabulary
|
|
38
|
+
* arm, unlike `session/update`'s closed 15-member union). Axom's wire vocabulary has the
|
|
39
39
|
* `elicitation_request`/`elicitation` pair (Plan B renamed the platform's own interaction events onto
|
|
40
|
-
* ACP's words)
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* `
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
40
|
+
* ACP's words); both are answerable end to end now, but along different paths. `elicitation_request`
|
|
41
|
+
* (`TimelineEvent.ElicitationRequested`) is what {@link handleFormElicitation} raises for `'form'`
|
|
42
|
+
* mode (this task, Task 18) — `TimelineEventWire.decodeElicitationRequested` rebuilds it, lands a
|
|
43
|
+
* `WorkItemKind.USER_INTERACTION` item, parks the run, and the user's answer travels back over its own
|
|
44
|
+
* frame, `AgentFrame.AgentElicitationAnswer` (Task 17), through `AcpSession.answerElicitation`. Bare
|
|
45
|
+
* `elicitation` (`UserInteractionRequested`) is the *platform's own* agents asking a question — a
|
|
46
|
+
* named decode branch since `TimelineEventWire.kt:127` — and answers through `ExternalAgentSessionRuntime
|
|
47
|
+
* .execute` forwarding the reply as a normal `channel.prompt(runId, context.userText, ...)`; a bridged
|
|
48
|
+
* agent's `elicitation/create` never produces this one.
|
|
49
|
+
*
|
|
50
|
+
* `'form'`'s wire capability (`acpClient.ts`'s `clientCapabilities.elicitation.form`) is what makes the
|
|
51
|
+
* whole thing reachable in the first place: `claude-agent-acp` puts `AskUserQuestion` into
|
|
52
|
+
* `disallowedTools` (`dist/acp-agent.js:5959`) unless it is declared, so before that line existed the
|
|
53
|
+
* model had no tool to call `elicitation/create` from at all. {@link handleFormElicitation} answers it
|
|
54
|
+
* following {@link handleRequestPermission}'s own shape — see that function's doc comment for the
|
|
55
|
+
* four properties carried over deliberately, and {@link handleFormElicitation}'s own doc comment for
|
|
56
|
+
* the one place the two diverge (a schema outside Axom's profile still raises the card before
|
|
57
|
+
* declining, rather than being refused in silence the way an empty form is). The open `default` arm
|
|
58
|
+
* still declines everything the SDK or a nonstandard agent sends outside `'form'`/`'url'`, for the
|
|
59
|
+
* same reason it always has: it has nowhere to go, and declining is what keeps it defensible — it
|
|
60
|
+
* never hangs the agent and it never fabricates a user's consent for input nobody was ever shown.
|
|
52
61
|
*
|
|
53
62
|
* `'url'` is different: it is the shape some agents use to hand a sign-in link to the reader (Codex's
|
|
54
63
|
* device-code flow reaches it through `elicitation/create` once `elicitation.url` is declared in
|
|
@@ -69,6 +78,7 @@
|
|
|
69
78
|
* complete it, so `authenticate` remembers every id it handed out and retracts what is left when
|
|
70
79
|
* the login settles.
|
|
71
80
|
*/
|
|
81
|
+
import { randomUUID } from 'node:crypto';
|
|
72
82
|
import { methods } from '@agentclientprotocol/sdk';
|
|
73
83
|
import { validateWireEvent } from './validate.js';
|
|
74
84
|
/** A value guaranteed not to equal any real `optionId` in `input.options` for a given call — used to purge a pending entry without accidentally supplying a matchable answer. Regenerated per call rather than a fixed literal, for the same anti-collision reasoning as `AcpSession`'s own abandonment sentinel (review finding, task-5 item 6). */
|
|
@@ -113,10 +123,8 @@ function toolCallPayload(toolCall) {
|
|
|
113
123
|
* ACP `RequestPermissionRequest` (`types.gen.d.ts:108`) on Axom's wire: `{toolCall, options}` plus
|
|
114
124
|
* `requestId`.
|
|
115
125
|
*
|
|
116
|
-
* `requestId` is
|
|
117
|
-
*
|
|
118
|
-
* envelope. `sessionId`, ACP's third field (`:112`), is deliberately not carried: the event is
|
|
119
|
-
* already being delivered on a specific run.
|
|
126
|
+
* `requestId` is a host-generated resolver token. The raw JSON-RPC ID stays local to its
|
|
127
|
+
* connection; exact `sessionId` travels in the wire event's `acpSessionId` origin field.
|
|
120
128
|
*
|
|
121
129
|
* **`toolCall` is nested, not flattened.** The flattened version lifted the tool's
|
|
122
130
|
* `name`/`title`/`kind`/`arguments` to the top level, which put `name` (the tool's programmatic name,
|
|
@@ -130,6 +138,7 @@ function toolCallPayload(toolCall) {
|
|
|
130
138
|
function buildPermissionRequestedEvent(input) {
|
|
131
139
|
return {
|
|
132
140
|
eventName: 'permission_request',
|
|
141
|
+
...(input.sessionId !== undefined ? { acpSessionId: input.sessionId } : {}),
|
|
133
142
|
payload: {
|
|
134
143
|
requestId: input.requestId,
|
|
135
144
|
toolCall: toolCallPayload(input.toolCall),
|
|
@@ -194,14 +203,17 @@ export async function handleRequestPermission(input, sink) {
|
|
|
194
203
|
};
|
|
195
204
|
sink.registerPendingApproval(input.requestId, (outcome) => {
|
|
196
205
|
settle(optionIds.has(outcome) ? { outcome: 'answered', optionId: outcome } : { outcome: 'rejected' });
|
|
197
|
-
});
|
|
206
|
+
}, input.sessionId === undefined ? undefined : { originNativeSessionId: input.sessionId, rpcRequestId: input.rpcRequestId ?? input.requestId });
|
|
207
|
+
if (settled)
|
|
208
|
+
return;
|
|
198
209
|
input.signal?.addEventListener('abort', () => {
|
|
199
210
|
settle({ outcome: 'rejected' });
|
|
200
211
|
// Purge the now-moot pending entry so a stray later answer doesn't linger against a request
|
|
201
212
|
// the agent already gave up on. The `settle` above has already fixed this promise's outcome
|
|
202
213
|
// (the `settled` guard makes the resolver this triggers a no-op), so the value passed here
|
|
203
214
|
// only has to avoid accidentally matching a real option id.
|
|
204
|
-
sink.
|
|
215
|
+
const cancel = sink.cancelPendingApproval?.bind(sink) ?? sink.answer.bind(sink);
|
|
216
|
+
cancel(input.requestId, unmatchableAnswerFor(input.options));
|
|
205
217
|
}, { once: true });
|
|
206
218
|
sink.emitEvent(event);
|
|
207
219
|
});
|
|
@@ -211,26 +223,35 @@ function declineElicitation() {
|
|
|
211
223
|
return { action: 'decline' };
|
|
212
224
|
}
|
|
213
225
|
/**
|
|
214
|
-
* Answer `elicitation/create`. `'url'` accepts and hands the URL out
|
|
215
|
-
* when both hold**: the request itself actually carries a string
|
|
216
|
-
* nonconforming agent that omits either gets declined, never
|
|
217
|
-
* real `
|
|
218
|
-
*
|
|
219
|
-
*
|
|
226
|
+
* Answer `elicitation/create`, dispatching on `request.mode`. `'url'` accepts and hands the URL out
|
|
227
|
+
* through `ctx.present` **only when both hold**: the request itself actually carries a string
|
|
228
|
+
* `elicitationId`/`url` (a nonconforming agent that omits either gets declined, never
|
|
229
|
+
* `present(undefined, undefined)`), and a real `ctx.present` is wired (see
|
|
230
|
+
* {@link PresentElicitationUrl}'s doc comment — its absence means declining, not accepting into a
|
|
231
|
+
* void). Accepting here does **not** mean the user finished anything; it means "the URL has been put
|
|
220
232
|
* in front of them", matching ACP's own `accept` semantics for this mode — which is exactly why both
|
|
221
|
-
* guards exist: an `accept` this module cannot back with a shown URL would make that claim false.
|
|
233
|
+
* guards exist: an `accept` this module cannot back with a shown URL would make that claim false. This
|
|
234
|
+
* branch, and this doc paragraph, are unchanged by Task 18 — only `'form'` moved.
|
|
235
|
+
*
|
|
236
|
+
* `'form'` is answered by {@link handleFormElicitation} — see its own doc comment, which is also
|
|
237
|
+
* where the four properties it deliberately carries over from {@link handleRequestPermission} are
|
|
238
|
+
* listed, and the one place it diverges.
|
|
222
239
|
*
|
|
223
240
|
* The `switch` has a real `default`, not just `'form'`/`'url'` cases, because `CreateElicitationRequest['mode']`
|
|
224
241
|
* is `"form" | "url" | string` — an **open** union (unlike `session/update`'s closed 15-member one).
|
|
225
242
|
* Falling through with no default would mean the client's response is `undefined` for any mode the SDK
|
|
226
243
|
* or a nonstandard agent invents next, sending a malformed JSON-RPC result the agent may wait on
|
|
227
244
|
* forever instead of a clean, immediate decline.
|
|
245
|
+
*
|
|
246
|
+
* `sink` is threaded through every mode uniformly (`registerPermissionHandlers` always has one, one
|
|
247
|
+
* per connection — see `PermissionSink`'s own doc comment) even though only `'form'` reads it; passing
|
|
248
|
+
* it unconditionally here is simpler than a second entry point that exists solely to omit an unused
|
|
249
|
+
* parameter, and every arm below is still free to ignore it.
|
|
228
250
|
*/
|
|
229
|
-
export function handleCreateElicitation(request,
|
|
251
|
+
export async function handleCreateElicitation(request, ctx, sink) {
|
|
230
252
|
switch (request.mode) {
|
|
231
253
|
case 'form':
|
|
232
|
-
|
|
233
|
-
return declineElicitation();
|
|
254
|
+
return handleFormElicitation(request, ctx, sink);
|
|
234
255
|
case 'url': {
|
|
235
256
|
const { elicitationId, url } = request;
|
|
236
257
|
if (typeof elicitationId !== 'string' || typeof url !== 'string') {
|
|
@@ -239,13 +260,13 @@ export function handleCreateElicitation(request, deps) {
|
|
|
239
260
|
// path. The elicitation still gets answered (declined), never left to hang.
|
|
240
261
|
return declineElicitation();
|
|
241
262
|
}
|
|
242
|
-
if (!
|
|
263
|
+
if (!ctx.present) {
|
|
243
264
|
// Nobody to show it to yet — see `PresentElicitationUrl`'s doc comment. Declining here is
|
|
244
265
|
// what an agent (or an MCP server proxied through one) raising this mid-turn can actually
|
|
245
266
|
// act on, unlike an `accept` that then goes nowhere.
|
|
246
267
|
return declineElicitation();
|
|
247
268
|
}
|
|
248
|
-
|
|
269
|
+
ctx.present(elicitationId, url);
|
|
249
270
|
return { action: 'accept' };
|
|
250
271
|
}
|
|
251
272
|
default:
|
|
@@ -255,6 +276,241 @@ export function handleCreateElicitation(request, deps) {
|
|
|
255
276
|
return declineElicitation();
|
|
256
277
|
}
|
|
257
278
|
}
|
|
279
|
+
/**
|
|
280
|
+
* Is `key` a free-text companion the server will *fold away* (`<key>_custom`,
|
|
281
|
+
* `ElicitationRequest.CUSTOM_SUFFIX` on the Kotlin side, `claude-agent-acp`'s own
|
|
282
|
+
* `question_<n>_custom` shape) rather than something the reader gets a row for? A folded companion
|
|
283
|
+
* must not be counted when deciding whether a schema has anything to draw at all.
|
|
284
|
+
*
|
|
285
|
+
* **The suffix alone is not the rule — the owner has to exist.** This used to return `true` for any
|
|
286
|
+
* `_custom`-suffixed key, which matched a server that *dropped* an ownerless one. Task 9 (ruling
|
|
287
|
+
* R15) changed that: `elicitationRequestFromPayload` now lands an ownerless `_custom` key as a
|
|
288
|
+
* read-only `UNPRESENTABLE` entry instead of discarding it, so a schema made *entirely* of such keys
|
|
289
|
+
* decodes into a real (if all-read-only) card. Measured against the real decoder: `{"foo_custom":
|
|
290
|
+
* {"type":"string"}}` alone → `fields=[foo_custom:UNPRESENTABLE]`, not `null`. With the old
|
|
291
|
+
* suffix-only rule this function returned `[]` for that schema and {@link handleFormElicitation}'s
|
|
292
|
+
* empty-form guard declined it **without ever raising a card** — the card silently never appears and
|
|
293
|
+
* the agent gets a decline it did not earn, which is the "too narrow" half of the drift R24 is about.
|
|
294
|
+
*
|
|
295
|
+
* Checking that the owner is *also a property* is deliberately weaker than the server's own rule
|
|
296
|
+
* (which additionally requires the owner to be a single-/multi-select, ruling R13), and the weakness
|
|
297
|
+
* only ever runs in the safe direction: it can under-count fields (a `_custom` key whose owner is a
|
|
298
|
+
* scalar is folded here but is a read-only row server-side) and never over-count. Under-counting
|
|
299
|
+
* cannot wrongly reach zero — for it to fold a key away, the owner has to be a property, and that
|
|
300
|
+
* owner is itself counted. Zero here therefore still means zero server-side, which is the only thing
|
|
301
|
+
* the empty-form guard reads.
|
|
302
|
+
*/
|
|
303
|
+
function isFoldedCompanionKey(key, properties) {
|
|
304
|
+
if (!key.endsWith('_custom'))
|
|
305
|
+
return false;
|
|
306
|
+
const owner = key.slice(0, -'_custom'.length);
|
|
307
|
+
return owner.length > 0 && Object.prototype.hasOwnProperty.call(properties, owner);
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* The property keys of a `'form'` request's `requestedSchema.properties` that the reader will
|
|
311
|
+
* actually get a row for — `[]` for anything that isn't a legible `{properties: {...}}` object,
|
|
312
|
+
* including a missing `requestedSchema` altogether. This alone decides
|
|
313
|
+
* {@link handleFormElicitation}'s empty-form guard: zero keys here is the same "nothing legible
|
|
314
|
+
* could ever be raised" shape as `handleRequestPermission`'s `options.length === 0`, regardless of
|
|
315
|
+
* *why* there are zero (no schema, an empty `properties`, or a `properties` that is entirely folded
|
|
316
|
+
* companions).
|
|
317
|
+
*
|
|
318
|
+
* All three of those are the shapes the server-side decoder still drops outright — measured, not
|
|
319
|
+
* inferred: `{"properties":{}}`, `properties` that isn't an object, and a missing `requestedSchema`
|
|
320
|
+
* each return `null` from `elicitationRequestFromPayload`, because `MODE_FORM` requires
|
|
321
|
+
* `fields.isNotEmpty()`. Everything else it now decodes (see {@link handleFormElicitation}).
|
|
322
|
+
*/
|
|
323
|
+
function elicitationFieldKeys(requestedSchema) {
|
|
324
|
+
const properties = requestedSchema?.properties;
|
|
325
|
+
if (properties === null || typeof properties !== 'object' || Array.isArray(properties))
|
|
326
|
+
return [];
|
|
327
|
+
const record = properties;
|
|
328
|
+
return Object.keys(record).filter((key) => !isFoldedCompanionKey(key, record));
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Kotlin's `String.isBlank()`, restated: empty, or entirely whitespace. `validate.ts`'s
|
|
332
|
+
* `nonEmptyString` (`.length > 0`) does **not** catch `" "` — `ElicitationRequest.init`'s
|
|
333
|
+
* `require(message.isNotBlank())` does, and so must this: a blank
|
|
334
|
+
* `message` throws inside `elicitationRequestFromPayload`'s `runCatching`. It is a **request-level**
|
|
335
|
+
* invariant, so unlike a malformed property it has no read-only fallback to degrade into — the whole
|
|
336
|
+
* request is dropped server-side, which is why this check survived Task 9's removal of the
|
|
337
|
+
* per-property profile predicate (see the ruling-R24 block below).
|
|
338
|
+
*/
|
|
339
|
+
function isBlank(value) {
|
|
340
|
+
return value.trim().length === 0;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* **Ruling R24, restated after Task 9 (D5′) — the predicate this block used to hold is gone because
|
|
344
|
+
* the server-side gap it predicted is gone.**
|
|
345
|
+
*
|
|
346
|
+
* There used to be an `isAxomProfileProperty`/`isAxomProfileSchema` pair here, a hand-written mirror
|
|
347
|
+
* of every `error()`/`require()` on `elicitationRequestFromPayload`'s answerable path. It existed
|
|
348
|
+
* because **one** malformed property took down the **whole** `elicitation_request` server-side: the
|
|
349
|
+
* throw escaped to that function's outer `runCatching`, the event was dropped, and the agent blocked
|
|
350
|
+
* on `elicitation/create` forever with nothing on screen to explain it. Declining before sending was
|
|
351
|
+
* the lesser evil — the agent at least unblocked — but it bought that by making the card never
|
|
352
|
+
* appear, which is the *other* silent failure.
|
|
353
|
+
*
|
|
354
|
+
* Task 9 removed the cause. Every one of those shapes now degrades to a single read-only
|
|
355
|
+
* `UNPRESENTABLE` entry inside an otherwise normal card (`ElicitationField`'s new kind; the
|
|
356
|
+
* per-property `runCatching` in `elicitationRequestFromPayload`), so there is nothing left at the
|
|
357
|
+
* *property* level for this client to predict. **Measured against the real Kotlin decoder, not read
|
|
358
|
+
* off the parser** — every payload `permission.test.ts` exercises was run through
|
|
359
|
+
* `elicitationRequestFromPayload` and only these still return `null`, all of them request-level and
|
|
360
|
+
* all of them already covered by the two guards {@link handleFormElicitation} keeps:
|
|
361
|
+
*
|
|
362
|
+
* | shape | verdict | guard that still catches it |
|
|
363
|
+
* |---|---|---|
|
|
364
|
+
* | **whitespace-only** `message` (`" "`) | `null` (`ElicitationRequest.init`) | {@link isBlank}, emit-then-decline |
|
|
365
|
+
* | **empty** `message` (`""`) | `null`, same requirement | `validateWireEvent` rejects the event first → decline, **no card** |
|
|
366
|
+
* | `properties: {}` | `null` (`MODE_FORM` needs `fields.isNotEmpty()`) | {@link elicitationFieldKeys} → `[]` |
|
|
367
|
+
* | `properties` not an object | `null`, same requirement | {@link elicitationFieldKeys} → `[]` |
|
|
368
|
+
* | `requestedSchema` absent | `null`, same requirement | {@link elicitationFieldKeys} → `[]` |
|
|
369
|
+
*
|
|
370
|
+
* Final review F7 split that first row in two, because merging them wrote the verdict backwards for
|
|
371
|
+
* half of it: `""` never reaches emit-then-decline at all. {@link validateWireEvent} runs first
|
|
372
|
+
* (`decodeElicitationRequested`'s `nonEmptyString`, `validate.ts:164`) and rejects the event, so the
|
|
373
|
+
* reader gets **no card** — precisely the outcome emit-then-decline exists to avoid. Only
|
|
374
|
+
* *whitespace* makes it past that gate and into the path this table describes; the {@link isBlank}
|
|
375
|
+
* KDoc had the reason right all along (`nonEmptyString` does not see `" "`), it was this row that
|
|
376
|
+
* folded the two cases into one sentence.
|
|
377
|
+
*
|
|
378
|
+
* Everything else decodes, including every shape this file used to decline: duplicate `const`s, a
|
|
379
|
+
* blank option `title`, a candidate-less `type: "array"`, an unknown or `_`-prefixed `type`, a
|
|
380
|
+
* scalar carrying candidates or a malformed `oneOf`/`items`/`anyOf`, a duplicate untitled `enum`
|
|
381
|
+
* value, a property that isn't an object at all, a blank property key, and an ownerless `_custom`
|
|
382
|
+
* key. Each becomes a visible read-only row; the reader can answer the rest of the card and submit,
|
|
383
|
+
* or press Skip (ruling D4/R19) to send `{action: 'decline'}` themselves.
|
|
384
|
+
*
|
|
385
|
+
* **The rule itself has not changed, only the answer it currently produces.** It is keyed on server
|
|
386
|
+
* behaviour: whenever whatever decodes a `CreateElicitationRequest` server-side starts *rejecting* a
|
|
387
|
+
* shape this client forwards, a predicate has to come back here in the same commit — otherwise the
|
|
388
|
+
* agent parks on `elicitation/create` forever waiting for an answer that cannot be produced. And if
|
|
389
|
+
* a reintroduced predicate is ever narrower than the decoder, the card silently never appears and
|
|
390
|
+
* the agent gets a decline it did not earn. Both directions have shipped bugs (Task 8, Task 8.5);
|
|
391
|
+
* measure them against the real decoder rather than against a reading of this file.
|
|
392
|
+
*/
|
|
393
|
+
/**
|
|
394
|
+
* ACP `CreateElicitationRequest`'s `'form'` arm folded onto Axom's `elicitation_request` wire shape —
|
|
395
|
+
* the mirror of `buildPermissionRequestedEvent` above, and the payload
|
|
396
|
+
* `TimelineEventWire.decodeElicitationRequested`/`elicitationRequestFromPayload` expect.
|
|
397
|
+
*
|
|
398
|
+
* `requestId` is `ctx.requestId` — the JSON-RPC envelope id — not ACP's own optional
|
|
399
|
+
* `ElicitationRequestScope.requestId` field on `request`, for the identical reason
|
|
400
|
+
* `buildPermissionRequestedEvent` reads its `requestId` off `PermissionRequestInput` rather than the
|
|
401
|
+
* request body: ACP correlates the eventual answer through the envelope, and an event stream has none.
|
|
402
|
+
*
|
|
403
|
+
* `requestedSchema` is forwarded **verbatim** — Global Constraint 3, "transport, not translation" (see
|
|
404
|
+
* `toolCallPayload`'s doc comment for the same rule applied to `ToolCallUpdate`): this function does
|
|
405
|
+
* not narrow a field to fit Axom's profile, and it does not strip a field outside it either. The wire
|
|
406
|
+
* event always carries exactly what the agent asked; {@link handleFormElicitation} is what decides,
|
|
407
|
+
* from that same schema, whether to wait for an answer or decline right after raising the card.
|
|
408
|
+
*/
|
|
409
|
+
function buildElicitationRequestedEvent(request, requestId) {
|
|
410
|
+
return {
|
|
411
|
+
eventName: 'elicitation_request',
|
|
412
|
+
payload: {
|
|
413
|
+
requestId,
|
|
414
|
+
...(typeof request.toolCallId === 'string' ? { toolCallId: request.toolCallId } : {}),
|
|
415
|
+
request: {
|
|
416
|
+
mode: request.mode,
|
|
417
|
+
message: request.message,
|
|
418
|
+
requestedSchema: request.requestedSchema,
|
|
419
|
+
},
|
|
420
|
+
answer: null,
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* `'form'`-mode `elicitation/create` — the counterpart to {@link handleRequestPermission} this
|
|
426
|
+
* module's class doc comment promises. Four properties carried over deliberately:
|
|
427
|
+
*
|
|
428
|
+
* 1. **Every path settles.** The `Promise` below always resolves — including the two guard clauses
|
|
429
|
+
* above it, both still inside this `async function` — because the agent is blocked on
|
|
430
|
+
* `session/prompt` until it does.
|
|
431
|
+
* 2. **An empty form is refused before it is raised**, mirroring `handleRequestPermission`'s
|
|
432
|
+
* `options.length === 0` hard refusal: a `requestedSchema` with zero answerable fields (missing
|
|
433
|
+
* entirely, `properties: {}`, or `properties` that is entirely `_custom` companions — see
|
|
434
|
+
* {@link elicitationFieldKeys}) is a card nobody could ever answer, which is worse than no card.
|
|
435
|
+
* 3. **`ctx.signal` aborting settles `{action: 'cancel'}` and purges the pending entry** — ACP's own
|
|
436
|
+
* real cancellation outcome (see `AcpSession`'s `ABANDONED_ELICITATION_ANSWER` doc comment for why
|
|
437
|
+
* this needs no randomized sentinel the way `handleRequestPermission`'s purge value does), so a
|
|
438
|
+
* card nobody is listening to answer does not linger in `AcpSession`'s pending map.
|
|
439
|
+
* 4. **The open `default` arm stays reachable** — one level up, in {@link handleCreateElicitation}'s
|
|
440
|
+
* own `switch`, not repeated here.
|
|
441
|
+
*
|
|
442
|
+
* **Where this diverges from the template**, deliberately: a blank `message` ({@link isBlank}) still
|
|
443
|
+
* **raises the card** before declining — the question is legible, `ElicitationRequest.init` just
|
|
444
|
+
* rejects the message server-side. It declines only *after* raising, instead of the guard-clause
|
|
445
|
+
* silence `handleRequestPermission` uses for its one hard-refusal case. Dropping the event here
|
|
446
|
+
* instead would leave the user seeing neither the question the agent asked nor any reason nothing
|
|
447
|
+
* happened (spec §12 D5′). This is the *opposite* of the empty-form case above, which has nothing
|
|
448
|
+
* legible to draw at all — raising a card for it would show the user a form with no fields on it,
|
|
449
|
+
* not a degraded but real question.
|
|
450
|
+
*
|
|
451
|
+
* **A schema with fields Axom cannot render no longer takes this exit** (Task 9, ruling R24): those
|
|
452
|
+
* decode into read-only rows server-side, so this handler waits for a real answer — which the reader
|
|
453
|
+
* supplies either by answering the rest of the card or by pressing Skip (`{action: 'decline'}`,
|
|
454
|
+
* ruling D4). See the ruling-R24 block above for the measured list of what still fails to decode.
|
|
455
|
+
*/
|
|
456
|
+
async function handleFormElicitation(request, ctx, sink) {
|
|
457
|
+
const requestId = ctx.requestId;
|
|
458
|
+
if (!sink || !requestId) {
|
|
459
|
+
// Defensive only: `registerPermissionHandlers` — the one real caller — always supplies both,
|
|
460
|
+
// reading `requestId` off the JSON-RPC envelope. Nothing to raise a card through or correlate an
|
|
461
|
+
// answer with, so there is nothing left to do but decline, same as the empty-form case below.
|
|
462
|
+
return declineElicitation();
|
|
463
|
+
}
|
|
464
|
+
if (ctx.signal?.aborted) {
|
|
465
|
+
// Mirrors `handleRequestPermission`'s identical early check: a listener added *after* `abort()`
|
|
466
|
+
// already fired never sees it (`AbortSignal` does not replay past events), so without this guard
|
|
467
|
+
// the mid-flight listener below would never fire and this would hang forever instead of settling.
|
|
468
|
+
return { action: 'cancel' };
|
|
469
|
+
}
|
|
470
|
+
const fieldKeys = elicitationFieldKeys(request.requestedSchema);
|
|
471
|
+
if (fieldKeys.length === 0) {
|
|
472
|
+
return declineElicitation();
|
|
473
|
+
}
|
|
474
|
+
const event = buildElicitationRequestedEvent(request, requestId);
|
|
475
|
+
const rejection = validateWireEvent(event);
|
|
476
|
+
if (rejection) {
|
|
477
|
+
// Defensive only, same reasoning as `handleRequestPermission`'s identical check: every field here
|
|
478
|
+
// is derived from ACP's own typed request, so a conforming agent can never actually trip this.
|
|
479
|
+
console.error(`[acp] refusing to raise an elicitation card that fails validateWireEvent: ${rejection.reason}`);
|
|
480
|
+
return declineElicitation();
|
|
481
|
+
}
|
|
482
|
+
if (isBlank(request.message)) {
|
|
483
|
+
// Legible, just not collectible here — raise it, then decline. See this function's own doc
|
|
484
|
+
// comment for why this is not the same as the empty-form guard above. A blank `message` is the
|
|
485
|
+
// **only** reason left for this branch: `ElicitationRequest.init`'s `require(message.isNotBlank())`
|
|
486
|
+
// is a request-level invariant, so the throw takes the whole request down with no read-only
|
|
487
|
+
// fallback to catch it, and `validate.ts`'s own `nonEmptyString` (`.length > 0`) does not see
|
|
488
|
+
// `" "`. Every *property*-level reason that used to route here now decodes into a read-only row
|
|
489
|
+
// instead — see the ruling-R24 block above for the measured table.
|
|
490
|
+
sink.emitEvent(event);
|
|
491
|
+
return declineElicitation();
|
|
492
|
+
}
|
|
493
|
+
return new Promise((resolve) => {
|
|
494
|
+
let settled = false;
|
|
495
|
+
const settle = (response) => {
|
|
496
|
+
if (settled)
|
|
497
|
+
return;
|
|
498
|
+
settled = true;
|
|
499
|
+
resolve(response);
|
|
500
|
+
};
|
|
501
|
+
sink.registerPendingElicitation(requestId, settle);
|
|
502
|
+
ctx.signal?.addEventListener('abort', () => {
|
|
503
|
+
settle({ action: 'cancel' });
|
|
504
|
+
// Purge the now-moot pending entry, exactly like `handleRequestPermission`'s own purge call —
|
|
505
|
+
// the `settled` guard above already makes a later real answer a no-op for this promise, but
|
|
506
|
+
// without this the entry would linger in `AcpSession.pendingElicitations` forever. `'cancel'`
|
|
507
|
+
// is ACP's own real cancellation outcome, so — unlike `handleRequestPermission`'s
|
|
508
|
+
// `unmatchableAnswerFor` — there is no collision to guard against here.
|
|
509
|
+
sink.answerElicitation(requestId, { action: 'cancel' });
|
|
510
|
+
}, { once: true });
|
|
511
|
+
sink.emitEvent(event);
|
|
512
|
+
});
|
|
513
|
+
}
|
|
258
514
|
/**
|
|
259
515
|
* `elicitation/complete` fires only for an elicitation the client previously accepted — now the `'url'`
|
|
260
516
|
* mode, once the agent considers that round trip finished. This turns it into a `dismiss` call so
|
|
@@ -282,12 +538,16 @@ export function handleElicitationComplete(notification, deps) {
|
|
|
282
538
|
export function registerPermissionHandlers(app, sink, elicitationDeps) {
|
|
283
539
|
app
|
|
284
540
|
.onRequest(methods.client.session.requestPermission, async (ctx) => {
|
|
285
|
-
|
|
541
|
+
if (typeof ctx.params.sessionId !== 'string' || !ctx.params.sessionId.trim()) {
|
|
542
|
+
console.error(JSON.stringify({ component: 'acp', error: 'invalid_permission_origin', rpcRequestId: String(ctx.requestId) }));
|
|
543
|
+
return { outcome: { outcome: 'cancelled' } };
|
|
544
|
+
}
|
|
545
|
+
const decision = await handleRequestPermission({ requestId: randomUUID(), rpcRequestId: String(ctx.requestId), sessionId: ctx.params.sessionId, toolCall: ctx.params.toolCall, options: ctx.params.options, signal: ctx.signal }, sink);
|
|
286
546
|
return {
|
|
287
547
|
outcome: decision.outcome === 'answered' ? { outcome: 'selected', optionId: decision.optionId } : { outcome: 'cancelled' },
|
|
288
548
|
};
|
|
289
549
|
})
|
|
290
|
-
.onRequest(methods.client.elicitation.create, (ctx) => handleCreateElicitation(ctx.params, elicitationDeps))
|
|
550
|
+
.onRequest(methods.client.elicitation.create, (ctx) => handleCreateElicitation(ctx.params, { present: elicitationDeps.present, requestId: String(ctx.requestId), signal: ctx.signal }, sink))
|
|
291
551
|
.onNotification(methods.client.elicitation.complete, (ctx) => handleElicitationComplete(ctx.params, elicitationDeps));
|
|
292
552
|
}
|
|
293
553
|
/**
|
|
@@ -309,8 +569,14 @@ export function createDeferredPermissionSink() {
|
|
|
309
569
|
return {
|
|
310
570
|
sink: {
|
|
311
571
|
emitEvent: (event) => requireBound().emitEvent(event),
|
|
312
|
-
registerPendingApproval: (requestId, resolve) => requireBound().registerPendingApproval(requestId, resolve),
|
|
572
|
+
registerPendingApproval: (requestId, resolve, address) => requireBound().registerPendingApproval(requestId, resolve, address),
|
|
573
|
+
cancelPendingApproval: (requestId, outcome) => {
|
|
574
|
+
const target = requireBound();
|
|
575
|
+
(target.cancelPendingApproval?.bind(target) ?? target.answer.bind(target))(requestId, outcome);
|
|
576
|
+
},
|
|
313
577
|
answer: (requestId, outcome) => requireBound().answer(requestId, outcome),
|
|
578
|
+
registerPendingElicitation: (requestId, resolve) => requireBound().registerPendingElicitation(requestId, resolve),
|
|
579
|
+
answerElicitation: (requestId, response) => requireBound().answerElicitation(requestId, response),
|
|
314
580
|
},
|
|
315
581
|
bind(target) {
|
|
316
582
|
// Review finding (task-5, item 4): silently replacing an already-bound sink on a second call
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { buildSessionMcpServers } from './delegationMcp.js';
|
|
6
|
+
export const QODER_MANAGED_VERSION = '1.1.61';
|
|
7
|
+
/** Owned by one process; the bearer is never an argument or environment variable. */
|
|
8
|
+
export async function prepareQoderStartup(launch, lease, cwd, env, agentEnv) {
|
|
9
|
+
buildSessionMcpServers([], lease);
|
|
10
|
+
if (launch.args.some((arg) => arg === '--' || /^(--mcp-config|--strict-mcp-config|--settings|--setting-sources)(=|$)/.test(arg))) {
|
|
11
|
+
throw new Error('Managed Qoder startup has conflicting configuration arguments');
|
|
12
|
+
}
|
|
13
|
+
const version = await new Promise((resolve, reject) => {
|
|
14
|
+
execFile(launch.command, [...launch.args, '--version'], {
|
|
15
|
+
cwd, env: { ...process.env, ...launch.env, ...env, ...agentEnv },
|
|
16
|
+
encoding: 'utf8', timeout: 10_000, maxBuffer: 64 * 1024, windowsHide: true,
|
|
17
|
+
}, (error, stdout) => error ? reject(new Error('Managed Qoder version could not be verified')) : resolve(stdout.trim()));
|
|
18
|
+
});
|
|
19
|
+
if (version !== QODER_MANAGED_VERSION)
|
|
20
|
+
throw new Error('Managed Qoder version is not certified');
|
|
21
|
+
const dir = await mkdtemp(join(tmpdir(), 'axom-qoder-mcp-'));
|
|
22
|
+
let disposal;
|
|
23
|
+
const dispose = () => disposal ??= rm(dir, { recursive: true, force: true });
|
|
24
|
+
try {
|
|
25
|
+
await chmod(dir, 0o700);
|
|
26
|
+
const config = join(dir, 'mcp.json');
|
|
27
|
+
const authorization = Object.values(lease.headers)[0];
|
|
28
|
+
await writeFile(config, JSON.stringify({ mcpServers: {
|
|
29
|
+
'axom-delegation': { type: 'http', url: lease.url, headers: { Authorization: authorization } },
|
|
30
|
+
} }), { mode: 0o600, flag: 'wx' });
|
|
31
|
+
// Validate again after the asynchronous version probe; an expired lease cannot start a process.
|
|
32
|
+
buildSessionMcpServers([], lease);
|
|
33
|
+
return {
|
|
34
|
+
launch: { ...launch, args: [...launch.args, '--strict-mcp-config', '--mcp-config', config,
|
|
35
|
+
'--setting-sources', '', '--settings', '{"autoMemoryEnabled":false}',
|
|
36
|
+
'--disallowed-tools', 'Agent', '--cwd', cwd,
|
|
37
|
+
'--append-system-prompt', 'Use the axom-delegation MCP tools for delegation. Native Agent execution is disabled in this session.'] },
|
|
38
|
+
secrets: [authorization, authorization.slice('Bearer '.length)], dispose,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
await dispose();
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|