@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
@@ -1,9 +1,18 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { AGENT_WIRE_AUTH_URL_VERSION, AGENT_WIRE_HEARTBEAT_INVENTORY_VERSION, AGENT_WIRE_ORPHAN_OFFER_VERSION, AGENT_WIRE_SESSION_AUTH_METHODS_VERSION, AGENT_WIRE_SESSION_LOST_VERSION, AGENT_WIRE_VERSION, outboundAgentFrameRejection, outboundFailureReply, } from './protocol/agent.js';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { AGENT_WIRE_AUTH_URL_VERSION, AGENT_WIRE_HEARTBEAT_INVENTORY_VERSION, AGENT_WIRE_SCOPED_ORIGIN_VERSION, AGENT_WIRE_ORPHAN_OFFER_VERSION, AGENT_WIRE_SESSION_AUTH_METHODS_VERSION, AGENT_WIRE_SESSION_LOST_VERSION, AGENT_WIRE_SESSION_STATE_VERSION, AGENT_WIRE_VERSION, AGENT_WIRE_DELEGATION_MCP_VERSION, AGENT_WIRE_DELEGATION_RENEW_VERSION, AGENT_WIRE_PROVIDER_TASK_VERSION, AGENT_WIRE_RUNTIME_PROFILE_VERSION, outboundAgentFrameRejection, outboundFailureReply, } from './protocol/agent.js';
4
5
  import { DEFAULT_JOURNAL_MAX_BYTES, SessionJournal } from './journal.js';
5
6
  import { AGENT_PROBE_TIMEOUT_MS, AgentRefusedSessionError } from './acp/acpAgents.js';
6
7
  import { validateWireEvent } from './acp/validate.js';
8
+ import { reportsActivity, supportsAsyncTaskStop } from './acp/activityReporting.js';
9
+ import { activityIsBusy } from './acp/activityBusy.js';
10
+ import { buildSessionMcpServers } from './acp/delegationMcp.js';
11
+ function delegationLeaseId(lease) {
12
+ return createHash('sha256').update(JSON.stringify([
13
+ lease.url, lease.headers, lease.bindingId,
14
+ ])).digest('hex');
15
+ }
7
16
  /**
8
17
  * 从线上收来的验证预算的绝对上限。
9
18
  *
@@ -71,6 +80,20 @@ function promptBlocks(frame) {
71
80
  : [];
72
81
  return [...text, ...images];
73
82
  }
83
+ /**
84
+ * Narrows `agent_elicitation_answer.action` to the three literals this daemon itself ever sends.
85
+ *
86
+ * `AGENT_FRAME_SHAPES` only validates `action` as *a* string — ACP's own `CreateElicitationResponse`
87
+ * has a fourth, open `action: string` arm for an agent's own custom/future actions, so the wire
88
+ * layer cannot reject an arbitrary string as malformed. But this daemon is the only sender of this
89
+ * frame, and the server only ever offers the user accept/decline/cancel, so anything else reaching
90
+ * `handleFrame` is a platform bug, not a legitimate custom action — and a bare `as
91
+ * CreateElicitationResponse['action']` cast at the call site would launder it through unchecked,
92
+ * reaching the agent as a value ACP itself still accepts as protocol-valid.
93
+ */
94
+ function isKnownElicitationAction(action) {
95
+ return action === 'accept' || action === 'decline' || action === 'cancel';
96
+ }
74
97
  /**
75
98
  * The server's applied-through watermark on an open frame, sanitized. Absent means "nothing yet".
76
99
  *
@@ -94,6 +117,15 @@ function errorMessage(err) {
94
117
  * arrive as one giant frame; the last batch of a stream is whatever remains below this size.
95
118
  */
96
119
  export const BACKFILL_BATCH_SIZE = 64;
120
+ /**
121
+ * 一条会话自报 `running` 之后多久没有任何新推送,就值得在日志里点名。
122
+ *
123
+ * **这不是超时,不会清任何状态**——裁决 A 说 vendor 报 running 就是 running,发明一个
124
+ * 到点就翻成 idle 的定时器正是它禁止的猜测。这条只是把「我们完全信任了一个已经沉默 30 分钟
125
+ * 的信源」这件事摆到排查者面前。取 30 分钟是因为比任何正常回合都长,又短到一次排查会话里
126
+ * 看得见。
127
+ */
128
+ export const STALE_ACTIVITY_WARN_MS = 30 * 60 * 1000;
97
129
  /**
98
130
  * Defensively turns the wire query of `agent_list_native` / `agent_verify` / `agent_authenticate`
99
131
  * — one shape, because all three ask the same thing of an agent that has no session yet: where to
@@ -265,7 +297,10 @@ export class AgentSessionManager {
265
297
  * honest failure it produces today.
266
298
  */
267
299
  async resumeOfferSessions() {
268
- const live = this.liveInventory();
300
+ // `true`: this is the once-per-connection offer, the one place `activityRaw` is allowed to
301
+ // ride — see {@link liveInventory}'s parameter doc for why {@link heartbeatInventory} must pass
302
+ // `false` at its own call site instead.
303
+ const live = this.liveInventory(true);
269
304
  if (this.serverAgentWire < AGENT_WIRE_ORPHAN_OFFER_VERSION)
270
305
  return live;
271
306
  // Every ref this daemon still has a handle on, not merely the ones it would report as live:
@@ -327,8 +362,17 @@ export class AgentSessionManager {
327
362
  * here, and lands in exactly the same server-side branch as a journal left by a previous daemon
328
363
  * life -- which is the point: "the agent is gone" is one fact with one handling, not a special
329
364
  * case per way of getting there.
365
+ *
366
+ * @param includeActivity Whether to carry each session's {@link LiveSession.lastActivityRaw}
367
+ * along as `activityRaw`. **Must be `false` from {@link heartbeatInventory} and `true` from
368
+ * {@link resumeOfferSessions}, never the other way round** — `activityRaw` is an unbounded vendor
369
+ * blob, and `AGENT_WIRE_HEARTBEAT_INVENTORY_VERSION`'s own doc comment already ruled that kind of
370
+ * cost off the heartbeat ("ten seconds of overhead forever is worth skipping"). Threading this as
371
+ * a required parameter at the one shared call site — rather than, say, a field this method reads
372
+ * off `this` — is what makes "never on a heartbeat" a fact about the call site instead of a fact
373
+ * someone has to remember to keep true.
330
374
  */
331
- liveInventory() {
375
+ liveInventory(includeActivity) {
332
376
  return [...this.sessions.values()]
333
377
  .filter((session) => !session.closed)
334
378
  .map((session) => {
@@ -338,6 +382,9 @@ export class AgentSessionManager {
338
382
  journalMaxSeq: session.journal.lastSeq,
339
383
  agentAlive: liveness.agentAlive,
340
384
  turnRunning: liveness.turnRunning,
385
+ ...(includeActivity && session.lastActivityRaw !== undefined
386
+ ? { activityRaw: session.lastActivityRaw }
387
+ : {}),
341
388
  };
342
389
  });
343
390
  }
@@ -363,7 +410,8 @@ export class AgentSessionManager {
363
410
  heartbeatInventory() {
364
411
  if (this.serverAgentWire < AGENT_WIRE_HEARTBEAT_INVENTORY_VERSION)
365
412
  return undefined;
366
- const sessions = this.liveInventory();
413
+ // `false`: activityRaw must stay absent on every heartbeat — see `liveInventory`'s own doc.
414
+ const sessions = this.liveInventory(false);
367
415
  return sessions.length > 0 ? sessions : undefined;
368
416
  }
369
417
  /**
@@ -463,6 +511,30 @@ export class AgentSessionManager {
463
511
  }
464
512
  async handleFrame(frame) {
465
513
  switch (frame.t) {
514
+ case 'agent_delegation_renew': {
515
+ if (this.serverAgentWire < AGENT_WIRE_DELEGATION_RENEW_VERSION)
516
+ return;
517
+ const live = this.liveSession(frame.sessionRef);
518
+ const now = Date.now();
519
+ if (!live || live.closed || !live.session.liveness.agentAlive || live.session.liveness.turnRunning ||
520
+ activityIsBusy(live.lastActivityRaw) || live.delegationRefreshPending ||
521
+ live.nativeSessionId !== frame.nativeSessionId || live.delegationInstanceId !== frame.instanceId ||
522
+ live.delegationBindingId !== frame.bindingId || !frame.bindingId ||
523
+ !Number.isSafeInteger(frame.renewalRevision) || frame.renewalRevision < 1 ||
524
+ !Number.isSafeInteger(frame.expiresAtEpochMs) || frame.expiresAtEpochMs <= now ||
525
+ frame.expiresAtEpochMs > now + 330_000 || frame.renewalRevision < (live.delegationRenewalRevision ?? 0) ||
526
+ frame.expiresAtEpochMs < (live.delegationExpiresAtEpochMs ?? 0) ||
527
+ (frame.renewalRevision === live.delegationRenewalRevision && frame.expiresAtEpochMs !== live.delegationExpiresAtEpochMs)) {
528
+ this.emit({ t: 'agent_delegation_renewed', id: frame.id, ok: false, error: 'The original managed instance cannot renew' });
529
+ return;
530
+ }
531
+ live.delegationExpiresAtEpochMs = frame.expiresAtEpochMs;
532
+ live.delegationRenewalRevision = frame.renewalRevision;
533
+ this.emit({ t: 'agent_delegation_renewed', id: frame.id, ok: true,
534
+ nativeSessionId: live.nativeSessionId, instanceId: live.delegationInstanceId, bindingId: live.delegationBindingId,
535
+ renewalRevision: live.delegationRenewalRevision, expiresAtEpochMs: live.delegationExpiresAtEpochMs });
536
+ return;
537
+ }
466
538
  case 'agent_open': {
467
539
  const admission = this.reserveOpen(frame.id, frame.sessionRef);
468
540
  if (admission === 'rejected')
@@ -480,7 +552,7 @@ export class AgentSessionManager {
480
552
  const live = this.routableSession(frame.sessionRef, 'prompt');
481
553
  if (!live)
482
554
  return;
483
- if (this.tasks && !this.tasks.agentTurnStarted(frame.sessionRef)) {
555
+ if (this.tasks && !this.tasks.tryAdmitTurn(frame.sessionRef)) {
484
556
  // No `authMethods` here, deliberately, even though this is a failed turn on a session
485
557
  // whose agent we have certainly connected to: the prompt never reached that agent, so
486
558
  // nothing about credentials is being reported. This frame is built here rather than in
@@ -489,10 +561,19 @@ export class AgentSessionManager {
489
561
  this.record(live, (seq) => ({
490
562
  t: 'agent_turn_done', sessionRef: frame.sessionRef, seq, status: 'failed',
491
563
  error: 'Workstation is not accepting new work',
564
+ platformTurnId: frame.platformTurnId,
492
565
  }));
493
566
  return;
494
567
  }
495
- live.session.prompt(promptBlocks(frame));
568
+ try {
569
+ live.session.prompt(promptBlocks(frame), frame.platformTurnId);
570
+ }
571
+ catch (error) {
572
+ this.record(live, (seq) => ({
573
+ t: 'agent_turn_done', sessionRef: frame.sessionRef, seq, status: 'failed',
574
+ error: error instanceof Error ? error.message : String(error), platformTurnId: frame.platformTurnId,
575
+ }));
576
+ }
496
577
  return;
497
578
  }
498
579
  case 'agent_answer': {
@@ -500,6 +581,28 @@ export class AgentSessionManager {
500
581
  live?.session.answer(frame.requestId, frame.outcome);
501
582
  return;
502
583
  }
584
+ case 'agent_elicitation_answer': {
585
+ const live = this.routableSession(frame.sessionRef, 'elicitationAnswer');
586
+ if (!live)
587
+ return;
588
+ if (!isKnownElicitationAction(frame.action)) {
589
+ // `AGENT_FRAME_SHAPES` only validates `action` as *a* string (ACP's own action union has
590
+ // an open `string` arm for agent-authored custom actions), but this daemon is the only
591
+ // sender of this frame and the server only ever offers the user accept/decline/cancel —
592
+ // so anything else reaching here is a platform bug (a typo, a stale enum), not a
593
+ // legitimate custom action. Declining rather than forwarding it verbatim keeps the agent
594
+ // from being handed a value ACP itself would still accept as "protocol-valid" — the exact
595
+ // shape `isString`-only validation cannot catch on its own.
596
+ console.warn(`[agent] agent_elicitation_answer for '${frame.sessionRef}' carried an unrecognised action '${frame.action}'; declining instead of forwarding it`);
597
+ live.session.answerElicitation(frame.requestId, { action: 'decline' });
598
+ return;
599
+ }
600
+ live.session.answerElicitation(frame.requestId, {
601
+ action: frame.action,
602
+ ...(frame.content ? { content: frame.content } : {}),
603
+ });
604
+ return;
605
+ }
503
606
  case 'agent_interrupt': {
504
607
  const live = this.routableSession(frame.sessionRef, 'interrupt');
505
608
  if (!live)
@@ -513,16 +616,101 @@ export class AgentSessionManager {
513
616
  return;
514
617
  }
515
618
  /**
516
- * A steer rides a turn that is **already** running, so it is deliberately not bracketed by
517
- * `tasks.agentTurnStarted` / `agentTurnFinished` the way `agent_prompt` is: the agent still
518
- * answers exactly one `session/prompt` and still sends exactly one `agent_turn_done`, so
519
- * there is no second completion to close a second bracket with.
619
+ * Stop **one** background task, leaving the running turn alone — ACP's
620
+ * `_session/async_task/stop`. The server sends this instead of marking its JOB row cancelled
621
+ * behind the agent's back, which is what it used to do: the row went terminal, the task kept
622
+ * running, and the UI said "stopped" while the truth was "still running".
623
+ *
624
+ * Doesn't touch `busy()`'s inputs, for the reason `agent_steer` documents at length: this is
625
+ * not a turn, so it must not push {@link AcpSession.inFlightTurns} — `busy()` is sampled live
626
+ * off that array and the agent's reported activity (2026-09-21 rewrite, no accumulator to
627
+ * leak any more), and `stopAsyncTask()` deliberately does none of that bookkeeping, same as
628
+ * `steer()` (see `AcpSession.stopAsyncTask`'s own doc).
629
+ *
630
+ * **No reply frame, so a failure can only be logged** — and that is by design for the ordinary
631
+ * case, not an omission (see `agent_async_task_stop`'s own doc in `protocol/agent.ts`): the
632
+ * question the server is waiting on is not "did the request arrive" but "did the task actually
633
+ * stop", and only the agent's own next `async_task_state_update` for this
634
+ * `(sessionRef, asyncTaskId)` answers it. That update travels the ordinary event stream and
635
+ * settles the JOB there.
636
+ *
637
+ * ## Known gap: a refusal here is invisible to the server
638
+ *
639
+ * A `Method not found` from an agent that advertised a name we have a collector for but does
640
+ * not implement `_session/async_task/stop` lands in this catch and goes no further. The server
641
+ * recorded a "Stop requested" note on that JOB and will now wait for a terminal update that is
642
+ * never coming; nothing on that side says why.
643
+ *
644
+ * **Narrow today, and deliberately not papered over.** The server only sends this frame to a
645
+ * session whose `agent_opened` explicitly reported `supportsAsyncTaskStop`, from the
646
+ * certified `InitializeResponse.agentInfo.name` compatibility map — and both adapters
647
+ * implement the method (verified: `acp-agent.js@0.79.0:165`,
648
+ * `codex-acp@1.12.0 dist/index.js:31608`). So the gap opens only if one of them drops the
649
+ * method in a future version, or a third-party agent adopts one of those names.
650
+ *
651
+ * **Why it is not closed here**: the only honest thing to report is "the stop request was
652
+ * refused; the task is still running", and this daemon has no channel that can carry it. The
653
+ * frame has no reply; synthesizing an `async_task_state_update` would invent a terminal state
654
+ * the agent never reported, which is the exact lie this whole path was built to remove; and a
655
+ * new event name or a reply frame is a wire-contract change that belongs in a reviewed task,
656
+ * not in a fix applied to one. Tracked as a Task 8 acceptance item. Until then this log line
657
+ * is the only evidence, so it names the consequence rather than just the error.
658
+ */
659
+ case 'agent_async_task_stop': {
660
+ const live = this.routableSession(frame.sessionRef, 'async_task_stop');
661
+ if (!live)
662
+ return;
663
+ try {
664
+ if (!live.nativeSessionId || !supportsAsyncTaskStop(live.session.agentInfo?.name) || !frame.asyncTaskId.trim()) {
665
+ throw new Error('No certified task-stop route and exact target for this session');
666
+ }
667
+ await live.session.stopAsyncTask(frame.asyncTaskId);
668
+ }
669
+ catch (err) {
670
+ console.error(`[agent] stopping background task ${frame.asyncTaskId} failed for ${frame.sessionRef}: ` +
671
+ `${errorMessage(err)} -- the task is still running and the server will keep waiting ` +
672
+ 'for a terminal update it will never receive');
673
+ }
674
+ return;
675
+ }
676
+ case 'agent_provider_task_stop': {
677
+ if (this.serverAgentWire < AGENT_WIRE_PROVIDER_TASK_VERSION) {
678
+ this.emit({ t: 'agent_provider_task_stopped', id: frame.id, state: 'rejected', error: 'Provider task control requires a newer server' });
679
+ return;
680
+ }
681
+ const live = this.routableSession(frame.sessionRef, 'provider_task_stop');
682
+ if (!live || !live.session.providerTaskAdapterSelected || !live.session.providerTaskStop ||
683
+ live.nativeSessionId !== frame.rootSessionId ||
684
+ live.session.runtimeDiagnostics?.profile?.runtimeGeneration !== frame.runtimeGeneration) {
685
+ this.emit({ t: 'agent_provider_task_stopped', id: frame.id, state: 'rejected', error: 'Provider task identity is not live and trusted' });
686
+ return;
687
+ }
688
+ try {
689
+ const state = await live.session.providerTaskStop(frame.taskId, frame.requestId);
690
+ this.emit({ t: 'agent_provider_task_stopped', id: frame.id, state });
691
+ }
692
+ catch (err) {
693
+ this.emit({ t: 'agent_provider_task_stopped', id: frame.id, state: 'unknown', error: errorMessage(err) });
694
+ }
695
+ return;
696
+ }
697
+ /**
698
+ * A steer rides a turn that is **already** running, so it must not push
699
+ * {@link AcpSession.inFlightTurns} the way `agent_prompt` does: `steer()` sends exactly one
700
+ * extension request and gets exactly one reply, not a second `agent_turn_done`, so there is
701
+ * no second completion to pop a second entry with.
520
702
  *
521
- * The leak that would open is not hypothetical. The server steers whenever **it** believes a
522
- * turn is running, and the agent is free to disagree and answer `promptRequired` — a steer on
523
- * an idle session. Nothing will ever send a `turn_done` for that one, so the sessionRef would
524
- * sit in `TaskRegistry`'s agent-task set forever and `canUpdate()` would be false for the rest
525
- * of this daemon's life. Same shape as the hung-prompt leak `readLoopDead` exists for.
703
+ * That distinction used to matter for a leak that was not hypothetical, and still explains why
704
+ * `steer()` pushes nothing today. The server steers whenever **it** believes a turn is
705
+ * running, and the agent is free to disagree and answer `promptRequired` — a steer on an idle
706
+ * session. Under the old bracketed `TaskRegistry.agentTasks` (removed 2026-09-21), nothing
707
+ * would ever send a `turn_done` to close that bracket, so the sessionRef sat there forever and
708
+ * `canUpdate()` stayed `false` for the rest of the daemon's life — the same shape as the
709
+ * hung-prompt leak `readLoopDead` exists for. `busy()` is sampled live today, off
710
+ * {@link AcpSession.turnRunning} and the agent's own reported activity, with no set to leave a
711
+ * ref in — but that safety is a property of `busy()`'s inputs, not of this call site, so
712
+ * `steer()` still deliberately pushes nothing to {@link AcpSession.inFlightTurns} (see its own
713
+ * doc): an idle-session steer must not be mistaken for the start of a turn.
526
714
  *
527
715
  * Every exit answers. The server is holding the steered message in its own queue table
528
716
  * (`session_message_queue`) while this request is in flight — the message is not on the
@@ -559,10 +747,12 @@ export class AgentSessionManager {
559
747
  /**
560
748
  * Set one session config option, round-tripping ACP `session/set_config_option`. Same shape
561
749
  * as `agent_steer` immediately above, and for the same reason: this is a real round trip the
562
- * server is blocked waiting on, not a turn, so it is not bracketed by
563
- * `tasks.agentTurnStarted` / `agentTurnFinished` — there is no second completion to close a
564
- * second bracket with, and a bracket opened here would never close, leaking the ref for the
565
- * life of the daemon.
750
+ * server is blocked waiting on, not a turn, so it must not push
751
+ * {@link AcpSession.inFlightTurns} — there is no second `agent_turn_done` to pop it with, and
752
+ * `busy()` is sampled live off that array plus the agent's own reported activity (2026-09-21
753
+ * rewrite: no accumulator here any more), so a push with nothing to pop it would pin a session
754
+ * "busy" for the life of the daemon exactly the way the old bracketed `TaskRegistry.agentTasks`
755
+ * used to leak.
566
756
  *
567
757
  * Every exit answers: a session that is not routable, an agent that throws (its own refusal,
568
758
  * or `Method not found` from one that never advertised the method), all send
@@ -575,11 +765,16 @@ export class AgentSessionManager {
575
765
  this.emit({ t: 'agent_config_option_set', id: frame.id, ok: false, error: `no live session '${frame.sessionRef}'` });
576
766
  return;
577
767
  }
768
+ const startedAt = performance.now();
769
+ const operation = `request=${frame.id} session=${frame.sessionRef} config=${frame.configId}`;
770
+ console.info(`${new Date().toISOString()} [agent/config] applying ${operation}`);
578
771
  try {
579
772
  const configOptions = await live.session.setConfigOption(frame.configId, frame.value);
773
+ console.info(`${new Date().toISOString()} [agent/config] confirmed ${operation} elapsedMs=${Math.round(performance.now() - startedAt)}`);
580
774
  this.emit({ t: 'agent_config_option_set', id: frame.id, ok: true, configOptions });
581
775
  }
582
776
  catch (err) {
777
+ console.warn(`${new Date().toISOString()} [agent/config] failed ${operation} elapsedMs=${Math.round(performance.now() - startedAt)}: ${errorMessage(err)}`);
583
778
  // Includes the agent's own refusal ("Invalid value…", "Unknown config option…") and a
584
779
  // `Method not found` from an agent without the method — both are the server's to show.
585
780
  this.emit({ t: 'agent_config_option_set', id: frame.id, ok: false, error: errorMessage(err) });
@@ -596,7 +791,13 @@ export class AgentSessionManager {
596
791
  if (!live)
597
792
  return;
598
793
  this.touch(live);
599
- await this.enqueue(live, () => live.journal.truncateThrough(frame.throughSeq));
794
+ await this.enqueue(live, async () => {
795
+ if (live.scopedReplayBlocked || this.blockIncompatibleJournal(live))
796
+ return;
797
+ await live.journal.truncateThrough(frame.throughSeq);
798
+ if (frame.throughSeq >= live.highestScopedJournalSeq)
799
+ live.highestScopedJournalSeq = 0;
800
+ });
600
801
  return;
601
802
  }
602
803
  case 'agent_list_native':
@@ -606,6 +807,13 @@ export class AgentSessionManager {
606
807
  }
607
808
  await this.onListNative(frame);
608
809
  return;
810
+ case 'agent_delete_native':
811
+ if (this.tasks && !this.tasks.accepting) {
812
+ this.emit({ t: 'agent_native_deleted', id: frame.id, ok: false, error: 'Workstation is not accepting new work' });
813
+ return;
814
+ }
815
+ await this.onDeleteNative(frame);
816
+ return;
609
817
  // Both of these spawn an agent subprocess, exactly as `agent_list_native` does, so they answer
610
818
  // a draining workstation the same way rather than starting work it is trying to finish.
611
819
  case 'agent_verify':
@@ -632,10 +840,39 @@ export class AgentSessionManager {
632
840
  await this.onInstall(frame);
633
841
  return;
634
842
  default:
635
- // Not an agent frame (or an outbound-only one) — the daemon routes those elsewhere.
843
+ this.warnUnhandledFrame(frame);
636
844
  return;
637
845
  }
638
846
  }
847
+ /** Frame types already reported as having no case on this daemon; see {@link warnUnhandledFrame}. */
848
+ warnedUnhandledFrameTypes = new Set();
849
+ /**
850
+ * Say once, per frame type, that a frame reached this dispatcher and nothing acted on it.
851
+ *
852
+ * **This used to be a bare `return` with a comment.** That silence is the exact half of the wire
853
+ * this side had no alarm for: `agent_async_task_stop` is the first server->client request frame
854
+ * whose only consumer is the `switch` above, so a version of this daemon that shipped the sending
855
+ * half without the receiving one would have dropped every stop request with no type error, no log
856
+ * line and no timeout — "shipped inert", and invisible. The Kotlin twin
857
+ * (`AgentChannelSession.warnUnhandledFrame`) has been loud here since the `ec341b8e4` incident,
858
+ * where three reply frames were dropped before their handler and three features were inert on
859
+ * every workstation from the day they shipped; this is the same alarm on the other end.
860
+ *
861
+ * Deduplicated by type rather than logged per frame, exactly as the Kotlin twin does it: the
862
+ * diagnosis is "this type has no case", and repeating it per frame would bury it.
863
+ *
864
+ * One benign arrival is possible and is deliberately **not** exempted: a `heartbeat_ack` for a seq
865
+ * the channel no longer has outstanding slips past `connectEncryptedChannel`'s ack filter and
866
+ * lands here. That costs one line for the life of the process, and an exemption list would be a
867
+ * second, parallel statement of which frames this daemon handles — precisely the hand-maintained
868
+ * copy `AGENT_FRAME_SHAPES`'s own doc exists to avoid.
869
+ */
870
+ warnUnhandledFrame(frame) {
871
+ if (this.warnedUnhandledFrameTypes.has(frame.t))
872
+ return;
873
+ this.warnedUnhandledFrameTypes.add(frame.t);
874
+ console.warn(`[agent] no case for an inbound '${frame.t}'; it reached this daemon and nothing consumed it`);
875
+ }
639
876
  /**
640
877
  * Classifies an open before its first await. A live or already-opening ref is continuation
641
878
  * traffic, so graceful drain lets it replay/piggyback without acquiring (and later releasing)
@@ -823,6 +1060,21 @@ export class AgentSessionManager {
823
1060
  * daemon's side of the divergence; the notice heals the server's.
824
1061
  */
825
1062
  async sweepIdle(ttlMs, now) {
1063
+ // Observation, not a decision: a session the agent still claims is `running` is never swept
1064
+ // for that alone (ruling A forbids inventing a timeout that overrides the vendor), but one
1065
+ // that has gone this long without another push is worth naming in the log so whoever is
1066
+ // debugging a stuck workstation can see it instead of just trusting a RUNNING that never moves.
1067
+ for (const live of this.sessions.values()) {
1068
+ if (!live.staleActivityWarned &&
1069
+ activityIsBusy(live.lastActivityRaw) &&
1070
+ live.lastActivityAtMs !== undefined &&
1071
+ now - live.lastActivityAtMs > STALE_ACTIVITY_WARN_MS) {
1072
+ live.staleActivityWarned = true;
1073
+ console.warn(`[acp:${live.agentId} agent:${live.agentKey}] session ${live.sessionRef} has reported \`running\` ` +
1074
+ `for ${Math.round((now - live.lastActivityAtMs) / 60000)} minutes with no further push; ` +
1075
+ 'the platform is reporting it as RUNNING because that is what the agent said.');
1076
+ }
1077
+ }
826
1078
  const idle = [...this.sessions.values()].filter((live) => now - live.lastActivityAt >= ttlMs);
827
1079
  // Snapshot first: retire() mutates `sessions`, and it awaits.
828
1080
  for (const live of idle) {
@@ -835,28 +1087,77 @@ export class AgentSessionManager {
835
1087
  return idle.map((live) => live.sessionRef);
836
1088
  }
837
1089
  async onOpen(frame) {
838
- // Reconnect / duplicate open: reuse the live session as-is and replay what the server
839
- // still misses.
1090
+ if (frame.delegationMcp && (this.serverAgentWire < AGENT_WIRE_DELEGATION_MCP_VERSION ||
1091
+ (frame.delegationMcp.bindingId && this.serverAgentWire < AGENT_WIRE_DELEGATION_RENEW_VERSION))) {
1092
+ this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: 'Managed delegation requires a newer server' });
1093
+ return;
1094
+ }
1095
+ if (frame.delegationMcp) {
1096
+ try {
1097
+ buildSessionMcpServers([], frame.delegationMcp);
1098
+ }
1099
+ catch {
1100
+ this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: 'Delegation MCP configuration is unavailable' });
1101
+ return;
1102
+ }
1103
+ }
1104
+ // Rotation is server-driven. A busy reconnect replays the original process's journal.
840
1105
  const existing = this.liveSession(frame.sessionRef);
1106
+ let openFrame = frame;
1107
+ let retireForRefresh;
841
1108
  if (existing) {
842
- // Reusing a live session: neither the process nor the native session moved.
843
- await this.resumeAndReply(existing, frame, false);
844
- // The session already exists, so any history it has was already streamed under whichever
845
- // open first established it (or never will be, if that open didn't ask for it) — replaying it
846
- // again here would duplicate what the platform already holds. But if THIS frame asked for
847
- // backfill, the server is waiting on exactly one `agent_backfill_done` for THIS open's id:
848
- // silence is not "no", it is a wait that never resolves. Answer it without replaying.
849
- this.replyNoReplayNeeded(frame);
850
- return;
1109
+ if (existing.delegationExpiresAtEpochMs && !frame.delegationMcp) {
1110
+ this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: 'Managed delegation lease is required' });
1111
+ return;
1112
+ }
1113
+ if (frame.delegationMcp && frame.nativeSessionId && frame.nativeSessionId !== existing.nativeSessionId) {
1114
+ this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: 'Managed delegation cannot reload the original session' });
1115
+ return;
1116
+ }
1117
+ const refresh = !!frame.delegationMcp &&
1118
+ (existing.delegationLeaseId !== delegationLeaseId(frame.delegationMcp) || existing.delegationRefreshPending === true);
1119
+ if (refresh) {
1120
+ if (!existing.nativeSessionId || (frame.nativeSessionId && frame.nativeSessionId !== existing.nativeSessionId)) {
1121
+ this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: 'Managed delegation cannot reload the original session' });
1122
+ return;
1123
+ }
1124
+ if (existing.session.liveness.turnRunning || activityIsBusy(existing.lastActivityRaw)) {
1125
+ existing.delegationRefreshPending = true;
1126
+ if (!await this.resumeAndReply(existing, frame, false))
1127
+ return;
1128
+ this.replyNoReplayNeeded(frame);
1129
+ return;
1130
+ }
1131
+ openFrame = { ...frame, nativeSessionId: existing.nativeSessionId, requireSessionLoad: true };
1132
+ retireForRefresh = existing;
1133
+ }
1134
+ else {
1135
+ // Reusing a live session: neither the process nor the native session moved.
1136
+ if (!await this.resumeAndReply(existing, frame, false))
1137
+ return;
1138
+ // The session already exists, so any history it has was already streamed under whichever
1139
+ // open first established it (or never will be, if that open didn't ask for it) — replaying it
1140
+ // again here would duplicate what the platform already holds. But if THIS frame asked for
1141
+ // backfill, the server is waiting on exactly one `agent_backfill_done` for THIS open's id:
1142
+ // silence is not "no", it is a wait that never resolves. Answer it without replaying.
1143
+ this.replyNoReplayNeeded(frame);
1144
+ return;
1145
+ }
851
1146
  }
852
1147
  const inFlight = this.opening.get(frame.sessionRef);
853
1148
  if (inFlight) {
854
1149
  // Piggyback on the attempt already running, and report its real failure — not a generic one.
855
1150
  const { live, error, agentRefused } = await inFlight;
856
1151
  if (live) {
1152
+ if (!!frame.delegationMcp !== !!live.delegationExpiresAtEpochMs ||
1153
+ (frame.delegationMcp && live.delegationLeaseId !== delegationLeaseId(frame.delegationMcp))) {
1154
+ this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: 'Managed delegation lease does not match the live session' });
1155
+ return;
1156
+ }
857
1157
  // Piggybacking on the attempt already running: a non-null `live` means that attempt was a
858
1158
  // real open (`session/load` or `session/new`), which this frame is reporting secondhand.
859
- await this.resumeAndReply(live, frame, true);
1159
+ if (!await this.resumeAndReply(live, frame, true))
1160
+ return;
860
1161
  // Same reasoning as the reconnect branch above: whichever frame actually drove openSession
861
1162
  // already streamed this session's history (if it asked to); this frame is a second, racing
862
1163
  // open of the same ref and must not trigger a second replay — but still owes its own
@@ -876,8 +1177,14 @@ export class AgentSessionManager {
876
1177
  }
877
1178
  return;
878
1179
  }
879
- const attempt = this.openSession(frame)
880
- .catch((err) => ({ live: null, error: errorMessage(err) }));
1180
+ const attempt = (async () => {
1181
+ if (retireForRefresh)
1182
+ await this.retire(retireForRefresh, 'keep');
1183
+ return this.openSession(openFrame);
1184
+ })()
1185
+ .catch((err) => ({
1186
+ live: null, error: openFrame.delegationMcp ? 'Managed delegation session could not open' : errorMessage(err),
1187
+ }));
881
1188
  this.opening.set(frame.sessionRef, attempt);
882
1189
  let outcome;
883
1190
  try {
@@ -900,7 +1207,8 @@ export class AgentSessionManager {
900
1207
  // resolving from the finally, and microtasks always drain before the next inbound frame.
901
1208
  if (outcome.live) {
902
1209
  // openSession returned successfully, having just done session/load or session/new.
903
- await this.resumeAndReply(outcome.live, frame, true);
1210
+ if (!await this.resumeAndReply(outcome.live, frame, true))
1211
+ return;
904
1212
  // Only the direct-attempt path: the existing-session and in-flight-piggyback branches above
905
1213
  // return before reaching here, so a racing duplicate `agent_open` (or a reconnect of an
906
1214
  // already-open ref) can never trigger a second replay of the same history.
@@ -975,6 +1283,9 @@ export class AgentSessionManager {
975
1283
  // legitimately rely on `this`, and a bare local `const backfill = session.backfill` would call
976
1284
  // it unbound.
977
1285
  for await (const event of session.backfill()) {
1286
+ if (event.acpSessionId !== undefined && this.serverAgentWire < AGENT_WIRE_SCOPED_ORIGIN_VERSION) {
1287
+ throw new Error(`Scoped backfill requires agent wire ${AGENT_WIRE_SCOPED_ORIGIN_VERSION}; native origin cannot be discarded`);
1288
+ }
978
1289
  batch.push(event);
979
1290
  count += 1;
980
1291
  if (batch.length >= BACKFILL_BATCH_SIZE) {
@@ -1015,8 +1326,16 @@ export class AgentSessionManager {
1015
1326
  const resumeFrom = resumeFromSeqOf(frame);
1016
1327
  // On the session's own queue so an agent event produced mid-replay is sent after it, not into
1017
1328
  // the middle of it.
1329
+ let incompatible = false;
1018
1330
  await this.enqueue(live, async () => {
1331
+ if (live.closed)
1332
+ return;
1333
+ incompatible = this.blockIncompatibleJournal(live);
1334
+ if (incompatible)
1335
+ return;
1019
1336
  const entries = await live.journal.entriesAfter(resumeFrom);
1337
+ if (live.closed)
1338
+ return;
1020
1339
  if (this.hasGap(live, entries, resumeFrom)) {
1021
1340
  this.emit({
1022
1341
  t: 'agent_event',
@@ -1033,7 +1352,19 @@ export class AgentSessionManager {
1033
1352
  // de-duplication does the deciding.
1034
1353
  for (const entry of entries)
1035
1354
  this.emit(entry.frame);
1355
+ live.scopedReplayBlocked = false;
1036
1356
  });
1357
+ if (incompatible) {
1358
+ const error = `Scoped journal replay requires agent wire ${AGENT_WIRE_SCOPED_ORIGIN_VERSION}; retained history was not delivered or acknowledged`;
1359
+ this.emit({ t: 'agent_opened', id: frame.id, ok: false, error });
1360
+ if (frame.backfill)
1361
+ this.emit({ t: 'agent_backfill_done', id: frame.id, ok: false, count: 0, error });
1362
+ return false;
1363
+ }
1364
+ if (live.closed) {
1365
+ this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: 'session was closed while opening' });
1366
+ return false;
1367
+ }
1037
1368
  this.emit({
1038
1369
  t: 'agent_opened',
1039
1370
  id: frame.id,
@@ -1044,6 +1375,12 @@ export class AgentSessionManager {
1044
1375
  // Emitted only when true, the same `supportsSteering` convention as below: absent is the
1045
1376
  // field's only "no", and it is also what an older peer sends.
1046
1377
  ...(reloaded ? { reloaded: true } : {}),
1378
+ ...(live.delegationExpiresAtEpochMs ? { delegationExpiresAtEpochMs: live.delegationExpiresAtEpochMs } : {}),
1379
+ ...(live.delegationRefreshPending ? { delegationRefreshPending: true } : {}),
1380
+ ...(this.serverAgentWire >= AGENT_WIRE_DELEGATION_RENEW_VERSION && live.delegationInstanceId ? {
1381
+ delegationInstanceId: live.delegationInstanceId, delegationBindingId: live.delegationBindingId,
1382
+ delegationRenewalRevision: live.delegationRenewalRevision,
1383
+ } : {}),
1047
1384
  // The server learns this here and nowhere else. It used to be published only on the roster in
1048
1385
  // the `agent_hello` handshake, which is a snapshot taken *before* any agent was connected to —
1049
1386
  // and the capability can only be observed on a live `initialize`, whose record
@@ -1078,6 +1415,7 @@ export class AgentSessionManager {
1078
1415
  // ruling R1: `agent_opened` is assembled from `live.session.*`, not from the roster, so a
1079
1416
  // field added to `BridgeInfo` would have no reader here).
1080
1417
  ...(live.session.agentInfo ? { agentInfo: live.session.agentInfo } : {}),
1418
+ ...(live.session.runtimeDiagnostics ? { runtimeDiagnostics: live.session.runtimeDiagnostics } : {}),
1081
1419
  // ACP `PromptCapabilities.image`, learned at the same `initialize` and for the same
1082
1420
  // session-not-roster reason as `supportsSteering` above: `true`-or-absent is this field's
1083
1421
  // whole vocabulary, so an agent that said nothing (or a peer too old to answer) reads the
@@ -1094,7 +1432,22 @@ export class AgentSessionManager {
1094
1432
  // would report the pre-change values to the server after every config change. See
1095
1433
  // {@link AcpAgentSession.configOptions} for what that costs.
1096
1434
  configOptions: live.session.configOptions,
1435
+ // Whether this daemon wired up an activity-state collector for this session's agent (design
1436
+ // §5.3) — decided from the same `agentInfo.name` just read above, never from whether a push
1437
+ // has actually arrived: those are different facts (see the field's own doc in
1438
+ // `protocol/agent.ts`). No version floor: an additive key on a frame the peer already decodes
1439
+ // with `ignoreUnknownKeys`, the same `supportsSteering`/`reloaded` shape, not the
1440
+ // new-discriminator shape `agent_session_state` itself is.
1441
+ ...(reportsActivity(live.session.agentInfo?.name) ? { reportsActivity: true } : {}),
1442
+ ...(live.nativeSessionId && supportsAsyncTaskStop(live.session.agentInfo?.name)
1443
+ ? { supportsAsyncTaskStop: true } : {}),
1444
+ // This session's activity-state snapshot as of right now, resent here because
1445
+ // `agent_session_state` is never journaled and a reopen would otherwise have nothing to
1446
+ // recover it from — see `LiveSession.lastActivityRaw`'s own doc. Absent when nothing has ever
1447
+ // arrived (including "no collector for this agent"), never a synthesized default.
1448
+ ...(live.lastActivityRaw !== undefined ? { activityRaw: live.lastActivityRaw } : {}),
1097
1449
  });
1450
+ return true;
1098
1451
  }
1099
1452
  /**
1100
1453
  * Whether the overflow drop actually took something the server still needs. `droppedOldest` alone
@@ -1132,8 +1485,16 @@ export class AgentSessionManager {
1132
1485
  // init() only reads — the file appears on the first append — so an open that fails here leaves
1133
1486
  // nothing behind.
1134
1487
  const journal = new SessionJournal(this.sessionsDir, frame.sessionRef, this.maxJournalBytes);
1488
+ let highestScopedJournalSeq = 0;
1135
1489
  try {
1136
1490
  await journal.init(resumeFromSeqOf(frame));
1491
+ // Recovery is the only full scan needed for compatibility. Appends and ACKs below maintain
1492
+ // the high watermark incrementally, including while a v17 connection is offline.
1493
+ for (const entry of await journal.entriesAfter(0)) {
1494
+ if (entry.frame.t === 'agent_event' && entry.frame.acpSessionId != null) {
1495
+ highestScopedJournalSeq = Math.max(highestScopedJournalSeq, entry.seq);
1496
+ }
1497
+ }
1137
1498
  }
1138
1499
  catch (err) {
1139
1500
  return { live: null, error: `journal init failed: ${errorMessage(err)}` };
@@ -1149,6 +1510,7 @@ export class AgentSessionManager {
1149
1510
  let nativeSessionId;
1150
1511
  try {
1151
1512
  const result = await this.agents.open({
1513
+ openRequestId: frame.id,
1152
1514
  // `frame.bridge` is the registry agent id now — the wire field keeps its name because Plan A
1153
1515
  // renames nothing (Global Constraint 3); ruling R2 replaced what it *means*.
1154
1516
  agentId: frame.bridge,
@@ -1158,12 +1520,18 @@ export class AgentSessionManager {
1158
1520
  ...(frame.command ? { command: frame.command } : {}),
1159
1521
  args: frame.args ?? [],
1160
1522
  ...(frame.nativeSessionId ? { nativeSessionId: frame.nativeSessionId } : {}),
1523
+ ...(frame.requireSessionLoad ? { requireSessionLoad: true } : {}),
1524
+ ...(frame.delegationMcp ? { delegationMcp: frame.delegationMcp } : {}),
1161
1525
  // Retaining a whole adopted history costs memory for the session's lifetime, so it is only
1162
1526
  // kept when this open is the one that will stream it back.
1163
1527
  backfill: frame.backfill === true,
1164
1528
  });
1165
1529
  session = result.session;
1166
1530
  nativeSessionId = result.nativeSessionId;
1531
+ if (frame.delegationMcp && !nativeSessionId) {
1532
+ await session.close();
1533
+ return { live: null, error: 'Managed delegation requires a native session ID' };
1534
+ }
1167
1535
  }
1168
1536
  catch (err) {
1169
1537
  // `open` throws on both sides of the agent's `initialize` — nothing to launch and a spawn
@@ -1172,7 +1540,7 @@ export class AgentSessionManager {
1172
1540
  // on `session/new` arrives as; see {@link OpenOutcome.agentRefused}.
1173
1541
  return {
1174
1542
  live: null,
1175
- error: errorMessage(err),
1543
+ error: frame.delegationMcp ? 'Managed delegation session could not open' : errorMessage(err),
1176
1544
  // The code comes off the marker, not off `errorMessage(err)` above: the wrapper captured it
1177
1545
  // from the original rejection at the moment that object still existed.
1178
1546
  ...(err instanceof AgentRefusedSessionError
@@ -1217,15 +1585,38 @@ export class AgentSessionManager {
1217
1585
  agentKey: frame.agentKey,
1218
1586
  session,
1219
1587
  nativeSessionId,
1588
+ ...(frame.delegationMcp ? { delegationExpiresAtEpochMs: frame.delegationMcp.expiresAtEpochMs } : {}),
1589
+ ...(frame.delegationMcp ? { delegationLeaseId: delegationLeaseId(frame.delegationMcp) } : {}),
1590
+ ...(frame.delegationMcp?.bindingId ? { delegationBindingId: frame.delegationMcp.bindingId,
1591
+ delegationInstanceId: randomUUID(), delegationRenewalRevision: 0 } : {}),
1220
1592
  journal,
1221
1593
  writes: Promise.resolve(),
1594
+ scopedReplayBlocked: false,
1595
+ highestScopedJournalSeq,
1222
1596
  closed: false,
1223
1597
  lastActivityAt: Date.now(),
1224
1598
  };
1225
1599
  this.sessions.set(frame.sessionRef, live);
1226
- this.tasks?.registerAgentSession(frame.sessionRef, () => this.retire(live, 'destroy'));
1600
+ this.tasks?.registerAgentSession(frame.sessionRef, () => this.retire(live, 'destroy'),
1601
+ // 两个层级量取或:本进程发出的提示词是否在途(agent 自己也许在续跑一个我们没发起的回合,
1602
+ // 那一种由第二项回答),以及 agent 自报的状态。两者都不累积,所以没有可泄漏的状态。
1603
+ () => live.session.liveness.turnRunning || activityIsBusy(live.lastActivityRaw));
1227
1604
  session.onEvent((e) => this.onAgentEvent(live, e));
1228
1605
  session.onTurnDone((r) => this.onAgentTurnDone(live, r));
1606
+ session.onActivity((raw) => this.onAgentActivity(live, raw));
1607
+ session.onRuntimeDiagnostics?.((diagnostics) => {
1608
+ if (live.closed || this.sessions.get(live.sessionRef) !== live ||
1609
+ this.serverAgentWire < AGENT_WIRE_RUNTIME_PROFILE_VERSION)
1610
+ return;
1611
+ this.emit({ t: 'agent_runtime_profile', sessionRef: live.sessionRef, diagnostics });
1612
+ });
1613
+ session.onProviderTaskEvent?.((event) => {
1614
+ if (live.closed || this.sessions.get(live.sessionRef) !== live || this.serverAgentWire < AGENT_WIRE_PROVIDER_TASK_VERSION ||
1615
+ !live.session.providerTaskAdapterSelected || event.rootSessionId !== live.nativeSessionId ||
1616
+ event.runtimeGeneration !== live.session.runtimeDiagnostics?.profile?.runtimeGeneration)
1617
+ return;
1618
+ this.emit({ t: 'agent_provider_task_event', sessionRef: live.sessionRef, ...event });
1619
+ });
1229
1620
  return { live };
1230
1621
  }
1231
1622
  /**
@@ -1268,6 +1659,25 @@ export class AgentSessionManager {
1268
1659
  this.emit({ t: 'agent_native_sessions', id: frame.id, ok: false, error: errorMessage(err) });
1269
1660
  }
1270
1661
  }
1662
+ /**
1663
+ * Answers `agent_delete_native`: `session/delete` per id over a throwaway connection, like
1664
+ * {@link onListNative}. Per-id outcomes ride in `results` (`ok:true`); `ok:false` means the
1665
+ * deletion could not be attempted at all (no launchable agent, bad query). Never throws.
1666
+ */
1667
+ async onDeleteNative(frame) {
1668
+ const spec = sanitizeNativeSessionQuery(frame.bridge, frame.query);
1669
+ if (!spec) {
1670
+ this.emit({ t: 'agent_native_deleted', id: frame.id, ok: false, error: 'native session query is missing cwd' });
1671
+ return;
1672
+ }
1673
+ try {
1674
+ const results = await this.agents.deleteNativeSessions(spec, frame.nativeSessionIds);
1675
+ this.emit({ t: 'agent_native_deleted', id: frame.id, ok: true, results });
1676
+ }
1677
+ catch (err) {
1678
+ this.emit({ t: 'agent_native_deleted', id: frame.id, ok: false, error: errorMessage(err) });
1679
+ }
1680
+ }
1271
1681
  /**
1272
1682
  * Answers `agent_verify` by actually starting the agent and seeing what it says — the only way the
1273
1683
  * roster's auth fields can be populated for an agent no session has ever run (see
@@ -1443,11 +1853,11 @@ export class AgentSessionManager {
1443
1853
  onAgentEvent(live, e) {
1444
1854
  if (live.closed)
1445
1855
  return;
1446
- // An agent can resume an existing native turn after a client restart, so an inbound prompt is
1447
- // not the only reliable start marker. These are the two wire events that represent work which
1448
- // may still require user action or native execution; turn_done below releases the mark.
1449
- if (e.eventName === 'tool_call' || e.eventName === 'permission_request')
1450
- this.tasks?.agentTurnStarted(live.sessionRef);
1856
+ // 这里曾经有一行:`tool_call` / `permission_request` 就登记一次「这条会话在忙」。它是在
1857
+ // 没有 vendor 状态信号的年代用来猜「agent 是不是在续跑一个本进程没发起的回合」的启发式,
1858
+ // 而它开的口子只有 `turn_done` 能关——任何一条没有配套 `turn_done` 的事件都会把这个 ref
1859
+ // 永久钉成忙碌。那个猜测现在有了真信号(`lastActivityRaw`,见本类的注册点),所以这一行
1860
+ // 不是被删掉了事,是被它当初代理的那个事实取代了。
1451
1861
  // Before the contract check: an agent emitting even non-conforming events is a live process,
1452
1862
  // and the idle sweep is about liveness, not correctness.
1453
1863
  this.touch(live);
@@ -1461,6 +1871,17 @@ export class AgentSessionManager {
1461
1871
  `${rejection.reason}${rejection.detail ? ` (${rejection.detail})` : ''}`);
1462
1872
  return;
1463
1873
  }
1874
+ if (e.acpSessionId !== undefined && this.serverAgentWire < AGENT_WIRE_SCOPED_ORIGIN_VERSION) {
1875
+ if (e.eventName === 'permission_request' && typeof e.payload.requestId === 'string') {
1876
+ const options = e.payload.options;
1877
+ let cancelled = '__axom_undeliverable_permission__';
1878
+ while (options.some((option) => option.optionId === cancelled))
1879
+ cancelled += '_';
1880
+ live.session.answer(e.payload.requestId, cancelled);
1881
+ }
1882
+ console.warn(`[acp:${live.agentId}] scoped live event '${e.eventName}' for '${e.acpSessionId}' disabled: requires agent wire ${AGENT_WIRE_SCOPED_ORIGIN_VERSION}`);
1883
+ return;
1884
+ }
1464
1885
  this.record(live, (seq) => ({
1465
1886
  t: 'agent_event',
1466
1887
  sessionRef: live.sessionRef,
@@ -1473,10 +1894,55 @@ export class AgentSessionManager {
1473
1894
  ...(e.acpSessionId ? { acpSessionId: e.acpSessionId } : {}),
1474
1895
  }));
1475
1896
  }
1897
+ /**
1898
+ * `session.onActivity`'s handler: one agent-reported activity-state push. Deliberately not
1899
+ * routed through {@link record} — an activity push is a level, not an edge (the
1900
+ * `agent_session_state` frame's own doc comment in `protocol/agent.ts`), so it must never be
1901
+ * journaled and must never advance {@link SessionJournal}'s seq the way `onAgentEvent`'s
1902
+ * `agent_event`s do.
1903
+ *
1904
+ * `live.lastActivityRaw` is updated **before** the version check, unconditionally: it feeds
1905
+ * `agent_opened.activityRaw` and `AgentResumeSessionWire.activityRaw`, neither of which is gated
1906
+ * on {@link AGENT_WIRE_SESSION_STATE_VERSION} (they are additive keys on frames the peer already
1907
+ * decodes, the `supportsSteering`/`reloaded` shape — see those fields' own doc comments), so the
1908
+ * cache has to be current even for a peer too old to receive the push itself.
1909
+ *
1910
+ * The push itself **is** gated, and the check has to live here — the client side of the
1911
+ * compatibility matrix `AGENT_WIRE_SESSION_STATE_VERSION`'s own doc comment describes as the
1912
+ * dangerous direction: a v15 client talking to a v14 server. `agent_session_state` is a new
1913
+ * discriminator, not an additive key, so an unrecognised one throws inside `AgentCodec.decode` on
1914
+ * the server and gets charged to `FailSession` — and this frame exists to be sent over and over,
1915
+ * so a run of them on an unupgraded server would close the whole agent channel. Below the floor
1916
+ * the push is silently dropped rather than queued or converted to anything else: a level nobody
1917
+ * downstream can read is no worse than one that was never collected.
1918
+ */
1919
+ onAgentActivity(live, raw) {
1920
+ if (live.closed)
1921
+ return;
1922
+ live.lastActivityRaw = raw;
1923
+ live.lastActivityAtMs = Date.now();
1924
+ live.staleActivityWarned = false;
1925
+ // 采样式判据没有「变空闲」事件;一次状态推送正是它可能翻转的时刻,喊一声让
1926
+ // waitForIdle 重新判一次。漏喊只会推迟到下次判定,多喊无害。
1927
+ this.tasks?.sessionBusyChanged();
1928
+ if (this.serverAgentWire < AGENT_WIRE_SESSION_STATE_VERSION)
1929
+ return;
1930
+ this.emit({
1931
+ t: 'agent_session_state',
1932
+ sessionRef: live.sessionRef,
1933
+ vendor: live.session.agentInfo?.name ?? '',
1934
+ raw,
1935
+ });
1936
+ }
1476
1937
  onAgentTurnDone(live, r) {
1477
1938
  if (live.closed)
1478
1939
  return;
1479
- this.tasks?.agentTurnFinished(live.sessionRef);
1940
+ // `agentTurnFinished` used to clear this ref's bracket entry here. There is no entry to clear
1941
+ // any more — busy is sampled straight off `live.session.liveness.turnRunning` (already false
1942
+ // by the time this fires; see `AcpSession.turnRunning`'s own doc) — but a session that was the
1943
+ // registry's *only* busy one may have just gone idle, and sampling has no event of its own to
1944
+ // announce that, so this is where the registry is told to look again.
1945
+ this.tasks?.sessionBusyChanged();
1480
1946
  this.touch(live);
1481
1947
  if (r.nativeSessionId)
1482
1948
  live.nativeSessionId = r.nativeSessionId;
@@ -1485,6 +1951,8 @@ export class AgentSessionManager {
1485
1951
  sessionRef: live.sessionRef,
1486
1952
  seq,
1487
1953
  status: r.status,
1954
+ platformTurnId: r.platformTurnId,
1955
+ executionEnded: r.executionEnded,
1488
1956
  stopReason: r.stopReason,
1489
1957
  nativeSessionId: r.nativeSessionId ?? null,
1490
1958
  error: r.error ?? null,
@@ -1516,6 +1984,9 @@ export class AgentSessionManager {
1516
1984
  const seq = live.journal.nextSeq();
1517
1985
  const frame = build(seq);
1518
1986
  void this.enqueue(live, async () => {
1987
+ if (frame.t === 'agent_event' && frame.acpSessionId != null) {
1988
+ live.highestScopedJournalSeq = Math.max(live.highestScopedJournalSeq, seq);
1989
+ }
1519
1990
  try {
1520
1991
  await live.journal.append({ seq, frame });
1521
1992
  }
@@ -1524,9 +1995,26 @@ export class AgentSessionManager {
1524
1995
  // will not survive a reconnect.
1525
1996
  console.error(`[agent] journal append failed for ${live.sessionRef}: ${errorMessage(err)}`);
1526
1997
  }
1998
+ // Keep subsequent root/turn frames durable but withheld too: sending a higher sequence
1999
+ // would let the old peer ACK past the child event it could not interpret.
2000
+ if (live.scopedReplayBlocked || this.blockIncompatibleJournal(live))
2001
+ return;
1527
2002
  this.emit(frame);
1528
2003
  });
1529
2004
  }
2005
+ /** Must run on the session queue, before replay, delivery or ACK truncation. */
2006
+ blockIncompatibleJournal(live) {
2007
+ if (this.serverAgentWire >= AGENT_WIRE_SCOPED_ORIGIN_VERSION)
2008
+ return false;
2009
+ if (live.scopedReplayBlocked)
2010
+ return true;
2011
+ // Conservatively retain this requirement through journal overflow: only a compatible ACK
2012
+ // proves the peer has applied (or explicitly accepted a gap through) that scoped sequence.
2013
+ const incompatible = live.highestScopedJournalSeq > 0;
2014
+ if (incompatible)
2015
+ live.scopedReplayBlocked = true;
2016
+ return incompatible;
2017
+ }
1530
2018
  /** Appends `op` to the session's serialized queue. The returned promise never rejects. */
1531
2019
  enqueue(live, op) {
1532
2020
  const next = live.writes.then(op).catch((err) => {