@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
@@ -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 now *has* the
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). The two names are not in the same state: `elicitation_request` (`InteractionRequested`)
41
- * still has no bridge decode path — `TimelineEventWire.decode` has no branch for it, so a bridge
42
- * emitting it is rejected outright — but bare `elicitation` (`UserInteractionRequested`) has had a named
43
- * decode branch since `TimelineEventWire.kt:127`, and it is answerable end to end: decode rebuilds the
44
- * typed event, `CoworkEngine` lands a `USER_INTERACTION` item and parks the turn, and once the user
45
- * answers, `ExternalAgentSessionRuntime.execute` forwards the reply through `channel.prompt(runId,
46
- * context.userText, ...)`. `'form'` still declines — it ships an arbitrary JSON-Schema
47
- * `requestedSchema` (`ElicitationSchema`) that nothing on Axom's wire translates into that fixed Q&A
48
- * shape — and the open `default` arm still declines everything else the SDK or a nonstandard agent
49
- * might send, for the same reason: neither has anywhere to go yet, and declining is what keeps them
50
- * defensible — it never hangs the agent and it never fabricates a user's consent for input nobody was
51
- * ever shown.
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 the one admitted extension — ACP correlates the answer through the JSON-RPC request
117
- * id, which lives in the envelope (`ClientRequestContext.requestId`), and an event stream has no
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.answer(input.requestId, unmatchableAnswerFor(input.options));
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 through `deps.present` **only
215
- * when both hold**: the request itself actually carries a string `elicitationId`/`url` (a
216
- * nonconforming agent that omits either gets declined, never `present(undefined, undefined)`), and a
217
- * real `deps.present` is wired (see {@link PresentElicitationUrl}'s doc comment — its absence means
218
- * declining, not accepting into a void). Every other arm still declines — see the class doc comment
219
- * for why. Accepting here does **not** mean the user finished anything; it means "the URL has been put
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, deps) {
251
+ export async function handleCreateElicitation(request, ctx, sink) {
230
252
  switch (request.mode) {
231
253
  case 'form':
232
- // No form-rendering UI exists on Axom's wire yet — see the class doc comment.
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 (!deps.present) {
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
- deps.present(elicitationId, url);
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
- const decision = await handleRequestPermission({ requestId: String(ctx.requestId), toolCall: ctx.params.toolCall, options: ctx.params.options, signal: ctx.signal }, sink);
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
+ }