@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
@@ -36,18 +36,17 @@
36
36
  * the server's turn-end / replay-end flush is what closes a message.
37
37
  */
38
38
  import { randomUUID } from 'node:crypto';
39
+ import { certifiedRuntime, ForegroundOutputBoundary } from '../qoderAdapter/foregroundOutput.js';
40
+ import { BackgroundTerminalRepair, nativeTerminalHistoryPath } from '../qoderAdapter/backgroundTerminal.js';
41
+ import { SubworkBoundary, supportsSubworkMeta } from '../qoderAdapter/subwork.js';
39
42
  import { methods } from '@agentclientprotocol/sdk';
40
43
  import { jsonRpcErrorCode } from './acpClient.js';
41
44
  import { settleUpdates } from './adoption.js';
42
45
  import { mapSessionUpdate, SessionMapperState } from './sessionUpdateMapper.js';
46
+ import { SessionMapperScopes } from './sessionScope.js';
47
+ import { newSessionMetaFor } from './activityReporting.js';
48
+ import { buildSessionMcpServers } from './delegationMcp.js';
43
49
  import { flatChoicesOf, modesAlreadyRepresented, normalizeConfigOptions, SYNTHESIZED_MODES_CONFIG_ID } from './configOptions.js';
44
- /**
45
- * A few ACP adapters reply to `session/prompt` before their last `session/update` notifications
46
- * have arrived. Those trailing chunks have no following turn boundary to synthesize
47
- * `agent_message`, so the server would keep them buffered until the reader sent another message.
48
- * This short quiet period lets a burst remain one message while ensuring it is rendered on its own.
49
- */
50
- const LATE_MESSAGE_SETTLE_MS = 250;
51
50
  /**
52
51
  * Points the connection's diverted-update stream at the session that owns it.
53
52
  *
@@ -76,6 +75,15 @@ function bindDivertedUpdates(connection, ownSessionId, session) {
76
75
  * {@link AcpConnection.supportsSteering} first.
77
76
  */
78
77
  const STEER_METHOD = '_session/steering';
78
+ /**
79
+ * The adapter's *stop one background task* extension method. **Not an ACP method** either — it is
80
+ * `claude-agent-acp`'s own `ASYNC_TASK_STOP_METHOD` (`acp-agent.js@0.79.0:165`), whose own comment
81
+ * reads *"Stops one Claude background task without cancelling the parent prompt turn."*
82
+ * `codex-acp@1.12.0` registers the same literal name with the same params
83
+ * (`dist/index.js:31608`, `:35126-35129`, `:32395-32406`). Reached through the untyped `request`
84
+ * overload, for the reason {@link STEER_METHOD} documents.
85
+ */
86
+ const ASYNC_TASK_STOP_METHOD = '_session/async_task/stop';
79
87
  /**
80
88
  * Narrow a `_session/steering` response onto {@link SteerOutcome}, or throw.
81
89
  *
@@ -119,6 +127,14 @@ export function mapSteerOutcome(result) {
119
127
  * sent over the wire to any agent, so no real option id can ever equal it in practice.
120
128
  */
121
129
  const ABANDONED_ANSWER = `__acp_session_abandoned_${randomUUID()}__`;
130
+ /**
131
+ * The value `registerPendingElicitation`'s resolver receives when nobody is ever going to answer.
132
+ * Unlike {@link ABANDONED_ANSWER}, this needs no randomized sentinel: `CreateElicitationResponse`'s
133
+ * `action` union already has `'cancel'` as a real ACP outcome meaning exactly this ("the client
134
+ * will not provide a response"), so there is no risk of it colliding with a real user answer the
135
+ * way a fixed permission-option-id literal would.
136
+ */
137
+ const ABANDONED_ELICITATION_ANSWER = { action: 'cancel' };
122
138
  /** ACP's `StopReason` (`end_turn | max_tokens | max_turn_requests | refusal | cancelled`) → the wire's `TurnStopReason`. `'cancelled'` is handled by the caller (it forces `status: 'interrupted'` instead, which carries no `stopReason` — see {@link AcpSession.finishTurn}), so it never actually reaches this function; the arm is kept so the switch stays exhaustive over every literal `StopReason` can hold. */
123
139
  function mapStopReason(reason) {
124
140
  switch (reason) {
@@ -169,6 +185,47 @@ async function steerRequest(connection, sessionId, blocks, priority) {
169
185
  });
170
186
  return mapSteerOutcome(result);
171
187
  }
188
+ /**
189
+ * One `_session/async_task/stop` round trip, shared by {@link AcpSession.open} and
190
+ * {@link AcpSession.attach} exactly as {@link steerRequest} is — the two differ only in where the
191
+ * session id comes from.
192
+ *
193
+ * The request shape is the adapter's own `parseAsyncTaskStopRequest`
194
+ * (`claude-agent-acp@0.79.0` `dist/acp-agent.js:166-173`): a non-empty `sessionId` and a non-empty
195
+ * `asyncTaskId`, both trimmed strings, and nothing else. `codex-acp@1.12.0` parses the identical
196
+ * pair (`dist/index.js:35126-35129`, a zod object with `.trim().min(1)` on both), so there is one
197
+ * shape here, not a per-vendor branch. The response is `{ stopped: boolean }` on both
198
+ * (`acp-agent.js:1784-1798`, `index.js:32395-32406`), which this layer deliberately does **not**
199
+ * turn into a verdict: `stopped: false` only means the adapter had no live task under that id
200
+ * right now, and the platform's real answer
201
+ * — did the background task actually end — arrives as the agent's own next `async_task_state_update`
202
+ * for this `(sessionId, asyncTaskId)`. Reporting `false` as a failure would make the server show an
203
+ * error for a task that is, by then, already settled.
204
+ *
205
+ * Reached through the untyped `request` overload for the same reason {@link steerRequest} is: this
206
+ * is an adapter extension in ACP's `_`-prefixed namespace, not an ACP method — `grep async_task`
207
+ * over the pinned SDK's `types.gen.d.ts` returns nothing.
208
+ */
209
+ async function asyncTaskStopRequest(connection, sessionId, asyncTaskId) {
210
+ const result = await connection.connection.agent.request(ASYNC_TASK_STOP_METHOD, {
211
+ sessionId,
212
+ asyncTaskId,
213
+ });
214
+ if (result?.stopped !== true) {
215
+ // Logged, never thrown: see this function's own doc for why `stopped: false` is not a failure.
216
+ console.warn(`[acp] ${ASYNC_TASK_STOP_METHOD} reported no live task '${asyncTaskId}' on session ${sessionId}`);
217
+ }
218
+ }
219
+ async function providerTaskStopRequest(connection, sessionId, taskId, requestId) {
220
+ const result = await connection.connection.agent.request('_axom/task/stop', {
221
+ sessionId, taskId, requestId,
222
+ });
223
+ if (result?.state === 'accepted')
224
+ return 'accepted';
225
+ if (result?.state === 'unknown')
226
+ return 'unknown';
227
+ throw new Error('Provider task stop was rejected');
228
+ }
172
229
  /**
173
230
  * One `session/set_config_option` (`SetSessionConfigOptionRequest`, `types.gen.d.ts:5102-5132`) or
174
231
  * — for the daemon's synthesized {@link SYNTHESIZED_MODES_CONFIG_ID} — `session/set_mode`
@@ -286,6 +343,7 @@ class AttachedQueue {
286
343
  }
287
344
  export class AcpSession {
288
345
  session;
346
+ connection;
289
347
  outbox = [];
290
348
  /**
291
349
  * This session's `toolCallId → name` memory, for the `tool_call_update`s ACP lets arrive with
@@ -295,9 +353,23 @@ export class AcpSession {
295
353
  * is: one `AcpSession` is one ACP session, and a registry shared between two of them would let
296
354
  * one session's tool call name a different session's update.
297
355
  */
298
- mapperState = new SessionMapperState();
356
+ rootRuntimeId = randomUUID();
357
+ scopes = new SessionMapperScopes((scope) => new SessionMapperState((raw) => { if (scope.nativeSessionId === this.session.sessionId)
358
+ this.reportActivity(raw); }));
359
+ mapperFor(nativeSessionId) {
360
+ return this.scopes.get({ rootRuntimeId: this.rootRuntimeId, nativeSessionId });
361
+ }
299
362
  eventSink = null;
300
363
  turnSink = null;
364
+ backgroundWriterUnconfirmed = false;
365
+ exactExecutionUnconfirmed = false;
366
+ /**
367
+ * This session's latest agent-reported activity-state consumer — `agentSessions.ts`, via
368
+ * `AcpAgentSession.onActivity`. Kept apart from {@link eventSink} on purpose: an activity push is
369
+ * a level, not an edge (see the `agent_session_state` frame's own doc comment), so it must never
370
+ * pass through the `WireEvent`/journal/seq machinery {@link eventSink} feeds.
371
+ */
372
+ activitySink = null;
301
373
  draining = false;
302
374
  /**
303
375
  * Every turn this session has started and not yet reported a result for, oldest first.
@@ -319,15 +391,19 @@ export class AcpSession {
319
391
  * per-prompt attribution would need an id ACP does not put on `session/update` either.
320
392
  */
321
393
  inFlightTurns = [];
322
- /** Timer which closes assistant text that an adapter sent after it had already ended the turn. */
323
- lateMessageFlushTimer = null;
324
394
  /**
325
395
  * Whether a `session/prompt` round trip is outstanding on the agent **right now**.
326
396
  *
327
397
  * This is the turn's ground truth: {@link inFlightTurns} is pushed when the prompt goes out and
328
398
  * shifted when the agent answers, so it says what the agent is doing rather than what anything
329
- * else recorded about it. The daemon's own `TaskRegistry` bracket is bookkeeping over the same
330
- * events and can outlive them (a steer on an idle session, a bracket never closed); this cannot.
399
+ * else recorded about it. `TaskRegistry.snapshot()` reads this getter directly, live, on every
400
+ * call -- there is no bracket of started/finished events kept elsewhere on the daemon side that
401
+ * could go stale or outlive it (that bookkeeping set is gone; see the design doc's §4.1/§4.2).
402
+ * The signal that *can* now outlive the events it describes is the agent's own self-reported
403
+ * activity (`lastActivityRaw`): a vendor that says `running` and then falls silent stays
404
+ * "running" until it says otherwise, by deliberate policy (no invented timeout; §4.4). This
405
+ * getter has no equivalent failure mode -- it cannot outlive the round trip it is asking about,
406
+ * because it *is* that round trip, sampled live rather than recorded.
331
407
  */
332
408
  get turnRunning() {
333
409
  return this.inFlightTurns.length > 0;
@@ -336,6 +412,9 @@ export class AcpSession {
336
412
  get readable() {
337
413
  return this.readLoopDead === null;
338
414
  }
415
+ nativeTerminalRepair;
416
+ foregroundOutput;
417
+ subworkBoundary;
339
418
  closed = false;
340
419
  connectionAborted = false;
341
420
  /**
@@ -348,10 +427,18 @@ export class AcpSession {
348
427
  * joins the same silence. Two of the consequences live outside this file, which is why no
349
428
  * per-file review caught them:
350
429
  *
351
- * - `agentSessions.ts` adds the ref to `TaskRegistry`'s `agentTasks` on every `agent_prompt` and
352
- * only `onAgentTurnDone` takes it out again, so one hung prompt pins `canUpdate()` to `false`
353
- * for the rest of this process's life — the daemon refuses **every** self-update until that
354
- * session is explicitly closed or swept.
430
+ * - `TaskRegistry.canUpdate()` samples {@link turnRunning} live off {@link inFlightTurns} rather
431
+ * than accumulating a bracketed ref (2026-09-21 rewrite — the old `agentTasks` Set could go
432
+ * stale on its own, holding a ref no real condition still justified; live sampling means a mark
433
+ * can no longer outlive the condition that set it). That does not make this concern go away,
434
+ * because the condition here would be real, not stale bookkeeping: {@link inFlightTurns} is
435
+ * still pushed unconditionally at the bottom of this method, and only {@link finishTurn} pops
436
+ * it. A prompt sent into a connection nobody is reading gets no `session/update`, so nothing
437
+ * would ever call {@link finishTurn} for that entry — the turn is genuinely, permanently
438
+ * unfinished, so live sampling faithfully reports it and pins `turnRunning` to `true`, and with
439
+ * it `canUpdate()` to `false`, for the rest of this process's life. Same failure mode as the old
440
+ * leak, one call frame earlier: a busy mark nothing will ever clear. This flag's early return is
441
+ * what pairs that push with an immediate {@link finishTurn} instead of leaving it stuck.
355
442
  * - Each retry `touch()`es the session, so retrying keeps `lastActivityAt` fresh and `sweepIdle`
356
443
  * never reclaims it either.
357
444
  *
@@ -371,16 +458,62 @@ export class AcpSession {
371
458
  * process forever. `permission.ts` is the only thing that populates it, via
372
459
  * {@link registerPendingApproval}; nothing in this file does.
373
460
  */
461
+ connectionGeneration = randomUUID();
462
+ permissionsInterrupted = false;
463
+ permissionOrigins = new Set();
464
+ // One parent per exact native ID, including edges whose parent has not arrived yet.
465
+ permissionParents = new Map();
466
+ unresolvedPermissionOrigins = new Set();
374
467
  pendingApprovals = new Map();
468
+ settledApprovals = new Map();
469
+ callbacksComplete = true;
470
+ publishCallbacks() {
471
+ this.connection.publishCallbacks?.({ rootSessionId: this.session.sessionId,
472
+ connectionGeneration: this.connectionGeneration, complete: this.callbacksComplete,
473
+ pending: this.callbacksComplete ? [...this.pendingApprovals].map(([requestId, entry]) => ({ requestId,
474
+ originNativeSessionId: entry.originNativeSessionId, rpcRequestId: entry.rpcRequestId })) : [],
475
+ settled: this.callbacksComplete ? [...this.settledApprovals].map(([requestId, reason]) => ({ requestId, reason })) : [] });
476
+ }
477
+ /**
478
+ * `requestId` → the resolver for one outstanding `elicitation/create` round trip. Parallel to
479
+ * {@link pendingApprovals}, and drained the same way for the same reason — `close()` and the
480
+ * connection-abort branch of `runReadLoop` must not leave the agent process blocked inside
481
+ * `elicitation/create` forever. A separate map rather than reusing `pendingApprovals`, because the
482
+ * resolved value differs in kind: a `CreateElicitationResponse` object, not a bare option-id
483
+ * string — see `AgentFrame.AgentElicitationAnswer`'s KDoc for why the two frames (and so the two
484
+ * maps) do not collapse into one.
485
+ */
486
+ pendingElicitations = new Map();
375
487
  readLoopDone;
376
- constructor(session, connectionSignal) {
488
+ constructor(session, connectionSignal, connection) {
377
489
  this.session = session;
490
+ this.connection = connection;
378
491
  if (connectionSignal.aborted)
379
492
  this.connectionAborted = true;
380
493
  else
381
- connectionSignal.addEventListener('abort', () => (this.connectionAborted = true), { once: true });
494
+ connectionSignal.addEventListener('abort', () => {
495
+ this.connectionAborted = true;
496
+ this.nativeTerminalRepair?.close();
497
+ }, { once: true });
498
+ if (certifiedRuntime(connection.agentInfo))
499
+ this.foregroundOutput = new ForegroundOutputBoundary(this.session.sessionId);
500
+ if (supportsSubworkMeta(connection.agentInfo))
501
+ this.subworkBoundary = new SubworkBoundary(this.session.sessionId);
382
502
  this.readLoopDone = this.runReadLoop();
383
503
  }
504
+ /** Only actual hosted sessions enable the certified Qoder boundary; probes never read history. */
505
+ enableBackgroundTerminalRepair(cwd, historyHome, restoreCalls = false) {
506
+ if (this.closed || this.connectionAborted || this.readLoopDead || this.nativeTerminalRepair)
507
+ return;
508
+ const path = nativeTerminalHistoryPath(this.connection.agentInfo, this.session.sessionId, cwd, historyHome);
509
+ if (!path)
510
+ return;
511
+ this.nativeTerminalRepair = new BackgroundTerminalRepair({
512
+ includeProcess: this.subworkBoundary !== undefined,
513
+ path, cwd, restoreCalls, nativeSessionId: this.session.sessionId, emit: event => this.emitEvent(event),
514
+ });
515
+ this.nativeTerminalRepair.start();
516
+ }
384
517
  /**
385
518
  * Open a new ACP session via `session/new` and start driving it.
386
519
  *
@@ -390,7 +523,18 @@ export class AcpSession {
390
523
  * degraded to `null` here rather than trusted at face value.
391
524
  */
392
525
  static async open(connection, options) {
393
- const activeSession = await connection.connection.agent.buildSession(options.cwd).start();
526
+ // Opts into claude-agent-acp's raw-SDK-message channel (design §5.1/§2.1) for exactly the
527
+ // session that needs it — `undefined` for every other agent, in which case `buildSession` keeps
528
+ // taking the plain `cwd` string it always has when no MCP server is configured. The explicit
529
+ // request path carries the same MCP list as session/load, including any scoped delegation lease.
530
+ const meta = newSessionMetaFor(connection.agentInfo?.name);
531
+ const mcpServers = buildSessionMcpServers(options.mcpServers, options.delegationMcp);
532
+ if (options.delegationMcp)
533
+ connection.redactStderrSecret(Object.values(options.delegationMcp.headers)[0]);
534
+ const builder = meta || mcpServers.length > 0
535
+ ? connection.connection.agent.buildSession({ cwd: options.cwd, mcpServers, ...(meta ? { _meta: meta } : {}) })
536
+ : connection.connection.agent.buildSession(options.cwd);
537
+ const activeSession = await builder.start();
394
538
  // `NewSessionResponse` (`acp.d.ts:329`'s `newSessionResponse` getter) carries both halves
395
539
  // `normalizeConfigOptions` needs: `configOptions` (`types.gen.d.ts:2546`) and `modes` (`:2542`).
396
540
  const response = activeSession.newSessionResponse;
@@ -408,8 +552,16 @@ export class AcpSession {
408
552
  cancel: () => connection.connection.agent.notify(methods.agent.session.cancel, { sessionId: activeSession.sessionId }),
409
553
  steer: (blocks, priority) => steerRequest(connection, activeSession.sessionId, blocks, priority),
410
554
  setConfigOption: setConfigOptionRequest(connection, activeSession.sessionId, () => snapshot, (next) => (snapshot = next)),
555
+ stopAsyncTask: (asyncTaskId) => asyncTaskStopRequest(connection, activeSession.sessionId, asyncTaskId),
556
+ providerTaskStop: (taskId, requestId) => providerTaskStopRequest(connection, activeSession.sessionId, taskId, requestId),
411
557
  };
412
- const session = new AcpSession(drivenSession, connection.connection.signal);
558
+ const session = new AcpSession(drivenSession, connection.connection.signal, connection);
559
+ // Claude's activity-state notification arrives on the connection, not on this session's own
560
+ // `session/update` stream (see `activityReporting.ts` and `AcpConnection.bindActivity`'s doc
561
+ // comment for why `extNotification` cannot be routed the way `session/update` is). Bound
562
+ // unconditionally: a connection whose agent never sends `_claude/sdkMessage` simply never
563
+ // invokes this sink.
564
+ connection.bindActivity((raw) => session.reportActivity(raw));
413
565
  const nativeSessionId = activeSession.sessionId.length > 0 ? activeSession.sessionId : null;
414
566
  bindDivertedUpdates(connection, activeSession.sessionId, session);
415
567
  return { session, nativeSessionId, configOptions: snapshot };
@@ -453,6 +605,22 @@ export class AcpSession {
453
605
  * array forever. `current()` instead re-reads `initial.configOptions` every time, until a real
454
606
  * `setConfigOption` call produces a snapshot of its own — so a caller that populates `initial`
455
607
  * (a mutable object) after construction is still seen.
608
+ *
609
+ * ### Opts claude into activity reporting exactly as {@link AcpSession.open} does
610
+ *
611
+ * `AcpSession.open` sends `_meta.claudeCode.emitRawSDKMessages` on `session/new` and calls
612
+ * `connection.bindActivity(...)` right after constructing its session (`:596`, `:631`). This method
613
+ * used to do neither, permanently losing activity reporting for every reconnect and every
614
+ * adoption of a claude session, even though `agentInfo.name` still made it report
615
+ * `reportsActivity: true` on `agent_opened` — see `protocol/agent.ts`'s `reportsActivity` field doc
616
+ * for how that gap was measured and closed. The `_meta` half is now sent by the caller: this
617
+ * method does not issue `session/load` itself (see this method's own opening paragraph — the
618
+ * caller already did, before calling this), but `adoption.ts`'s `replayNativeSession` — the
619
+ * function that does issue it — sends the exact same opt-in `AcpSession.open` sends, via the same
620
+ * `newSessionMetaFor` filter, so there is exactly one place that decides which agents opt in. This
621
+ * method's own half is `bindActivity` below, called unconditionally right after construction, the
622
+ * same as `open` — a connection whose agent never sends `_claude/sdkMessage` simply never invokes
623
+ * the sink.
456
624
  */
457
625
  static attach(connection, sessionId, updates, initial) {
458
626
  const agent = connection.connection.agent;
@@ -508,12 +676,17 @@ export class AcpSession {
508
676
  cancel: () => agent.notify(methods.agent.session.cancel, { sessionId }),
509
677
  steer: (blocks, priority) => steerRequest(connection, sessionId, blocks, priority),
510
678
  setConfigOption: setConfigOptionRequest(connection, sessionId, current, (next) => (snapshot = next)),
679
+ stopAsyncTask: (asyncTaskId) => asyncTaskStopRequest(connection, sessionId, asyncTaskId),
511
680
  dispose: () => {
512
681
  unsubscribe();
513
682
  queue.fail(new Error(`[acp] session ${sessionId} was disposed`));
514
683
  },
515
684
  };
516
- const attached = new AcpSession(driven, connection.connection.signal);
685
+ const attached = new AcpSession(driven, connection.connection.signal, connection);
686
+ // Same wiring `AcpSession.open` does right after constructing its session (`:631`) — see this
687
+ // method's own doc comment ("Opts claude into activity reporting exactly as `AcpSession.open`
688
+ // does") for why this half was missing and what closes it.
689
+ connection.bindActivity((raw) => attached.reportActivity(raw));
517
690
  bindDivertedUpdates(connection, sessionId, attached);
518
691
  return attached;
519
692
  }
@@ -527,7 +700,7 @@ export class AcpSession {
527
700
  * `PromptCapabilities.image` (`AcpConnection.capabilities.promptImage`, surfaced to callers as
528
701
  * `AcpAgentSession.supportsImageInput`), and enforcing that is the caller's job, not this method's.
529
702
  */
530
- prompt(blocks) {
703
+ prompt(blocks, platformTurnId) {
531
704
  // Checked ahead of `closed`, and the reason this branch exists at all: a session can outlive its
532
705
  // read loop without anyone having closed it (a dead connection, an unmappable update), and then
533
706
  // nothing but this call is left to end the turn. Registered as a turn and finished on the spot,
@@ -535,7 +708,7 @@ export class AcpSession {
535
708
  // path exactly as it does on the live one — `agentSessions.ts` unblocks `TaskRegistry` and the
536
709
  // server closes the round off `onTurnDone` and nothing else. See {@link readLoopDead}.
537
710
  if (this.readLoopDead !== null) {
538
- this.inFlightTurns.push({ interrupted: false });
711
+ this.inFlightTurns.push({ interrupted: false, platformTurnId });
539
712
  this.finishTurn('failed', undefined, this.readLoopDead);
540
713
  return;
541
714
  }
@@ -543,13 +716,16 @@ export class AcpSession {
543
716
  console.warn('[acp] dropping prompt for a session that is no longer running');
544
717
  return;
545
718
  }
546
- // An adapter may have ended the preceding prompt before delivering its final chunks. Do not let
547
- // those chunks borrow this prompt's completion marker: that makes the old answer appear only
548
- // after the reader submits a new message.
549
- this.flushLateMessageCompletion();
719
+ if (this.exactExecutionUnconfirmed || (this.inFlightTurns.length > 0 && (platformTurnId !== undefined ||
720
+ this.inFlightTurns.some((turn) => turn.platformTurnId !== undefined)))) {
721
+ throw new Error('An exact platform turn is still running; overlapping prompts are unavailable');
722
+ }
723
+ this.permissionsInterrupted = false;
550
724
  // One turn per prompt, always — see {@link inFlightTurns} for why an overlapping prompt must not
551
725
  // fold into the turn already running.
552
- this.inFlightTurns.push({ interrupted: false });
726
+ this.inFlightTurns.push({ interrupted: false, platformTurnId });
727
+ if (platformTurnId !== undefined)
728
+ this.exactExecutionUnconfirmed = true;
553
729
  // The `.catch(() => {})` is not error handling — `ActiveSession.prompt()` already routes this
554
730
  // same rejection onto the `nextUpdate()` queue internally (see the file doc comment), and
555
731
  // `runReadLoop`'s catch branch is what turns it into a `TurnResult`. It exists only so the
@@ -590,25 +766,65 @@ export class AcpSession {
590
766
  async setConfigOption(configId, value) {
591
767
  return this.session.setConfigOption(configId, value);
592
768
  }
769
+ /**
770
+ * Stop **one** background task on this session, leaving the running turn alone — the
771
+ * `_session/async_task/stop` extension; see {@link asyncTaskStopRequest}.
772
+ *
773
+ * No {@link inFlightTurns} bookkeeping, for the same reason {@link steer} does none: this does not
774
+ * create a turn and, unlike {@link interrupt}, it does not end one either. The adapter's own
775
+ * comment is explicit that the parent prompt turn keeps running.
776
+ *
777
+ * Throws on a closed session rather than warning-and-returning the way {@link interrupt} does: a
778
+ * dropped interrupt still leaves the turn marked interrupted locally, so the caller's picture
779
+ * stays true, whereas a dropped stop leaves a background task running with the server told
780
+ * nothing — the exact silence this whole path exists to remove.
781
+ */
782
+ async stopAsyncTask(asyncTaskId) {
783
+ if (this.closed)
784
+ throw new Error('[acp] cannot stop a background task on a session that is no longer running');
785
+ return this.session.stopAsyncTask(asyncTaskId);
786
+ }
787
+ async providerTaskStop(taskId, requestId) {
788
+ if (this.closed)
789
+ throw new Error('[acp] cannot stop a provider task on a closed session');
790
+ return this.session.providerTaskStop?.(taskId, requestId) ?? Promise.reject(new Error('Provider task stop unavailable'));
791
+ }
593
792
  /**
594
793
  * Resolve one pending `session/request_permission` round trip. Unknown/already-answered
595
794
  * `requestId`s are silently ignored, matching the deleted Claude Code bridge's `answer()` — a
596
795
  * platform relaying a stale answer must not throw.
597
796
  */
598
797
  answer(requestId, outcome) {
599
- const resolve = this.pendingApprovals.get(requestId);
600
- if (!resolve)
798
+ this.settleApproval(requestId, outcome, 'answered');
799
+ }
800
+ cancelPendingApproval(requestId, outcome) {
801
+ this.settleApproval(requestId, outcome, 'canceled');
802
+ }
803
+ settleApproval(requestId, outcome, reason) {
804
+ const entry = this.pendingApprovals.get(requestId);
805
+ if (!entry || entry.connectionGeneration !== this.connectionGeneration)
601
806
  return;
602
807
  this.pendingApprovals.delete(requestId);
603
- resolve(outcome);
808
+ if (this.settledApprovals.size >= 64)
809
+ this.callbacksComplete = false;
810
+ else
811
+ this.settledApprovals.set(requestId, reason);
812
+ this.publishCallbacks();
813
+ entry.resolve(outcome);
604
814
  }
605
815
  /**
606
816
  * Register a pending `session/request_permission` round trip. `permission.ts` calls this from its
607
817
  * `session/request_permission` handler and resolves the registered callback once the user (or
608
818
  * `close()`) answers it; nothing in this file calls it.
609
819
  */
610
- registerPendingApproval(requestId, resolve) {
611
- if (this.closed) {
820
+ registerPendingApproval(requestId, resolve, address) {
821
+ const origin = address?.originNativeSessionId ?? this.session.sessionId;
822
+ if (!origin.trim() || (origin !== this.session.sessionId && !this.permissionOrigins.has(origin))) {
823
+ console.error(JSON.stringify({ component: 'acp', error: 'invalid_permission_origin', origin, rootRuntimeId: this.rootRuntimeId }));
824
+ resolve(ABANDONED_ANSWER);
825
+ return;
826
+ }
827
+ if (this.closed || this.readLoopDead !== null || this.connectionAborted || this.permissionsInterrupted) {
612
828
  // A permission request that reaches us after our own close() has nobody left to answer it —
613
829
  // review finding (task-5, item 2): registering it anyway would park a card `close()`'s own
614
830
  // drain already ran past, and the agent would wait forever for a response nothing will send.
@@ -617,7 +833,44 @@ export class AcpSession {
617
833
  resolve(ABANDONED_ANSWER);
618
834
  return;
619
835
  }
620
- this.pendingApprovals.set(requestId, resolve);
836
+ if ([requestId, origin, address?.rpcRequestId ?? requestId].some((value) => !value.trim() || Buffer.byteLength(value, 'utf8') > 256 || /[\x00-\x1f\x7f]/.test(value)) ||
837
+ this.pendingApprovals.size >= 64 || this.pendingApprovals.has(requestId) || this.settledApprovals.has(requestId)) {
838
+ this.callbacksComplete = false;
839
+ this.publishCallbacks();
840
+ resolve(ABANDONED_ANSWER);
841
+ return;
842
+ }
843
+ this.pendingApprovals.set(requestId, {
844
+ connectionGeneration: this.connectionGeneration, rootRuntimeId: this.rootRuntimeId,
845
+ originNativeSessionId: origin, rpcRequestId: address?.rpcRequestId ?? requestId, resolve,
846
+ });
847
+ this.publishCallbacks();
848
+ }
849
+ /**
850
+ * Resolve one pending `elicitation/create` round trip. Same contract as {@link answer}: an
851
+ * unknown or already-answered `requestId` is silently ignored, because a stale elicitation answer
852
+ * for a request that already settled must not throw.
853
+ */
854
+ answerElicitation(requestId, response) {
855
+ const resolve = this.pendingElicitations.get(requestId);
856
+ if (!resolve)
857
+ return;
858
+ this.pendingElicitations.delete(requestId);
859
+ resolve(response);
860
+ }
861
+ /**
862
+ * Register a pending `elicitation/create` round trip. Same contract as
863
+ * {@link registerPendingApproval}: once this session is closed, there is nobody left to answer,
864
+ * so the resolver settles immediately (`{action: 'cancel'}`, ACP's own real cancellation outcome
865
+ * — unlike a permission option id, there is no collision to guard against here) instead of ever
866
+ * being stored.
867
+ */
868
+ registerPendingElicitation(requestId, resolve) {
869
+ if (this.closed) {
870
+ resolve(ABANDONED_ELICITATION_ANSWER);
871
+ return;
872
+ }
873
+ this.pendingElicitations.set(requestId, resolve);
621
874
  }
622
875
  /**
623
876
  * Emit a wire event that did not come from `session/update` — `permission.ts` calls this from its
@@ -629,12 +882,78 @@ export class AcpSession {
629
882
  * comment) instead of racing a second, independent path a consumer would have to merge itself.
630
883
  */
631
884
  emitEvent(event) {
885
+ this.nativeTerminalRepair?.observe(event);
632
886
  if (this.closed) {
633
887
  // Same reasoning as `registerPendingApproval` above: once closed, nothing is left reading the
634
888
  // outbox on the other end that could still make sense of a fresh card.
635
889
  return;
636
890
  }
637
- this.enqueue({ kind: 'event', event });
891
+ const payload = { ...event.payload };
892
+ if (event.eventName === 'permission_request') {
893
+ delete payload.callbackContext;
894
+ const callbackContext = this.callbackContextFor(event);
895
+ if (callbackContext)
896
+ payload.callbackContext = callbackContext;
897
+ const call = payload.toolCall;
898
+ const meta = call && typeof call === 'object' && !Array.isArray(call)
899
+ ? call._meta : undefined;
900
+ const sdkCallback = meta && typeof meta === 'object' && !Array.isArray(meta) &&
901
+ Object.prototype.hasOwnProperty.call(meta, 'qoderSdk');
902
+ if (sdkCallback && !callbackContext) {
903
+ if (typeof payload.requestId === 'string')
904
+ this.cancelPendingApproval(payload.requestId, ABANDONED_ANSWER);
905
+ return;
906
+ }
907
+ }
908
+ const normalized = { ...event, payload,
909
+ ...(event.acpSessionId === this.session.sessionId ? { acpSessionId: undefined } : {}) };
910
+ for (const mapped of this.subworkBoundary?.map(normalized) ?? [normalized]) {
911
+ this.observePermissionOrigin(mapped);
912
+ this.enqueue({ kind: 'event', event: mapped });
913
+ }
914
+ }
915
+ callbackContextFor(event) {
916
+ if (event.eventName !== 'permission_request' || event.acpSessionId !== this.session.sessionId)
917
+ return;
918
+ const diagnostics = this.connection.runtimeDiagnostics;
919
+ const profile = diagnostics.profile;
920
+ const callbacks = diagnostics.callbacks;
921
+ const requestId = event.payload.requestId;
922
+ const toolCall = event.payload.toolCall;
923
+ if (!diagnostics.available || profile?.mode !== 'sdk-adapter' || profile.initialized !== true ||
924
+ profile.rootConfirmed !== true || profile.rootSessionId !== this.session.sessionId ||
925
+ !profile.runtimeGeneration || callbacks?.complete !== true ||
926
+ callbacks.rootSessionId !== this.session.sessionId ||
927
+ typeof requestId !== 'string' || !this.pendingApprovals.has(requestId) ||
928
+ !toolCall || typeof toolCall !== 'object' || Array.isArray(toolCall))
929
+ return;
930
+ const call = toolCall;
931
+ const sdkMeta = call._meta && typeof call._meta === 'object' && !Array.isArray(call._meta)
932
+ ? call._meta.qoderSdk : undefined;
933
+ if (!sdkMeta || typeof sdkMeta !== 'object' || Array.isArray(sdkMeta))
934
+ return;
935
+ const sdk = sdkMeta;
936
+ const origin = sdk.origin;
937
+ if (sdk.version !== 1 || sdk.blockingScope !== 'root' || sdk.callbackOnly !== true ||
938
+ typeof sdk.requestId !== 'string' || !/^[0-9a-f-]{36}$/i.test(sdk.requestId) ||
939
+ !origin || typeof origin !== 'object' || Array.isArray(origin))
940
+ return;
941
+ const source = origin;
942
+ const toolUseId = call.toolCallId;
943
+ const agentId = source.agentId;
944
+ if (source.rootSessionId !== this.session.sessionId || source.runtimeGeneration !== profile.runtimeGeneration ||
945
+ typeof toolUseId !== 'string' || source.toolUseId !== toolUseId ||
946
+ !toolUseId || Buffer.byteLength(toolUseId, 'utf8') > 256 ||
947
+ (agentId !== undefined && (typeof agentId !== 'string' || !agentId || Buffer.byteLength(agentId, 'utf8') > 256)))
948
+ return;
949
+ const pending = callbacks.pending.find((entry) => entry.requestId === requestId &&
950
+ entry.originNativeSessionId === this.session.sessionId);
951
+ if (!pending || callbacks.pending.length > 64 || this.pendingApprovals.get(requestId)?.rpcRequestId !== pending.rpcRequestId)
952
+ return;
953
+ return { rootSessionId: this.session.sessionId, hostGeneration: diagnostics.generation,
954
+ connectionGeneration: callbacks.connectionGeneration, requestId,
955
+ runtimeGeneration: profile.runtimeGeneration, toolUseId,
956
+ ...(agentId ? { agentId } : {}), independent: false };
638
957
  }
639
958
  /**
640
959
  * Applies one `session/update` the transport diverted before the SDK could destroy it.
@@ -654,10 +973,10 @@ export class AcpSession {
654
973
  * on the connection, not just this one.
655
974
  */
656
975
  applyDivertedUpdate(diverted) {
657
- const isChild = diverted.reason === 'foreign-session';
976
+ const isChild = diverted.sessionId !== this.session.sessionId;
658
977
  let events;
659
978
  try {
660
- events = mapSessionUpdate(diverted.update, this.mapperState);
979
+ events = mapSessionUpdate(diverted.update, this.mapperFor(diverted.sessionId));
661
980
  }
662
981
  catch (err) {
663
982
  // An unmapped variant is a contract gap worth naming, not worth killing a session over. The
@@ -688,6 +1007,9 @@ export class AcpSession {
688
1007
  // after the user pressed Stop.
689
1008
  for (const turn of this.inFlightTurns)
690
1009
  turn.interrupted = true;
1010
+ this.permissionsInterrupted = true;
1011
+ this.drainPendingApprovals();
1012
+ this.drainPendingElicitations();
691
1013
  try {
692
1014
  await this.session.cancel();
693
1015
  }
@@ -714,8 +1036,9 @@ export class AcpSession {
714
1036
  async close() {
715
1037
  if (!this.closed) {
716
1038
  this.closed = true;
717
- this.cancelLateMessageCompletion();
1039
+ this.nativeTerminalRepair?.close();
718
1040
  this.drainPendingApprovals();
1041
+ this.drainPendingElicitations();
719
1042
  try {
720
1043
  await this.session.cancel();
721
1044
  }
@@ -725,6 +1048,10 @@ export class AcpSession {
725
1048
  this.session.dispose();
726
1049
  }
727
1050
  await this.readLoopDone;
1051
+ this.scopes.clear();
1052
+ this.permissionOrigins.clear();
1053
+ this.permissionParents.clear();
1054
+ this.unresolvedPermissionOrigins.clear();
728
1055
  }
729
1056
  /**
730
1057
  * Resolve every still-pending permission request with {@link ABANDONED_ANSWER} and clear the map.
@@ -735,9 +1062,18 @@ export class AcpSession {
735
1062
  * connection ever calls `close()`.
736
1063
  */
737
1064
  drainPendingApprovals() {
738
- for (const resolve of this.pendingApprovals.values())
739
- resolve(ABANDONED_ANSWER);
1065
+ const pending = [...this.pendingApprovals.values()];
740
1066
  this.pendingApprovals.clear();
1067
+ this.callbacksComplete = false;
1068
+ this.publishCallbacks();
1069
+ for (const entry of pending)
1070
+ entry.resolve(ABANDONED_ANSWER);
1071
+ }
1072
+ /** {@link pendingElicitations}' half of {@link drainPendingApprovals} — same callers, same reason. */
1073
+ drainPendingElicitations() {
1074
+ for (const resolve of this.pendingElicitations.values())
1075
+ resolve(ABANDONED_ELICITATION_ANSWER);
1076
+ this.pendingElicitations.clear();
741
1077
  }
742
1078
  onEvent(cb) {
743
1079
  this.eventSink = cb;
@@ -747,6 +1083,21 @@ export class AcpSession {
747
1083
  this.turnSink = cb;
748
1084
  this.drain();
749
1085
  }
1086
+ /** Registers this session's activity-state consumer. See {@link activitySink}. */
1087
+ onActivity(cb) {
1088
+ this.activitySink = cb;
1089
+ }
1090
+ /**
1091
+ * Feeds one agent-reported activity-state push into this session's sink — the join point for both
1092
+ * collection paths: codex-acp's `session_info_update._meta.codex.threadStatus`, via
1093
+ * {@link scopes}' injected callback, and claude-agent-acp's `_claude/sdkMessage`
1094
+ * extNotification, wired directly onto the connection in `AcpSession.open` (see there for why
1095
+ * that registration cannot live inside `mapSessionUpdate`'s ordinary flow: the notification is not
1096
+ * a `session/update` at all).
1097
+ */
1098
+ reportActivity(raw) {
1099
+ this.activitySink?.(raw);
1100
+ }
750
1101
  // `backfill?()` is deliberately not implemented here: replaying an existing native session's
751
1102
  // history is `session/load`, which is `adoption.ts`'s job ("领养"), not this file's. Not
752
1103
  // `session/resume(replayFrom)`, as the plan text and an earlier version of this comment said —
@@ -777,6 +1128,7 @@ export class AcpSession {
777
1128
  // the manager's onTurnDone would never fire for it. All of them, not just the oldest: the
778
1129
  // queue is permanently failed, so no further stop message is ever coming for the rest.
779
1130
  this.readLoopDead = errorMessage(err);
1131
+ this.nativeTerminalRepair?.close();
780
1132
  this.finishAllTurns('interrupted', errorMessage(err));
781
1133
  return;
782
1134
  }
@@ -792,7 +1144,8 @@ export class AcpSession {
792
1144
  // branch below has always depended on that).
793
1145
  // `err` itself, not its message: this is the only frame in which the agent's JSON-RPC code
794
1146
  // still exists. `errorMessage(err)` on the same line is what destroyed it before.
795
- this.finishTurn('failed', undefined, errorMessage(err), !this.connectionAborted, jsonRpcErrorCode(err));
1147
+ const peerErrorCode = jsonRpcErrorCode(err);
1148
+ this.finishTurn('failed', undefined, errorMessage(err), !this.connectionAborted, peerErrorCode, !this.connectionAborted && peerErrorCode !== undefined);
796
1149
  if (this.connectionAborted) {
797
1150
  // The connection died out from under us without our own close() ever running. Same
798
1151
  // reasoning as the `this.closed` branch above: the queue is now permanently failed, so no
@@ -803,16 +1156,18 @@ export class AcpSession {
803
1156
  // which is what {@link readLoopDead} gives it — finishing the turns open right now says
804
1157
  // nothing about the retry the user sends ten seconds from now.
805
1158
  this.readLoopDead = errorMessage(err);
1159
+ this.nativeTerminalRepair?.close();
806
1160
  this.finishAllTurns('failed', errorMessage(err));
807
1161
  // Nothing will drain pendingApprovals otherwise (review finding, task-5 item 5).
808
1162
  this.drainPendingApprovals();
1163
+ this.drainPendingElicitations();
809
1164
  return; // the connection itself is gone; further reads would just spin
810
1165
  }
811
1166
  continue;
812
1167
  }
813
1168
  if (message.kind === 'session_update') {
814
1169
  try {
815
- for (const event of mapSessionUpdate(message.update, this.mapperState))
1170
+ for (const event of mapSessionUpdate(message.update, this.mapperFor(this.session.sessionId)))
816
1171
  this.routeMappedEvent(event);
817
1172
  }
818
1173
  catch (err) {
@@ -848,6 +1203,7 @@ export class AcpSession {
848
1203
  // turn with the shared cause, drain the approvals, stop reading.
849
1204
  console.error(`[acp] unmappable session/update; ending this session's turns: ${errorMessage(err)}`);
850
1205
  this.readLoopDead = errorMessage(err);
1206
+ this.nativeTerminalRepair?.close();
851
1207
  this.finishAllTurns('failed', errorMessage(err));
852
1208
  this.drainPendingApprovals();
853
1209
  return;
@@ -858,10 +1214,88 @@ export class AcpSession {
858
1214
  // own interrupt() was never called (a defensive backstop, not the primary path — see
859
1215
  // `finishTurn`'s interrupted-flag handling for that).
860
1216
  const status = message.stopReason === 'cancelled' ? 'interrupted' : 'completed';
861
- this.finishTurn(status, message.stopReason);
1217
+ this.finishTurn(status, message.stopReason, undefined, false, undefined, true);
1218
+ }
1219
+ }
1220
+ }
1221
+ /** Host-owned load history restores addresses, never actionable interactions or live events. */
1222
+ observeReplayEvent(event) {
1223
+ this.nativeTerminalRepair?.observe(event);
1224
+ this.observePermissionOrigin(event);
1225
+ this.observeBackgroundWriter(event);
1226
+ }
1227
+ observeBackgroundWriter(event) {
1228
+ const input = event.payload.rawInput;
1229
+ if (input && typeof input === 'object' && !Array.isArray(input) &&
1230
+ input.run_in_background === true) {
1231
+ // An explicitly requested detached writer has no authenticated termination signal here.
1232
+ this.backgroundWriterUnconfirmed = true;
1233
+ }
1234
+ const output = event.payload.rawOutput;
1235
+ if (output && typeof output === 'object' && !Array.isArray(output)) {
1236
+ const evidence = output;
1237
+ if (evidence.backgrounded === true && (typeof evidence.taskId === 'string' || typeof evidence.pid === 'number')) {
1238
+ this.backgroundWriterUnconfirmed = true;
862
1239
  }
863
1240
  }
864
1241
  }
1242
+ /** Connection-local relations grant approval eligibility only after a complete path to our root. */
1243
+ observePermissionOrigin(event) {
1244
+ if (event.eventName !== 'subagent_spawned')
1245
+ return;
1246
+ const parent = event.acpSessionId ?? this.session.sessionId;
1247
+ const child = event.payload.subagentSessionId;
1248
+ const reject = (reason) => console.warn(JSON.stringify({
1249
+ component: 'acp', error: 'invalid_permission_relation', reason,
1250
+ parent, child, rootRuntimeId: this.rootRuntimeId,
1251
+ }));
1252
+ if (typeof parent !== 'string' || !parent.trim() || typeof child !== 'string' || !child.trim()) {
1253
+ reject('invalid_native_id');
1254
+ return;
1255
+ }
1256
+ if (child === parent || child === this.session.sessionId) {
1257
+ reject(child === parent ? 'self_parent' : 'root_reparent');
1258
+ return;
1259
+ }
1260
+ const existing = this.permissionParents.get(child);
1261
+ if (existing !== undefined) {
1262
+ if (existing !== parent)
1263
+ reject('conflicting_parent');
1264
+ return;
1265
+ }
1266
+ // Include unresolved edges: otherwise two untrusted parents could first form a cycle and
1267
+ // become eligible later when one is named by the root. Rejected edges never enter the graph.
1268
+ for (let ancestor = parent; ancestor !== undefined; ancestor = this.permissionParents.get(ancestor)) {
1269
+ if (ancestor === child) {
1270
+ reject('cycle');
1271
+ return;
1272
+ }
1273
+ }
1274
+ const trusted = parent === this.session.sessionId || this.permissionOrigins.has(parent);
1275
+ if (!trusted && this.unresolvedPermissionOrigins.size >= 1024) {
1276
+ reject('unresolved_capacity');
1277
+ return;
1278
+ }
1279
+ this.permissionParents.set(child, parent);
1280
+ if (!trusted) {
1281
+ this.unresolvedPermissionOrigins.add(child);
1282
+ return;
1283
+ }
1284
+ this.permissionOrigins.add(child);
1285
+ // A bounded fixed-point walk handles arbitrarily reversed arrival order, including replay.
1286
+ // Only relation events run it; ordinary event/approval traffic never scans these edges.
1287
+ let promoted;
1288
+ do {
1289
+ promoted = false;
1290
+ for (const candidate of this.unresolvedPermissionOrigins) {
1291
+ if (this.permissionOrigins.has(this.permissionParents.get(candidate))) {
1292
+ this.unresolvedPermissionOrigins.delete(candidate);
1293
+ this.permissionOrigins.add(candidate);
1294
+ promoted = true;
1295
+ }
1296
+ }
1297
+ } while (promoted);
1298
+ }
865
1299
  /**
866
1300
  * Routes one mapped `WireEvent` to the outbox, tracking `agent_message_chunk`/`agent_thought_chunk`
867
1301
  * on the way through so `agent_message` can be synthesized at the right moments — the one per-turn
@@ -871,17 +1305,18 @@ export class AcpSession {
871
1305
  * event, and the server folds it.
872
1306
  */
873
1307
  routeMappedEvent(event) {
874
- const isAssistantChunk = event.eventName === 'agent_message_chunk' || event.eventName === 'agent_thought_chunk';
875
- if (isAssistantChunk) {
876
- this.trackMessageBoundary(event);
1308
+ if (this.foregroundOutput && !this.foregroundOutput.accept(event))
1309
+ return;
1310
+ this.nativeTerminalRepair?.observe(event);
1311
+ for (const mapped of this.subworkBoundary?.map(event) ?? [event]) {
1312
+ this.observePermissionOrigin(mapped);
1313
+ this.observeBackgroundWriter(mapped);
1314
+ if ((!this.subworkBoundary || !mapped.acpSessionId || mapped.acpSessionId === this.session.sessionId) &&
1315
+ (mapped.eventName === 'agent_message_chunk' || mapped.eventName === 'agent_thought_chunk')) {
1316
+ this.trackMessageBoundary(mapped);
1317
+ }
1318
+ this.enqueue({ kind: 'event', event: mapped });
877
1319
  }
878
- this.enqueue({ kind: 'event', event });
879
- // `finishTurn` has already consumed the normal boundary. If an adapter emits content after it,
880
- // give the rest of that burst a short chance to arrive, then synthesize the missing boundary
881
- // ourselves. Without this, CoworkEngine correctly buffers the delta but can only flush it when
882
- // an unrelated later user message happens to append an item.
883
- if (isAssistantChunk && !this.turnRunning)
884
- this.scheduleLateMessageCompletion();
885
1320
  }
886
1321
  /**
887
1322
  * Notes that assistant content/thought is streaming, and — if a *different* `messageId` from the
@@ -912,26 +1347,6 @@ export class AcpSession {
912
1347
  this.sawMessageContent = false;
913
1348
  this.currentMessageId = null;
914
1349
  }
915
- /** Debounce a late chunk burst into one synthetic `agent_message` marker. */
916
- scheduleLateMessageCompletion() {
917
- this.cancelLateMessageCompletion();
918
- this.lateMessageFlushTimer = setTimeout(() => {
919
- this.lateMessageFlushTimer = null;
920
- if (!this.closed && !this.turnRunning)
921
- this.flushMessageCompleted();
922
- }, LATE_MESSAGE_SETTLE_MS);
923
- }
924
- /** Flush a late tail before its contents can be mistaken for a later prompt's answer. */
925
- flushLateMessageCompletion() {
926
- this.cancelLateMessageCompletion();
927
- this.flushMessageCompleted();
928
- }
929
- cancelLateMessageCompletion() {
930
- if (this.lateMessageFlushTimer === null)
931
- return;
932
- clearTimeout(this.lateMessageFlushTimer);
933
- this.lateMessageFlushTimer = null;
934
- }
935
1350
  /**
936
1351
  * Terminate every turn still in flight — for the one path (a permanently failed read queue) where
937
1352
  * no further stop message can arrive for any of them.
@@ -956,12 +1371,14 @@ export class AcpSession {
956
1371
  * `agentRefused` — the single `if` below is what keeps the code and the attribution from ever
957
1372
  * disagreeing, rather than two conditions someone can later edit apart.
958
1373
  */
959
- finishTurn(status, stopReason, error, agentRefused = false, errorCode) {
1374
+ finishTurn(status, stopReason, error, agentRefused = false, errorCode, executionEnded = false) {
960
1375
  // The oldest unfinished turn, popped: one stop message (or one prompt rejection) ends exactly
961
1376
  // one turn, so a second overlapping turn keeps its own result instead of being swallowed.
962
1377
  const turn = this.inFlightTurns.shift();
963
1378
  if (!turn)
964
1379
  return;
1380
+ if (this.inFlightTurns.length === 0)
1381
+ this.foregroundOutput?.endTurn();
965
1382
  // Must land before the TurnResult below. Unconditionally safe to call — a no-op when nothing
966
1383
  // was accumulated — so every path that can end a turn (a normal stop, an interrupt, a failed
967
1384
  // request, or close() mid-turn) goes through exactly this one place rather than each needing to
@@ -971,7 +1388,18 @@ export class AcpSession {
971
1388
  // A turn this session itself interrupted always reports 'interrupted', regardless of what the
972
1389
  // agent's own response claims — mirrors the deleted Claude Code bridge's finishTurn.
973
1390
  const resolved = turn.interrupted ? 'interrupted' : status;
1391
+ if (resolved === 'interrupted') {
1392
+ this.permissionsInterrupted = true;
1393
+ this.drainPendingApprovals();
1394
+ this.drainPendingElicitations();
1395
+ }
974
1396
  const result = { status: resolved };
1397
+ if (turn.platformTurnId !== undefined)
1398
+ result.platformTurnId = turn.platformTurnId;
1399
+ if (executionEnded && this.inFlightTurns.length === 0 && !this.backgroundWriterUnconfirmed) {
1400
+ result.executionEnded = true;
1401
+ this.exactExecutionUnconfirmed = false;
1402
+ }
975
1403
  if (resolved === 'completed' && stopReason && stopReason !== 'cancelled') {
976
1404
  result.stopReason = mapStopReason(stopReason);
977
1405
  }