dsh-ntr 0.3.1 → 0.3.3

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 (57) hide show
  1. package/README.en.md +3 -1
  2. package/README.md +3 -1
  3. package/cordis.patch.yml +17 -1
  4. package/lib/bridge.js +2436 -82
  5. package/lib/client/acp-ui.js +103 -3
  6. package/lib/client/acp-view.js +24 -4
  7. package/lib/client/activity-pill.js +156 -0
  8. package/lib/client/branch-action.js +149 -0
  9. package/lib/client/composer-mode.js +48 -13
  10. package/lib/client/context-meter.js +86 -0
  11. package/lib/client/index.js +279 -16
  12. package/lib/client/model-seat.js +8 -2
  13. package/lib/client/panel.js +96 -29
  14. package/lib/client/released-banner.js +66 -0
  15. package/lib/client/session-controls.js +38 -13
  16. package/lib/client/slash-isolation.js +2 -2
  17. package/lib/client/store.js +380 -13
  18. package/lib/client.js +1364 -70
  19. package/lib/client.js.map +1 -1
  20. package/lib/impl.js +1229 -8
  21. package/lib/index.js +34 -0
  22. package/lib/protocol.js +19 -0
  23. package/lib/tps.js +32 -0
  24. package/lib/types/bridge.d.ts +850 -18
  25. package/lib/types/client/acp-ui.d.ts +34 -2
  26. package/lib/types/client/acp-view.d.ts +13 -2
  27. package/lib/types/client/activity-pill.d.ts +81 -0
  28. package/lib/types/client/branch-action.d.ts +47 -0
  29. package/lib/types/client/composer-mode.d.ts +28 -0
  30. package/lib/types/client/context-meter.d.ts +51 -0
  31. package/lib/types/client/model-seat.d.ts +21 -0
  32. package/lib/types/client/released-banner.d.ts +34 -0
  33. package/lib/types/client/slash-isolation.d.ts +1 -1
  34. package/lib/types/client/store.d.ts +108 -1
  35. package/lib/types/impl.d.ts +94 -7
  36. package/lib/types/protocol.d.ts +145 -0
  37. package/lib/types/tps.d.ts +17 -0
  38. package/package.json +2 -1
  39. package/src/bridge.ts +2754 -86
  40. package/src/client/acp-ui.tsx +123 -2
  41. package/src/client/acp-view.tsx +30 -5
  42. package/src/client/activity-pill.tsx +201 -0
  43. package/src/client/branch-action.tsx +178 -0
  44. package/src/client/composer-mode.tsx +59 -13
  45. package/src/client/context-meter.tsx +128 -0
  46. package/src/client/index.tsx +298 -15
  47. package/src/client/model-seat.tsx +28 -3
  48. package/src/client/panel.tsx +157 -40
  49. package/src/client/primitives.d.ts +27 -0
  50. package/src/client/released-banner.tsx +97 -0
  51. package/src/client/session-controls.tsx +34 -11
  52. package/src/client/slash-isolation.ts +2 -1
  53. package/src/client/store.ts +412 -14
  54. package/src/impl.ts +1251 -13
  55. package/src/index.ts +33 -0
  56. package/src/protocol.ts +145 -0
  57. package/src/tps.ts +40 -0
package/src/bridge.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  PROTOCOL_VERSION,
21
21
  RequestError,
22
22
  type ClientConnection,
23
+ type ContentBlock,
23
24
  type InitializeResponse,
24
25
  type McpServer,
25
26
  type McpServerHttp,
@@ -52,6 +53,7 @@ import {
52
53
  resolveFsPath,
53
54
  resolveSessionCwd,
54
55
  selectForwardPrompt,
56
+ isAbsolutePath,
55
57
  acpPresetStatusFromRoster,
56
58
  splitAdapterModelId,
57
59
  updateContentToText,
@@ -69,11 +71,12 @@ import type {
69
71
  RosterPresetRow,
70
72
  } from './impl.ts'
71
73
  import type {
72
- AcpAgentCatalog, AcpAgentDef, AcpApprovalDeadlines, AcpConversationChoice, AcpEvent, AcpMcpServerDef, AcpPresetStatus, AcpReleaseCause, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentTestResult, AgentView, ChatSessionView, ChatTranscriptView, DetectedAgentInfo, GenerateOptions, LlmAdapterLike,
74
+ AcpAgentCatalog, AcpAgentDef, AcpApprovalDeadlines, AcpConversationChoice, AcpEvent, AcpMcpServerDef, AcpPresetStatus, AcpReleaseCause, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentTestResult, AgentView, ChatSessionForkInfo, ChatSessionView, ChatTranscriptView, DetectedAgentInfo, GenerateOptions, LlmAdapterLike,
73
75
  LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo, LlmReasoningEffortInfo, LlmResolvedModelInfo,
74
- ApprovalDeadlineKey, ApprovalDeadlinePatch, PermissionOutcome, PermissionOptionView, PermissionTimeoutMode, PluginUpdateState, PreparedAdapterCall, SessionView, StreamChunk,
76
+ AcpIdleRelease, ApprovalDeadlineKey, ApprovalDeadlinePatch, PermissionOutcome, PermissionOptionView, PermissionTimeoutMode, PluginUpdateState, PreparedAdapterCall, SessionView, StreamChunk,
75
77
  } from './protocol.ts'
76
- import { DEFAULT_TAG_HALO } from './protocol.ts'
78
+ import { DEFAULT_IDLE_TIMEOUT_MS, DEFAULT_TAG_HALO, IDLE_RELEASE_MS } from './protocol.ts'
79
+ import { OUTPUT_CHARS_PER_TOKEN, TurnTpsTracker } from './tps.ts'
77
80
  import { UpdateService } from './update.ts'
78
81
 
79
82
  /**
@@ -134,10 +137,42 @@ interface LiveSession {
134
137
  availableCommands?: SessionView['availableCommands']
135
138
  plan?: unknown[]
136
139
  usage?: SessionView['usage']
140
+ /**
141
+ * The plugin's own measurable burst aggregate over this ACP session's turns
142
+ * (see `src/tps.ts`). Persisted with the chat-session record so the dock's
143
+ * rate survives a host restart; absent until a turn contributes a measurable
144
+ * burst (>= 1 inter-chunk gap).
145
+ */
146
+ burst?: SessionView['burst']
137
147
  running: boolean
148
+ /**
149
+ * The HOST's prompt slot for this session: true from the moment a turn
150
+ * starts until the agent answers it (or the host aborts the turn, or the
151
+ * turn is discarded). Together with the {@link settled} gate — which STAYS
152
+ * claimed after an abort until the agent acknowledges the cancel — this is
153
+ * what `claimTurn` arbitrates, NOT `running`.
154
+ *
155
+ * The three are deliberately different:
156
+ * - an ambient wait keeps `running` true (the background drain is still the
157
+ * session's work, so nothing may release the process) while this slot is
158
+ * already FREE (the agent answered; only the drain keeps talking);
159
+ * - an aborted turn drops THIS flag at once (our face of the turn ends) but
160
+ * leaves `settled` in place, so the next prompt still waits out
161
+ * `turnSettleMs` for the agent instead of racing a concurrent prompt.
162
+ */
163
+ promptInFlight?: boolean
138
164
  closed: boolean
139
165
  /** The agent process died; the session awaits a `session/load` resume. */
140
166
  released: boolean
167
+ /**
168
+ * The agent-side session has history while the LOCAL buffer is empty: this
169
+ * record was restored from disk after a host restart (the replay has not
170
+ * landed yet), or a resume cleared the buffer ahead of the replay. The ACP
171
+ * view then says history replays on restore instead of "nothing yet". Set
172
+ * by `doResumeSession` (captured before the reset) and the restore path; the
173
+ * serializer ORs it with the live buffer length.
174
+ */
175
+ hasHistory?: boolean
141
176
  /**
142
177
  * When the agent last showed ANY sign of life on this session: an update, a
143
178
  * permission ask (arrival or answer), or a turn start. The stall watchdog
@@ -235,9 +270,73 @@ interface LiveSession {
235
270
  * the controller's own `retire` when it still points at it.
236
271
  */
237
272
  turnController?: TurnStreamController
273
+ /**
274
+ * A background turn the agent started on its OWN initiative (CodeBuddy's
275
+ * multitask drains: a sub-agent completion notice starts a run the client
276
+ * never asked for). Its first `session_info_update` announces the request id
277
+ * every later update of that run carries in `_meta`; a `session/prompt`
278
+ * answered instantly with no conversation request id was folded into this
279
+ * drain and its real answer arrives as the drain's updates.
280
+ *
281
+ * Only lifetime events clear it (close / release / resume / stop): a drain
282
+ * may outlive the turn that was steered into it, and clearing on a normal
283
+ * settle would misclassify the NEXT steered prompt.
284
+ */
285
+ ambient?: AmbientTurn
286
+ /**
287
+ * Request ids whose `agent/notice` already went out, so a repeated
288
+ * announcement of the same background run is not logged twice.
289
+ */
290
+ ambientNotices?: Set<string>
291
+ /**
292
+ * Background drains this session already STOPPED waiting on (an ambient wait
293
+ * that closed on silence, or a record that outlived `ambientStaleMs`). The
294
+ * record is gone from {@link ambient}, but the drain itself may still be
295
+ * running: a queued message or a long tool makes it speak again while the
296
+ * NEXT turn is on air. Without this list the late text would teach that turn
297
+ * the drain's id (`noteTurnRequestId` / `adoptTurnRequestId`) and fold the
298
+ * background run's answer into the user's answer (a double answer).
299
+ *
300
+ * An update or re-announcement carrying a listed id REVIVES it
301
+ * (`reviveAmbientId` + a rebuilt {@link ambient}): the run is alive, so its
302
+ * next steered prompt must receive the drain's text. Only while the record
303
+ * stays retired is its text transcript-only. Timestamps let the zero-output
304
+ * report recognise a prompt folded into a recently quieted drain
305
+ * (`hasRecentRetiredAmbient`). Bounded ({@link RETIRED_AMBIENT_MAX}), oldest
306
+ * evicted first; every lifecycle boundary clears the list
307
+ * (`clearAmbientState`).
308
+ */
309
+ retiredAmbients?: RetiredAmbient[]
238
310
  transcript: Transcript
239
311
  }
240
312
 
313
+ /** One unsolicited (background) turn the agent is running, as announced in `_meta`. */
314
+ interface AmbientTurn {
315
+ /** `codebuddy.ai/requestId` of the drain; every update of the run carries it. */
316
+ requestId: string
317
+ startedAt: number
318
+ /**
319
+ * When this drain last showed any sign of life (announcement or an update
320
+ * carrying its request id). {@link BridgeTuning.ambientStaleMs} expires the
321
+ * record from here, so a drain that died silently does not capture a later
322
+ * normal turn as steered.
323
+ */
324
+ lastActivityAt: number
325
+ /** Whether the drain already streamed prose (text or thought) into the transcript. */
326
+ sawOutput: boolean
327
+ }
328
+
329
+ /** A drain id the session stopped waiting on, and when it was retired. */
330
+ interface RetiredAmbient {
331
+ requestId: string
332
+ /**
333
+ * When the wait was retired. `hasRecentRetiredAmbient` uses it to tell a
334
+ * prompt folded into a just-quieted drain from one that ended on its own;
335
+ * `adoptTurnRequestId` / `noteTurnRequestId` ignore the id at any age.
336
+ */
337
+ retiredAt: number
338
+ }
339
+
241
340
  /** Reliability tuning, threaded from `AcpPluginConfig` through the manager. */
242
341
  export interface BridgeTuning {
243
342
  startupTimeoutMs?: number
@@ -332,6 +431,65 @@ export interface BridgeTuning {
332
431
  * probe-only process has no stake in. `0` disables it.
333
432
  */
334
433
  probeIdleTimeoutMs?: number
434
+ /**
435
+ * How long an ambient wait (see `TurnStreamController.awaitAmbient`) stays
436
+ * open after the LAST session update before the turn closes itself.
437
+ *
438
+ * A drain's end is its silence — the SDK's schema rejects the `session_end`
439
+ * CodeBuddy never sends anyway — but "silence" must be measured in the
440
+ * agent's own cadence, not in a chat-app-paced one. Measured against
441
+ * CodeBuddy 2.179.0: the gaps between drain ticks (the
442
+ * `model_requesting`→`model_streaming` hand-off of a real model call) run
443
+ * 1.0–1.4s, so any window below that closes the turn BEFORE the answer
444
+ * exists. 10s leaves room for two slow hand-offs without holding the turn
445
+ * open for a drain that actually finished.
446
+ *
447
+ * Deliberately NOT keyed on an `agentPhase` idle flag: a drain goes idle
448
+ * between queued messages too, so that signal closes the turn in the middle
449
+ * of its own work.
450
+ */
451
+ ambientQuietMs?: number
452
+ /**
453
+ * The hard ceiling on ONE ambient wait: a background run that keeps ticking
454
+ * for minutes without finishing must not hold the conversation's turn open
455
+ * forever. At the ceiling the turn says where the rest of the output will
456
+ * land and closes.
457
+ */
458
+ ambientMaxMs?: number
459
+ /**
460
+ * The hard ceiling on the WORKER phase of a detached-worker hold (see
461
+ * `TurnStreamController.holdForBackgroundDispatch`): the turn dispatched one
462
+ * or more background workers through `Agent` tool calls, the agent answered
463
+ * the prompt, and at least one worker has not reported back yet. The worker
464
+ * phase has NO quiet window on purpose — measured against CodeBuddy 2.179.0,
465
+ * a detached worker's own updates are 13–24s apart while it is perfectly
466
+ * alive — so only EVERY known dispatch reporting (completed / failed) or
467
+ * this ceiling can end it; a drain announcement is recorded but arms no
468
+ * clock before then. Once they have all reported, the drain's own
469
+ * `ambientQuietMs` / `ambientMaxMs` windows take over.
470
+ *
471
+ * Default 600000 ms (10 minutes).
472
+ */
473
+ backgroundTaskMaxMs?: number
474
+ /**
475
+ * How long a PARKED detached-worker hold may wait for the host to open the
476
+ * driver step that carries the worker's report. The prompt response landed
477
+ * between two steps (the host is about to execute the `Agent` card), and a
478
+ * host that never opens the continuation step must not hang the turn. The
479
+ * host normally opens it at once, so this is a safety bound, not a cadence —
480
+ * deliberately far larger than one model hand-off.
481
+ *
482
+ * Default 30000 ms (30 seconds).
483
+ */
484
+ backgroundParkedGraceMs?: number
485
+ /**
486
+ * How long an announced background drain may go without ANY update before a
487
+ * later instant prompt stops treating itself as folded into it. A drain that
488
+ * went silent for minutes is over even though nothing told us; keeping the
489
+ * record would misclassify the next normal turn as steered. Checked lazily
490
+ * when a prompt response arrives (see `streamPrompt`), never on a timer.
491
+ */
492
+ ambientStaleMs?: number
335
493
  }
336
494
 
337
495
  /**
@@ -415,8 +573,13 @@ const DEFAULT_TUNING: Required<BridgeTuning> = {
415
573
  turnStallMs: 600_000,
416
574
  turnStallToolMs: 1_800_000,
417
575
  turnStallTickMs: 30_000,
418
- idleTimeoutMs: 1_800_000,
576
+ idleTimeoutMs: DEFAULT_IDLE_TIMEOUT_MS,
419
577
  probeIdleTimeoutMs: 300_000,
578
+ ambientQuietMs: 10_000,
579
+ ambientMaxMs: 120_000,
580
+ backgroundTaskMaxMs: 600_000,
581
+ backgroundParkedGraceMs: 30_000,
582
+ ambientStaleMs: 300_000,
420
583
  }
421
584
 
422
585
  type BridgeState = 'stopped' | 'starting' | 'ready' | 'error'
@@ -453,7 +616,7 @@ export class AgentBridge {
453
616
  private readonly staleCancelRetryDelayMs: number
454
617
  private readonly staleCancelMaxRetries: number
455
618
  private readonly turnSettleMs: number
456
- private readonly defaultIdleTimeoutMs: number
619
+ private defaultIdleTimeoutMs: number
457
620
  private readonly probeIdleTimeoutMs: number
458
621
  /** Armed while READY; fires the idle-release check. */
459
622
  private idleTimer?: ReturnType<typeof setTimeout>
@@ -474,6 +637,13 @@ export class AgentBridge {
474
637
  * that a caller is waiting on the child it is about to kill.
475
638
  */
476
639
  private pendingCreates = 0
640
+ /**
641
+ * `session/fork` requests in flight (the adopt path of a branched
642
+ * conversation). Same teardown hazard as {@link pendingCreates}: until the
643
+ * forked session is registered, nothing else would stop an idle release
644
+ * from killing the child out from under the caller.
645
+ */
646
+ private pendingForks = 0
477
647
  /**
478
648
  * Whether a REAL session has ever been registered on this bridge. Probes
479
649
  * bypass session bookkeeping, so a bridge that has never hosted anything was
@@ -516,6 +686,20 @@ export class AgentBridge {
516
686
  * this bridge was built.
517
687
  */
518
688
  private readonly sessionWorkspace?: () => SessionWorkspaceResolver | undefined,
689
+ /**
690
+ * Told whenever the SESSION RECORDS change shape (a release parks them, a
691
+ * resume revives them): the manager persists `chatSessions` through it, so
692
+ * a parked conversation stays resumable across a host restart (see
693
+ * `BridgeManager.loadPersistedChatSessions`).
694
+ */
695
+ private readonly onSessionsDirty?: () => void,
696
+ /**
697
+ * Told whenever a fresh `initialize` reports the agent's
698
+ * `sessionCapabilities.fork` bit — the LAST KNOWN fork capability the
699
+ * manager persists and serves to the browser (the bridge's own
700
+ * `initResult` dies with the process; the manager's copy survives it).
701
+ */
702
+ private readonly onForkCapability?: (agentId: string, supported: boolean) => void,
519
703
  ) {
520
704
  const merged = { ...DEFAULT_TUNING, ...tuning }
521
705
  this.startupTimeoutMs = merged.startupTimeoutMs
@@ -550,6 +734,24 @@ export class AgentBridge {
550
734
  this.permissionTimeoutMs = normalizeApprovalWindow(this.permissionTimeoutMode, this.permissionTimeoutMs)
551
735
  }
552
736
 
737
+ /**
738
+ * Adopt a rewritten host-default idle window (the settings page's write).
739
+ *
740
+ * Only the DEFAULT moves: an agent that names its own `idleTimeoutMs` keeps
741
+ * it (that promise is about the agent, not about the deployment), and a
742
+ * probe-only child keeps the short probe window.
743
+ *
744
+ * The timer is re-armed so the new window governs from NOW: an already
745
+ * armed timer still holds the old window, so a shortened window would not
746
+ * take effect until the old one elapsed.
747
+ */
748
+ applyIdleRelease(defaultIdleTimeoutMs: number): void {
749
+ if (this.defaultIdleTimeoutMs === defaultIdleTimeoutMs) return
750
+ this.defaultIdleTimeoutMs = defaultIdleTimeoutMs
751
+ this.clearIdleTimer()
752
+ this.touch()
753
+ }
754
+
553
755
  /**
554
756
  * The idle-release window this agent honors for its CHAT process: its own
555
757
  * field, else the host default. This is the number the panel prefills and
@@ -650,11 +852,16 @@ export class AgentBridge {
650
852
  * same session — forbidden by the protocol and rejected (or worse, mishandled)
651
853
  * by the agent. Waiting turns a protocol error into a short delay.
652
854
  *
855
+ * The gate is {@link LiveSession.promptInFlight} + the settle promise, NOT
856
+ * {@link LiveSession.running}: an ambient turn keeps `running` true for the
857
+ * whole drain while its prompt slot is already free, so `running` would
858
+ * refuse every message the user sends during a background task.
859
+ *
653
860
  * @param session - the session asking for the slot.
654
861
  * @returns whether the slot was claimed, and why not when it was not.
655
862
  */
656
863
  async claimTurn(session: LiveSession): Promise<{ ok: true } | { ok: false; reason: 'busy' | 'unsettled' }> {
657
- if (session.running) return { ok: false, reason: 'busy' }
864
+ if (this.promptSlotHeld(session)) return { ok: false, reason: 'busy' }
658
865
  const pending = session.settled
659
866
  if (pending === undefined) return { ok: true }
660
867
  let timer: ReturnType<typeof setTimeout> | undefined
@@ -672,13 +879,21 @@ export class AgentBridge {
672
879
  // The agent never acknowledged: sending now would start a concurrent turn,
673
880
  // so report it honestly instead of handing the agent two prompts at once.
674
881
  if (session.settled !== undefined) return { ok: false, reason: 'unsettled' }
675
- if (session.running) return { ok: false, reason: 'busy' }
882
+ // Re-checked after the wait: another turn may have claimed the slot while
883
+ // this one was waiting on the gate.
884
+ if (this.promptSlotHeld(session)) return { ok: false, reason: 'busy' }
676
885
  return { ok: true }
677
886
  }
678
887
 
888
+ /** Whether a turn currently holds the agent prompt slot (see `promptInFlight`). */
889
+ private promptSlotHeld(session: LiveSession): boolean {
890
+ return session.promptInFlight === true
891
+ }
892
+
679
893
  /**
680
- * Mark the agent-side turn as in flight and hand back the settler its stream
681
- * owner must call once the agent is actually done with it.
894
+ * Mark the agent-side turn as in flight — claim the agent prompt slot and
895
+ * open the settle gate — and hand back the settler its stream owner must
896
+ * call once the agent is actually done with it.
682
897
  *
683
898
  * @param session - the session whose turn is starting.
684
899
  * @returns the settler (idempotent; also used by connection loss).
@@ -688,11 +903,16 @@ export class AgentBridge {
688
903
  const settled = new Promise<void>(resolve => { settle = resolve })
689
904
  session.settled = settled
690
905
  session.settledResolve = settle
906
+ session.promptInFlight = true
691
907
  this.touch()
692
908
  return () => {
909
+ // Identity-guarded: only THIS turn's settler may release the slot. A
910
+ // superseded turn's late settler must not clear the flag a NEWER turn
911
+ // has already claimed (its settle gate was replaced with a new promise).
693
912
  if (session.settled === settled) {
694
913
  session.settled = undefined
695
914
  session.settledResolve = undefined
915
+ session.promptInFlight = false
696
916
  }
697
917
  settle()
698
918
  // The turn is over: a fresh window starts here rather than at the prompt
@@ -704,12 +924,14 @@ export class AgentBridge {
704
924
  /**
705
925
  * Open a session's settle gate without owning its turn stream: the turn's
706
926
  * stream is gone (a resume, a release) but the next prompt still waits on
707
- * this promise, and nothing is left that could ever resolve it.
927
+ * this promise, and nothing is left that could ever resolve it. The prompt
928
+ * slot itself goes with it: whatever held it is gone too.
708
929
  */
709
930
  private openSettleGate(session: LiveSession): void {
710
931
  const settle = session.settledResolve
711
932
  session.settled = undefined
712
933
  session.settledResolve = undefined
934
+ session.promptInFlight = false
713
935
  settle?.()
714
936
  }
715
937
 
@@ -744,6 +966,7 @@ export class AgentBridge {
744
966
  private hasActiveWork(): boolean {
745
967
  if (this.pendingProbes > 0
746
968
  || this.pendingCreates > 0
969
+ || this.pendingForks > 0
747
970
  || this.pendingCommands.size > 0
748
971
  || this.resuming.size > 0
749
972
  || this.permissions.sessionIds().length > 0
@@ -768,6 +991,7 @@ export class AgentBridge {
768
991
  hasOtherActiveWork(session: LiveSession): boolean {
769
992
  if (this.pendingProbes > 0
770
993
  || this.pendingCreates > 0
994
+ || this.pendingForks > 0
771
995
  || this.resuming.size > 0
772
996
  || this.starting !== undefined) return true
773
997
  for (const sessionId of this.pendingCommands.keys()) {
@@ -944,6 +1168,10 @@ export class AgentBridge {
944
1168
  'initialize',
945
1169
  )
946
1170
  this.initResult = init
1171
+ // The fork capability is agent knowledge that must outlive the process:
1172
+ // report it so the manager can persist a LAST KNOWN copy and serve the
1173
+ // browser's branch button after a release (`initResult` itself dies here).
1174
+ this.onForkCapability?.(this.def.id, this.initResult?.agentCapabilities?.sessionCapabilities?.fork != null)
947
1175
  }
948
1176
 
949
1177
  /**
@@ -1094,6 +1322,103 @@ export class AgentBridge {
1094
1322
  }
1095
1323
  }
1096
1324
 
1325
+ /**
1326
+ * Fork one live session through the agent's own `session/fork` (the
1327
+ * UNSTABLE fourth session-creation point): the agent copies its internal
1328
+ * state into a NEW ACP session that remembers the parent's context — the
1329
+ * second half of a branched conversation, after the host's `sessions.fork`
1330
+ * has already built the DSH session tree. The child is addressed by the
1331
+ * CHILD DSH conversation id, so its first message reaches an agent that
1332
+ * actually remembers the parent.
1333
+ *
1334
+ * The parent must be live on this connection — a released parent has no
1335
+ * process to fork from (the browser blocks that click; this is the hard
1336
+ * stop behind it). The ACP request carries the SAME `mcpServers` list as
1337
+ * `session/new` / `session/load` (one list per process; an empty list would
1338
+ * evict the live sessions' MCP scope) and is bounded by the startup timeout
1339
+ * like every session-creation step. Counted in {@link pendingForks} while
1340
+ * in flight so an idle release cannot kill the child out from under the
1341
+ * caller.
1342
+ */
1343
+ async forkSession(parent: LiveSession, dshSessionId: string): Promise<LiveSession> {
1344
+ if (this.initResult?.agentCapabilities?.sessionCapabilities?.fork == null) {
1345
+ throw new AcpError('fork_unsupported', `acp: agent "${this.def.id}" does not advertise the session/fork capability`)
1346
+ }
1347
+ const conn = this.conn
1348
+ if (conn === undefined) throw new AcpError('agent_unreachable', 'acp: connection is not open')
1349
+ // The child runs where the parent runs: its cwd is the parent's already
1350
+ // validated workspace, re-trusted as its own extra root (the same minimal
1351
+ // trust `resolveSessionCwd` applied when the parent was created — never
1352
+ // the agent's configured sandbox cwd, which is not where the conversation
1353
+ // lives).
1354
+ const resolved = resolveSessionCwd(this.def, parent.cwd, p => realpathSync(p), [parent.cwd])
1355
+ this.pendingForks += 1
1356
+ try {
1357
+ const result = await this.withStartupTimeout(
1358
+ conn.agent.request(methods.agent.session.fork, {
1359
+ sessionId: parent.acpSessionId,
1360
+ cwd: resolved,
1361
+ mcpServers: toAcpMcpServers(this.def.mcpServers),
1362
+ }),
1363
+ 'session/fork',
1364
+ )
1365
+ // A fork answer that names the parent (or any session already routed on
1366
+ // this connection) would interleave the parent's updates, asks and
1367
+ // permission cards into the child — refuse it rather than register it.
1368
+ if (typeof result.sessionId !== 'string' || result.sessionId === '') {
1369
+ throw new AcpError('fork_failed', 'acp: session/fork returned no session id')
1370
+ }
1371
+ if (result.sessionId === parent.acpSessionId || this.acpIndex.has(result.sessionId)) {
1372
+ throw new AcpError('fork_id_conflict', 'acp: session/fork returned an id that already exists on this connection')
1373
+ }
1374
+ const session: LiveSession = {
1375
+ sessionId: randomUUID(),
1376
+ agentId: this.def.id,
1377
+ acpSessionId: result.sessionId,
1378
+ cwd: resolved,
1379
+ running: false,
1380
+ turnSeq: 0,
1381
+ closed: false,
1382
+ released: false,
1383
+ lastAgentActivityAt: Date.now(),
1384
+ activeToolCalls: new Set(),
1385
+ transcript: new Transcript(this.transcriptMax),
1386
+ dshSessionId,
1387
+ ...(result.modes !== undefined && result.modes !== null
1388
+ ? {
1389
+ modes: {
1390
+ currentModeId: result.modes.currentModeId,
1391
+ availableModes: result.modes.availableModes.map(m => ({
1392
+ id: m.id,
1393
+ name: m.name,
1394
+ ...(m.description !== null ? { description: m.description } : {}),
1395
+ })),
1396
+ },
1397
+ }
1398
+ : {}),
1399
+ ...(usableConfigOptions(result.configOptions) ? { configOptions: result.configOptions } : {}),
1400
+ }
1401
+ // The fork answer's config options are the AGENT-LEVEL surface too (the
1402
+ // same feed `session/new` provides): absorb them so the composer chips
1403
+ // of the fresh conversation render without a probe.
1404
+ if (this.onCatalog !== undefined && usableConfigOptions(result.configOptions)) {
1405
+ this.onCatalog(this.def.id, result.configOptions)
1406
+ }
1407
+ this.sessions.set(session.sessionId, session)
1408
+ this.acpIndex.set(session.acpSessionId, session)
1409
+ // Same flip as `createSession`: a registered session makes this a chat
1410
+ // process; re-arm the idle clock on the agent's own window.
1411
+ this.clearIdleTimer()
1412
+ this.everHostedSession = true
1413
+ this.hub.broadcast({ type: 'session/created', sessionId: session.sessionId, session: snapshot(session) })
1414
+ this.touch()
1415
+ return session
1416
+ } finally {
1417
+ this.pendingForks -= 1
1418
+ this.touch()
1419
+ }
1420
+ }
1421
+
1097
1422
  /**
1098
1423
  * Fire one prompt turn. Returns after the agent settles the turn; the caller
1099
1424
  * (manager) keeps the UI informed through broadcast events, so the RPC that
@@ -1106,25 +1431,67 @@ export class AgentBridge {
1106
1431
  * {@link BridgeTuning.staleCancelRetryDelayMs}, out-waiting the cleanup
1107
1432
  * window, bounded by {@link BridgeTuning.staleCancelMaxRetries}; a genuinely
1108
1433
  * requested cancel (`cancelRequestedAtTurn` still names this generation)
1109
- * returns as-is.
1434
+ * returns as-is — checked BOTH before the delay and after it, so a second
1435
+ * stop click inside the retry window does not resend the prompt the user
1436
+ * just stopped.
1437
+ *
1438
+ * `turnGen` is the {@link LiveSession.turnSeq} this prompt was sent for. A
1439
+ * stale-cancel resend is only allowed while that generation still owns the
1440
+ * session: once a successor turn began (the cancel fallback finalized this
1441
+ * one and the user moved on), resending would race a second `session/prompt`
1442
+ * into the successor's turn. The late answer is returned as-is instead.
1443
+ *
1444
+ * The RESPONSE's `_meta` travels with the stop reason: it is the only place
1445
+ * an agent says whether the prompt actually queued a conversation turn (see
1446
+ * `CodebuddyMeta.conversationRequestId`), which decides whether an instant
1447
+ * answer was the turn ending or a fold into a background drain.
1110
1448
  */
1111
- async runPrompt(session: LiveSession, text: string, turnId: string): Promise<string> {
1449
+ async runPrompt(session: LiveSession, prompt: string | ContentBlock[], turnId: string, turnGen: number): Promise<{ stopReason: string; meta?: Record<string, unknown> }> {
1112
1450
  const conn = this.conn
1113
1451
  if (conn === undefined || session.closed || session.released) throw new AcpError('session_closed', 'acp: session is closed')
1114
1452
  this.touch()
1453
+
1454
+ const blocks: ContentBlock[] = typeof prompt === 'string'
1455
+ ? [{ type: 'text', text: prompt }]
1456
+ : prompt
1457
+
1458
+ if (blocks.length === 0) {
1459
+ throw new AcpError('empty_prompt', 'acp: prompt cannot be empty')
1460
+ }
1461
+
1462
+ const hasImages = blocks.some(b => b.type === 'image')
1463
+ if (hasImages && this.initResult?.agentCapabilities?.promptCapabilities?.image !== true) {
1464
+ throw new AcpError('image_unsupported', `acp: agent "${this.def.id}" does not support image input`)
1465
+ }
1466
+
1115
1467
  for (let attempt = 0; ; attempt += 1) {
1116
1468
  // The response is activity too: the idle window must start at the turn's
1117
1469
  // END, not at the prompt that may have started a long time ago.
1118
1470
  const result = await conn.agent.request(methods.agent.session.prompt, {
1119
1471
  sessionId: session.acpSessionId,
1120
- prompt: [{ type: 'text', text }],
1472
+ prompt: blocks,
1121
1473
  }).finally(() => { this.touch() })
1474
+ const answered = () => ({
1475
+ stopReason: result.stopReason,
1476
+ ...(result._meta !== undefined && result._meta !== null ? { meta: result._meta } : {}),
1477
+ })
1122
1478
  if (result.stopReason !== 'cancelled' || session.cancelRequestedAtTurn === session.turnSeq) {
1123
- return result.stopReason
1479
+ return answered()
1124
1480
  }
1125
- if (attempt >= this.staleCancelMaxRetries) return result.stopReason
1481
+ // The turn this answer belongs to no longer owns the session (a cancel
1482
+ // fallback finalized it and a successor began): resending would fire a
1483
+ // second `session/prompt` into the successor's turn.
1484
+ if (session.turnSeq !== turnGen) return answered()
1485
+ if (attempt >= this.staleCancelMaxRetries) return answered()
1126
1486
  await new Promise(resolve => setTimeout(resolve, this.staleCancelRetryDelayMs))
1127
1487
  if (session.closed || this.conn !== conn) throw new AcpError('session_closed', 'acp: session is closed')
1488
+ // Re-check after the delay: the successor may have started while waiting.
1489
+ if (session.turnSeq !== turnGen) return answered()
1490
+ // ...and a SECOND stop during the delay names this same generation. The
1491
+ // user already said stop, so the stale-cancel answer is not a swallow to
1492
+ // out-wait anymore: resending would make the agent run the prompt the
1493
+ // user just stopped (the double-click case). Return as-is.
1494
+ if (session.cancelRequestedAtTurn === session.turnSeq) return answered()
1128
1495
  this.touch()
1129
1496
  }
1130
1497
  }
@@ -1175,16 +1542,30 @@ export class AgentBridge {
1175
1542
 
1176
1543
  /**
1177
1544
  * Force the local end of one turn whose agent never acknowledged it: clear
1178
- * `running`, open the settle gate, and broadcast the turn end with `turnId:
1179
- * ''` (there is no agent response to attribute it to). Shared by the cancel
1545
+ * `running`, open the settle gate, and end the turn. Shared by the cancel
1180
1546
  * fallback and the stall watchdog.
1181
1547
  *
1548
+ * A live controller owns the turn's streaming face — its segment queue, its
1549
+ * ambient clocks and its `turn/end` id. Ending the turn behind its back
1550
+ * would leave the driver step parked on an open queue, and the controller's
1551
+ * own timers would later broadcast a SECOND `turn/end` for a turn already
1552
+ * closed. So when the session still points at a current controller, the
1553
+ * force goes THROUGH it (`forceFinalize`), which runs the same closing work
1554
+ * as a normal settle exactly once. Only a turn with no live controller (an
1555
+ * `/acp`-RPC turn, or one already retired) falls back to the local broadcast
1556
+ * with `turnId: ''` — there is no stream whose id could be named.
1557
+ *
1182
1558
  * Generation-guarded: a turn that already ended — or that a later turn
1183
1559
  * superseded — is not this call's to finalize. Returns whether it acted; a
1184
1560
  * `false` means the caller was late and must leave the session alone.
1185
1561
  */
1186
1562
  finalizeTurn(session: LiveSession, turnGen: number, message: string): boolean {
1187
1563
  if (session.closed || session.turnSeq !== turnGen) return false
1564
+ const controller = session.turnController
1565
+ if (controller !== undefined && !controller.stale) {
1566
+ controller.forceFinalize({ kind: 'error', message })
1567
+ return true
1568
+ }
1188
1569
  // Nothing is in flight anymore (the turn settled on its own): nothing to
1189
1570
  // finalize. A still-pending settle gate counts as in flight even though
1190
1571
  // the stream is gone — that gate is what the next prompt waits on.
@@ -1241,17 +1622,23 @@ export class AgentBridge {
1241
1622
  if (conn === undefined) throw new AcpError('agent_unreachable', 'acp: connection is not open')
1242
1623
  const resolved = resolveSessionCwd(this.def, session.cwd, p => realpathSync(p), workspaceRoot === undefined ? [] : [workspaceRoot])
1243
1624
  // Replay arrives as brand-new updates, so the transcript restarts too;
1244
- // seq keeps meaning "position in the current epoch".
1625
+ // seq keeps meaning "position in the current epoch". What the buffer held
1626
+ // is now the agent's to replay — remember that there IS history before
1627
+ // dropping it, or a failed resume parks an empty buffer back and the
1628
+ // view claims the conversation never happened.
1629
+ session.hasHistory = session.hasHistory === true || session.transcript.since(0).length > 0
1245
1630
  session.transcript = new Transcript(this.transcriptMax)
1246
1631
  session.cwd = resolved
1247
1632
  session.running = false
1248
1633
  session.closed = false
1249
1634
  session.released = false
1250
1635
  // A resume is a fresh agent-side state: nothing from the dead process's
1251
- // turn survives it, including the settle gate and the stall markers.
1636
+ // turn survives it, including the settle gate and the stall markers. A
1637
+ // background drain belonged to that process too.
1252
1638
  session.orphanPromptTurnSeq = undefined
1253
1639
  session.reclaimedTurnSeq = undefined
1254
1640
  session.activeToolCalls.clear()
1641
+ clearAmbientState(session)
1255
1642
  session.lastAgentActivityAt = Date.now()
1256
1643
  this.openSettleGate(session)
1257
1644
  // Route replay updates into the fresh transcript while the load request
@@ -1285,6 +1672,7 @@ export class AgentBridge {
1285
1672
  this.touch()
1286
1673
  // Republish so configOptions/modes observed on load reach the browser.
1287
1674
  this.hub.broadcast({ type: 'session/meta', sessionId: session.sessionId, session: snapshot(session) })
1675
+ this.onSessionsDirty?.()
1288
1676
  return session
1289
1677
  } catch (error) {
1290
1678
  this.acpIndex.delete(session.acpSessionId)
@@ -1292,6 +1680,7 @@ export class AgentBridge {
1292
1680
  session.released = true
1293
1681
  this.released.set(session.sessionId, session)
1294
1682
  this.hub.broadcast({ type: 'session/released', sessionId: session.sessionId, session: snapshot(session), reason: 'resume failed', cause: 'resume-failed' })
1683
+ this.onSessionsDirty?.()
1295
1684
  throw error
1296
1685
  }
1297
1686
  }
@@ -1312,8 +1701,10 @@ export class AgentBridge {
1312
1701
  // would make the next prompt wait for an answer that can no longer come.
1313
1702
  this.openSettleGate(session)
1314
1703
  // No call from the dead process can still be running: drop the tool set
1315
- // so a resumed turn starts on the plain window.
1704
+ // so a resumed turn starts on the plain window. A background drain died
1705
+ // with it, and a stale ambient would misclassify the NEXT prompt.
1316
1706
  session.activeToolCalls.clear()
1707
+ clearAmbientState(session)
1317
1708
  session.released = true
1318
1709
  this.settlePermissions(session)
1319
1710
  this.released.set(session.sessionId, session)
@@ -1327,6 +1718,10 @@ export class AgentBridge {
1327
1718
  this.state = 'stopped'
1328
1719
  this.initResult = undefined
1329
1720
  this.publishStatus()
1721
+ // The parked records are what keeps them resumable after a host restart —
1722
+ // persist now, while their transcripts (the `hasHistory` evidence) are
1723
+ // still the ones they were parked with.
1724
+ this.onSessionsDirty?.()
1330
1725
  }
1331
1726
 
1332
1727
  /** Detach one released session record for a resume attempt. */
@@ -1342,6 +1737,15 @@ export class AgentBridge {
1342
1737
  return this.released.delete(sessionId)
1343
1738
  }
1344
1739
 
1740
+ /**
1741
+ * Register a session record restored from disk as already parked: a host
1742
+ * restart killed every process, so it comes back exactly as `releaseAll`
1743
+ * would have left it — resumable via `session/load`, never live.
1744
+ */
1745
+ parkReleased(session: LiveSession): void {
1746
+ this.released.set(session.sessionId, session)
1747
+ }
1748
+
1345
1749
  get releasedList(): LiveSession[] {
1346
1750
  return [...this.released.values()]
1347
1751
  }
@@ -1411,6 +1815,12 @@ export class AgentBridge {
1411
1815
  async closeSession(session: LiveSession, reason: 'closed' | 'agent-stopped' | 'agent-died'): Promise<void> {
1412
1816
  if (session.closed) return
1413
1817
  session.closed = true
1818
+ // The session is going away: whatever background drain it announced ends
1819
+ // with it (a re-created session must not inherit a stale ambient).
1820
+ clearAmbientState(session)
1821
+ // ...and so does its prompt slot: nothing may keep rejecting a new prompt
1822
+ // for a record that is being deleted.
1823
+ session.promptInFlight = false
1414
1824
  // Closing a session is activity of its own: restart the idle window here
1415
1825
  // rather than at the prompt that opened it.
1416
1826
  this.touch()
@@ -1485,6 +1895,7 @@ export class AgentBridge {
1485
1895
  this.settlePermissions(session)
1486
1896
  this.openSettleGate(session)
1487
1897
  session.activeToolCalls.clear()
1898
+ clearAmbientState(session)
1488
1899
  this.sessions.delete(session.sessionId)
1489
1900
  this.acpIndex.delete(session.acpSessionId)
1490
1901
  this.hub.broadcast({ type: 'session/closed', sessionId: session.sessionId, reason })
@@ -1767,6 +2178,60 @@ export class AgentBridge {
1767
2178
  // tool, which earns the wider watchdog window.
1768
2179
  session.lastAgentActivityAt = Date.now()
1769
2180
  const kind = params.update.sessionUpdate
2181
+ const meta = codebuddyMetaOf(params.update)
2182
+ // A RETIRED drain that speaks again is alive, not retired. Un-retire it
2183
+ // BEFORE this update is classified and rebuild the ambient record: the run
2184
+ // may never re-announce itself, yet the next prompt folded into it must
2185
+ // still be recognised as steered and receive the drain's text. A
2186
+ // re-announcement below does the same for its id.
2187
+ if (meta?.requestId !== undefined && isRetiredAmbientId(session, meta.requestId)) {
2188
+ reviveAmbientId(session, meta.requestId)
2189
+ session.ambient = {
2190
+ requestId: meta.requestId,
2191
+ startedAt: session.ambient?.startedAt ?? Date.now(),
2192
+ lastActivityAt: Date.now(),
2193
+ sawOutput: session.ambient?.sawOutput ?? false,
2194
+ }
2195
+ }
2196
+ if (meta?.unsolicitedTurn !== undefined) {
2197
+ // The agent started a background turn on its own: remember it so a
2198
+ // prompt the agent folds into it (answered instantly, no conversation
2199
+ // request id) can be kept open until the drain speaks. Announced once per
2200
+ // request id — a repeated announcement is the same run, not news.
2201
+ const run = meta.unsolicitedTurn
2202
+ // A re-announcement also revives a drain whose wait already closed: the
2203
+ // same run is alive, and the notice de-dupe set keeps it silent.
2204
+ reviveAmbientId(session, run.requestId)
2205
+ session.ambient = { requestId: run.requestId, startedAt: Date.now(), lastActivityAt: Date.now(), sawOutput: false }
2206
+ const announced = session.ambientNotices ?? new Set<string>()
2207
+ session.ambientNotices = announced
2208
+ if (!announced.has(run.requestId)) {
2209
+ announced.add(run.requestId)
2210
+ // The de-dupe set outlives individual turns (a hard ceiling keeps the
2211
+ // drain record), so it is bounded: evict the oldest ids first.
2212
+ while (announced.size > AMBIENT_NOTICE_MAX) {
2213
+ const oldest = announced.values().next().value
2214
+ if (oldest === undefined) break
2215
+ announced.delete(oldest)
2216
+ }
2217
+ this.hub.broadcast({
2218
+ type: 'agent/notice',
2219
+ agentId: session.agentId,
2220
+ level: 'info',
2221
+ text: `acp: agent "${this.def.id}" started a background task turn (${run.reason ?? 'unsolicited'}); its output will appear in the ACP transcript view, or in the turn that was waiting for it.`,
2222
+ })
2223
+ }
2224
+ }
2225
+ if (session.ambient !== undefined
2226
+ && meta?.requestId === session.ambient.requestId) {
2227
+ // The run is alive: refresh the staleness clock on ANY update it owns,
2228
+ // not just prose (a tool-heavy stretch is still the drain working).
2229
+ session.ambient.lastActivityAt = Date.now()
2230
+ if ((kind === 'agent_message_chunk' || kind === 'agent_thought_chunk')
2231
+ && chunkTextOf((params.update as { content?: unknown }).content) !== '') {
2232
+ session.ambient.sawOutput = true
2233
+ }
2234
+ }
1770
2235
  if (kind === 'tool_call' || kind === 'tool_call_update') {
1771
2236
  const { toolCallId, status } = params.update as { toolCallId: string; status?: string | null }
1772
2237
  if (status === 'completed' || status === 'failed') session.activeToolCalls.delete(toolCallId)
@@ -1824,6 +2289,178 @@ export class AgentBridge {
1824
2289
  }
1825
2290
  }
1826
2291
 
2292
+ /**
2293
+ * The CodeBuddy `_meta` facts this bridge reads, when the object carries them.
2294
+ *
2295
+ * CodeBuddy runs "background drains": a sub-agent completion notice starts a
2296
+ * turn the client never prompted, announced by a `session_info_update` whose
2297
+ * `_meta["codebuddy.ai/unsolicitedTurn"]` names the run. Every update of that
2298
+ * run then carries `_meta["codebuddy.ai/requestId"]`, and a `session/prompt`
2299
+ * answered instantly (with no `codebuddy.ai/conversationRequestId` on the
2300
+ * RESPONSE) had its message folded into the drain — the real answer streams
2301
+ * afterwards, under the drain's request id.
2302
+ *
2303
+ * The same run also explains a DETACHED WORKER: `_meta["codebuddy.ai/toolName"]`
2304
+ * names the `Agent` call that dispatched it, the call is re-sent with
2305
+ * `_meta["codebuddy.ai/isBackground"] = true` once the worker runs off-turn, and
2306
+ * every update the worker emits carries
2307
+ * `_meta["codebuddy.ai/parentToolCallId"]` naming that call. Those updates are
2308
+ * the worker's own turn — they belong in the transcript, not in the session's
2309
+ * assistant message. The completion of the dispatch call itself carries NO
2310
+ * `_meta` at all, so a known call's status is read from its id.
2311
+ *
2312
+ * No other agent sends these keys: an object without them yields `undefined`,
2313
+ * so every existing path is untouched. Values are only read when they have the
2314
+ * declared shape (never guessed from a message body).
2315
+ */
2316
+ interface CodebuddyMeta {
2317
+ /** The run this update belongs to. */
2318
+ requestId?: string
2319
+ /** Set on the `session_info_update` that STARTS a background turn. */
2320
+ unsolicitedTurn?: { requestId: string; reason?: string }
2321
+ /** The agent's own phase heartbeat (`{ phase }`), a liveness tick. */
2322
+ agentPhase?: { phase: string }
2323
+ /** Set on a `session/prompt` RESPONSE that really queued a conversation turn. */
2324
+ conversationRequestId?: string
2325
+ /**
2326
+ * The tool a `tool_call` names, when the agent omits `title`. `Agent` (or
2327
+ * `Task`) means the call DISPATCHED a detached worker: the call stays in
2328
+ * progress while the worker runs on its own.
2329
+ */
2330
+ toolName?: string
2331
+ /**
2332
+ * Set on every update a DETACHED WORKER emits (prose, tools, its report):
2333
+ * it names the `Agent` tool call that dispatched the worker. Such an update
2334
+ * belongs to the worker's own turn, never to this session's answer.
2335
+ */
2336
+ parentToolCallId?: string
2337
+ /**
2338
+ * Set on the dispatch call RE-SENT after the worker started
2339
+ * (`codebuddy.ai/isBackground: true`): the call handed its work to a worker
2340
+ * that keeps running off-turn, which is the structural "detached" signal.
2341
+ * The first send of the same call does not carry it.
2342
+ */
2343
+ isBackground?: boolean
2344
+ /** `codebuddy.ai/isSubagent` — the same re-send declares the worker a sub-agent. */
2345
+ isSubagent?: boolean
2346
+ }
2347
+
2348
+ /** Read the CodeBuddy `_meta` keys off an update or a `{ _meta }` wrapper. */
2349
+ function codebuddyMetaOf(source: unknown): CodebuddyMeta | undefined {
2350
+ if (typeof source !== 'object' || source === null) return undefined
2351
+ const row = source as Record<string, unknown>
2352
+ const rawMeta = row._meta
2353
+ if (typeof rawMeta !== 'object' || rawMeta === null) return undefined
2354
+ const meta = rawMeta as Record<string, unknown>
2355
+ const out: CodebuddyMeta = {}
2356
+ const requestId = meta['codebuddy.ai/requestId']
2357
+ if (typeof requestId === 'string' && requestId !== '') out.requestId = requestId
2358
+ const conversationRequestId = meta['codebuddy.ai/conversationRequestId']
2359
+ if (typeof conversationRequestId === 'string' && conversationRequestId !== '') {
2360
+ out.conversationRequestId = conversationRequestId
2361
+ }
2362
+ const toolName = meta['codebuddy.ai/toolName']
2363
+ if (typeof toolName === 'string' && toolName !== '') out.toolName = toolName
2364
+ const parentToolCallId = meta['codebuddy.ai/parentToolCallId']
2365
+ if (typeof parentToolCallId === 'string' && parentToolCallId !== '') out.parentToolCallId = parentToolCallId
2366
+ // Booleans, read as booleans only: a string "true" is not the declared shape
2367
+ // and guessing from it would make another agent's unrelated key look detached.
2368
+ if (meta['codebuddy.ai/isBackground'] === true) out.isBackground = true
2369
+ if (meta['codebuddy.ai/isSubagent'] === true) out.isSubagent = true
2370
+ const unsolicited = meta['codebuddy.ai/unsolicitedTurn']
2371
+ if (typeof unsolicited === 'object' && unsolicited !== null) {
2372
+ const run = unsolicited as Record<string, unknown>
2373
+ if (typeof run.requestId === 'string' && run.requestId !== '') {
2374
+ out.unsolicitedTurn = {
2375
+ requestId: run.requestId,
2376
+ ...(typeof run.reason === 'string' && run.reason !== '' ? { reason: run.reason } : {}),
2377
+ }
2378
+ }
2379
+ }
2380
+ const phase = meta['codebuddy.ai/agentPhase']
2381
+ if (typeof phase === 'object' && phase !== null) {
2382
+ const tick = phase as Record<string, unknown>
2383
+ if (typeof tick.phase === 'string' && tick.phase !== '') out.agentPhase = { phase: tick.phase }
2384
+ }
2385
+ return Object.keys(out).length === 0 ? undefined : out
2386
+ }
2387
+
2388
+ /**
2389
+ * Remember a background drain this session stopped waiting on. A quiet close
2390
+ * drops the drain record (so an ordinary next turn is not held open for it),
2391
+ * but the run may revive afterwards; keeping its id lets every later update be
2392
+ * recognized as the drain's (`isRetiredAmbientId`) instead of being adopted as
2393
+ * the next turn's own. Bounded, oldest first — an id this far back names a run
2394
+ * no late update can plausibly still carry. Re-retiring an id refreshes its
2395
+ * timestamp and moves it to the end.
2396
+ */
2397
+ function retireAmbientId(session: LiveSession, requestId: string): void {
2398
+ const retired = session.retiredAmbients ?? []
2399
+ const existing = retired.findIndex(entry => entry.requestId === requestId)
2400
+ if (existing >= 0) retired.splice(existing, 1)
2401
+ retired.push({ requestId, retiredAt: Date.now() })
2402
+ while (retired.length > RETIRED_AMBIENT_MAX) retired.shift()
2403
+ session.retiredAmbients = retired
2404
+ }
2405
+
2406
+ /** Whether this request id names a drain the session already stopped waiting on. */
2407
+ function isRetiredAmbientId(session: LiveSession, requestId: string | undefined): boolean {
2408
+ return requestId !== undefined
2409
+ && session.retiredAmbients?.some(entry => entry.requestId === requestId) === true
2410
+ }
2411
+
2412
+ /**
2413
+ * Whether the session retired a drain recently enough that a prompt answered
2414
+ * with no ambient record and no conversation id may have been folded into it.
2415
+ * A quiet close is only evidence WE stopped waiting, so the next instant
2416
+ * answer is ambiguous; past this horizon the drain is treated as gone and the
2417
+ * ordinary zero-output warning applies.
2418
+ */
2419
+ function hasRecentRetiredAmbient(session: LiveSession, withinMs: number): boolean {
2420
+ const retired = session.retiredAmbients
2421
+ if (retired === undefined || retired.length === 0) return false
2422
+ const cutoff = Date.now() - withinMs
2423
+ return retired.some(entry => entry.retiredAt >= cutoff)
2424
+ }
2425
+
2426
+ /**
2427
+ * The retired drain spoke again (an update or a re-announcement carried its
2428
+ * id): it is alive, not retired. The caller rebuilds {@link LiveSession.ambient}
2429
+ * so the next prompt folded into it is recognised as steered and its text can
2430
+ * reach the user's turn; only while the id stays retired does its text remain
2431
+ * transcript-only.
2432
+ */
2433
+ function reviveAmbientId(session: LiveSession, requestId: string): void {
2434
+ const retired = session.retiredAmbients
2435
+ if (retired === undefined) return
2436
+ const index = retired.findIndex(entry => entry.requestId === requestId)
2437
+ if (index < 0) return
2438
+ retired.splice(index, 1)
2439
+ if (retired.length === 0) session.retiredAmbients = undefined
2440
+ }
2441
+
2442
+ /**
2443
+ * Forget every background-drain trace a session carried. Called at each
2444
+ * lifecycle boundary (close / release / resume / force reset): a fresh
2445
+ * agent-side state neither inherits a stale drain nor the ids of drains that
2446
+ * belonged to the old one.
2447
+ */
2448
+ function clearAmbientState(session: LiveSession): void {
2449
+ session.ambient = undefined
2450
+ session.ambientNotices?.clear()
2451
+ session.retiredAmbients = undefined
2452
+ }
2453
+
2454
+ /**
2455
+ * The closing line of a turn the agent deferred to its OWN background run: the
2456
+ * answer exists, but in the ACP transcript view rather than in this bubble.
2457
+ * Shared by the ambient wait and the detached-worker hold, so both say the
2458
+ * same thing in the same words.
2459
+ */
2460
+ function backgroundDeferralLine(agentId: string): string {
2461
+ return `\n[dsh-ntr] agent "${agentId}" deferred this turn to a background task run; its output is in the ACP transcript view. Send again after the background run finishes.\n`
2462
+ }
2463
+
1827
2464
  /** Human-facing identity of an ACP tool call (the approval headline). */
1828
2465
  /**
1829
2466
  * Human-facing name of the tool an ask is about, for the chat line and the
@@ -1915,13 +2552,92 @@ function snapshot(session: LiveSession): SessionView {
1915
2552
  ...(session.availableCommands !== undefined ? { availableCommands: session.availableCommands } : {}),
1916
2553
  ...(session.plan !== undefined ? { plan: session.plan } : {}),
1917
2554
  ...(session.usage !== undefined ? { usage: session.usage } : {}),
2555
+ ...(session.burst !== undefined ? { burst: session.burst } : {}),
1918
2556
  running: session.running,
1919
2557
  closed: session.closed,
1920
2558
  ...(session.released ? { released: true } : {}),
2559
+ ...(session.hasHistory === true ? { hasHistory: true } : {}),
1921
2560
  seq: session.transcript.head,
1922
2561
  }
1923
2562
  }
1924
2563
 
2564
+ /**
2565
+ * The `fork` half of one `session/chat-list` row: the host's verdict on
2566
+ * forking this session's ACP agent. The capability half is the agent's LAST
2567
+ * KNOWN bit (see `canForkNow`); the state half is the session's own, released
2568
+ * checked before running (a parked record is never running). The browser
2569
+ * renders its branch button from this verdict and never reads a capability
2570
+ * bit itself.
2571
+ */
2572
+ function forkRowInfo(session: LiveSession, capability: boolean): { fork: ChatSessionForkInfo } {
2573
+ if (!capability) return { fork: { supported: false, reason: 'unsupported' } }
2574
+ if (session.released) return { fork: { supported: false, reason: 'released' } }
2575
+ if (session.running || session.settled !== undefined) return { fork: { supported: false, reason: 'busy' } }
2576
+ return { fork: { supported: true } }
2577
+ }
2578
+
2579
+ /**
2580
+ * One chat-bound session record, as persisted across host restarts (the
2581
+ * `chatSessions` key of the agent registry). Deliberately identity + display
2582
+ * mirrors ONLY — the transcript is the agent's to replay on `session/load`,
2583
+ * and `configOptions` come back from the same load. `hasHistory` is what lets
2584
+ * an empty restored view say "history replays on restore" instead of
2585
+ * "nothing yet".
2586
+ */
2587
+ interface PersistedChatSession {
2588
+ sessionId: string
2589
+ acpSessionId: string
2590
+ agentId: string
2591
+ dshSessionId: string
2592
+ cwd: string
2593
+ title?: string
2594
+ modelId?: string
2595
+ reasoningEffort?: string
2596
+ modes?: SessionView['modes']
2597
+ usage?: SessionView['usage']
2598
+ burst?: SessionView['burst']
2599
+ hasHistory?: boolean
2600
+ }
2601
+
2602
+ function persistedChatSession(session: LiveSession): PersistedChatSession {
2603
+ return {
2604
+ sessionId: session.sessionId,
2605
+ acpSessionId: session.acpSessionId,
2606
+ agentId: session.agentId,
2607
+ dshSessionId: session.dshSessionId as string,
2608
+ cwd: session.cwd,
2609
+ ...(session.title === undefined ? {} : { title: session.title }),
2610
+ ...(session.modelId === undefined ? {} : { modelId: session.modelId }),
2611
+ ...(session.reasoningEffort === undefined ? {} : { reasoningEffort: session.reasoningEffort }),
2612
+ ...(session.modes === undefined ? {} : { modes: session.modes }),
2613
+ ...(session.usage === undefined ? {} : { usage: session.usage }),
2614
+ ...(session.burst === undefined ? {} : { burst: session.burst }),
2615
+ ...(session.hasHistory === true || session.transcript.since(0).length > 0 ? { hasHistory: true } : {}),
2616
+ }
2617
+ }
2618
+
2619
+ /** Tolerant shape check for a `modes` value read back from disk. */
2620
+ function isPersistedModes(value: unknown): value is SessionView['modes'] {
2621
+ if (value === null || typeof value !== 'object') return false
2622
+ const modes = value as { currentModeId?: unknown; availableModes?: unknown }
2623
+ return typeof modes.currentModeId === 'string' && Array.isArray(modes.availableModes)
2624
+ }
2625
+
2626
+ /** Tolerant shape check for a `usage` value read back from disk. */
2627
+ function isPersistedUsage(value: unknown): value is SessionView['usage'] {
2628
+ if (value === null || typeof value !== 'object') return false
2629
+ const usage = value as { used?: unknown; size?: unknown }
2630
+ return typeof usage.used === 'number' && typeof usage.size === 'number'
2631
+ }
2632
+
2633
+ /** Tolerant shape check for a `burst` aggregate read back from disk. */
2634
+ function isPersistedBurst(value: unknown): value is NonNullable<SessionView['burst']> {
2635
+ if (value === null || typeof value !== 'object') return false
2636
+ const burst = value as { outputTokens?: unknown; burstMs?: unknown }
2637
+ return typeof burst.outputTokens === 'number' && burst.outputTokens >= 0
2638
+ && typeof burst.burstMs === 'number' && burst.burstMs >= 0
2639
+ }
2640
+
1925
2641
  /**
1926
2642
  * The DSH chat session's OWN workspace, when one is resolvable: the directory
1927
2643
  * the user selected in the DSH sidebar, which the host reports back as the
@@ -2687,12 +3403,17 @@ interface SegmentState {
2687
3403
  activeToolCall: AcpToolCallSnapshot | undefined
2688
3404
  /** Whether this segment opened at least one tool-call block. */
2689
3405
  hasCard: boolean
3406
+ /**
3407
+ * The segment belongs to an ambient wait: the agent folded our prompt into a
3408
+ * background drain it started itself. Tool traffic is recorded (so the
3409
+ * transcript and any parked tool-output waiter get it) but opens NO card —
3410
+ * a card would make the host loop execute a tool for a turn whose answer has
3411
+ * not even started streaming yet.
3412
+ */
3413
+ ambient: boolean
2690
3414
  finished: boolean
2691
3415
  }
2692
3416
 
2693
- /** ACP `usage_update` is context occupancy, not request tokens; host usage is a 4-chars/token OUTPUT estimate (tool args excluded). */
2694
- const OUTPUT_CHARS_PER_TOKEN = 4
2695
-
2696
3417
  /** How one segment's stream ends, in `StreamChunk.finish` terms. */
2697
3418
  type SegmentFinishReason =
2698
3419
  | { kind: 'stop' }
@@ -2705,6 +3426,153 @@ type TurnSettle =
2705
3426
  | { kind: 'cancelled' }
2706
3427
  | { kind: 'error'; message: string; code?: string }
2707
3428
 
3429
+ /** How often a running turn's live burst aggregate is pushed to the browser. */
3430
+ const TPS_PUSH_INTERVAL_MS = 1000
3431
+
3432
+ /**
3433
+ * Bounds on the updates withheld while a turn's own request id is still
3434
+ * unknown (see `TurnStreamController.bufferAmbientUpdate`). The buffer exists
3435
+ * only for the response window of one prompt, but a chatty drain could stream
3436
+ * for a while inside it; over the bound the OLDEST entries are dropped — the
3437
+ * transcript still has every one of them.
3438
+ */
3439
+ const PENDING_AMBIENT_MAX_ENTRIES = 200
3440
+ const PENDING_AMBIENT_MAX_CHARS = 64 * 1024
3441
+
3442
+ /**
3443
+ * Bound on {@link LiveSession.ambientNotices}. The set now outlives the turn
3444
+ * that announced a drain (a hard ceiling keeps the record, and a repeated
3445
+ * announcement of the same run must stay silent), so it needs a ceiling of its
3446
+ * own for a chatty agent. Oldest ids are evicted first; an id that far back is
3447
+ * a run nobody is waiting on anymore.
3448
+ */
3449
+ const AMBIENT_NOTICE_MAX = 64
3450
+
3451
+ /**
3452
+ * Bound on {@link LiveSession.retiredAmbients}. The ids only exist to catch a
3453
+ * drain that speaks again after its wait closed, and the timestamp lets the
3454
+ * next silent close recognise a recent fold — a short-horizon concern, so a
3455
+ * small ring is enough; the oldest is evicted first.
3456
+ */
3457
+ const RETIRED_AMBIENT_MAX = 32
3458
+
3459
+ /**
3460
+ * Floor for the "recently retired" lookback the zero-output fallback uses
3461
+ * (twice `ambientQuietMs`). Only test-sized quiet windows fall below it; a
3462
+ * production window (10s) always wins. Without the floor a 250ms quiet window
3463
+ * would leave a 500ms lookback, too tight to survive a loaded CI scheduler
3464
+ * between one turn ending and the next prompt's response.
3465
+ */
3466
+ const RETIRED_AMBIENT_RECENT_MIN_MS = 1_000
3467
+
3468
+ /** One ambient wait's clocks and the stop reason it will close the turn with. */
3469
+ interface AmbientWait {
3470
+ /** The drain's request id: the turn is waiting on this run, not on its own. */
3471
+ requestId: string
3472
+ quietTimer: ReturnType<typeof setTimeout>
3473
+ hardTimer: ReturnType<typeof setTimeout>
3474
+ /** The stop reason the agent's `session/prompt` response carried. */
3475
+ stopReason: string
3476
+ /**
3477
+ * Whether the CEILING closed this wait (the drain was still ticking) rather
3478
+ * than silence. A hard ending must keep the session's drain record: the next
3479
+ * prompt may be folded into the same run, and it is still alive.
3480
+ */
3481
+ hard?: boolean
3482
+ }
3483
+
3484
+ /** An update withheld from the segment until the turn's own request id is known. */
3485
+ interface PendingAmbientUpdate {
3486
+ seq: number
3487
+ update: Record<string, unknown>
3488
+ /** `JSON.stringify(update).length`, kept so eviction need not re-serialize. */
3489
+ chars: number
3490
+ }
3491
+
3492
+ /**
3493
+ * The `Agent` tool call that handed work to a DETACHED worker.
3494
+ *
3495
+ * CodeBuddy 2.179.0 with multitask on: the coordinator opens an `Agent` call
3496
+ * (`_meta["codebuddy.ai/toolName"] = "Agent"`), says "已派…", and ENDS ITS OWN
3497
+ * PROMPT normally while the worker keeps running off-turn. The call itself
3498
+ * stays `in_progress` until the worker reports, and only then does the agent
3499
+ * start the background drain that carries the worker's report into the
3500
+ * session. A turn that closed at its prompt response therefore lost the only
3501
+ * answer it was ever going to produce; this record is what the controller
3502
+ * holds the turn open for.
3503
+ */
3504
+ interface BackgroundDispatch {
3505
+ /** The `Agent` call's id — the card that stays on the plane. */
3506
+ toolCallId: string
3507
+ /** The last status the agent declared for the dispatch call itself. */
3508
+ status: string
3509
+ /** Whether the call already reported completed / failed. */
3510
+ completed: boolean
3511
+ /**
3512
+ * Whether the work runs DETACHED, read from the wire's own structure: the
3513
+ * call re-sent with `_meta["codebuddy.ai/isBackground"] = true`, or any
3514
+ * update naming this call as its `parentToolCallId`. A synchronous
3515
+ * `Agent` / `Task` call carries neither, so it can never make the turn wait
3516
+ * after its own answer.
3517
+ */
3518
+ detached: boolean
3519
+ /** Whether any update came FROM the worker (`codebuddy.ai/parentToolCallId`). */
3520
+ sawWorkerUpdate: boolean
3521
+ }
3522
+
3523
+ /**
3524
+ * A turn held open for the detached worker its `Agent` call dispatched.
3525
+ *
3526
+ * Two phases, because their liveness signals differ:
3527
+ * - `worker`: at least one worker runs off-turn and may be silent for tens of
3528
+ * seconds between model calls — NO quiet timer, only
3529
+ * {@link BridgeTuning.backgroundTaskMaxMs}. It ends when EVERY known dispatch
3530
+ * reported (completed / failed); a drain announcement is recorded meanwhile
3531
+ * but arms no clock.
3532
+ * - `drain`: every dispatch reported and the background turn that carries
3533
+ * their report is (or is about to start) — the existing ambient windows
3534
+ * apply (`ambientQuietMs` quiet, reset by every update; `ambientMaxMs`
3535
+ * ceiling from the switch).
3536
+ */
3537
+ interface BackgroundHold {
3538
+ /** The stop reason the prompt RESPONSE carried (what the turn closes with). */
3539
+ stopReason: string
3540
+ /** The run this hold currently waits on: the response's id or the last announcement. */
3541
+ requestId?: string
3542
+ /**
3543
+ * Every drain id this hold may fold text from: the response's
3544
+ * `conversationRequestId` / `requestId` plus every announced id. A run
3545
+ * announced before the response may name a different id than the response —
3546
+ * both are this turn's answer, and anything else stays background noise.
3547
+ */
3548
+ acceptedIds: Set<string>
3549
+ phase: 'worker' | 'drain'
3550
+ /**
3551
+ * Agent MESSAGE text folded into this hold. The parent's interim
3552
+ * acknowledgement ("已派…") landed before the hold and is not the drained
3553
+ * run's answer: a hold that never received that answer must still say where
3554
+ * it will appear.
3555
+ */
3556
+ answerChars: number
3557
+ /**
3558
+ * The transcript head when the hold began. Text at or below it arrived
3559
+ * before the hold — a parked hold replays the parent's ack from there — so
3560
+ * it is not counted in {@link answerChars}; the drain updates withheld for
3561
+ * the response are the exception and are counted explicitly when flushed.
3562
+ */
3563
+ floorSeq: number
3564
+ quietTimer?: ReturnType<typeof setTimeout>
3565
+ hardTimer?: ReturnType<typeof setTimeout>
3566
+ /**
3567
+ * The bounded wait for a driver step to speak into. Only while the hold is
3568
+ * PARKED (`pendingBackgroundHold`): the response landed between steps, and
3569
+ * the host is expected to open the continuation step for the Agent card.
3570
+ */
3571
+ graceTimer?: ReturnType<typeof setTimeout>
3572
+ /** Whether the run's ids were already released (a close, cancel or teardown). */
3573
+ retired: boolean
3574
+ }
3575
+
2708
3576
  /**
2709
3577
  * The state of ONE ACP prompt, shared across every driver step of the turn.
2710
3578
  *
@@ -2718,13 +3586,111 @@ type TurnSettle =
2718
3586
  class TurnStreamController {
2719
3587
  private segment: SegmentState | undefined
2720
3588
  private settled = false
3589
+ /** Whether the turn's closing work already ran (see `closeTurn`). */
3590
+ private closedTurn = false
2721
3591
  private disposed = false
2722
3592
  private readonly seenCalls = new Set<string>()
2723
3593
  private readonly announcedKinds = new Map<string, string>()
2724
3594
  /** Narrative lines (permission asks) that arrived while no segment was open. */
2725
3595
  private readonly pendingProse: string[] = []
2726
3596
  sawVisibleBlock = false
3597
+ /**
3598
+ * Whether THIS turn streamed its own prose (text or reasoning), i.e. an
3599
+ * answer that is not the background drain's. Tool cards do not count — a
3600
+ * drain that only ran tools delivered no words to the conversation.
3601
+ */
3602
+ private sawAgentProse = false
3603
+ /**
3604
+ * Whether any `agent_message_chunk` text reached the plane. Reasoning does
3605
+ * not count: a steer whose drain only thought is still a turn with no answer
3606
+ * text, and must say so when it closes.
3607
+ */
3608
+ private sawVisibleText = false
3609
+ /**
3610
+ * The CodeBuddy request id this turn has adopted as its own (from the
3611
+ * prompt RESPONSE's `conversationRequestId`, or from the first update that
3612
+ * named one). Used to keep a background drain's prose out of the user's
3613
+ * answer; `undefined` means "unknown" and nothing is split (fail-open).
3614
+ */
3615
+ private turnRequestId?: string
3616
+ /**
3617
+ * Updates the session's background drain already streamed while this turn's
3618
+ * OWN request id was still unknown. They are NOT folded on arrival: a drain
3619
+ * tick that lands before the prompt response could otherwise teach the turn
3620
+ * the drain's id (`noteTurnRequestId`) and mix the drain's text into the
3621
+ * user's answer. Withheld here (cursor already advanced — the transcript
3622
+ * keeps a copy) until the response decides what this turn is:
3623
+ * - a normal turn (response carries a conversation id) discards them — they
3624
+ * were never this turn's answer;
3625
+ * - a steered turn flushes them into the segment, in order, once
3626
+ * `awaitAmbient` marks the segment ambient (tool cards suppressed).
3627
+ */
3628
+ private readonly pendingAmbientUpdates: PendingAmbientUpdate[] = []
3629
+ private pendingAmbientChars = 0
3630
+ /**
3631
+ * Whether the bounded buffer dropped at least one update. The flush then
3632
+ * says so in the conversation instead of silently losing the front of the
3633
+ * drain's output (constraint 29: ACP payloads are never dropped silently).
3634
+ */
3635
+ private pendingAmbientTruncated = false
3636
+ /** The ambient wait in progress, if the agent folded this prompt into a drain. */
3637
+ private awaitingAmbient?: AmbientWait
3638
+ /**
3639
+ * The prompt response named no conversation turn while a drain this session
3640
+ * retired RECENTLY may still be running without re-announcing. The turn still
3641
+ * settles at once (a drain record that no update revived is not waited on),
3642
+ * but its zero-output report says the turn may have been folded into that run
3643
+ * instead of blaming the agent and suggesting a resend.
3644
+ */
3645
+ private recentRetiredDrain = false
3646
+ /** The outcome of a settle whose closing work is waiting one macrotask (see `settle`). */
3647
+ private pendingOutcome?: TurnSettle
3648
+ private closeScheduled = false
2727
3649
  private cursorSeq: number
3650
+ /**
3651
+ * Burst tracker for this turn, fed from the LIVE hub frames only. Segment
3652
+ * replay must not touch it: every event is already counted once when it
3653
+ * arrived, and replay would double the numerator and the gaps.
3654
+ */
3655
+ private readonly tps = new TurnTpsTracker()
3656
+ private tpsSeq: number
3657
+ private lastTpsPushAt = 0
3658
+ /**
3659
+ * The ambient quiet/ceiling windows, read once from the manager: they are
3660
+ * deployment tuning, not per-tick facts, and small test values must be stable
3661
+ * for the whole turn (see {@link BridgeTuning.ambientQuietMs} for the
3662
+ * measured CodeBuddy tick cadence these defaults come from).
3663
+ */
3664
+ private readonly ambientTuning: { quietMs: number; maxMs: number }
3665
+ /**
3666
+ * The detached workers this turn dispatched (see {@link BackgroundDispatch}),
3667
+ * by `toolCallId`, recorded from the wire while the turn is still streaming —
3668
+ * typically in the first step, before the prompt response lands. A turn may
3669
+ * dispatch several, and the hold waits for ALL of them.
3670
+ */
3671
+ private readonly dispatches = new Map<string, BackgroundDispatch>()
3672
+ /** The detached-worker hold in progress, once a segment carries it. */
3673
+ private backgroundHold?: BackgroundHold
3674
+ /**
3675
+ * A hold parked because the prompt response landed while NO driver segment
3676
+ * was open (the host is between steps, about to execute the Agent card). It
3677
+ * begins when the next `startSegment` opens — or closes after a bounded
3678
+ * grace so a host that never opens one does not hang the turn.
3679
+ */
3680
+ private pendingBackgroundHold?: BackgroundHold
3681
+ /**
3682
+ * The hold's own windows, read once at construction like the ambient pair:
3683
+ * `taskMaxMs` is the worker phase's ceiling, `graceMs` the parked hold's
3684
+ * bounded wait for a driver step (see {@link BridgeTuning.backgroundTaskMaxMs}
3685
+ * and {@link BridgeTuning.backgroundParkedGraceMs}).
3686
+ */
3687
+ private readonly backgroundTuning: { taskMaxMs: number; graceMs: number }
3688
+ /**
3689
+ * When this controller was built. Only a drain announced AFTER this moment
3690
+ * may be adopted by the hold: an `ambient` record that predates the turn is
3691
+ * some earlier run's, and folding its text would mix two answers.
3692
+ */
3693
+ private readonly constructedAt: number
2728
3694
 
2729
3695
  constructor(
2730
3696
  private readonly manager: BridgeManager,
@@ -2737,6 +3703,10 @@ class TurnStreamController {
2737
3703
  ) {
2738
3704
  this.turnSeqAtStart = session.turnSeq
2739
3705
  this.cursorSeq = session.transcript.head
3706
+ this.tpsSeq = session.transcript.head
3707
+ this.constructedAt = Date.now()
3708
+ this.ambientTuning = manager.ambientTuning
3709
+ this.backgroundTuning = manager.backgroundTuning
2740
3710
  this.unsubscribe = hub.subscribe(event => this.onEvent(event))
2741
3711
  }
2742
3712
 
@@ -2772,6 +3742,12 @@ class TurnStreamController {
2772
3742
  * consuming it, so the next segment replays it again.
2773
3743
  */
2774
3744
  startSegment(): SegmentState {
3745
+ // A step opening on an already settled turn (the response landed a tick
3746
+ // ago and the tail drain is still scheduled) closes that turn FIRST: the
3747
+ // step must read a settled controller and the session must not stay
3748
+ // `running` across the macrotask boundary — the host's next prompt would be
3749
+ // refused as busy for no reason.
3750
+ this.flushScheduledClose()
2775
3751
  // A step opening is the HOST handing the turn back to the agent — this
2776
3752
  // runs for the first step and for each continuation after the host
2777
3753
  // executed a card. The watchdog measures the stretch the AGENT holds the
@@ -2787,34 +3763,793 @@ class TurnStreamController {
2787
3763
  outputChars: 0,
2788
3764
  activeToolCall: undefined,
2789
3765
  hasCard: false,
3766
+ // A segment opened DURING an ambient wait (never the steered first step,
3767
+ // which marks its own) inherits the suppression: the drain's tools still
3768
+ // stay transcript-only until the turn closes. A detached-worker hold
3769
+ // suppresses its cards for the same reason, and a PARKED hold must
3770
+ // suppress from the first replayed update on — the drain's TaskOutput
3771
+ // arrives in exactly that step.
3772
+ ambient: this.awaitingAmbient !== undefined
3773
+ || this.backgroundHold !== undefined
3774
+ || this.pendingBackgroundHold !== undefined,
2790
3775
  finished: false,
2791
3776
  }
2792
- this.segment = seg
2793
- const entries = this.session.transcript.since(this.cursorSeq)
2794
- for (const entry of entries) {
2795
- if (seg.finished) break
2796
- if (!this.foldInto(seg, entry.update as Record<string, unknown>)) break
2797
- this.cursorSeq = entry.seq
3777
+ this.segment = seg
3778
+ const entries = this.session.transcript.since(this.cursorSeq)
3779
+ for (const entry of entries) {
3780
+ if (seg.finished) break
3781
+ const update = entry.update as Record<string, unknown>
3782
+ // The same split the live path makes: a background drain's prose is not
3783
+ // this turn's answer, even when it landed while no step was open. The
3784
+ // record (tool outputs) still goes in; the cursor moves past it.
3785
+ if (this.isForeignAmbientText(update)) {
3786
+ this.recordWithoutSegment(update)
3787
+ this.cursorSeq = entry.seq
3788
+ continue
3789
+ }
3790
+ if (!this.foldInto(seg, update, entry.seq)) break
3791
+ this.cursorSeq = entry.seq
3792
+ }
3793
+ if (!seg.finished && this.pendingProse.length > 0) {
3794
+ // Narrative that arrived between segments (a permission ask during card
3795
+ // execution): it belongs to the turn's story, so it opens this segment.
3796
+ const lines = this.pendingProse.splice(0, this.pendingProse.length).join('')
3797
+ this.emitProse(seg, 'reasoning', lines)
3798
+ }
3799
+ const parkedHold = this.pendingBackgroundHold
3800
+ if (parkedHold !== undefined && !seg.finished) {
3801
+ // The host opened the continuation step for the Agent card: the hold
3802
+ // finally has a driver step to speak the drain's answer into.
3803
+ this.adoptBackgroundHold(seg, parkedHold)
3804
+ }
3805
+ if (this.settled) this.finishAfterSettle(seg)
3806
+ return seg
3807
+ }
3808
+
3809
+ /** Finish the segment on air (if any). Used by cancel paths and teardown. */
3810
+ finishActive(reason: SegmentFinishReason): void {
3811
+ if (this.segment !== undefined) this.finishSegment(this.segment, reason)
3812
+ }
3813
+
3814
+ /** Whether this turn already streamed prose of its own (not a drain's). */
3815
+ get hasAgentProse(): boolean {
3816
+ return this.sawAgentProse
3817
+ }
3818
+
3819
+ /**
3820
+ * Keep the turn open for a background drain the agent folded our prompt into.
3821
+ *
3822
+ * THE SHAPE: the agent answered `session/prompt` at once (CodeBuddy returns
3823
+ * `end_turn` in ~14ms when it merges the message into a drain it is already
3824
+ * running) and the real answer streams afterwards under the DRAIN's request
3825
+ * id. Closing on the response would cut the turn before its text exists —
3826
+ * which is exactly the bug this state exists for. So:
3827
+ *
3828
+ * - the agent-side prompt slot is released (`endTurn`: the response HAS
3829
+ * landed) but the turn is NOT settled: `settled` means "the turn is over",
3830
+ * and it is not — no `turn/end`, no `running = false`, no segment close;
3831
+ * - the segment keeps folding the session's updates, with cards suppressed
3832
+ * (see `SegmentState.ambient`): the host must not execute a background
3833
+ * tool for a turn whose answer has not begun;
3834
+ * - any update on the session resets the quiet clock, and the drain's end is
3835
+ * its silence (a `session_end` is rejected by the SDK's schema and never
3836
+ * reaches us — never wait for one); a hard ceiling closes what never ends.
3837
+ */
3838
+ awaitAmbient(requestId: string, stopReason: string): void {
3839
+ if (this.settled || this.disposed || this.awaitingAmbient !== undefined) return
3840
+ const seg = this.segment
3841
+ if (seg === undefined || seg.finished) {
3842
+ // No live driver step to speak into: nothing can be folded, so close the
3843
+ // turn the ordinary way rather than holding state that renders nothing.
3844
+ this.clearPendingAmbient()
3845
+ this.settle({ kind: 'stop', stopReason })
3846
+ return
3847
+ }
3848
+ seg.ambient = true
3849
+ // The response landed: the agent no longer holds a prompt for this turn.
3850
+ this.endTurn()
3851
+ // The drain may have spoken BEFORE the response was judged. Those updates
3852
+ // were withheld while this turn's own request id was unknowable (see
3853
+ // `pendingAmbientUpdates`); the turn IS the drain, so they are its answer —
3854
+ // folded in arrival order, now that `seg.ambient` suppresses their cards.
3855
+ this.flushPendingAmbient(seg)
3856
+ if (seg.finished) {
3857
+ this.settle({ kind: 'stop', stopReason })
3858
+ return
3859
+ }
3860
+ const wait: AmbientWait = {
3861
+ requestId,
3862
+ quietTimer: setTimeout(() => { this.onAmbientQuiet(wait) }, this.ambientTuning.quietMs),
3863
+ hardTimer: setTimeout(() => { this.onAmbientHard(wait) }, this.ambientTuning.maxMs),
3864
+ stopReason,
3865
+ }
3866
+ wait.quietTimer.unref?.()
3867
+ wait.hardTimer.unref?.()
3868
+ this.awaitingAmbient = wait
3869
+ }
3870
+
3871
+ /**
3872
+ * The prompt response named no conversation turn while a drain the session
3873
+ * retired recently may still be running (and did not re-announce): the turn
3874
+ * cannot be held open, but a zero-output report must not blame the agent.
3875
+ * See `hasRecentRetiredAmbient` for the window.
3876
+ */
3877
+ noteRecentRetiredDrain(): void {
3878
+ this.recentRetiredDrain = true
3879
+ }
3880
+
3881
+ /**
3882
+ * Whether this turn dispatched a detached worker whose answer is still
3883
+ * outstanding. Read when the prompt response lands: such a turn is NOT over
3884
+ * even though the agent answered it (see {@link holdForBackgroundDispatch}).
3885
+ *
3886
+ * Two ways a dispatch keeps the turn open:
3887
+ * - it has not reported back yet (the worker is still running), or
3888
+ * - it DID report — before the response, even — but it was structurally
3889
+ * detached and emitted worker updates: the drain that carries its report
3890
+ * is still coming.
3891
+ * A synchronous `Agent` / `Task` call (completed, no `isBackground`, no
3892
+ * worker updates) matches neither and closes normally.
3893
+ */
3894
+ get dispatchedDetachedWorker(): boolean {
3895
+ for (const dispatch of this.dispatches.values()) {
3896
+ if (!dispatch.completed) return true
3897
+ if (dispatch.detached && dispatch.sawWorkerUpdate) return true
3898
+ }
3899
+ return false
3900
+ }
3901
+
3902
+ /** The hold this turn is inside — adopted, or parked waiting for a step. */
3903
+ private activeBackgroundHold(): BackgroundHold | undefined {
3904
+ return this.backgroundHold ?? this.pendingBackgroundHold
3905
+ }
3906
+
3907
+ /** Whether every KNOWN dispatch has reported (no dispatch at all counts as done). */
3908
+ private allDispatchesCompleted(): boolean {
3909
+ for (const dispatch of this.dispatches.values()) {
3910
+ if (!dispatch.completed) return false
3911
+ }
3912
+ return true
3913
+ }
3914
+
3915
+ /**
3916
+ * Hold the turn open for the detached worker(s) its `Agent` tool calls
3917
+ * dispatched — the second controlled ambient-family exception (constraint
3918
+ * 23), not a new idle watchdog.
3919
+ *
3920
+ * THE SHAPE (measured against CodeBuddy 2.179.0, multitask on): the
3921
+ * coordinator opens an `Agent` call that hands work to a background worker,
3922
+ * says "已派…", and the `session/prompt` RESPONSE arrives normally while the
3923
+ * worker keeps running. The worker's own updates (requestId B +
3924
+ * `parentToolCallId`) never touch this session's message; when it finishes,
3925
+ * the agent starts a background DRAIN (requestId A — the response's own
3926
+ * `conversationRequestId`) whose steps carry the report and the final answer.
3927
+ * Nothing tells the client the worker is running: no `session_end` (the SDK's
3928
+ * schema rejects it) and an `agentPhase` idle tick even fires BETWEEN the
3929
+ * worker and the drain. So the response cannot close the turn.
3930
+ *
3931
+ * The hold:
3932
+ * - releases the agent-side prompt slot (`endTurn`: the response landed)
3933
+ * while keeping `running` and the driver step open;
3934
+ * - marks the segment background, so EVERY tool call for the rest of the
3935
+ * hold — the workers', the drain's TaskOutput — stays transcript-only and
3936
+ * the final message carries text with no card in front of it;
3937
+ * - waits in two phases: the worker phase only has the
3938
+ * {@link BridgeTuning.backgroundTaskMaxMs} ceiling (a detached worker goes
3939
+ * silent for tens of seconds while it works) and ends only when ALL known
3940
+ * dispatches reported, after which the drain phase uses the ambient
3941
+ * windows.
3942
+ */
3943
+ holdForBackgroundDispatch(stopReason: string, requestId?: string): void {
3944
+ if (this.settled || this.disposed || this.closedTurn || this.stale) return
3945
+ if (this.activeBackgroundHold() !== undefined) return
3946
+ const hold: BackgroundHold = {
3947
+ stopReason,
3948
+ requestId,
3949
+ acceptedIds: new Set<string>(),
3950
+ phase: 'worker',
3951
+ answerChars: 0,
3952
+ floorSeq: this.session.transcript.head,
3953
+ retired: false,
3954
+ }
3955
+ if (requestId !== undefined) hold.acceptedIds.add(requestId)
3956
+ // A drain announced while this turn was ALREADY on air belongs to it (the
3957
+ // announcement may have named a different id than the response). One that
3958
+ // predates the turn is some earlier run's, and folding its text would mix
3959
+ // two answers.
3960
+ const announced = this.session.ambient
3961
+ if (announced !== undefined && announced.startedAt >= this.constructedAt) {
3962
+ hold.acceptedIds.add(announced.requestId)
3963
+ if (hold.requestId === undefined) hold.requestId = announced.requestId
3964
+ }
3965
+ // The response landed: the agent no longer holds a prompt for this turn.
3966
+ this.endTurn()
3967
+ hold.hardTimer = setTimeout(() => { this.onBackgroundHard(hold) }, this.backgroundTuning.taskMaxMs)
3968
+ hold.hardTimer.unref?.()
3969
+ const seg = this.segment
3970
+ if (seg !== undefined && !seg.finished) {
3971
+ this.adoptBackgroundHold(seg, hold)
3972
+ } else {
3973
+ // The response landed BETWEEN driver steps: the host is about to execute
3974
+ // the Agent card and open its continuation step. Park the hold for that
3975
+ // step, with a bounded wait — a host that never opens one must not hang
3976
+ // the turn forever.
3977
+ hold.graceTimer = setTimeout(() => { this.onBackgroundGraceExpired(hold) }, this.backgroundTuning.graceMs)
3978
+ hold.graceTimer.unref?.()
3979
+ this.pendingBackgroundHold = hold
3980
+ }
3981
+ // A FAST detached worker may already have reported (even before the
3982
+ // response): with every known dispatch complete there is no worker phase
3983
+ // left to wait out, so the drain phase starts now.
3984
+ if (this.allDispatchesCompleted()) this.startBackgroundDrain(hold)
3985
+ }
3986
+
3987
+ /** Give a (possibly parked) hold a driver segment: cards off for the rest of it. */
3988
+ private adoptBackgroundHold(seg: SegmentState, hold: BackgroundHold): void {
3989
+ if (hold.graceTimer !== undefined) {
3990
+ clearTimeout(hold.graceTimer)
3991
+ hold.graceTimer = undefined
3992
+ }
3993
+ if (this.pendingBackgroundHold === hold) this.pendingBackgroundHold = undefined
3994
+ seg.ambient = true
3995
+ this.backgroundHold = hold
3996
+ // The drain may have spoken BEFORE the response was judged. Those updates
3997
+ // were withheld while this turn's own id was unknown
3998
+ // (`pendingAmbientUpdates`); only the runs THIS hold owns are its answer —
3999
+ // another run's withheld text is dropped (the transcript keeps it).
4000
+ this.flushBackgroundHoldUpdates(seg, hold)
4001
+ // The parked grace owns the wait while no step is on air; now that one is,
4002
+ // a hold whose drain phase already began arms its quiet clock.
4003
+ if (hold.phase === 'drain') this.armBackgroundQuiet(hold)
4004
+ }
4005
+
4006
+ /**
4007
+ * The worker phase is over once EVERY known dispatch reported: the drain
4008
+ * that carries their reports is (about to be) running, so the existing
4009
+ * ambient windows take over, measured from HERE.
4010
+ *
4011
+ * An announced run is accepted immediately, but while any dispatch is still
4012
+ * incomplete it only records the id and switches the hold's primary run —
4013
+ * arming the quiet clock there would let a drain that spoke early cut the
4014
+ * turn off before its other worker ever reports.
4015
+ */
4016
+ private startBackgroundDrain(hold: BackgroundHold, requestId?: string): void {
4017
+ if (hold !== this.backgroundHold && hold !== this.pendingBackgroundHold) return
4018
+ if (requestId !== undefined) {
4019
+ hold.acceptedIds.add(requestId)
4020
+ hold.requestId = requestId
4021
+ }
4022
+ if (!this.allDispatchesCompleted()) return
4023
+ if (hold.phase === 'drain') {
4024
+ if (this.backgroundHold === hold) this.armBackgroundQuiet(hold)
4025
+ return
4026
+ }
4027
+ hold.phase = 'drain'
4028
+ if (hold.hardTimer !== undefined) clearTimeout(hold.hardTimer)
4029
+ hold.hardTimer = setTimeout(() => { this.onBackgroundHard(hold) }, this.ambientTuning.maxMs)
4030
+ hold.hardTimer.unref?.()
4031
+ // A parked hold belongs to the grace until a driver step opens; a hold
4032
+ // with a step arms its quiet clock now.
4033
+ if (this.backgroundHold === hold) this.armBackgroundQuiet(hold)
4034
+ }
4035
+
4036
+ /**
4037
+ * A dispatch that lands while the drain phase already began means the
4038
+ * coordinator handed out NEW work: the worker phase is back, and with it the
4039
+ * absence of a quiet window.
4040
+ */
4041
+ private enterBackgroundWorkerPhase(hold: BackgroundHold): void {
4042
+ if (hold.phase === 'worker') return
4043
+ hold.phase = 'worker'
4044
+ if (hold.quietTimer !== undefined) {
4045
+ clearTimeout(hold.quietTimer)
4046
+ hold.quietTimer = undefined
4047
+ }
4048
+ if (hold.hardTimer !== undefined) clearTimeout(hold.hardTimer)
4049
+ hold.hardTimer = setTimeout(() => { this.onBackgroundHard(hold) }, this.backgroundTuning.taskMaxMs)
4050
+ hold.hardTimer.unref?.()
4051
+ }
4052
+
4053
+ /** Push the drain phase's quiet clock out: every update is evidence of work. */
4054
+ private armBackgroundQuiet(hold: BackgroundHold): void {
4055
+ if (hold.quietTimer !== undefined) clearTimeout(hold.quietTimer)
4056
+ hold.quietTimer = setTimeout(() => { this.onBackgroundQuiet(hold) }, this.ambientTuning.quietMs)
4057
+ hold.quietTimer.unref?.()
4058
+ }
4059
+
4060
+ /**
4061
+ * Any session update restarts the drain phase's quiet clock. Only an
4062
+ * ADOPTED hold has one: a parked hold's wait belongs to its grace timer, and
4063
+ * the worker phase never has a clock at all.
4064
+ */
4065
+ private noteBackgroundHoldActivity(): void {
4066
+ const hold = this.backgroundHold
4067
+ if (hold === undefined || hold.phase !== 'drain') return
4068
+ this.armBackgroundQuiet(hold)
4069
+ }
4070
+
4071
+ /** The drained run went quiet: the turn ends with the stop reason the response gave. */
4072
+ private onBackgroundQuiet(hold: BackgroundHold): void {
4073
+ if (this.backgroundHold !== hold || hold.phase !== 'drain') return
4074
+ this.closeBackgroundHold(hold, { hard: false })
4075
+ }
4076
+
4077
+ /** The hold outlived its ceiling (worker or drain): the run is still going. */
4078
+ private onBackgroundHard(hold: BackgroundHold): void {
4079
+ if (hold !== this.backgroundHold && hold !== this.pendingBackgroundHold) return
4080
+ this.closeBackgroundHold(hold, { hard: true })
4081
+ }
4082
+
4083
+ /**
4084
+ * No driver step opened inside the grace: the host is not coming back for
4085
+ * this turn's Agent card. Close it the way a quiet ambient ending does — the
4086
+ * deferral line, never the zero-output blame — and keep ONE drain record:
4087
+ * the worker may still report into a run no turn is waiting on.
4088
+ *
4089
+ * A parked hold has NO segment by definition, so the chat plane has nowhere
4090
+ * to put the deferral line: it goes to the settings log (`agent/notice`)
4091
+ * instead of being parked on prose nobody will read.
4092
+ */
4093
+ private onBackgroundGraceExpired(hold: BackgroundHold): void {
4094
+ if (this.pendingBackgroundHold !== hold) return
4095
+ this.pendingBackgroundHold = undefined
4096
+ this.clearBackgroundHoldTimers(hold)
4097
+ const line = hold.answerChars > 0 ? undefined : backgroundDeferralLine(this.session.agentId)
4098
+ this.releaseBackgroundHoldIds(hold, { hard: true })
4099
+ if (line !== undefined) this.broadcastBackgroundDeferral(line)
4100
+ this.closeTurn({ kind: 'stop', stopReason: hold.stopReason }, { line, keepAmbient: true, requestId: hold.requestId })
4101
+ }
4102
+
4103
+ /**
4104
+ * Close a detached-worker hold exactly once. A turn that folded NO answer
4105
+ * text from its held runs says where the rest of the output will land (the
4106
+ * deferral wording, never the silent-failure blame); the parent's interim
4107
+ * acknowledgement does not count as that answer.
4108
+ *
4109
+ * Ownership of the drain ids at close (`releaseBackgroundHoldIds`): a quiet
4110
+ * close retires every accepted id and drops the session record; a hard or
4111
+ * grace close keeps exactly ONE record, because the run is provably still
4112
+ * going and a later update or re-announcement must revive it normally.
4113
+ */
4114
+ private closeBackgroundHold(hold: BackgroundHold, options: { hard: boolean }): void {
4115
+ if (this.pendingBackgroundHold === hold) this.pendingBackgroundHold = undefined
4116
+ else if (this.backgroundHold === hold) this.backgroundHold = undefined
4117
+ else return
4118
+ this.clearBackgroundHoldTimers(hold)
4119
+ const line = hold.answerChars > 0 ? undefined : backgroundDeferralLine(this.session.agentId)
4120
+ this.releaseBackgroundHoldIds(hold, { hard: options.hard })
4121
+ const onAir = this.segment !== undefined && !this.segment.finished
4122
+ // With no segment on air the closing line has no chat bubble to land in:
4123
+ // log it, so the fact is recorded rather than silently dropped.
4124
+ if (line !== undefined && !onAir) this.broadcastBackgroundDeferral(line)
4125
+ this.closeTurn(
4126
+ { kind: 'stop', stopReason: hold.stopReason },
4127
+ { line, keepAmbient: options.hard, requestId: hold.requestId },
4128
+ )
4129
+ }
4130
+
4131
+ /** Write a hold's deferral line into the settings log (`agent/notice`). */
4132
+ private broadcastBackgroundDeferral(line: string): void {
4133
+ this.hub.broadcast({
4134
+ type: 'agent/notice',
4135
+ agentId: this.session.agentId,
4136
+ level: 'info',
4137
+ text: line.trim(),
4138
+ })
4139
+ }
4140
+
4141
+ /** Drop a hold's clocks. The hold object itself stays for its `retired` mark. */
4142
+ private clearBackgroundHoldTimers(hold: BackgroundHold): void {
4143
+ if (hold.quietTimer !== undefined) clearTimeout(hold.quietTimer)
4144
+ if (hold.hardTimer !== undefined) clearTimeout(hold.hardTimer)
4145
+ if (hold.graceTimer !== undefined) clearTimeout(hold.graceTimer)
4146
+ hold.quietTimer = undefined
4147
+ hold.hardTimer = undefined
4148
+ hold.graceTimer = undefined
4149
+ }
4150
+
4151
+ /**
4152
+ * Drop every hold clock (teardown / a forced ending). `retire` also gives up
4153
+ * every accepted run id — a SUCCESSOR turn must never fold the abandoned
4154
+ * hold's drain or worker text into its own answer.
4155
+ */
4156
+ private clearBackgroundHold(options?: { retire?: boolean }): void {
4157
+ const pending = this.pendingBackgroundHold
4158
+ if (pending !== undefined) {
4159
+ this.pendingBackgroundHold = undefined
4160
+ this.clearBackgroundHoldTimers(pending)
4161
+ if (options?.retire === true) this.releaseBackgroundHoldIds(pending, { hard: false })
4162
+ }
4163
+ const hold = this.backgroundHold
4164
+ if (hold === undefined) return
4165
+ this.backgroundHold = undefined
4166
+ this.clearBackgroundHoldTimers(hold)
4167
+ if (options?.retire === true) this.releaseBackgroundHoldIds(hold, { hard: false })
4168
+ }
4169
+
4170
+ /**
4171
+ * Settle what happens to the session's drain records when a hold closes.
4172
+ *
4173
+ * `hard: false` (quiet close, cancel, teardown): the run is no longer waited
4174
+ * on, so EVERY accepted id is retired — its late text stays transcript-only
4175
+ * for whatever turn runs next — and the session record is dropped if it
4176
+ * names one of them.
4177
+ *
4178
+ * `hard: true` (ceiling / parked grace): the run is provably still going.
4179
+ * Exactly ONE record survives — the session's current drain if it has one,
4180
+ * else the hold's primary run — with its activity clock refreshed; the other
4181
+ * accepted ids are retired. A hold whose primary id had no session record
4182
+ * CREATES one, so a successor's `noteTurnRequestId` cannot adopt that id.
4183
+ * Every accepted id therefore ends up either retired or owned by
4184
+ * `session.ambient`; never both unowned and unretired.
4185
+ */
4186
+ private releaseBackgroundHoldIds(hold: BackgroundHold, options: { hard: boolean }): void {
4187
+ if (hold.retired) return
4188
+ hold.retired = true
4189
+ if (!options.hard) {
4190
+ for (const id of hold.acceptedIds) retireAmbientId(this.session, id)
4191
+ if (this.session.ambient !== undefined && hold.acceptedIds.has(this.session.ambient.requestId)) {
4192
+ this.session.ambient = undefined
4193
+ }
4194
+ return
4195
+ }
4196
+ const survivor = this.session.ambient?.requestId ?? hold.requestId
4197
+ for (const id of hold.acceptedIds) {
4198
+ if (id === survivor) continue
4199
+ retireAmbientId(this.session, id)
4200
+ }
4201
+ if (survivor === undefined) return
4202
+ const now = Date.now()
4203
+ if (this.session.ambient?.requestId === survivor) {
4204
+ this.session.ambient.lastActivityAt = now
4205
+ } else {
4206
+ this.session.ambient = {
4207
+ requestId: survivor,
4208
+ startedAt: now,
4209
+ lastActivityAt: now,
4210
+ sawOutput: hold.answerChars > 0,
4211
+ }
4212
+ }
4213
+ // The surviving run is alive, not retired (an earlier wait of this turn
4214
+ // may have retired it before it spoke again).
4215
+ reviveAmbientId(this.session, survivor)
4216
+ }
4217
+
4218
+ /**
4219
+ * Fold the withheld drain updates that belong to this hold into its segment.
4220
+ * The rest of the buffer was some OTHER run's text: it is dropped here (the
4221
+ * transcript keeps it), never leaked into this turn's answer.
4222
+ */
4223
+ private flushBackgroundHoldUpdates(seg: SegmentState, hold: BackgroundHold): void {
4224
+ if (this.pendingAmbientUpdates.length === 0) {
4225
+ this.pendingAmbientTruncated = false
4226
+ return
4227
+ }
4228
+ const pending = this.pendingAmbientUpdates.splice(0, this.pendingAmbientUpdates.length)
4229
+ this.pendingAmbientChars = 0
4230
+ const truncated = this.pendingAmbientTruncated
4231
+ this.pendingAmbientTruncated = false
4232
+ const accepted = pending.filter(entry => {
4233
+ const requestId = codebuddyMetaOf(entry.update)?.requestId
4234
+ return requestId !== undefined && hold.acceptedIds.has(requestId)
4235
+ })
4236
+ if (accepted.length === 0) return
4237
+ if (truncated && !seg.finished) {
4238
+ // The front of the drain's output was evicted from the bounded buffer:
4239
+ // say so rather than letting the turn quietly start mid-story.
4240
+ this.emitProse(seg, 'text', '\n[dsh-ntr] background output was truncated; the ACP transcript view still has every update.\n')
4241
+ }
4242
+ for (const entry of accepted) {
4243
+ if (seg.finished) break
4244
+ this.foldInto(seg, entry.update, entry.seq)
4245
+ // The buffered drain text arrived BEFORE the hold, but it IS the held
4246
+ // run's answer: below the hold's floor it was not counted by `foldInto`,
4247
+ // so count it here (the floor exists to skip the parent's earlier ack,
4248
+ // not the drain's own words).
4249
+ if (entry.seq <= hold.floorSeq
4250
+ && String(entry.update.sessionUpdate ?? '') === 'agent_message_chunk') {
4251
+ hold.answerChars += chunkTextOf(entry.update.content).length
4252
+ }
4253
+ }
4254
+ }
4255
+
4256
+ /**
4257
+ * Follow the detached-worker signals of one live update.
4258
+ *
4259
+ * Only CodeBuddy sends them (`codebuddyMetaOf` answers `undefined` for every
4260
+ * other agent, so nothing here can fire for them):
4261
+ * - an `Agent` / `Task` `tool_call` records the dispatch — the FIRST send
4262
+ * needs that CodeBuddy shape;
4263
+ * - the SAME call re-sent with `isBackground: true`, or any update naming it
4264
+ * as its `parentToolCallId`, marks the work DETACHED;
4265
+ * - that call reporting completed / failed is read BY ID: the real
4266
+ * completion frame carries NO `_meta` at all, so requiring the CodeBuddy
4267
+ * shape here would miss every real completion;
4268
+ * - any update carrying `parentToolCallId` is the worker's OWN turn — it
4269
+ * proves the worker alive but never belongs to this session's answer;
4270
+ * - an `unsolicitedTurn` announcement is the drain that carries the report.
4271
+ */
4272
+ private trackBackgroundDispatch(update: Record<string, unknown>): void {
4273
+ if (this.closedTurn) return
4274
+ const meta = codebuddyMetaOf(update)
4275
+ const kind = String(update.sessionUpdate ?? '')
4276
+ const row = update as { toolCallId?: unknown; status?: unknown }
4277
+ const toolCallId = typeof row.toolCallId === 'string' ? row.toolCallId.trim() : ''
4278
+ const status = typeof row.status === 'string' ? row.status : ''
4279
+ const completedStatus = status === 'completed' || status === 'failed'
4280
+ if ((kind === 'tool_call' || kind === 'tool_call_update') && toolCallId !== '') {
4281
+ const known = this.dispatches.get(toolCallId)
4282
+ if (known !== undefined) {
4283
+ // A known dispatch: the id is the whole evidence (see the doc above).
4284
+ if (status !== '') known.status = status
4285
+ if (meta?.isBackground === true) known.detached = true
4286
+ if (completedStatus && !known.completed) {
4287
+ known.completed = true
4288
+ this.noteDispatchProgress()
4289
+ }
4290
+ } else if (meta !== undefined
4291
+ && (meta.toolName === 'Agent' || meta.toolName === 'Task')
4292
+ && (kind === 'tool_call' || meta.isBackground === true)) {
4293
+ // A NEW dispatch needs the CodeBuddy shape. A turn may dispatch more
4294
+ // than one worker; the hold waits for all of them.
4295
+ const dispatch: BackgroundDispatch = {
4296
+ toolCallId,
4297
+ status,
4298
+ completed: completedStatus,
4299
+ detached: meta.isBackground === true,
4300
+ sawWorkerUpdate: false,
4301
+ }
4302
+ this.dispatches.set(toolCallId, dispatch)
4303
+ if (!dispatch.completed) {
4304
+ const held = this.activeBackgroundHold()
4305
+ // A dispatch registered after the drain phase began means new work:
4306
+ // the worker phase (and its absence of a quiet clock) is back.
4307
+ if (held !== undefined) this.enterBackgroundWorkerPhase(held)
4308
+ }
4309
+ }
4310
+ }
4311
+ if (meta?.parentToolCallId !== undefined) {
4312
+ const parent = this.dispatches.get(meta.parentToolCallId)
4313
+ if (parent !== undefined) {
4314
+ parent.detached = true
4315
+ parent.sawWorkerUpdate = true
4316
+ }
4317
+ }
4318
+ if (meta?.unsolicitedTurn !== undefined) {
4319
+ const held = this.activeBackgroundHold()
4320
+ if (held !== undefined) this.startBackgroundDrain(held, meta.unsolicitedTurn.requestId)
4321
+ }
4322
+ }
4323
+
4324
+ /**
4325
+ * A dispatch just reported: once the LAST one has, the drain phase starts.
4326
+ * Until then the turn stays in its worker phase, with no quiet clock.
4327
+ */
4328
+ private noteDispatchProgress(): void {
4329
+ if (!this.allDispatchesCompleted()) return
4330
+ const held = this.activeBackgroundHold()
4331
+ if (held !== undefined) this.startBackgroundDrain(held)
4332
+ }
4333
+
4334
+ /** Any session update is evidence the drain still works: push the quiet clock out. */
4335
+ private noteAmbientActivity(): void {
4336
+ const wait = this.awaitingAmbient
4337
+ if (wait === undefined) return
4338
+ clearTimeout(wait.quietTimer)
4339
+ wait.quietTimer = setTimeout(() => { this.onAmbientQuiet(wait) }, this.ambientTuning.quietMs)
4340
+ wait.quietTimer.unref?.()
4341
+ }
4342
+
4343
+ /** The drain went quiet: the turn ends with the stop reason the response gave. */
4344
+ private onAmbientQuiet(wait: AmbientWait): void {
4345
+ if (this.awaitingAmbient !== wait) return
4346
+ this.finishAmbient(wait)
4347
+ }
4348
+
4349
+ /** The drain outlived the ceiling: say where the rest will land, then close. */
4350
+ private onAmbientHard(wait: AmbientWait): void {
4351
+ if (this.awaitingAmbient !== wait) return
4352
+ const seg = this.segment
4353
+ if (seg !== undefined && !seg.finished) {
4354
+ this.emitProse(seg, 'text', `\n[dsh-ntr] agent "${this.session.agentId}" is still running a background task; the rest of its output will appear in the ACP transcript view.\n`)
4355
+ }
4356
+ // The ceiling is evidence the drain is STILL RUNNING (unlike a quiet close),
4357
+ // so the ending keeps the session's drain record; see `closeTurn`.
4358
+ wait.hard = true
4359
+ // The hard explanation already says the turn was cut short; the deferred
4360
+ // line would only repeat it in different words.
4361
+ this.finishAmbient(wait, { announceDeferral: false })
4362
+ }
4363
+
4364
+ /**
4365
+ * Close an ambient wait exactly once. A turn that produced no answer TEXT
4366
+ * says so: the drain's output is in the ACP transcript view, and this
4367
+ * bubble must not be empty (the zero-output report, reworded for a turn that
4368
+ * was deferred rather than failed).
4369
+ */
4370
+ private finishAmbient(wait: AmbientWait, options?: { announceDeferral?: boolean }): void {
4371
+ if (this.awaitingAmbient !== wait) return
4372
+ const line = options?.announceDeferral === false || this.sawVisibleText
4373
+ ? undefined
4374
+ : backgroundDeferralLine(this.session.agentId)
4375
+ this.closeTurn(
4376
+ { kind: 'stop', stopReason: wait.stopReason },
4377
+ { line, keepAmbient: wait.hard === true, requestId: wait.requestId },
4378
+ )
4379
+ }
4380
+
4381
+ /** Drop the ambient wait's clocks (the turn is being closed by someone else). */
4382
+ private clearAmbientWait(): void {
4383
+ const wait = this.awaitingAmbient
4384
+ if (wait === undefined) return
4385
+ this.awaitingAmbient = undefined
4386
+ clearTimeout(wait.quietTimer)
4387
+ clearTimeout(wait.hardTimer)
4388
+ }
4389
+
4390
+ /**
4391
+ * Withhold one background-drain update until the prompt response says what
4392
+ * this turn is. The cursor advances past it anyway: it is already in the
4393
+ * transcript, and replaying it into a later segment would emit it twice.
4394
+ * Bounded (see `PENDING_AMBIENT_MAX_*`) — the transcript still keeps every
4395
+ * entry the buffer drops.
4396
+ */
4397
+ private bufferAmbientUpdate(seq: number, update: Record<string, unknown>): void {
4398
+ const chars = JSON.stringify(update).length
4399
+ this.pendingAmbientUpdates.push({ seq, update, chars })
4400
+ this.pendingAmbientChars += chars
4401
+ while (this.pendingAmbientUpdates.length > PENDING_AMBIENT_MAX_ENTRIES
4402
+ || (this.pendingAmbientChars > PENDING_AMBIENT_MAX_CHARS && this.pendingAmbientUpdates.length > 1)) {
4403
+ const dropped = this.pendingAmbientUpdates.shift()
4404
+ if (dropped === undefined) break
4405
+ this.pendingAmbientChars -= dropped.chars
4406
+ this.pendingAmbientTruncated = true
4407
+ }
4408
+ }
4409
+
4410
+ /** Fold the withheld drain updates into the now-ambient segment, once, in order. */
4411
+ private flushPendingAmbient(seg: SegmentState): void {
4412
+ if (this.pendingAmbientUpdates.length === 0) {
4413
+ this.pendingAmbientTruncated = false
4414
+ return
4415
+ }
4416
+ const truncated = this.pendingAmbientTruncated
4417
+ this.pendingAmbientTruncated = false
4418
+ const pending = this.pendingAmbientUpdates.splice(0, this.pendingAmbientUpdates.length)
4419
+ this.pendingAmbientChars = 0
4420
+ if (truncated && !seg.finished) {
4421
+ // The front of the drain's output was evicted from the bounded buffer:
4422
+ // say so rather than letting the turn quietly start mid-story.
4423
+ this.emitProse(seg, 'text', '\n[dsh-ntr] background output was truncated; the ACP transcript view still has every update.\n')
2798
4424
  }
2799
- if (!seg.finished && this.pendingProse.length > 0) {
2800
- // Narrative that arrived between segments (a permission ask during card
2801
- // execution): it belongs to the turn's story, so it opens this segment.
2802
- const lines = this.pendingProse.splice(0, this.pendingProse.length).join('')
2803
- this.emitProse(seg, 'reasoning', lines)
4425
+ for (const entry of pending) {
4426
+ if (seg.finished) break
4427
+ this.foldInto(seg, entry.update, entry.seq)
2804
4428
  }
2805
- if (this.settled) this.finishAfterSettle(seg)
2806
- return seg
2807
4429
  }
2808
4430
 
2809
- /** Finish the segment on air (if any). Used by cancel paths and teardown. */
2810
- finishActive(reason: SegmentFinishReason): void {
2811
- if (this.segment !== undefined) this.finishSegment(this.segment, reason)
4431
+ /** Drop withheld drain updates: this turn is not the drain, or it is closing. */
4432
+ private clearPendingAmbient(): void {
4433
+ this.pendingAmbientUpdates.length = 0
4434
+ this.pendingAmbientChars = 0
4435
+ this.pendingAmbientTruncated = false
2812
4436
  }
2813
4437
 
2814
4438
  /** The agent settled its `session/prompt`. Ends the turn's streaming face. */
2815
4439
  settle(outcome: TurnSettle): void {
2816
4440
  if (this.settled) return
4441
+ // `settled` is claimed NOW (a second settle, a cancel or a watchdog that
4442
+ // races the tail drain must find the turn already spoken for), but the
4443
+ // CLOSING work waits one macrotask: the SDK dispatches `session/update`
4444
+ // notifications independently of the `session/prompt` RESPONSE, so the
4445
+ // agent's last chunks can still be queued when this lands (see
4446
+ // `settleTranscript`). Only what already reached the transcript is read —
4447
+ // one macrotask, never an open-ended wait.
4448
+ this.settled = true
4449
+ this.clearAmbientWait()
4450
+ this.clearPendingAmbient()
4451
+ // An outside ending (watchdog, cancel) abandons a detached-worker hold: its
4452
+ // run is no longer this turn's, so its id is retired before the close.
4453
+ this.clearBackgroundHold({ retire: true })
4454
+ this.pendingOutcome = outcome
4455
+ this.closeScheduled = true
4456
+ setImmediate(() => { this.flushScheduledClose() })
4457
+ }
4458
+
4459
+ /**
4460
+ * Force the local end of this turn — the cancel fallback and any watchdog
4461
+ * that reached the controller. Runs the SAME closing work as a normal settle
4462
+ * (`closeTurn`): the ambient clocks stop, the on-air segment closes (so the
4463
+ * driver step parked on its queue ends), exactly one `turn/end` goes out
4464
+ * with THIS turn's id, the session stops running and the prompt slot is
4465
+ * released. Idempotent: a turn that already closed ignores the call.
4466
+ */
4467
+ forceFinalize(outcome: TurnSettle): void {
4468
+ if (this.stale || this.closedTurn) return
4469
+ this.clearAmbientWait()
4470
+ // A forced ending abandons a detached-worker hold: retire its run's id so
4471
+ // its late words stay transcript-only for the next turn.
4472
+ this.clearBackgroundHold({ retire: true })
4473
+ // A settle's deferred tail read is moot once this close runs: the turn is
4474
+ // over now, and the pending macrotask must not drain anything into it.
4475
+ this.pendingOutcome = undefined
4476
+ this.closeScheduled = false
4477
+ this.closeTurn(outcome)
4478
+ }
4479
+
4480
+ /**
4481
+ * Run the deferred closing work now, when a driver step is about to open on
4482
+ * an already settled turn: the continuation step must see the closed turn
4483
+ * (and the session NOT left `running` across the macrotask boundary).
4484
+ * Idempotent — the scheduled tick then finds nothing left to do.
4485
+ */
4486
+ private flushScheduledClose(): void {
4487
+ if (!this.closeScheduled) return
4488
+ this.closeScheduled = false
4489
+ const outcome = this.pendingOutcome
4490
+ this.pendingOutcome = undefined
4491
+ if (outcome === undefined) return
4492
+ // A DISPOSE that raced this queued close cleared it (see `dispose`), so a
4493
+ // close still scheduled here belongs to a settle that landed AFTER the
4494
+ // stream was retired (a crash rejects its prompt only after the teardown).
4495
+ // That turn still ends through its own queue and real id — the teardown's
4496
+ // releaseAll frame carried an empty id.
4497
+ this.drainTail()
4498
+ this.closeTurn(outcome)
4499
+ }
4500
+
4501
+ /**
4502
+ * Fold the notifications that reached the transcript but not the segment:
4503
+ * the tail a response raced past. Stops AT a cutting event without consuming
4504
+ * it (same rule as `startSegment`) — its next segment is not this turn's.
4505
+ */
4506
+ private drainTail(): void {
4507
+ // The deferred read belongs to the turn that scheduled it. If the turn was
4508
+ // retired or a NEWER turn already began (turnSeq moved on) in this one
4509
+ // macrotask window, everything past the cursor is the successor's: folding
4510
+ // it here would leak its text into this turn's driver step.
4511
+ if (this.stale) return
4512
+ const seg = this.segment
4513
+ if (seg === undefined || seg.finished) return
4514
+ for (const entry of this.session.transcript.since(this.cursorSeq)) {
4515
+ if (seg.finished) break
4516
+ const update = entry.update as Record<string, unknown>
4517
+ // The same split the live path makes: a background drain's text in the
4518
+ // tail is not this turn's answer. Record what the registry needs (tool
4519
+ // outputs), move the cursor past it, and do NOT fold it — otherwise the
4520
+ // deferred read smuggles the drain's prose into a normal turn.
4521
+ if (this.isForeignAmbientText(update)) {
4522
+ this.recordWithoutSegment(update)
4523
+ this.cursorSeq = entry.seq
4524
+ continue
4525
+ }
4526
+ if (!this.foldInto(seg, update, entry.seq)) break
4527
+ this.cursorSeq = entry.seq
4528
+ }
4529
+ }
4530
+
4531
+ /**
4532
+ * End the turn exactly once: burst metrics, ONE `turn/end`, the provisional
4533
+ * lock, the closing text, the segment, the session flags, the prompt slot.
4534
+ *
4535
+ * `ambient.line` marks a controlled ambient ending: the given line closes
4536
+ * the segment when the turn carried no answer text, instead of the generic
4537
+ * zero-output warning (which would blame the agent for a turn the drain
4538
+ * simply had not finished yet). `ambient.keepAmbient` says the ending was the
4539
+ * CEILING, not silence: see the drain-record comment below.
4540
+ */
4541
+ private closeTurn(outcome: TurnSettle, ambient?: { line?: string; keepAmbient?: boolean; requestId?: string }): void {
4542
+ if (this.closedTurn) return
4543
+ this.closedTurn = true
4544
+ // A closed turn is a settled turn, whichever path got here first (an
4545
+ // ambient ending does not go through `settle`).
2817
4546
  this.settled = true
4547
+ this.clearAmbientWait()
4548
+ this.clearPendingAmbient()
4549
+ // Whoever closed this turn already decided what happens to the held
4550
+ // run's record (`closeBackgroundHold` retired or kept it); only the clocks
4551
+ // are still ours to drop.
4552
+ this.clearBackgroundHold()
2818
4553
  // A late answer to a SUPERSEDED turn still has to close this controller's
2819
4554
  // queue (a continuation step may be parked on it) and its settle gate —
2820
4555
  // but it must not touch the session the NEWER turn now owns, nor release
@@ -2826,6 +4561,38 @@ class TurnStreamController {
2826
4561
  ? { kind: 'error', failure: { message: outcome.message, code: outcome.code ?? 'INTERNAL' } }
2827
4562
  : { kind: 'stop' }
2828
4563
  if (current) {
4564
+ // An ambient ending that came from SILENCE retires the drain record with
4565
+ // its turn: a record that outlives the wait would make the NEXT instant
4566
+ // answer with no conversation request id look steered (see
4567
+ // `awaitAmbient`), holding a normal turn open for a drain this one
4568
+ // already finished waiting on. A HARD ceiling ending is the opposite
4569
+ // evidence — the drain was still ticking when the turn was cut — so the
4570
+ // record SURVIVES (activity clock refreshed; `ambientStaleMs` expires it
4571
+ // lazily) precisely so the next prompt folded into the same drain is
4572
+ // still recognised as steered. `ambientNotices` is never cleared here:
4573
+ // a repeated announcement of the same run must stay silent, and the set
4574
+ // is bounded where entries are added.
4575
+ if (ambient !== undefined) {
4576
+ if (ambient.keepAmbient === true) {
4577
+ if (this.session.ambient !== undefined) this.session.ambient.lastActivityAt = Date.now()
4578
+ } else {
4579
+ // Silence closed this wait, but silence is not proof the run ended: a
4580
+ // queued message or a long tool can make the drain speak again. So
4581
+ // RETIRE its id before dropping the record — a revived run's late
4582
+ // text must stay in the ACP transcript view instead of teaching the
4583
+ // NEXT turn the drain's id and mixing the drain's answer into the
4584
+ // user's (a double answer). The trade-off is deliberate: a next
4585
+ // prompt the agent folds into that same revived drain may not receive
4586
+ // the drain's text on the chat plane (transcript-only); a double
4587
+ // answer is worse.
4588
+ if (ambient.requestId !== undefined) retireAmbientId(this.session, ambient.requestId)
4589
+ this.session.ambient = undefined
4590
+ }
4591
+ }
4592
+ // The turn's burst aggregate must be visible before the turn is marked
4593
+ // over: the final `turn/metrics` lands first, then `turn/end` flips the
4594
+ // session/pill state. Persisting and broadcasting share one value.
4595
+ this.finalizeTps()
2829
4596
  if (outcome.kind === 'cancelled') {
2830
4597
  this.hub.broadcast({ type: 'turn/end', sessionId: this.session.sessionId, turnId: this.turnId, stopReason: 'cancelled' })
2831
4598
  } else if (outcome.kind === 'error') {
@@ -2835,19 +4602,28 @@ class TurnStreamController {
2835
4602
  }
2836
4603
  // The provisional lock settles BEFORE any synthesized warning below: the
2837
4604
  // warning is our text, not evidence that the first turn delivered anything.
4605
+ //
4606
+ // An AMBIENT ending ends the provisional phase WITHOUT a rollback: a
4607
+ // deferral to a background drain is not a failed first turn, the drain's
4608
+ // text still lands in the ACP transcript view, and rolling the lock back
4609
+ // would close the session under it — dropping exactly the output the
4610
+ // deferred turn promised. The lock therefore becomes permanent.
2838
4611
  if (this.session.provisionalLock === true) {
2839
4612
  this.session.provisionalLock = false
2840
- if (!this.sawVisibleBlock) this.rollbackFirstTurn(this.session)
4613
+ if (!this.sawVisibleBlock && ambient === undefined) this.rollbackFirstTurn(this.session)
2841
4614
  }
2842
4615
  }
2843
4616
  const seg = this.segment
2844
4617
  if (seg !== undefined) {
2845
- if (current && outcome.kind === 'stop' && !this.sawVisibleBlock) {
4618
+ if (ambient !== undefined) {
4619
+ // The ambient line is OUR report, so it only appears when the drain
4620
+ // delivered no answer text of its own; reasoning alone does not count.
4621
+ if (ambient.line !== undefined && current) this.emitProse(seg, 'text', ambient.line)
4622
+ } else if (current && outcome.kind === 'stop' && !this.sawVisibleBlock) {
2846
4623
  // The silent-failure report: a normal stop with nothing the chat
2847
4624
  // plane renders. Aborted or failing turns need no extra hint; this
2848
4625
  // is specifically "protocol says success, the bubble is empty".
2849
- const line = `\n[dsh-ntr] agent "${this.session.agentId}" ended the turn with no visible output (stop reason: ${outcome.stopReason}). Some agents fold upstream failures — auth, billing, rate limits — into a normal stop; try resending, and check the agent process logs if it repeats.\n`
2850
- this.emitProse(seg, 'text', line)
4626
+ this.emitProse(seg, 'text', this.zeroOutputLine(outcome.stopReason))
2851
4627
  }
2852
4628
  this.finishSegment(seg, reason)
2853
4629
  }
@@ -2862,23 +4638,58 @@ class TurnStreamController {
2862
4638
  }
2863
4639
  this.endTurn()
2864
4640
  // The controller stays indexed only while a continuation step can still
2865
- // come for its cards. A cancelled, failed, or card-less turn has none:
2866
- // retire it now; the next turn's `rememberTurnStream` would anyway.
4641
+ // come for its cards. A cancelled, failed, or card-less turn has none: retire
4642
+ // it now; the next turn's `rememberTurnStream` would anyway. An ambient turn
4643
+ // ALWAYS retires: its suppressed calls never open cards, so keeping the index
4644
+ // would pin the controller (and its session id) to a turn nobody can continue.
2867
4645
  if (!current) this.retire()
2868
- else if (outcome.kind !== 'stop' || this.seenCalls.size === 0) this.dispose()
4646
+ else if (ambient !== undefined || outcome.kind !== 'stop' || this.seenCalls.size === 0) this.dispose()
2869
4647
  }
2870
4648
 
2871
- /** Cancelled by the user: finish the on-air segment and release the turn's slots. */
4649
+ /**
4650
+ * Cancelled by the user: finish the on-air segment and release the turn's
4651
+ * slots. An AMBIENT turn has already released the prompt slot (its response
4652
+ * landed) and holds no segment the cancel fallback could close, so the
4653
+ * cancel closes the turn itself with the cancelled stop.
4654
+ */
2872
4655
  abortActiveSegment(): void {
2873
4656
  if (this.stale) return
4657
+ this.clearPendingAmbient()
4658
+ if (this.closeScheduled) {
4659
+ // `settle` claimed the turn but queued its closing work for the next
4660
+ // macrotask. An abort landing inside that window must not let the
4661
+ // scheduled close broadcast the RESPONSE's stop reason while the segment
4662
+ // already finished `aborted`: drop the queued outcome and close NOW with
4663
+ // the cancelled one, so `turn/end` and the segment's finish agree.
4664
+ this.closeScheduled = false
4665
+ this.pendingOutcome = undefined
4666
+ this.closeTurn({ kind: 'cancelled' })
4667
+ return
4668
+ }
4669
+ if (this.awaitingAmbient !== undefined) {
4670
+ this.settle({ kind: 'cancelled' })
4671
+ return
4672
+ }
4673
+ if (this.backgroundHold !== undefined || this.pendingBackgroundHold !== undefined) {
4674
+ // A detached-worker hold is a turn whose prompt response already landed
4675
+ // (the slot is free) and whose segment is still open on purpose: the
4676
+ // cancel ends OUR face of it now, once, and the held run's id is retired
4677
+ // so its late text cannot join the next turn.
4678
+ this.clearBackgroundHold({ retire: true })
4679
+ this.settle({ kind: 'cancelled' })
4680
+ return
4681
+ }
2874
4682
  this.finishActive({ kind: 'aborted', failure: { message: 'aborted', code: 'ABORTED' } })
2875
4683
  // The agent may take its time acknowledging the cancel, but OUR face of
2876
- // the turn ends now: the session stops running and a provisional first
2877
- // lock settles against what was actually shown. The turn SLOT stays
2878
- // claimed, though: the agent-side prompt is still in flight, and a second
4684
+ // the turn ends now: the session stops running, the HOST's prompt slot is
4685
+ // released (so a new prompt may claim it), and a provisional first lock
4686
+ // settles against what was actually shown. The AGENT-side gate (`settled`)
4687
+ // stays claimed: the prompt is still in flight over there, and a second
2879
4688
  // prompt sent into that window would be a concurrent `session/prompt` the
2880
- // protocol forbids — `settle` (or the cancel fallback) releases the slot.
4689
+ // protocol forbids — `claimTurn` waits it out, `settle` (or the cancel
4690
+ // fallback) opens it.
2881
4691
  this.session.running = false
4692
+ this.session.promptInFlight = false
2882
4693
  if (this.session.provisionalLock === true) {
2883
4694
  this.session.provisionalLock = false
2884
4695
  if (!this.sawVisibleBlock) this.rollbackFirstTurn(this.session)
@@ -2886,9 +4697,29 @@ class TurnStreamController {
2886
4697
  releaseAllToolOutputWaiters()
2887
4698
  }
2888
4699
 
2889
- /** Stop observing and release the manager's index. Idempotent. */
4700
+ /**
4701
+ * Stop observing and release the manager's index. Idempotent.
4702
+ *
4703
+ * The segment is CLOSED here (aborted) before unsubscribing: a driver step
4704
+ * parked on its queue (`yield* segment.queue`) only ends when the queue
4705
+ * closes, and a controller replaced by the conversation's next turn would
4706
+ * otherwise leave that step hanging forever. An already finished segment is a
4707
+ * no-op, and the session's flags are left alone — a superseded turn must not
4708
+ * mark the newer turn's session idle.
4709
+ */
2890
4710
  dispose(): void {
2891
4711
  if (this.disposed) return
4712
+ // A settle may have queued its closing work for the next macrotask: this
4713
+ // close owns the turn now, so that tick must not broadcast a second
4714
+ // `turn/end` (with this turn's real id) over the teardown.
4715
+ this.closeScheduled = false
4716
+ this.pendingOutcome = undefined
4717
+ this.finishActive({ kind: 'aborted', failure: { message: 'turn stream retired', code: 'RETIRED' } })
4718
+ this.clearAmbientWait()
4719
+ this.clearPendingAmbient()
4720
+ // A successor turn must never fold this hold's drain or worker text: the
4721
+ // abandoned run's id is retired before the subscription goes away.
4722
+ this.clearBackgroundHold({ retire: true })
2892
4723
  this.retire()
2893
4724
  releaseAllToolOutputWaiters()
2894
4725
  }
@@ -2949,20 +4780,73 @@ class TurnStreamController {
2949
4780
  } else {
2950
4781
  // A settled turn that never showed anything: the zero-output warning the
2951
4782
  // plane would have gotten from a single-stream turn (constraint 23).
2952
- const line = `\n[dsh-ntr] agent "${this.session.agentId}" ended the turn with no visible output. Some agents fold upstream failures — auth, billing, rate limits — into a normal stop; try resending, and check the agent process logs if it repeats.\n`
4783
+ const line = this.zeroOutputLine()
2953
4784
  if (seg.index < 0) this.emitProse(seg, 'text', line)
2954
4785
  this.finishSegment(seg, { kind: 'stop' })
2955
4786
  }
2956
4787
  }
2957
4788
 
4789
+ /**
4790
+ * The "protocol says success, the bubble is empty" line. When a recently
4791
+ * retired drain may have taken the prompt without re-announcing itself, say
4792
+ * that instead of blaming the agent: the output exists in the transcript.
4793
+ */
4794
+ private zeroOutputLine(stopReason?: string): string {
4795
+ if (this.recentRetiredDrain) {
4796
+ return `\n[dsh-ntr] agent "${this.session.agentId}" produced no visible output this turn; it may have been folded into a background run; its output is in the ACP transcript view.\n`
4797
+ }
4798
+ const reason = stopReason === undefined ? '' : ` (stop reason: ${stopReason})`
4799
+ return `\n[dsh-ntr] agent "${this.session.agentId}" ended the turn with no visible output${reason}. Some agents fold upstream failures — auth, billing, rate limits — into a normal stop; try resending, and check the agent process logs if it repeats.\n`
4800
+ }
4801
+
2958
4802
  private onEvent(event: unknown): void {
2959
4803
  if (this.disposed) return
2960
4804
  const row = event as { type?: string; sessionId?: string }
2961
4805
  if (row.sessionId !== undefined && row.sessionId !== this.session.sessionId) return
2962
4806
  if (row.type === 'session/update') {
2963
4807
  const typed = event as { sessionId: string; seq: number; update: unknown }
2964
- if (typed.seq <= this.cursorSeq) return
4808
+ // Any update — a `usage_update` / phase tick included — says the
4809
+ // background drain is still working, so the ambient quiet clock restarts.
4810
+ this.noteAmbientActivity()
4811
+ // A detached-worker hold's drain phase has the same rule; its worker
4812
+ // phase never does (see `BridgeTuning.backgroundTaskMaxMs`).
4813
+ this.noteBackgroundHoldActivity()
2965
4814
  const update = typed.update as Record<string, unknown>
4815
+ // The dispatch / worker / announcement bookkeeping runs BEFORE the cursor
4816
+ // guard: those are turn-level facts, not segment content, and an update
4817
+ // withheld by the ambient buffer must still keep them.
4818
+ this.trackBackgroundDispatch(update)
4819
+ if (typed.seq <= this.cursorSeq) return
4820
+ // A background drain's prose is not this turn's answer: keep it out of
4821
+ // the plane (the transcript still has it). Fail-open — nothing is split
4822
+ // until this turn has a request id of its own to compare against.
4823
+ this.noteTurnRequestId(update)
4824
+ if (this.isForeignAmbientText(update)) {
4825
+ this.recordWithoutSegment(update)
4826
+ // Advance past it only when nothing is left unconsumed behind it: the
4827
+ // event that CUT the previous segment is parked for the next one, and
4828
+ // jumping the cursor over it would lose its text for good. The replay
4829
+ // applies the same foreign split, so leaving this one behind is safe.
4830
+ if (typed.seq === this.cursorSeq + 1) this.cursorSeq = typed.seq
4831
+ return
4832
+ }
4833
+ // The turn's OWN request id is still unknown and this update belongs to
4834
+ // the background drain: folding it now could make the drain's tick teach
4835
+ // `noteTurnRequestId` the drain's id, mixing the drain's text into the
4836
+ // user's answer. Withhold it until the prompt response decides what this
4837
+ // turn is (see `pendingAmbientUpdates`); the cursor moves on because the
4838
+ // transcript already holds the entry.
4839
+ if (this.turnRequestId === undefined
4840
+ && this.awaitingAmbient === undefined
4841
+ && this.isAmbientOwned(update)) {
4842
+ this.bufferAmbientUpdate(typed.seq, update)
4843
+ this.cursorSeq = typed.seq
4844
+ return
4845
+ }
4846
+ // Live-only burst accounting runs BEFORE the segment cursor: prose that
4847
+ // arrives between segments still belongs to this turn's output, and its
4848
+ // gaps are real. Replay never reaches this path.
4849
+ this.trackTps(typed)
2966
4850
  const seg = this.segment
2967
4851
  if (seg === undefined || seg.finished) {
2968
4852
  // Between segments: tool outputs still reach the shared registry (the
@@ -2975,16 +4859,75 @@ class TurnStreamController {
2975
4859
  }
2976
4860
  // The cursor advances only past events fully consumed into this
2977
4861
  // segment; the event that CUTS the segment belongs to the next one.
2978
- if (this.foldInto(seg, update)) this.cursorSeq = typed.seq
4862
+ if (this.foldInto(seg, update, typed.seq)) this.cursorSeq = typed.seq
2979
4863
  } else if (row.type === 'permission/request') {
4864
+ this.tps.onBreak()
2980
4865
  const typed = event as Extract<AcpEvent, { type: 'permission/request' }>
2981
4866
  this.pushProse(this.permissionLine(typed))
2982
4867
  } else if (row.type === 'permission/expired') {
4868
+ this.tps.onBreak()
2983
4869
  const typed = event as { toolCall: unknown; timeoutMs: number; outcome: PermissionOutcome; optionName?: string }
2984
4870
  this.pushProse(this.expiredLine(typed))
2985
4871
  }
2986
4872
  }
2987
4873
 
4874
+ /** Feed one live update into the turn's burst tracker. Never called by replay. */
4875
+ private trackTps(typed: { seq: number; update: unknown }): void {
4876
+ if (typed.seq <= this.tpsSeq) return
4877
+ this.tpsSeq = typed.seq
4878
+ const update = typed.update as Record<string, unknown>
4879
+ const kind = String(update.sessionUpdate ?? '')
4880
+ if (kind === 'agent_thought_chunk' || kind === 'agent_message_chunk') {
4881
+ const text = chunkTextOf(update.content)
4882
+ if (text === '') return
4883
+ this.tps.onProse(text, Date.now())
4884
+ this.pushLiveTps()
4885
+ } else if (kind === 'tool_call' || kind === 'tool_call_update') {
4886
+ this.tps.onBreak()
4887
+ }
4888
+ }
4889
+
4890
+ /** Throttled live aggregate so the dock's speed moves during a long turn. */
4891
+ private pushLiveTps(): void {
4892
+ const dshSessionId = this.session.dshSessionId
4893
+ if (dshSessionId === undefined) return
4894
+ const now = Date.now()
4895
+ if (now - this.lastTpsPushAt < TPS_PUSH_INTERVAL_MS) return
4896
+ const snap = this.tps.snapshot()
4897
+ if (snap.outputTokens <= 0 || snap.burstMs <= 0) return
4898
+ this.lastTpsPushAt = now
4899
+ const base = this.session.burst ?? { outputTokens: 0, burstMs: 0 }
4900
+ this.hub.broadcast({
4901
+ type: 'turn/metrics',
4902
+ sessionId: this.session.sessionId,
4903
+ dshSessionId,
4904
+ turnId: this.turnId,
4905
+ outputTokens: base.outputTokens + snap.outputTokens,
4906
+ burstMs: base.burstMs + snap.burstMs,
4907
+ final: false,
4908
+ })
4909
+ }
4910
+
4911
+ /** Close the turn's metrics: persist the measurable part, then broadcast it. */
4912
+ private finalizeTps(): void {
4913
+ const dshSessionId = this.session.dshSessionId
4914
+ if (dshSessionId === undefined) return
4915
+ const snap = this.tps.snapshot()
4916
+ if (snap.outputTokens > 0 && snap.burstMs > 0) {
4917
+ this.manager.noteSessionBurst(this.session, snap.outputTokens, snap.burstMs)
4918
+ }
4919
+ const aggregate = this.session.burst ?? { outputTokens: 0, burstMs: 0 }
4920
+ this.hub.broadcast({
4921
+ type: 'turn/metrics',
4922
+ sessionId: this.session.sessionId,
4923
+ dshSessionId,
4924
+ turnId: this.turnId,
4925
+ outputTokens: aggregate.outputTokens,
4926
+ burstMs: aggregate.burstMs,
4927
+ final: true,
4928
+ })
4929
+ }
4930
+
2988
4931
  /**
2989
4932
  * Put one narrative line into the turn's prose stream.
2990
4933
  *
@@ -3021,15 +4964,115 @@ class TurnStreamController {
3021
4964
  recordAcpToolOutput(callId, updateContentToText(update.content))
3022
4965
  }
3023
4966
 
4967
+ /**
4968
+ * Whether this update belongs to a background drain — the one the session is
4969
+ * waiting on, or one whose wait already closed (retired) but whose run can
4970
+ * still speak.
4971
+ */
4972
+ private isAmbientOwned(update: Record<string, unknown>): boolean {
4973
+ const requestId = codebuddyMetaOf(update)?.requestId
4974
+ if (requestId === undefined) return false
4975
+ return requestId === this.session.ambient?.requestId
4976
+ || isRetiredAmbientId(this.session, requestId)
4977
+ }
4978
+
4979
+ /** Learn this turn's own CodeBuddy request id from the first update naming one. */
4980
+ private noteTurnRequestId(update: Record<string, unknown>): void {
4981
+ if (this.turnRequestId !== undefined) return
4982
+ const meta = codebuddyMetaOf(update)
4983
+ // A detached worker's update names A WORKER, never this turn: adopting it
4984
+ // would let the worker's own turn pass as the session's answer.
4985
+ if (meta?.parentToolCallId !== undefined) return
4986
+ // While a hold runs, the only ids this turn owns are the drain ids it
4987
+ // accepted (see `BackgroundHold.acceptedIds`) — and those belong to the
4988
+ // background run, never to a second "own turn id" that would outlive the
4989
+ // hold. Nothing else may be adopted.
4990
+ if (this.activeBackgroundHold() !== undefined) return
4991
+ const requestId = meta?.requestId
4992
+ // A background drain's update names THE DRAIN, never this turn: adopting it
4993
+ // would make `isForeignAmbientText` pass the drain's own text through. A
4994
+ // RETIRED drain (its wait closed on silence, the run revived) is the same
4995
+ // trap — that id belongs to the background run whichever turn is on air.
4996
+ if (requestId === undefined || requestId === this.session.ambient?.requestId) return
4997
+ if (isRetiredAmbientId(this.session, requestId)) return
4998
+ this.turnRequestId = requestId
4999
+ }
5000
+
5001
+ /** Adopt the request id the prompt RESPONSE declared for this turn. */
5002
+ adoptTurnRequestId(requestId: string): void {
5003
+ // A retired drain id is never this turn's own: adopting it would make the
5004
+ // background run's prose pass `isForeignAmbientText` as the answer. The
5005
+ // response still ends a normal turn, so the withheld drain buffer is
5006
+ // dropped either way (the transcript keeps it).
5007
+ if (!isRetiredAmbientId(this.session, requestId)) this.turnRequestId = requestId
5008
+ // A normal turn: whatever the drain streamed while the id was unknown was
5009
+ // never this turn's answer. Drop it (the transcript keeps it) — it must not
5010
+ // surface when a later segment replays or when the turn flushes anything.
5011
+ this.clearPendingAmbient()
5012
+ }
5013
+
5014
+ /**
5015
+ * Prose of the session's background drain while this turn runs on its OWN
5016
+ * request id: it belongs to the background run, not to the user's answer.
5017
+ * Fail-open by design — an unknown turn id splits nothing, and a turn that
5018
+ * was itself folded into the drain keeps its prose.
5019
+ */
5020
+ private isForeignAmbientText(update: Record<string, unknown>): boolean {
5021
+ const meta = codebuddyMetaOf(update)
5022
+ const requestId = meta?.requestId
5023
+ // A detached worker's own turn is never this session's answer, whichever
5024
+ // turn is on air (the fold-level guard lives in `foldInto` as well, for
5025
+ // the replay paths that never ask this question). This includes a worker
5026
+ // update that also names a drain id: its prose is the WORKER's.
5027
+ if (meta?.parentToolCallId !== undefined) return true
5028
+ // A detached-worker hold owns the drain phase: any run it accepted (the
5029
+ // response's id, an announcement that named a different one) is this
5030
+ // turn's answer, and every OTHER id is background noise — whatever the
5031
+ // turn's own id says. Parked holds count: the response already owns them.
5032
+ const hold = this.activeBackgroundHold()
5033
+ if (hold !== undefined) {
5034
+ if (requestId === undefined) return false
5035
+ return !hold.acceptedIds.has(requestId)
5036
+ }
5037
+ // The drain THIS turn is waiting on owns its text unconditionally: the
5038
+ // wait was opened for this id (a revived drain may still carry a retired
5039
+ // mark if the update raced the revival), so the whole steered answer must
5040
+ // reach the user instead of being filtered as background noise.
5041
+ if (requestId !== undefined && this.awaitingAmbient?.requestId === requestId) return false
5042
+ // A RETIRED drain is foreign even though its record is gone: the quiet
5043
+ // close only means WE stopped waiting, not that the run ended. Its late
5044
+ // text must never join the turn now on air — even before that turn has
5045
+ // learned a request id of its own (the fail-open below does not apply to
5046
+ // an id known to be a background run).
5047
+ if (isRetiredAmbientId(this.session, requestId)) return true
5048
+ if (this.turnRequestId === undefined) return false
5049
+ if (requestId === undefined || requestId === this.turnRequestId) return false
5050
+ const ambient = this.session.ambient
5051
+ return ambient !== undefined && requestId === ambient.requestId
5052
+ }
5053
+
3024
5054
  /**
3025
5055
  * The turn's whole fold, shared by live events and segment replay. Visible
3026
5056
  * non-tool events END a card-bearing segment instead of joining it — that is
3027
5057
  * the segmentation rule that puts each card between two assistant messages.
3028
5058
  * Returns whether the event was consumed into this segment: the cutting
3029
5059
  * event is left unconsumed (and thus behind the cursor) for the next one.
5060
+ *
5061
+ * `seq` is the update's transcript position when the caller has one: the
5062
+ * detached-worker hold uses it to count only the answer text that arrived
5063
+ * AFTER the hold began (a parked hold replays the parent's earlier ack).
3030
5064
  */
3031
- private foldInto(seg: SegmentState, update: Record<string, unknown>): boolean {
5065
+ private foldInto(seg: SegmentState, update: Record<string, unknown>, seq?: number): boolean {
3032
5066
  const kind = String(update.sessionUpdate ?? '')
5067
+ // A DETACHED WORKER's own turn: its words and tools are the worker's, not
5068
+ // this session's answer. The transcript keeps them (the ACP view is where
5069
+ // the user can read them) and the shared tool-output registry still sees
5070
+ // the call, but nothing reaches this turn's message and no card opens for
5071
+ // the host to execute. This applies to EVERY turn, not just a hold.
5072
+ if (codebuddyMetaOf(update)?.parentToolCallId !== undefined) {
5073
+ this.noteToolRecord(update)
5074
+ return true
5075
+ }
3033
5076
  if (kind === 'agent_thought_chunk' || kind === 'agent_message_chunk') {
3034
5077
  const text = chunkTextOf(update.content)
3035
5078
  if (text === '') return true
@@ -3038,10 +5081,34 @@ class TurnStreamController {
3038
5081
  return false
3039
5082
  }
3040
5083
  seg.outputChars += text.length
5084
+ // "The turn's own answer", as opposed to a background drain's prose that
5085
+ // happens to stream through the same session (see `isForeignAmbientText`).
5086
+ if (!this.isAmbientOwned(update)) this.sawAgentProse = true
5087
+ if (kind === 'agent_message_chunk') {
5088
+ this.sawVisibleText = true
5089
+ // A hold's closing line asks whether ITS runs delivered answer text.
5090
+ // The parent's interim acknowledgement arrived before the hold and
5091
+ // already set `sawVisibleText`, so the hold keeps its own counter —
5092
+ // and only text that arrived after the hold began counts (a parked
5093
+ // hold replays that earlier ack from the transcript).
5094
+ const hold = this.activeBackgroundHold()
5095
+ if (hold !== undefined && (seq === undefined || seq > hold.floorSeq)) {
5096
+ hold.answerChars += text.length
5097
+ }
5098
+ }
3041
5099
  this.emitProse(seg, kind === 'agent_thought_chunk' ? 'reasoning' : 'text', text)
3042
5100
  return true
3043
5101
  } else if (kind === 'tool_call' || kind === 'tool_call_update') {
3044
5102
  this.noteToolRecord(update)
5103
+ if (seg.ambient || this.isAmbientOwned(update)) {
5104
+ // A background drain's tools stay in the transcript: opening a card
5105
+ // would make the host execute a tool and propose a continuation step
5106
+ // for a turn whose answer has not started yet. The ownership check
5107
+ // covers a NORMAL turn too — its own segment is not ambient, but a
5108
+ // drain can stream a tool call on the same session while it runs, and
5109
+ // that call is not this turn's work.
5110
+ return true
5111
+ }
3045
5112
  const snapshot = acpToolCallFromUpdate(update)
3046
5113
  if (snapshot === undefined) return true
3047
5114
  if (seg.activeType === 'tool-call' && seg.activeToolCall?.callId === snapshot.callId) {
@@ -3053,6 +5120,12 @@ class TurnStreamController {
3053
5120
  return true
3054
5121
  }
3055
5122
  if (this.seenCalls.has(snapshot.callId)) return true // a restatement of a card already settled
5123
+ // A BARE status-only update for a call this turn never announced is not
5124
+ // a card. The real wire sends the detached dispatch's completion exactly
5125
+ // that way (no `_meta`, no title, no input); a turn that begins after the
5126
+ // dispatch belongs to another turn's card, and opening one here would
5127
+ // make the host execute a phantom tool.
5128
+ if (kind === 'tool_call_update' && snapshot.name === '' && snapshot.argsRaw === '') return true
3056
5129
  this.seenCalls.add(snapshot.callId)
3057
5130
  this.openToolCallBlock(seg, snapshot)
3058
5131
  return true
@@ -3240,6 +5313,29 @@ export class BridgeManager {
3240
5313
  * write would fight with no way to tell which one is live.
3241
5314
  */
3242
5315
  private deadlineOverride: ApprovalDeadlinePatch = {}
5316
+ /**
5317
+ * The idle-release window the settings page picked, in ms, persisted
5318
+ * with the agent registry. `undefined` means the deployment baseline runs.
5319
+ *
5320
+ * Merged OVER {@link baselineIdleTimeoutMs} exactly like
5321
+ * {@link deadlineOverride} over {@link baselineDeadline}: a UI pick is what
5322
+ * runs, and `cordis.patch.yml` becomes the value the reset button returns
5323
+ * to. Without this split the two sources would fight with no way to tell
5324
+ * which one is live.
5325
+ */
5326
+ private idleReleaseOverride?: number
5327
+ /**
5328
+ * The window `cordis.patch.yml` names, when it names one. Kept apart
5329
+ * from the override for the same reason the deadline keeps its baseline
5330
+ * apart: the settings card has to be able to say "the file asks for
5331
+ * 10 minutes, the value set here wins".
5332
+ */
5333
+ private configuredIdleTimeoutMs?: number
5334
+ /**
5335
+ * The idle-release window before any override — the tuning this manager was
5336
+ * constructed with (the deployment baseline, or the built-in 30 minutes).
5337
+ */
5338
+ private baselineIdleTimeoutMs: number
3243
5339
  /**
3244
5340
  * Tuning in force, defaults applied. The approval deadline is rewritten
3245
5341
  * IN PLACE by {@link applyDeadlineTuning}, which is why this is a live object
@@ -3263,6 +5359,34 @@ export class BridgeManager {
3263
5359
  return this.turnStreams.get(dshSessionId)
3264
5360
  }
3265
5361
 
5362
+ /**
5363
+ * The ambient windows a turn's controller reads once at construction (see
5364
+ * {@link BridgeTuning.ambientQuietMs}). Exposed as a plain snapshot so the
5365
+ * controller never pokes at the manager's tuning object, and tests can tune
5366
+ * the cadence through the constructor like every other window.
5367
+ */
5368
+ get ambientTuning(): { quietMs: number; maxMs: number } {
5369
+ return {
5370
+ quietMs: this.tuning.ambientQuietMs,
5371
+ maxMs: this.tuning.ambientMaxMs,
5372
+ }
5373
+ }
5374
+
5375
+ /**
5376
+ * The detached-worker hold's windows, read once at controller construction
5377
+ * for the same reason as {@link ambientTuning}: `taskMaxMs` is the worker
5378
+ * phase's only clock (see {@link BridgeTuning.backgroundTaskMaxMs}) and
5379
+ * `graceMs` the bounded wait a PARKED hold gives the host to open its
5380
+ * continuation step (see {@link BridgeTuning.backgroundParkedGraceMs}). The
5381
+ * drain phase reuses {@link ambientTuning}.
5382
+ */
5383
+ get backgroundTuning(): { taskMaxMs: number; graceMs: number } {
5384
+ return {
5385
+ taskMaxMs: this.tuning.backgroundTaskMaxMs,
5386
+ graceMs: this.tuning.backgroundParkedGraceMs,
5387
+ }
5388
+ }
5389
+
3266
5390
  /** Index a turn's controller, retiring whatever turn it supersedes. */
3267
5391
  rememberTurnStream(dshSessionId: string, controller: TurnStreamController): void {
3268
5392
  const previous = this.turnStreams.get(dshSessionId)
@@ -3401,6 +5525,7 @@ export class BridgeManager {
3401
5525
  // it. Everything else in `tuning` is fixed for the manager's lifetime, so
3402
5526
  // only this needs a snapshot.
3403
5527
  this.baselineDeadline = this.tuning.permissionTimeoutMs
5528
+ this.baselineIdleTimeoutMs = this.tuning.idleTimeoutMs
3404
5529
  this.baselineMode = this.tuning.permissionTimeoutMode
3405
5530
  for (const def of defs) {
3406
5531
  this.defsMap.set(def.id, def)
@@ -3417,6 +5542,10 @@ export class BridgeManager {
3417
5542
  // A persisted deadline override must reach the tuning the bridges are built
3418
5543
  // from, not just the settings card's readout.
3419
5544
  this.applyDeadlineTuning()
5545
+ // The same for the idle-release window: a persisted override must
5546
+ // reach the tuning the bridges are built from, not just the
5547
+ // settings card's readout.
5548
+ this.applyIdleReleaseTuning()
3420
5549
  // The request-path rewrite falls back to this default for a conversation
3421
5550
  // the user never bound explicitly — the same agent the composer chip shows.
3422
5551
  routeBindingRegistry().defaultModel = this.defaultAgent()
@@ -3427,8 +5556,10 @@ export class BridgeManager {
3427
5556
  onConnectionLost: (bridge: AgentBridge, reason: string) => void,
3428
5557
  onCatalog?: (agentId: string, configOptions: unknown, shapeOnly?: boolean) => void,
3429
5558
  onCommands?: (agentId: string, commands: NonNullable<SessionView['availableCommands']>) => void,
5559
+ onSessionsDirty?: () => void,
5560
+ onForkCapability?: (agentId: string, supported: boolean) => void,
3430
5561
  ): AgentBridge {
3431
- return new AgentBridge(def, this.hub, this.transcriptMax, onConnectionLost, onCatalog, onCommands, this.tuning, () => this.sessionWorkspaceResolver)
5562
+ return new AgentBridge(def, this.hub, this.transcriptMax, onConnectionLost, onCatalog, onCommands, this.tuning, () => this.sessionWorkspaceResolver, onSessionsDirty, onForkCapability)
3432
5563
  }
3433
5564
 
3434
5565
  /**
@@ -3454,6 +5585,13 @@ export class BridgeManager {
3454
5585
  this.sessionWorkspaceResolver = resolver
3455
5586
  }
3456
5587
 
5588
+ /**
5589
+ * Set the attachment service or context used to resolve image attachments in user prompts.
5590
+ */
5591
+ setAttachmentResolver(resolver?: unknown): void {
5592
+ this.attachmentResolver = resolver
5593
+ }
5594
+
3457
5595
  /**
3458
5596
  * Wire the ACP-mode probe: given a DSH session id, report whether that
3459
5597
  * session runs the ACP agent preset. The host half reads it back from the
@@ -3567,12 +5705,88 @@ export class BridgeManager {
3567
5705
  ...(session.reasoningEffort === undefined ? {} : { reasoningEffort: session.reasoningEffort }),
3568
5706
  ...(session.modes === undefined ? {} : { modes: session.modes }),
3569
5707
  ...(session.availableCommands === undefined ? {} : { availableCommands: session.availableCommands }),
5708
+ ...(session.usage === undefined ? {} : { usage: session.usage }),
5709
+ ...(session.burst === undefined ? {} : { burst: session.burst }),
3570
5710
  ...(session.released ? { released: true } : {}),
5711
+ ...(session.running ? { running: true } : {}),
5712
+ ...forkRowInfo(session, this.canForkNow(session.agentId)),
3571
5713
  })
3572
5714
  }
3573
5715
  return out
3574
5716
  }
3575
5717
 
5718
+ /**
5719
+ * Adopt the ACP side of a branched conversation: the browser has already
5720
+ * asked the HOST to fork the DSH session tree (`sessions.fork`), and this
5721
+ * runs the agent's own `session/fork` of the parent conversation's live ACP
5722
+ * session so the child actually remembers the parent context — then binds
5723
+ * the child to it (the fourth session-creation point, with every discipline
5724
+ * the other three carry).
5725
+ *
5726
+ * Failure is deliberately all-or-nothing: nothing is written for the child
5727
+ * until the agent has answered, so a failed adopt leaves the child an
5728
+ * ordinary copied-history conversation whose first message starts a fresh
5729
+ * `session/new` (today's behavior) — never a half-bound record.
5730
+ *
5731
+ * @param childDshSessionId - the DSH session the host fork created.
5732
+ * @param parentDshSessionId - the conversation being branched; its ACP
5733
+ * session must be LIVE on this connection (a released parent has no
5734
+ * process to fork from — the browser blocks the click, this is the hard
5735
+ * stop behind it).
5736
+ */
5737
+ async adoptForkedSession(childDshSessionId: string, parentDshSessionId: string): Promise<{ sessionId: string; acpSessionId: string }> {
5738
+ // One conversation, one agent (the lock). The parent's LIVE session is
5739
+ // the fork source; released or in-flight-settling parents are refused —
5740
+ // forking a parked record would silently resume it, and forking mid-turn
5741
+ // would fork an agent state the parent turn is still changing.
5742
+ const parent = [...this.dshSessionMap.values()].find(session =>
5743
+ session.dshSessionId === parentDshSessionId && !session.closed && !session.released)
5744
+ if (parent === undefined) {
5745
+ throw new AcpError('parent_not_live', 'acp: the conversation to branch has no live ACP session; send a message to resume it first')
5746
+ }
5747
+ if (parent.running || parent.settled !== undefined) {
5748
+ throw new AcpError('turn_in_flight', 'acp: the conversation to branch is running a turn; wait for it to finish')
5749
+ }
5750
+ const agentId = parent.agentId
5751
+ const childKey = `${childDshSessionId}:${agentId}`
5752
+ if (this.dshSessionMap.has(childKey)) {
5753
+ throw new AcpError('id_conflict', 'acp: the forked conversation is already bound to an ACP session')
5754
+ }
5755
+ // Capability gate BEFORE any request: an agent that never advertised
5756
+ // `session/fork` must not be asked (it would answer an unknown-method
5757
+ // error at best). Live init wins, last-known falls back — the same
5758
+ // verdict the browser's row already showed.
5759
+ if (!this.canForkNow(agentId)) {
5760
+ throw new AcpError('fork_unsupported', `acp: agent "${agentId}" does not advertise the session/fork capability`)
5761
+ }
5762
+ const session = await this.bridge(agentId).forkSession(parent, childDshSessionId)
5763
+ // The fork answered — the child is real on the agent side. From here the
5764
+ // binding is written through the SAME paths as any chat session: one
5765
+ // choice record (a copy of the parent's, locked — a branched conversation
5766
+ // belongs to its agent from birth), the route pin, and persistence.
5767
+ this.dshSessionMap.set(childKey, session)
5768
+ const parentChoice = this.chatAgentIntent.get(parentDshSessionId)
5769
+ this.patchChatChoice(childDshSessionId, {
5770
+ agentId,
5771
+ locked: true,
5772
+ ...(parentChoice?.modelId === undefined ? {} : { modelId: parentChoice.modelId }),
5773
+ ...(parentChoice?.effort === undefined ? {} : { effort: parentChoice.effort }),
5774
+ ...(parentChoice?.mode === undefined ? {} : { mode: parentChoice.mode }),
5775
+ ...(parentChoice?.extraConfigs === undefined ? {} : { extraConfigs: { ...parentChoice.extraConfigs } }),
5776
+ })
5777
+ // The fourth creation point follows the same discipline as `createSession`
5778
+ // and `doResumeSession`: apply the recorded pick to the fresh session, so
5779
+ // mode and extra configs (which only this path applies) cannot drift from
5780
+ // what the composer shows — a forked session starts on the agent-side
5781
+ // state the fork copied, and the record must match what runs.
5782
+ await this.applyChatChoice(session)
5783
+ // Re-publish so the browser's list and the composer's occupancy ring see
5784
+ // the child immediately (with its just-bound choice mirrors).
5785
+ this.hub.broadcast({ type: 'session/meta', sessionId: session.sessionId, session: snapshot(session) })
5786
+ this.persistAgents()
5787
+ return { sessionId: session.sessionId, acpSessionId: session.acpSessionId }
5788
+ }
5789
+
3576
5790
  /**
3577
5791
  * Bind one DSH chat session to an ACP agent.
3578
5792
  *
@@ -3827,11 +6041,12 @@ export class BridgeManager {
3827
6041
  this.patchChatChoice(dshSessionId, { agentId, modelId: choice === undefined ? undefined : modelId })
3828
6042
  for (const session of this.dshSessionMap.values()) {
3829
6043
  if (session.dshSessionId !== dshSessionId || session.agentId !== agentId) continue
3830
- if (session.released) continue
3831
- await this.alignSessionConfig(session, choice, session.reasoningEffort)
3832
6044
  session.choice = choice
3833
6045
  session.modelId = modelId
6046
+ if (session.released) continue
6047
+ await this.alignSessionConfig(session, choice, session.reasoningEffort)
3834
6048
  }
6049
+ this.persistAgents()
3835
6050
  }
3836
6051
 
3837
6052
  /**
@@ -3857,12 +6072,15 @@ export class BridgeManager {
3857
6072
  // session is skipped here and re-aligned by `applyChatChoice` on resume.
3858
6073
  this.patchChatChoice(dshSessionId, { ...(agentId === undefined ? {} : { agentId }), effort })
3859
6074
  for (const session of this.dshSessionMap.values()) {
3860
- if (session.dshSessionId !== dshSessionId || session.released) continue
6075
+ if (session.dshSessionId !== dshSessionId) continue
3861
6076
  if (agentId !== undefined && session.agentId !== agentId) continue
6077
+ session.reasoningEffort = effort
6078
+ if (session.released) continue
3862
6079
  const aligned = await this.alignSessionConfig(session, session.choice, effort)
3863
6080
  // Same rule as the model axis: a dropped level is no longer "in force".
3864
6081
  session.reasoningEffort = aligned.effortStale ? undefined : effort
3865
6082
  }
6083
+ this.persistAgents()
3866
6084
  }
3867
6085
 
3868
6086
  /**
@@ -4051,9 +6269,15 @@ export class BridgeManager {
4051
6269
  }
4052
6270
 
4053
6271
  /**
4054
- * Switch one chat-bound session's ACP mode. A released session is resumed
4055
- * first (`session/load`), matching the "send to recover" promise — the mode
4056
- * switch is itself a reason to bring the agent back.
6272
+ * Switch one chat-bound session's ACP mode.
6273
+ *
6274
+ * A released session is deliberately NOT resumed for it (user decision,
6275
+ * 2026-10-30 — the composer's axes are "what the next turn should use", and
6276
+ * the process comes back only when the user sends a message or hits the
6277
+ * released banner's resume button). The pick is recorded and
6278
+ * `applyChatChoice` applies it on resume, exactly like `setChatSessionModel`
6279
+ * / `setChatSessionEffort` / `setChatSessionConfig`; only a LIVE session is
6280
+ * told immediately (`session/set_mode`).
4057
6281
  */
4058
6282
  async setChatSessionMode(dshSessionId: string, modeId: string, agentId?: string): Promise<void> {
4059
6283
  if (agentId !== undefined) {
@@ -4083,9 +6307,20 @@ export class BridgeManager {
4083
6307
  if (target.closed) {
4084
6308
  throw new AcpError('unknown_session', `acp: the ACP session bound to chat session ${dshSessionId} is closed`)
4085
6309
  }
6310
+ // A PARKED session gets the same record-only treatment (its agent is its
6311
+ // own `agentId`, whatever the caller passed — the selection above already
6312
+ // matched it). Resuming here was the one composer axis that broke ranks
6313
+ // with the others, and it woke a process the user had explicitly parked.
6314
+ if (target.released) {
6315
+ this.patchChatChoice(dshSessionId, { agentId: target.agentId, mode: modeId })
6316
+ if (target.modes !== undefined) {
6317
+ target.modes = { ...target.modes, currentModeId: modeId }
6318
+ }
6319
+ this.persistAgents()
6320
+ return
6321
+ }
4086
6322
  const bridge = this.bridge(target.agentId)
4087
- const session = target.released ? await bridge.resumeSession(target) : target
4088
- await bridge.setMode(session, modeId)
6323
+ await bridge.setMode(target, modeId)
4089
6324
  // Persisted only AFTER the agent accepted it: a mode the agent rejected must
4090
6325
  // not come back on the next restore. This is the same contract the model and
4091
6326
  // thought level follow (user decision, 2026-10-18 — every control in the
@@ -4093,6 +6328,42 @@ export class BridgeManager {
4093
6328
  this.patchChatChoice(dshSessionId, { agentId: target.agentId, mode: modeId })
4094
6329
  }
4095
6330
 
6331
+ /**
6332
+ * Bring one chat conversation's released session back on a fresh process on
6333
+ * the USER's explicit gesture (the "恢复进程" banner) — the same work the
6334
+ * next prompt performs lazily (`getOrCreateDshSession`'s released branch),
6335
+ * minus the create path: a banner button must never materialize a session
6336
+ * the conversation has not had yet. A live target passes through untouched,
6337
+ * so a double click (or a click racing the next message) spawns nothing.
6338
+ */
6339
+ async resumeChatSession(dshSessionId: string): Promise<void> {
6340
+ const candidates = [...this.dshSessionMap.values()].filter(
6341
+ session => session.dshSessionId === dshSessionId && !session.closed,
6342
+ )
6343
+ // The session the NEXT message would use — the bound agent's — is the one
6344
+ // to bring back; the first candidate is the fallback for a conversation
6345
+ // whose binding has drifted (same selection `setChatSessionMode` makes
6346
+ // without an explicit agentId).
6347
+ const target = candidates.find(session => session.agentId === this.chatAgentFor(dshSessionId)) ?? candidates[0]
6348
+ if (target === undefined) {
6349
+ throw new AcpError('unknown_session', `acp: no ACP session is bound to chat session ${dshSessionId}`)
6350
+ }
6351
+ if (!target.released) return
6352
+ const bridge = this.bridge(target.agentId)
6353
+ const resumed = await bridge.resumeSession(target)
6354
+ // Chat-side rebind + pick re-application, verbatim the lazy path's: the
6355
+ // freshly loaded session comes back at the agent's defaults, so the
6356
+ // conversation's model / thought level must be re-applied or the composer
6357
+ // would show one thing and the process run another (see
6358
+ // `getOrCreateDshSession`).
6359
+ resumed.dshSessionId = dshSessionId
6360
+ this.lockChatAgent(dshSessionId, target.agentId)
6361
+ if (this.chatAgentIntent.has(dshSessionId)) this.syncConversationRoute(dshSessionId)
6362
+ else routeBindingRegistry().conversations.set(dshSessionId, resumed.modelId ?? target.agentId)
6363
+ await this.applyChatChoice(resumed)
6364
+ resumed.provisionalLock = false
6365
+ }
6366
+
4096
6367
  /**
4097
6368
  * Wire the harness's preset roster (`ctx.agentPresets`) into `presetStatus`.
4098
6369
  *
@@ -4276,7 +6547,21 @@ export class BridgeManager {
4276
6547
  } catch {
4277
6548
  this.deadlineOverride = {}
4278
6549
  }
6550
+ // The settings page's idle-release pick. Re-validated on every boot
6551
+ // because the state file is user- and version-editable: an
6552
+ // unreadable value must not poison the window, and a value the
6553
+ // validator accepts must be the value that runs.
6554
+ try {
6555
+ const raw = (parsed as { idleRelease?: unknown }).idleRelease
6556
+ const value = (raw as { idleTimeoutMs?: unknown } | undefined)?.idleTimeoutMs
6557
+ this.idleReleaseOverride = typeof value === 'number' && IDLE_RELEASE_MS.includes(value)
6558
+ ? value
6559
+ : undefined
6560
+ } catch {
6561
+ this.idleReleaseOverride = undefined
6562
+ }
4279
6563
  this.loadPersistedChoices((parsed as { conversations?: unknown }).conversations)
6564
+ this.loadPersistedChatSessions((parsed as { chatSessions?: unknown }).chatSessions)
4280
6565
  }
4281
6566
  } catch {
4282
6567
  // Storage load failure is non-fatal
@@ -4319,10 +6604,32 @@ export class BridgeManager {
4319
6604
  for (const dshSessionId of this.chatAgentIntent.keys()) this.syncConversationRoute(dshSessionId)
4320
6605
  }
4321
6606
 
6607
+ /**
6608
+ * Fold one settled ACP turn's measurable burst into the session aggregate and
6609
+ * persist it. A turn with no measured inter-chunk gap contributes neither
6610
+ * side: adding its tokens with zero time would inflate the session rate.
6611
+ */
6612
+ noteSessionBurst(session: LiveSession, outputTokens: number, burstMs: number): void {
6613
+ if (outputTokens <= 0 || burstMs <= 0) return
6614
+ const base = session.burst ?? { outputTokens: 0, burstMs: 0 }
6615
+ session.burst = {
6616
+ outputTokens: base.outputTokens + outputTokens,
6617
+ burstMs: base.burstMs + burstMs,
6618
+ }
6619
+ this.persistAgents()
6620
+ }
6621
+
4322
6622
  private persistAgents(): void {
4323
6623
  const filePath = this.getStoragePath()
4324
6624
  if (!filePath) return
4325
6625
  try {
6626
+ // The chat-bound session records themselves: a host restart kills the
6627
+ // processes but not the agent-side sessions, so restoring these as
6628
+ // PARKED keeps a conversation resumable (`session/load`) instead of
6629
+ // its next message silently starting a context-less new one.
6630
+ const chatSessions = [...this.dshSessionMap.values()]
6631
+ .filter(session => session.dshSessionId !== undefined && !session.closed)
6632
+ .map(persistedChatSession)
4326
6633
  const payload = {
4327
6634
  version: 2,
4328
6635
  ...(this.defaultAgentId === undefined ? {} : { defaultAgentId: this.defaultAgentId }),
@@ -4333,12 +6640,18 @@ export class BridgeManager {
4333
6640
  ...(Object.keys(this.deadlineOverride).length === 0
4334
6641
  ? {}
4335
6642
  : { approvalDeadlines: { ...this.deadlineOverride } }),
6643
+ // The settings page's idle-release pick. Absent while none was
6644
+ // ever written, so a fresh deployment's state file stays as it was.
6645
+ ...(this.idleReleaseOverride === undefined
6646
+ ? {}
6647
+ : { idleRelease: { idleTimeoutMs: this.idleReleaseOverride } }),
4336
6648
  // The per-conversation picks: agent + model + thought level + mode +
4337
6649
  // extra config values chosen in the composer, including for
4338
6650
  // conversations with no live session yet.
4339
6651
  ...(this.chatAgentIntent.size === 0
4340
6652
  ? {}
4341
6653
  : { conversations: Object.fromEntries(this.chatAgentIntent) }),
6654
+ ...(chatSessions.length === 0 ? {} : { chatSessions }),
4342
6655
  }
4343
6656
  writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8')
4344
6657
  } catch {
@@ -4346,9 +6659,74 @@ export class BridgeManager {
4346
6659
  }
4347
6660
  }
4348
6661
 
6662
+ /**
6663
+ * Restore chat session records from disk as PARKED sessions.
6664
+ *
6665
+ * Every process died with the host, so nothing comes back live — but the
6666
+ * agent-side sessions did not. Restoring each record as `released` is what
6667
+ * makes the released banner, its resume button and the "next message
6668
+ * resumes it" promise survive a restart (the alternative is the next
6669
+ * message quietly starting a NEW, context-less session — constraint 10's
6670
+ * "released ≠ destroyed" lost at the process boundary). Tolerant, one row
6671
+ * at a time: a single corrupt entry must not cost every other conversation
6672
+ * its context. Records whose agent is no longer configured are dropped —
6673
+ * there is nothing to resume ON, and the next write omits them.
6674
+ */
6675
+ private loadPersistedChatSessions(raw: unknown): void {
6676
+ if (!Array.isArray(raw)) return
6677
+ for (const row of raw) {
6678
+ if (row === null || typeof row !== 'object') continue
6679
+ const record = row as Record<string, unknown>
6680
+ const { sessionId, acpSessionId, agentId, dshSessionId, cwd } = record
6681
+ if (typeof sessionId !== 'string' || sessionId === '') continue
6682
+ if (typeof acpSessionId !== 'string' || acpSessionId === '') continue
6683
+ if (typeof agentId !== 'string' || agentId === '') continue
6684
+ if (typeof dshSessionId !== 'string' || dshSessionId === '') continue
6685
+ if (typeof cwd !== 'string') continue
6686
+ if (!this.defsMap.has(agentId)) continue
6687
+ const { title, modelId, reasoningEffort, modes, usage, burst, hasHistory } = record as {
6688
+ title?: unknown; modelId?: unknown; reasoningEffort?: unknown; modes?: unknown; usage?: unknown; burst?: unknown; hasHistory?: unknown
6689
+ }
6690
+ const session: LiveSession = {
6691
+ sessionId,
6692
+ acpSessionId,
6693
+ agentId,
6694
+ dshSessionId,
6695
+ cwd,
6696
+ ...(typeof title === 'string' && title !== '' ? { title } : {}),
6697
+ ...(typeof modelId === 'string' && modelId !== ''
6698
+ ? { modelId, ...(splitAdapterModelId(modelId).choice === undefined ? {} : { choice: splitAdapterModelId(modelId).choice }) }
6699
+ : {}),
6700
+ ...(typeof reasoningEffort === 'string' && reasoningEffort !== '' ? { reasoningEffort } : {}),
6701
+ ...(isPersistedModes(modes) ? { modes } : {}),
6702
+ ...(isPersistedUsage(usage) ? { usage } : {}),
6703
+ ...(isPersistedBurst(burst) ? { burst } : {}),
6704
+ ...(hasHistory === true ? { hasHistory: true } : {}),
6705
+ running: false,
6706
+ turnSeq: 0,
6707
+ closed: false,
6708
+ released: true,
6709
+ lastAgentActivityAt: Date.now(),
6710
+ activeToolCalls: new Set<string>(),
6711
+ transcript: new Transcript(this.transcriptMax),
6712
+ }
6713
+ this.dshSessionMap.set(`${dshSessionId}:${agentId}`, session)
6714
+ this.bridge(agentId).parkReleased(session)
6715
+ }
6716
+ }
6717
+
4349
6718
  // ---- Model catalog ----
4350
6719
 
4351
6720
  private readonly catalogs = new Map<string, { catalog: AcpAgentCatalog; updatedAt: number }>()
6721
+ /**
6722
+ * Each agent's LAST KNOWN `sessionCapabilities.fork` bit, persisted with the
6723
+ * model catalog. A live `initialize` result outranks it whenever one exists
6724
+ * (see {@link canForkNow}) — this copy answers when the process is gone
6725
+ * (a released session's branch button must not flash into "supported" just
6726
+ * because `initResult` died with it, nor read `view().agentCapabilities`,
6727
+ * which a release clears).
6728
+ */
6729
+ private readonly forkCaps = new Map<string, boolean>()
4352
6730
  /**
4353
6731
  * The slash commands each agent declared in its most recent session, cached
4354
6732
  * per agent and persisted alongside the model catalog.
@@ -4415,7 +6793,10 @@ export class BridgeManager {
4415
6793
  const stale = parsed.version !== BridgeManager.CATALOG_SCHEMA
4416
6794
  for (const [agentId, raw] of Object.entries(agents)) {
4417
6795
  if (typeof raw !== 'object' || raw === null) continue
4418
- const row = raw as { catalog?: unknown; updatedAt?: unknown; commands?: unknown; commandsUpdatedAt?: unknown }
6796
+ const row = raw as { catalog?: unknown; updatedAt?: unknown; commands?: unknown; commandsUpdatedAt?: unknown; fork?: unknown }
6797
+ // The persisted fork capability: a LAST KNOWN bit, loaded before any
6798
+ // bridge can report a fresh one (a fresh `initialize` overwrites it).
6799
+ if (typeof row.fork === 'boolean') this.forkCaps.set(agentId, row.fork)
4419
6800
  // Commands and the model catalog are learned independently (a session
4420
6801
  // may declare one before the other), so either half alone is a valid row.
4421
6802
  if (Array.isArray(row.commands)) {
@@ -4445,11 +6826,18 @@ export class BridgeManager {
4445
6826
  updatedAt?: number
4446
6827
  commands?: NonNullable<SessionView['availableCommands']>
4447
6828
  commandsUpdatedAt?: number
6829
+ fork?: boolean
4448
6830
  }> = {}
4449
6831
  for (const [agentId, entry] of this.catalogs) agents[agentId] = { catalog: entry.catalog, updatedAt: entry.updatedAt }
4450
6832
  for (const [agentId, entry] of this.commandCache) {
4451
6833
  agents[agentId] = { ...(agents[agentId] ?? {}), commands: entry.commands, commandsUpdatedAt: entry.updatedAt }
4452
6834
  }
6835
+ // The LAST KNOWN fork capability rides the same file: it outlives the
6836
+ // agent process, which is exactly when the browser needs it (see
6837
+ // `canForkNow`).
6838
+ for (const [agentId, supported] of this.forkCaps) {
6839
+ agents[agentId] = { ...(agents[agentId] ?? {}), fork: supported }
6840
+ }
4453
6841
  writeFileSync(filePath, JSON.stringify({ version: BridgeManager.CATALOG_SCHEMA, agents }, null, 2), 'utf-8')
4454
6842
  } catch {
4455
6843
  // Storage persist failure is non-fatal
@@ -4541,6 +6929,33 @@ export class BridgeManager {
4541
6929
  this.hub.broadcast({ type: 'catalog/updated', agentId, updatedAt: entry.updatedAt })
4542
6930
  }
4543
6931
 
6932
+ /**
6933
+ * Record a fresh `initialize`'s `sessionCapabilities.fork` verdict (the
6934
+ * bridge reports it right after the handshake). Persisted with the model
6935
+ * catalog; no broadcast — the browser learns it through `session/chat-list`
6936
+ * rows, and its branch button deliberately runs on a last-known basis (the
6937
+ * agent's own next `initialize` is the refresh).
6938
+ */
6939
+ private noteForkCapability(agentId: string, supported: boolean): void {
6940
+ if (this.forkCaps.get(agentId) === supported) return
6941
+ this.forkCaps.set(agentId, supported)
6942
+ this.persistCatalogs()
6943
+ }
6944
+
6945
+ /**
6946
+ * Whether an agent's fork can be attempted RIGHT NOW: a live `initialize`
6947
+ * answer wins (the process is up, its handshake is current); only when the
6948
+ * process is gone does the LAST KNOWN persisted bit answer. `false` covers
6949
+ * "never seen" — an unknown agent simply never gets a fork attempt.
6950
+ */
6951
+ private canForkNow(agentId: string): boolean {
6952
+ const bridge = this.bridges.get(agentId)
6953
+ if (bridge?.initResult !== undefined) {
6954
+ return bridge.initResult.agentCapabilities?.sessionCapabilities?.fork != null
6955
+ }
6956
+ return this.forkCaps.get(agentId) === true
6957
+ }
6958
+
4544
6959
  /**
4545
6960
  * Append models this observation newly introduced to the agent's selection.
4546
6961
  *
@@ -4966,6 +7381,10 @@ export class BridgeManager {
4966
7381
  else this.absorbCatalog(observedAgentId, configOptions)
4967
7382
  }, (observedAgentId, commands) => {
4968
7383
  this.absorbCommands(observedAgentId, commands)
7384
+ }, () => {
7385
+ this.persistAgents()
7386
+ }, (observedAgentId, supported) => {
7387
+ this.noteForkCapability(observedAgentId, supported)
4969
7388
  })
4970
7389
  this.bridges.set(agentId, bridge)
4971
7390
  }
@@ -5125,11 +7544,15 @@ export class BridgeManager {
5125
7544
  if (splitAdapterModelId(model).agentId === agentId) routeBindingRegistry().conversations.delete(dshSessionId)
5126
7545
  }
5127
7546
  if (routeBindingRegistry().defaultModel === agentId) routeBindingRegistry().defaultModel = undefined
5128
- // Both maps are independent (either may be missing), so delete both before
5129
- // deciding whether the file needs rewriting.
7547
+ // All three maps are independent (any may be missing), so delete all
7548
+ // before deciding whether the file needs rewriting. The fork capability
7549
+ // goes with the agent: a same-id rebuild must not inherit the OLD agent's
7550
+ // "supported" and click into a `fork_unsupported` wall before its own
7551
+ // first `initialize` answers.
5130
7552
  const droppedCatalog = this.catalogs.delete(agentId)
5131
7553
  const droppedCommands = this.commandCache.delete(agentId)
5132
- if (droppedCatalog || droppedCommands) this.persistCatalogs()
7554
+ const droppedFork = this.forkCaps.delete(agentId)
7555
+ if (droppedCatalog || droppedCommands || droppedFork) this.persistCatalogs()
5133
7556
  this.persistAgents()
5134
7557
  this.hub.broadcast({
5135
7558
  type: 'agent/status',
@@ -5176,7 +7599,7 @@ export class BridgeManager {
5176
7599
  return { sessionId: session.sessionId }
5177
7600
  }
5178
7601
 
5179
- async prompt(sessionId: string, text: string): Promise<{ turnId: string }> {
7602
+ async prompt(sessionId: string, prompt: string | ContentBlock[]): Promise<{ turnId: string }> {
5180
7603
  let located = await this.locateOrResume(sessionId)
5181
7604
  // The watchdog abandoned a previous turn here and the agent may still be
5182
7605
  // holding its prompt slot on the old process: reclaim BEFORE sending, or
@@ -5194,7 +7617,40 @@ export class BridgeManager {
5194
7617
  located = await this.locateOrResume(sessionId)
5195
7618
  }
5196
7619
  const { bridge, session } = located
5197
- if (session.running) throw new AcpError('turn_in_flight', 'acp: this session already has a prompt in flight')
7620
+ // The agent prompt slot is SHARED with chat turns: `running` alone misses
7621
+ // a chat turn whose cancellation already released it (`running` false,
7622
+ // `promptInFlight` still naming the agent-side turn), and a live chat
7623
+ // controller means a continuation step may resume the SAME agent turn.
7624
+ // A controller that already SETTLED (kept only so its card's continuation
7625
+ // can still stream) has given the agent slot back: `/acp` may use it, and
7626
+ // advancing `turnSeq` retires it — the behaviour the stale-controller
7627
+ // watchdog case pins.
7628
+ const chatController = session.turnController
7629
+ if (session.running
7630
+ || session.promptInFlight === true
7631
+ || (chatController !== undefined && !chatController.stale && !chatController.hasSettled)) {
7632
+ throw new AcpError('turn_in_flight', 'acp: this session already has a prompt in flight')
7633
+ }
7634
+
7635
+ const blocks: ContentBlock[] = typeof prompt === 'string'
7636
+ ? [{ type: 'text', text: prompt }]
7637
+ : prompt
7638
+
7639
+ if (blocks.length === 0) {
7640
+ throw new AcpError('empty_prompt', 'acp: prompt cannot be empty')
7641
+ }
7642
+
7643
+ const hasImages = blocks.some(b => b.type === 'image')
7644
+ if (hasImages && bridge.initResult?.agentCapabilities?.promptCapabilities?.image !== true) {
7645
+ throw new AcpError('image_unsupported', `acp: agent "${bridge.def.id}" does not support image input`)
7646
+ }
7647
+
7648
+ // Claim the SAME slot the chat path claims (`claimTurn` + `beginTurn`): an
7649
+ // `/acp` turn in flight must make the next chat prompt wait, and vice
7650
+ // versa. The settler is idempotent and identity-guarded; the turn still
7651
+ // ends through `turn/end` below, so chat and RPC turns share one gate
7652
+ // without sharing a stream controller.
7653
+ const endTurn = bridge.beginTurn(session)
5198
7654
  session.running = true
5199
7655
  session.turnSeq += 1
5200
7656
  const turnGen = session.turnSeq
@@ -5205,9 +7661,12 @@ export class BridgeManager {
5205
7661
  session.reclaimedTurnSeq = undefined
5206
7662
  this.armStallTimer()
5207
7663
  const turnId = randomUUID()
5208
- this.hub.broadcast({ type: 'turn/start', sessionId, turnId, text })
5209
- void bridge.runPrompt(session, text, turnId)
5210
- .then(stopReason => {
7664
+ const promptText = typeof prompt === 'string'
7665
+ ? prompt
7666
+ : blocks.filter(b => b.type === 'text').map(b => (b as { text: string }).text).join('\n')
7667
+ this.hub.broadcast({ type: 'turn/start', sessionId, turnId, text: promptText })
7668
+ void bridge.runPrompt(session, blocks, turnId, turnGen)
7669
+ .then(result => {
5211
7670
  // The reclaim already ended this turn locally; the answer it caused is
5212
7671
  // that same turn reporting back, not a second turn end for the caller.
5213
7672
  if (session.reclaimedTurnSeq === turnGen) {
@@ -5217,8 +7676,11 @@ export class BridgeManager {
5217
7676
  // A superseded turn's late answer must not end the turn now on air.
5218
7677
  if (session.turnSeq !== turnGen) return
5219
7678
  this.clearOrphanPrompt(session, turnGen)
7679
+ // Release the shared slot BEFORE the terminal frame: a listener that
7680
+ // sends the next prompt on `turn/end` must not read a stale claim.
7681
+ endTurn()
5220
7682
  session.running = false
5221
- this.hub.broadcast({ type: 'turn/end', sessionId, turnId, stopReason })
7683
+ this.hub.broadcast({ type: 'turn/end', sessionId, turnId, stopReason: result.stopReason })
5222
7684
  })
5223
7685
  .catch((error: unknown) => {
5224
7686
  if (session.reclaimedTurnSeq === turnGen) {
@@ -5235,6 +7697,7 @@ export class BridgeManager {
5235
7697
  return
5236
7698
  }
5237
7699
  this.clearOrphanPrompt(session, turnGen)
7700
+ endTurn()
5238
7701
  session.running = false
5239
7702
  this.hub.broadcast({
5240
7703
  type: 'turn/end',
@@ -5243,6 +7706,9 @@ export class BridgeManager {
5243
7706
  error: describeError(error),
5244
7707
  })
5245
7708
  })
7709
+ // Every outcome — normal answer, failure, reclaim early-return, or a
7710
+ // close's rejection — releases the shared prompt slot exactly once.
7711
+ .finally(() => { endTurn() })
5246
7712
  return { turnId }
5247
7713
  }
5248
7714
 
@@ -5378,6 +7844,23 @@ export class BridgeManager {
5378
7844
  this.persistAgents()
5379
7845
  }
5380
7846
 
7847
+ /**
7848
+ * Declare the deployment baseline for the idle-release window — the value
7849
+ * `cordis.patch.yml` names, passed RAW (only when it names one).
7850
+ *
7851
+ * Kept separate from the validated tuning for the same reason the
7852
+ * deadline keeps its baseline apart: "the file says 10 minutes" and
7853
+ * "the file says nothing, so 30 minutes applies" are different facts.
7854
+ * The first is the reset target and shows as the fact being overridden;
7855
+ * the second is a built-in default. Called once by the host entry,
7856
+ * before any bridge exists.
7857
+ */
7858
+ setConfiguredIdleRelease(idleTimeoutMs: number | undefined): void {
7859
+ this.configuredIdleTimeoutMs = idleTimeoutMs
7860
+ if (idleTimeoutMs !== undefined) this.baselineIdleTimeoutMs = idleTimeoutMs
7861
+ this.applyIdleReleaseTuning()
7862
+ }
7863
+
5381
7864
  /**
5382
7865
  * Declare the deployment baseline for the approval deadline — the values
5383
7866
  * `cordis.patch.yml` names, passed RAW (only the keys it actually contains).
@@ -5424,6 +7907,58 @@ export class BridgeManager {
5424
7907
  return deadlines
5425
7908
  }
5426
7909
 
7910
+ /**
7911
+ * The idle-release window in force, plus the facts that explain a value the
7912
+ * user did not pick: the deployment baseline an override is outranking.
7913
+ *
7914
+ * `defaultIdleTimeoutMs` is the window a RESET returns to — the deployment
7915
+ * baseline when the config file names one, else the built-in default. It is
7916
+ * NOT a hardcoded constant: a deployment that names 10 minutes must reset
7917
+ * to 10 minutes, not to the built-in 30.
7918
+ *
7919
+ * The config file's own number is reported only when the file names one, so
7920
+ * the settings card can stay silent about a plain default.
7921
+ */
7922
+ idleRelease(): AcpIdleRelease {
7923
+ const release: AcpIdleRelease = {
7924
+ idleTimeoutMs: this.idleTimeoutMs(),
7925
+ defaultIdleTimeoutMs: this.baselineIdleTimeoutMs,
7926
+ }
7927
+ if (this.configuredIdleTimeoutMs !== undefined) release.configuredIdleTimeoutMs = this.configuredIdleTimeoutMs
7928
+ return release
7929
+ }
7930
+
7931
+ /**
7932
+ * Write the idle-release window, or drop the persisted override with
7933
+ * `reset` so the deployment baseline applies again.
7934
+ *
7935
+ * HOT: the rewrite reaches every live bridge, so a user changing the window
7936
+ * never has to kill a running session to see it take effect.
7937
+ */
7938
+ async setIdleRelease(idleTimeoutMs: number | undefined, reset = false): Promise<void> {
7939
+ this.idleReleaseOverride = reset ? undefined : idleTimeoutMs
7940
+ this.applyIdleReleaseTuning()
7941
+ this.persistAgents()
7942
+ }
7943
+
7944
+ /**
7945
+ * Recompute the idle-release window from override → deployment baseline, and
7946
+ * push the result to every live bridge.
7947
+ */
7948
+ private applyIdleReleaseTuning(): void {
7949
+ const window = this.idleTimeoutMs()
7950
+ this.tuning.idleTimeoutMs = window
7951
+ for (const bridge of this.bridges.values()) bridge.applyIdleRelease(window)
7952
+ }
7953
+
7954
+ /**
7955
+ * The idle-release window that runs, in ms: the settings page's pick, else
7956
+ * the deployment baseline.
7957
+ */
7958
+ private idleTimeoutMs(): number {
7959
+ return this.idleReleaseOverride ?? this.baselineIdleTimeoutMs
7960
+ }
7961
+
5427
7962
  /**
5428
7963
  * Write the approval deadline, or the mode (omitted keys keep their value),
5429
7964
  * or drop the persisted override with `reset` so the config-file baseline —
@@ -5486,12 +8021,20 @@ export class BridgeManager {
5486
8021
 
5487
8022
  /** Drop every chat-keyed record pointing at one host session (close/stop paths). */
5488
8023
  private forgetDshSession(sessionId: string): void {
8024
+ let dropped = false
5489
8025
  for (const [key, session] of this.dshSessionMap) {
5490
- if (session.sessionId === sessionId) this.dshSessionMap.delete(key)
8026
+ if (session.sessionId === sessionId) {
8027
+ this.dshSessionMap.delete(key)
8028
+ dropped = true
8029
+ }
5491
8030
  }
8031
+ // The disk record goes with it: a closed session is destroyed, not parked,
8032
+ // and must not come back on the next boot (constraint 10).
8033
+ if (dropped) this.persistAgents()
5492
8034
  }
5493
8035
  private sessionWorkspaceResolver?: SessionWorkspaceResolver
5494
8036
  private acpModeResolver?: AcpModeResolver
8037
+ private attachmentResolver?: unknown
5495
8038
  /** Cached `chatSessionAcpMode` answers, keyed by DSH chat session id. */
5496
8039
  private readonly acpModeCache = new Map<string, boolean>()
5497
8040
  /** In-flight cold reads behind `chatSessionAcpModeAnswer`, keyed by session id. */
@@ -5638,6 +8181,9 @@ export class BridgeManager {
5638
8181
  // A RESUMED session had context before this call; a lock written here is
5639
8182
  // not provisional even if the conversation record was missing it.
5640
8183
  resumed.provisionalLock = false
8184
+ // The load refreshed the record's mirrors (modes / usage / hasHistory);
8185
+ // persist so a later restart restores what this resume saw.
8186
+ this.persistAgents()
5641
8187
  return resumed
5642
8188
  }
5643
8189
  // A chat session runs where its DSH session lives, not in the plugin's
@@ -5687,6 +8233,9 @@ export class BridgeManager {
5687
8233
  // conversation back (a failed first attempt must not trap it).
5688
8234
  session.provisionalLock = this.lockChatAgent(dshSessionId, agentId)
5689
8235
  }
8236
+ // The record is on disk from here: a host restart restores it as parked
8237
+ // instead of the next message starting a context-less new session.
8238
+ this.persistAgents()
5690
8239
  return session
5691
8240
  }
5692
8241
 
@@ -5851,8 +8400,36 @@ export class BridgeManager {
5851
8400
  // Forward the person's own words: the host folds its context sections
5852
8401
  // (AGENTS.md digest, skill catalog, runtime snapshot) after the claimed user
5853
8402
  // batch precisely so a consumer can tell them apart by `source` — see
5854
- // `selectForwardPrompt` and AGENTS.md rule 16.
5855
- const selection = selectForwardPrompt(options.messages ?? [])
8403
+ const workspaceRoot = chatWorkspaceRoot(this.sessionWorkspaceResolver, options.sessionId)
8404
+ const defaultReadFile = (p: string) => {
8405
+ try {
8406
+ if (existsSync(p)) return readFileSync(p)
8407
+ if (workspaceRoot !== undefined && !isAbsolutePath(p)) {
8408
+ const joined = join(workspaceRoot, p)
8409
+ if (existsSync(joined)) return readFileSync(joined)
8410
+ }
8411
+ } catch {}
8412
+ return undefined
8413
+ }
8414
+
8415
+ const resolver = this.attachmentResolver
8416
+ const attachmentContext = resolver !== undefined && typeof resolver === 'object' && resolver !== null
8417
+ ? Object.assign(Object.create(resolver), {
8418
+ readFile: (p: string) => {
8419
+ if (typeof (resolver as { readFile?: unknown }).readFile === 'function') {
8420
+ try {
8421
+ const res = (resolver as { readFile: (p: string) => unknown }).readFile(p)
8422
+ if (res !== undefined) return res
8423
+ } catch {}
8424
+ }
8425
+ return defaultReadFile(p)
8426
+ },
8427
+ })
8428
+ : {
8429
+ readFile: defaultReadFile,
8430
+ }
8431
+
8432
+ const selection = selectForwardPrompt(options.messages ?? [], attachmentContext)
5856
8433
  if (selection.kind === 'no-user-input') {
5857
8434
  // An injected-context wake: nothing the user said, nothing to send.
5858
8435
  yield { type: 'finish', reason: { kind: 'stop' } }
@@ -5919,14 +8496,31 @@ export class BridgeManager {
5919
8496
  return
5920
8497
  }
5921
8498
 
8499
+ const hasImages = (selection.images?.length ?? 0) > 0 || (selection.content?.some(b => b.type === 'image') ?? false)
8500
+ if (hasImages && bridge.initResult?.agentCapabilities?.promptCapabilities?.image !== true) {
8501
+ if (session.provisionalLock === true) this.rollbackFirstTurn(session)
8502
+ yield {
8503
+ type: 'finish',
8504
+ reason: {
8505
+ kind: 'error',
8506
+ failure: {
8507
+ message: `acp: agent "${agentId}" does not support image input`,
8508
+ code: 'image_unsupported',
8509
+ },
8510
+ },
8511
+ }
8512
+ return
8513
+ }
8514
+
5922
8515
  // One prompt slot per session, and a turn the user aborted still occupies
5923
8516
  // the AGENT's slot until it acknowledges the cancel (see `claimTurn`).
5924
8517
  const claim = await bridge.claimTurn(session)
5925
8518
  if (!claim.ok) {
5926
- // A first turn that never got the prompt slot delivered nothing either:
5927
- // give the conversation back instead of leaving it locked to an agent
5928
- // that refused the only message it was asked to serve.
5929
- if (session.provisionalLock === true) this.rollbackFirstTurn(session)
8519
+ // Never roll the provisional lock back here: a failed claim means ANOTHER
8520
+ // prompt holds the slot (or the agent has not settled it), and that turn
8521
+ // owns the conversation's lock. Closing the session would kill a turn
8522
+ // that is actively streaming — the refusal already tells the user to
8523
+ // retry.
5930
8524
  yield {
5931
8525
  type: 'finish',
5932
8526
  reason: {
@@ -6064,8 +8658,12 @@ export class BridgeManager {
6064
8658
  // next turn has to respect. Settlement reaches the controller, which
6065
8659
  // releases the slot (`endTurn`) whether the segment on air is the first
6066
8660
  // or a continuation one.
6067
- bridge.runPrompt(session, promptText, turnId)
6068
- .then(stopReason => {
8661
+ const promptBlocks: ContentBlock[] = selection.content && selection.content.length > 0
8662
+ ? selection.content as ContentBlock[]
8663
+ : [{ type: 'text', text: promptText }]
8664
+
8665
+ bridge.runPrompt(session, promptBlocks, turnId, turnGen)
8666
+ .then(result => {
6069
8667
  // The reclaim already ended this turn locally, and the close it
6070
8668
  // performed rejected this very request: the late answer is that same
6071
8669
  // turn, not a second one to broadcast.
@@ -6073,9 +8671,79 @@ export class BridgeManager {
6073
8671
  session.reclaimedTurnSeq = undefined
6074
8672
  return
6075
8673
  }
6076
- controller.settle(stopReason === 'cancelled'
8674
+ const responseMeta = codebuddyMetaOf({ _meta: result.meta })
8675
+ // A DETACHED-WORKER HOLD: this turn's `Agent` call handed real work
8676
+ // to a background worker that has not produced its answer yet, yet
8677
+ // the agent answered the prompt normally (CodeBuddy says "已派…" and
8678
+ // ends its own turn). Closing here loses the only answer the turn
8679
+ // will ever produce — the worker's report streams into this same
8680
+ // session later, as a background drain. Hold the turn open until
8681
+ // every dispatch reports and the drain goes quiet (see
8682
+ // `holdForBackgroundDispatch`); the prompt slot is released there.
8683
+ //
8684
+ // The decision comes BEFORE `adoptTurnRequestId`: adopting the id
8685
+ // would clear the updates the drain streamed while this turn's own id
8686
+ // was unknown, and those are exactly the ones the hold must fold.
8687
+ const holdDispatch = controller.dispatchedDetachedWorker
8688
+ && !session.released
8689
+ && !session.closed
8690
+ && result.stopReason !== 'cancelled'
8691
+ if (!holdDispatch && responseMeta?.conversationRequestId !== undefined) {
8692
+ // The response names the conversation turn it queued: an instant
8693
+ // answer that still carries one is a real (if silent) turn, and its
8694
+ // prose can be told apart from a background drain's.
8695
+ controller.adoptTurnRequestId(responseMeta.conversationRequestId)
8696
+ }
8697
+ // An announced drain that has shown NO sign of life for the staleness
8698
+ // window is over, even though nothing said so (CodeBuddy never sends
8699
+ // `session_end`). Keeping the record would make a later instant answer
8700
+ // with no conversation request id look steered, and hold a normal turn
8701
+ // open against a drain that is never coming back. RETIRE the id
8702
+ // first, so a late update or a response naming it is never adopted as
8703
+ // this session's own turn; then forget the record (the notice de-dupe
8704
+ // set with it) so this response settles normally.
8705
+ if (session.ambient !== undefined
8706
+ && Date.now() - session.ambient.lastActivityAt > this.tuning.ambientStaleMs) {
8707
+ retireAmbientId(session, session.ambient.requestId)
8708
+ session.ambient = undefined
8709
+ session.ambientNotices?.clear()
8710
+ }
8711
+ if (holdDispatch) {
8712
+ // The response may name one run while an announcement named
8713
+ // another; the hold accepts both (see `BackgroundHold.acceptedIds`).
8714
+ controller.holdForBackgroundDispatch(
8715
+ result.stopReason,
8716
+ responseMeta?.conversationRequestId ?? responseMeta?.requestId,
8717
+ )
8718
+ return
8719
+ }
8720
+ // An instant answer with NO conversation request id, while the agent
8721
+ // announced a background drain this turn has not spoken into: the
8722
+ // prompt was folded into that drain (CodeBuddy's multitask). The
8723
+ // response is a formality — keep the turn open until the drain speaks.
8724
+ if (session.ambient !== undefined
8725
+ && !session.released
8726
+ && !session.closed
8727
+ && result.stopReason !== 'cancelled'
8728
+ && responseMeta?.conversationRequestId === undefined
8729
+ && !controller.hasAgentProse) {
8730
+ controller.awaitAmbient(session.ambient.requestId, result.stopReason)
8731
+ return
8732
+ }
8733
+ // No ambient record to wait on, but a drain this session stopped
8734
+ // waiting on was retired moments ago: the run may have taken this
8735
+ // prompt WITHOUT re-announcing itself. The turn cannot be held open
8736
+ // (nothing names the drain), but a silent close must say the output
8737
+ // may be in the transcript instead of blaming the agent.
8738
+ if (session.ambient === undefined
8739
+ && result.stopReason !== 'cancelled'
8740
+ && responseMeta?.conversationRequestId === undefined
8741
+ && hasRecentRetiredAmbient(session, Math.max(this.tuning.ambientQuietMs * 2, RETIRED_AMBIENT_RECENT_MIN_MS))) {
8742
+ controller.noteRecentRetiredDrain()
8743
+ }
8744
+ controller.settle(result.stopReason === 'cancelled'
6077
8745
  ? { kind: 'cancelled' }
6078
- : { kind: 'stop', stopReason })
8746
+ : { kind: 'stop', stopReason: result.stopReason })
6079
8747
  })
6080
8748
  .catch((err: unknown) => {
6081
8749
  if (session.reclaimedTurnSeq === turnGen) {