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/lib/bridge.js CHANGED
@@ -12,8 +12,9 @@ import { delimiter, dirname, join } from 'node:path';
12
12
  import { Writable, Readable } from 'node:stream';
13
13
  import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
14
14
  import { client, methods, ndJsonStream, PROTOCOL_VERSION, RequestError, } from '@agentclientprotocol/sdk';
15
- import { AcpError, EventHub, PermissionLedger, Transcript, acpToolCallDelta, acpToolCallFromUpdate, buildAdapterModelId, childEnv, isAcpToolContinuation, isUnderPath, mergeCatalogShape, parseCatalogOptions, usableConfigOptions, permissionRequestFrame, pickAutoOption, quoteCommandToken, randomUUID, resolveFsPath, resolveSessionCwd, selectForwardPrompt, acpPresetStatusFromRoster, splitAdapterModelId, updateContentToText, describeUnhandledUpdate, BOOKKEEPING_UPDATES, validateAgentDef, validateTagHalo, validateApprovalDeadlines, validatePermissionTimeoutMode, normalizeApprovalWindow, } from "./impl.js";
16
- import { DEFAULT_TAG_HALO } from "./protocol.js";
15
+ import { AcpError, EventHub, PermissionLedger, Transcript, acpToolCallDelta, acpToolCallFromUpdate, buildAdapterModelId, childEnv, isAcpToolContinuation, isUnderPath, mergeCatalogShape, parseCatalogOptions, usableConfigOptions, permissionRequestFrame, pickAutoOption, quoteCommandToken, randomUUID, resolveFsPath, resolveSessionCwd, selectForwardPrompt, isAbsolutePath, acpPresetStatusFromRoster, splitAdapterModelId, updateContentToText, describeUnhandledUpdate, BOOKKEEPING_UPDATES, validateAgentDef, validateTagHalo, validateApprovalDeadlines, validatePermissionTimeoutMode, normalizeApprovalWindow, } from "./impl.js";
16
+ import { DEFAULT_IDLE_TIMEOUT_MS, DEFAULT_TAG_HALO, IDLE_RELEASE_MS } from "./protocol.js";
17
+ import { OUTPUT_CHARS_PER_TOKEN, TurnTpsTracker } from "./tps.js";
17
18
  /**
18
19
  * Version of this installed package, read from the shipped `package.json` —
19
20
  * one level above both `src/` at test time and `lib/` at runtime. The settings
@@ -104,8 +105,13 @@ const DEFAULT_TUNING = {
104
105
  turnStallMs: 600_000,
105
106
  turnStallToolMs: 1_800_000,
106
107
  turnStallTickMs: 30_000,
107
- idleTimeoutMs: 1_800_000,
108
+ idleTimeoutMs: DEFAULT_IDLE_TIMEOUT_MS,
108
109
  probeIdleTimeoutMs: 300_000,
110
+ ambientQuietMs: 10_000,
111
+ ambientMaxMs: 120_000,
112
+ backgroundTaskMaxMs: 600_000,
113
+ backgroundParkedGraceMs: 30_000,
114
+ ambientStaleMs: 300_000,
109
115
  };
110
116
  export class AgentBridge {
111
117
  def;
@@ -115,6 +121,8 @@ export class AgentBridge {
115
121
  onCatalog;
116
122
  onCommands;
117
123
  sessionWorkspace;
124
+ onSessionsDirty;
125
+ onForkCapability;
118
126
  state = 'stopped';
119
127
  initResult;
120
128
  lastError;
@@ -166,6 +174,13 @@ export class AgentBridge {
166
174
  * that a caller is waiting on the child it is about to kill.
167
175
  */
168
176
  pendingCreates = 0;
177
+ /**
178
+ * `session/fork` requests in flight (the adopt path of a branched
179
+ * conversation). Same teardown hazard as {@link pendingCreates}: until the
180
+ * forked session is registered, nothing else would stop an idle release
181
+ * from killing the child out from under the caller.
182
+ */
183
+ pendingForks = 0;
169
184
  /**
170
185
  * Whether a REAL session has ever been registered on this bridge. Probes
171
186
  * bypass session bookkeeping, so a bridge that has never hosted anything was
@@ -202,7 +217,21 @@ export class AgentBridge {
202
217
  * Resolved per turn: the host entry may wire the workspace reader after
203
218
  * this bridge was built.
204
219
  */
205
- sessionWorkspace) {
220
+ sessionWorkspace,
221
+ /**
222
+ * Told whenever the SESSION RECORDS change shape (a release parks them, a
223
+ * resume revives them): the manager persists `chatSessions` through it, so
224
+ * a parked conversation stays resumable across a host restart (see
225
+ * `BridgeManager.loadPersistedChatSessions`).
226
+ */
227
+ onSessionsDirty,
228
+ /**
229
+ * Told whenever a fresh `initialize` reports the agent's
230
+ * `sessionCapabilities.fork` bit — the LAST KNOWN fork capability the
231
+ * manager persists and serves to the browser (the bridge's own
232
+ * `initResult` dies with the process; the manager's copy survives it).
233
+ */
234
+ onForkCapability) {
206
235
  this.def = def;
207
236
  this.hub = hub;
208
237
  this.transcriptMax = transcriptMax;
@@ -210,6 +239,8 @@ export class AgentBridge {
210
239
  this.onCatalog = onCatalog;
211
240
  this.onCommands = onCommands;
212
241
  this.sessionWorkspace = sessionWorkspace;
242
+ this.onSessionsDirty = onSessionsDirty;
243
+ this.onForkCapability = onForkCapability;
213
244
  const merged = { ...DEFAULT_TUNING, ...tuning };
214
245
  this.startupTimeoutMs = merged.startupTimeoutMs;
215
246
  this.permissionTimeoutMode = merged.permissionTimeoutMode;
@@ -243,6 +274,24 @@ export class AgentBridge {
243
274
  // on arrival.
244
275
  this.permissionTimeoutMs = normalizeApprovalWindow(this.permissionTimeoutMode, this.permissionTimeoutMs);
245
276
  }
277
+ /**
278
+ * Adopt a rewritten host-default idle window (the settings page's write).
279
+ *
280
+ * Only the DEFAULT moves: an agent that names its own `idleTimeoutMs` keeps
281
+ * it (that promise is about the agent, not about the deployment), and a
282
+ * probe-only child keeps the short probe window.
283
+ *
284
+ * The timer is re-armed so the new window governs from NOW: an already
285
+ * armed timer still holds the old window, so a shortened window would not
286
+ * take effect until the old one elapsed.
287
+ */
288
+ applyIdleRelease(defaultIdleTimeoutMs) {
289
+ if (this.defaultIdleTimeoutMs === defaultIdleTimeoutMs)
290
+ return;
291
+ this.defaultIdleTimeoutMs = defaultIdleTimeoutMs;
292
+ this.clearIdleTimer();
293
+ this.touch();
294
+ }
246
295
  /**
247
296
  * The idle-release window this agent honors for its CHAT process: its own
248
297
  * field, else the host default. This is the number the panel prefills and
@@ -341,11 +390,16 @@ export class AgentBridge {
341
390
  * same session — forbidden by the protocol and rejected (or worse, mishandled)
342
391
  * by the agent. Waiting turns a protocol error into a short delay.
343
392
  *
393
+ * The gate is {@link LiveSession.promptInFlight} + the settle promise, NOT
394
+ * {@link LiveSession.running}: an ambient turn keeps `running` true for the
395
+ * whole drain while its prompt slot is already free, so `running` would
396
+ * refuse every message the user sends during a background task.
397
+ *
344
398
  * @param session - the session asking for the slot.
345
399
  * @returns whether the slot was claimed, and why not when it was not.
346
400
  */
347
401
  async claimTurn(session) {
348
- if (session.running)
402
+ if (this.promptSlotHeld(session))
349
403
  return { ok: false, reason: 'busy' };
350
404
  const pending = session.settled;
351
405
  if (pending === undefined)
@@ -368,13 +422,20 @@ export class AgentBridge {
368
422
  // so report it honestly instead of handing the agent two prompts at once.
369
423
  if (session.settled !== undefined)
370
424
  return { ok: false, reason: 'unsettled' };
371
- if (session.running)
425
+ // Re-checked after the wait: another turn may have claimed the slot while
426
+ // this one was waiting on the gate.
427
+ if (this.promptSlotHeld(session))
372
428
  return { ok: false, reason: 'busy' };
373
429
  return { ok: true };
374
430
  }
431
+ /** Whether a turn currently holds the agent prompt slot (see `promptInFlight`). */
432
+ promptSlotHeld(session) {
433
+ return session.promptInFlight === true;
434
+ }
375
435
  /**
376
- * Mark the agent-side turn as in flight and hand back the settler its stream
377
- * owner must call once the agent is actually done with it.
436
+ * Mark the agent-side turn as in flight — claim the agent prompt slot and
437
+ * open the settle gate — and hand back the settler its stream owner must
438
+ * call once the agent is actually done with it.
378
439
  *
379
440
  * @param session - the session whose turn is starting.
380
441
  * @returns the settler (idempotent; also used by connection loss).
@@ -384,11 +445,16 @@ export class AgentBridge {
384
445
  const settled = new Promise(resolve => { settle = resolve; });
385
446
  session.settled = settled;
386
447
  session.settledResolve = settle;
448
+ session.promptInFlight = true;
387
449
  this.touch();
388
450
  return () => {
451
+ // Identity-guarded: only THIS turn's settler may release the slot. A
452
+ // superseded turn's late settler must not clear the flag a NEWER turn
453
+ // has already claimed (its settle gate was replaced with a new promise).
389
454
  if (session.settled === settled) {
390
455
  session.settled = undefined;
391
456
  session.settledResolve = undefined;
457
+ session.promptInFlight = false;
392
458
  }
393
459
  settle();
394
460
  // The turn is over: a fresh window starts here rather than at the prompt
@@ -399,12 +465,14 @@ export class AgentBridge {
399
465
  /**
400
466
  * Open a session's settle gate without owning its turn stream: the turn's
401
467
  * stream is gone (a resume, a release) but the next prompt still waits on
402
- * this promise, and nothing is left that could ever resolve it.
468
+ * this promise, and nothing is left that could ever resolve it. The prompt
469
+ * slot itself goes with it: whatever held it is gone too.
403
470
  */
404
471
  openSettleGate(session) {
405
472
  const settle = session.settledResolve;
406
473
  session.settled = undefined;
407
474
  session.settledResolve = undefined;
475
+ session.promptInFlight = false;
408
476
  settle?.();
409
477
  }
410
478
  view() {
@@ -437,6 +505,7 @@ export class AgentBridge {
437
505
  hasActiveWork() {
438
506
  if (this.pendingProbes > 0
439
507
  || this.pendingCreates > 0
508
+ || this.pendingForks > 0
440
509
  || this.pendingCommands.size > 0
441
510
  || this.resuming.size > 0
442
511
  || this.permissions.sessionIds().length > 0
@@ -462,6 +531,7 @@ export class AgentBridge {
462
531
  hasOtherActiveWork(session) {
463
532
  if (this.pendingProbes > 0
464
533
  || this.pendingCreates > 0
534
+ || this.pendingForks > 0
465
535
  || this.resuming.size > 0
466
536
  || this.starting !== undefined)
467
537
  return true;
@@ -640,6 +710,10 @@ export class AgentBridge {
640
710
  clientInfo: { name: 'dsh-ntr', title: 'DSH NTR', version: PLUGIN_VERSION },
641
711
  }), 'initialize');
642
712
  this.initResult = init;
713
+ // The fork capability is agent knowledge that must outlive the process:
714
+ // report it so the manager can persist a LAST KNOWN copy and serve the
715
+ // browser's branch button after a release (`initResult` itself dies here).
716
+ this.onForkCapability?.(this.def.id, this.initResult?.agentCapabilities?.sessionCapabilities?.fork != null);
643
717
  }
644
718
  /**
645
719
  * Bound one agent-startup request (`initialize`, `session/new`,
@@ -783,6 +857,101 @@ export class AgentBridge {
783
857
  this.touch();
784
858
  }
785
859
  }
860
+ /**
861
+ * Fork one live session through the agent's own `session/fork` (the
862
+ * UNSTABLE fourth session-creation point): the agent copies its internal
863
+ * state into a NEW ACP session that remembers the parent's context — the
864
+ * second half of a branched conversation, after the host's `sessions.fork`
865
+ * has already built the DSH session tree. The child is addressed by the
866
+ * CHILD DSH conversation id, so its first message reaches an agent that
867
+ * actually remembers the parent.
868
+ *
869
+ * The parent must be live on this connection — a released parent has no
870
+ * process to fork from (the browser blocks that click; this is the hard
871
+ * stop behind it). The ACP request carries the SAME `mcpServers` list as
872
+ * `session/new` / `session/load` (one list per process; an empty list would
873
+ * evict the live sessions' MCP scope) and is bounded by the startup timeout
874
+ * like every session-creation step. Counted in {@link pendingForks} while
875
+ * in flight so an idle release cannot kill the child out from under the
876
+ * caller.
877
+ */
878
+ async forkSession(parent, dshSessionId) {
879
+ if (this.initResult?.agentCapabilities?.sessionCapabilities?.fork == null) {
880
+ throw new AcpError('fork_unsupported', `acp: agent "${this.def.id}" does not advertise the session/fork capability`);
881
+ }
882
+ const conn = this.conn;
883
+ if (conn === undefined)
884
+ throw new AcpError('agent_unreachable', 'acp: connection is not open');
885
+ // The child runs where the parent runs: its cwd is the parent's already
886
+ // validated workspace, re-trusted as its own extra root (the same minimal
887
+ // trust `resolveSessionCwd` applied when the parent was created — never
888
+ // the agent's configured sandbox cwd, which is not where the conversation
889
+ // lives).
890
+ const resolved = resolveSessionCwd(this.def, parent.cwd, p => realpathSync(p), [parent.cwd]);
891
+ this.pendingForks += 1;
892
+ try {
893
+ const result = await this.withStartupTimeout(conn.agent.request(methods.agent.session.fork, {
894
+ sessionId: parent.acpSessionId,
895
+ cwd: resolved,
896
+ mcpServers: toAcpMcpServers(this.def.mcpServers),
897
+ }), 'session/fork');
898
+ // A fork answer that names the parent (or any session already routed on
899
+ // this connection) would interleave the parent's updates, asks and
900
+ // permission cards into the child — refuse it rather than register it.
901
+ if (typeof result.sessionId !== 'string' || result.sessionId === '') {
902
+ throw new AcpError('fork_failed', 'acp: session/fork returned no session id');
903
+ }
904
+ if (result.sessionId === parent.acpSessionId || this.acpIndex.has(result.sessionId)) {
905
+ throw new AcpError('fork_id_conflict', 'acp: session/fork returned an id that already exists on this connection');
906
+ }
907
+ const session = {
908
+ sessionId: randomUUID(),
909
+ agentId: this.def.id,
910
+ acpSessionId: result.sessionId,
911
+ cwd: resolved,
912
+ running: false,
913
+ turnSeq: 0,
914
+ closed: false,
915
+ released: false,
916
+ lastAgentActivityAt: Date.now(),
917
+ activeToolCalls: new Set(),
918
+ transcript: new Transcript(this.transcriptMax),
919
+ dshSessionId,
920
+ ...(result.modes !== undefined && result.modes !== null
921
+ ? {
922
+ modes: {
923
+ currentModeId: result.modes.currentModeId,
924
+ availableModes: result.modes.availableModes.map(m => ({
925
+ id: m.id,
926
+ name: m.name,
927
+ ...(m.description !== null ? { description: m.description } : {}),
928
+ })),
929
+ },
930
+ }
931
+ : {}),
932
+ ...(usableConfigOptions(result.configOptions) ? { configOptions: result.configOptions } : {}),
933
+ };
934
+ // The fork answer's config options are the AGENT-LEVEL surface too (the
935
+ // same feed `session/new` provides): absorb them so the composer chips
936
+ // of the fresh conversation render without a probe.
937
+ if (this.onCatalog !== undefined && usableConfigOptions(result.configOptions)) {
938
+ this.onCatalog(this.def.id, result.configOptions);
939
+ }
940
+ this.sessions.set(session.sessionId, session);
941
+ this.acpIndex.set(session.acpSessionId, session);
942
+ // Same flip as `createSession`: a registered session makes this a chat
943
+ // process; re-arm the idle clock on the agent's own window.
944
+ this.clearIdleTimer();
945
+ this.everHostedSession = true;
946
+ this.hub.broadcast({ type: 'session/created', sessionId: session.sessionId, session: snapshot(session) });
947
+ this.touch();
948
+ return session;
949
+ }
950
+ finally {
951
+ this.pendingForks -= 1;
952
+ this.touch();
953
+ }
954
+ }
786
955
  /**
787
956
  * Fire one prompt turn. Returns after the agent settles the turn; the caller
788
957
  * (manager) keeps the UI informed through broadcast events, so the RPC that
@@ -795,28 +964,69 @@ export class AgentBridge {
795
964
  * {@link BridgeTuning.staleCancelRetryDelayMs}, out-waiting the cleanup
796
965
  * window, bounded by {@link BridgeTuning.staleCancelMaxRetries}; a genuinely
797
966
  * requested cancel (`cancelRequestedAtTurn` still names this generation)
798
- * returns as-is.
967
+ * returns as-is — checked BOTH before the delay and after it, so a second
968
+ * stop click inside the retry window does not resend the prompt the user
969
+ * just stopped.
970
+ *
971
+ * `turnGen` is the {@link LiveSession.turnSeq} this prompt was sent for. A
972
+ * stale-cancel resend is only allowed while that generation still owns the
973
+ * session: once a successor turn began (the cancel fallback finalized this
974
+ * one and the user moved on), resending would race a second `session/prompt`
975
+ * into the successor's turn. The late answer is returned as-is instead.
976
+ *
977
+ * The RESPONSE's `_meta` travels with the stop reason: it is the only place
978
+ * an agent says whether the prompt actually queued a conversation turn (see
979
+ * `CodebuddyMeta.conversationRequestId`), which decides whether an instant
980
+ * answer was the turn ending or a fold into a background drain.
799
981
  */
800
- async runPrompt(session, text, turnId) {
982
+ async runPrompt(session, prompt, turnId, turnGen) {
801
983
  const conn = this.conn;
802
984
  if (conn === undefined || session.closed || session.released)
803
985
  throw new AcpError('session_closed', 'acp: session is closed');
804
986
  this.touch();
987
+ const blocks = typeof prompt === 'string'
988
+ ? [{ type: 'text', text: prompt }]
989
+ : prompt;
990
+ if (blocks.length === 0) {
991
+ throw new AcpError('empty_prompt', 'acp: prompt cannot be empty');
992
+ }
993
+ const hasImages = blocks.some(b => b.type === 'image');
994
+ if (hasImages && this.initResult?.agentCapabilities?.promptCapabilities?.image !== true) {
995
+ throw new AcpError('image_unsupported', `acp: agent "${this.def.id}" does not support image input`);
996
+ }
805
997
  for (let attempt = 0;; attempt += 1) {
806
998
  // The response is activity too: the idle window must start at the turn's
807
999
  // END, not at the prompt that may have started a long time ago.
808
1000
  const result = await conn.agent.request(methods.agent.session.prompt, {
809
1001
  sessionId: session.acpSessionId,
810
- prompt: [{ type: 'text', text }],
1002
+ prompt: blocks,
811
1003
  }).finally(() => { this.touch(); });
1004
+ const answered = () => ({
1005
+ stopReason: result.stopReason,
1006
+ ...(result._meta !== undefined && result._meta !== null ? { meta: result._meta } : {}),
1007
+ });
812
1008
  if (result.stopReason !== 'cancelled' || session.cancelRequestedAtTurn === session.turnSeq) {
813
- return result.stopReason;
1009
+ return answered();
814
1010
  }
1011
+ // The turn this answer belongs to no longer owns the session (a cancel
1012
+ // fallback finalized it and a successor began): resending would fire a
1013
+ // second `session/prompt` into the successor's turn.
1014
+ if (session.turnSeq !== turnGen)
1015
+ return answered();
815
1016
  if (attempt >= this.staleCancelMaxRetries)
816
- return result.stopReason;
1017
+ return answered();
817
1018
  await new Promise(resolve => setTimeout(resolve, this.staleCancelRetryDelayMs));
818
1019
  if (session.closed || this.conn !== conn)
819
1020
  throw new AcpError('session_closed', 'acp: session is closed');
1021
+ // Re-check after the delay: the successor may have started while waiting.
1022
+ if (session.turnSeq !== turnGen)
1023
+ return answered();
1024
+ // ...and a SECOND stop during the delay names this same generation. The
1025
+ // user already said stop, so the stale-cancel answer is not a swallow to
1026
+ // out-wait anymore: resending would make the agent run the prompt the
1027
+ // user just stopped (the double-click case). Return as-is.
1028
+ if (session.cancelRequestedAtTurn === session.turnSeq)
1029
+ return answered();
820
1030
  this.touch();
821
1031
  }
822
1032
  }
@@ -862,10 +1072,19 @@ export class AgentBridge {
862
1072
  }
863
1073
  /**
864
1074
  * Force the local end of one turn whose agent never acknowledged it: clear
865
- * `running`, open the settle gate, and broadcast the turn end with `turnId:
866
- * ''` (there is no agent response to attribute it to). Shared by the cancel
1075
+ * `running`, open the settle gate, and end the turn. Shared by the cancel
867
1076
  * fallback and the stall watchdog.
868
1077
  *
1078
+ * A live controller owns the turn's streaming face — its segment queue, its
1079
+ * ambient clocks and its `turn/end` id. Ending the turn behind its back
1080
+ * would leave the driver step parked on an open queue, and the controller's
1081
+ * own timers would later broadcast a SECOND `turn/end` for a turn already
1082
+ * closed. So when the session still points at a current controller, the
1083
+ * force goes THROUGH it (`forceFinalize`), which runs the same closing work
1084
+ * as a normal settle exactly once. Only a turn with no live controller (an
1085
+ * `/acp`-RPC turn, or one already retired) falls back to the local broadcast
1086
+ * with `turnId: ''` — there is no stream whose id could be named.
1087
+ *
869
1088
  * Generation-guarded: a turn that already ended — or that a later turn
870
1089
  * superseded — is not this call's to finalize. Returns whether it acted; a
871
1090
  * `false` means the caller was late and must leave the session alone.
@@ -873,6 +1092,11 @@ export class AgentBridge {
873
1092
  finalizeTurn(session, turnGen, message) {
874
1093
  if (session.closed || session.turnSeq !== turnGen)
875
1094
  return false;
1095
+ const controller = session.turnController;
1096
+ if (controller !== undefined && !controller.stale) {
1097
+ controller.forceFinalize({ kind: 'error', message });
1098
+ return true;
1099
+ }
876
1100
  // Nothing is in flight anymore (the turn settled on its own): nothing to
877
1101
  // finalize. A still-pending settle gate counts as in flight even though
878
1102
  // the stream is gone — that gate is what the next prompt waits on.
@@ -930,17 +1154,23 @@ export class AgentBridge {
930
1154
  throw new AcpError('agent_unreachable', 'acp: connection is not open');
931
1155
  const resolved = resolveSessionCwd(this.def, session.cwd, p => realpathSync(p), workspaceRoot === undefined ? [] : [workspaceRoot]);
932
1156
  // Replay arrives as brand-new updates, so the transcript restarts too;
933
- // seq keeps meaning "position in the current epoch".
1157
+ // seq keeps meaning "position in the current epoch". What the buffer held
1158
+ // is now the agent's to replay — remember that there IS history before
1159
+ // dropping it, or a failed resume parks an empty buffer back and the
1160
+ // view claims the conversation never happened.
1161
+ session.hasHistory = session.hasHistory === true || session.transcript.since(0).length > 0;
934
1162
  session.transcript = new Transcript(this.transcriptMax);
935
1163
  session.cwd = resolved;
936
1164
  session.running = false;
937
1165
  session.closed = false;
938
1166
  session.released = false;
939
1167
  // A resume is a fresh agent-side state: nothing from the dead process's
940
- // turn survives it, including the settle gate and the stall markers.
1168
+ // turn survives it, including the settle gate and the stall markers. A
1169
+ // background drain belonged to that process too.
941
1170
  session.orphanPromptTurnSeq = undefined;
942
1171
  session.reclaimedTurnSeq = undefined;
943
1172
  session.activeToolCalls.clear();
1173
+ clearAmbientState(session);
944
1174
  session.lastAgentActivityAt = Date.now();
945
1175
  this.openSettleGate(session);
946
1176
  // Route replay updates into the fresh transcript while the load request
@@ -972,6 +1202,7 @@ export class AgentBridge {
972
1202
  this.touch();
973
1203
  // Republish so configOptions/modes observed on load reach the browser.
974
1204
  this.hub.broadcast({ type: 'session/meta', sessionId: session.sessionId, session: snapshot(session) });
1205
+ this.onSessionsDirty?.();
975
1206
  return session;
976
1207
  }
977
1208
  catch (error) {
@@ -980,6 +1211,7 @@ export class AgentBridge {
980
1211
  session.released = true;
981
1212
  this.released.set(session.sessionId, session);
982
1213
  this.hub.broadcast({ type: 'session/released', sessionId: session.sessionId, session: snapshot(session), reason: 'resume failed', cause: 'resume-failed' });
1214
+ this.onSessionsDirty?.();
983
1215
  throw error;
984
1216
  }
985
1217
  }
@@ -1000,8 +1232,10 @@ export class AgentBridge {
1000
1232
  // would make the next prompt wait for an answer that can no longer come.
1001
1233
  this.openSettleGate(session);
1002
1234
  // No call from the dead process can still be running: drop the tool set
1003
- // so a resumed turn starts on the plain window.
1235
+ // so a resumed turn starts on the plain window. A background drain died
1236
+ // with it, and a stale ambient would misclassify the NEXT prompt.
1004
1237
  session.activeToolCalls.clear();
1238
+ clearAmbientState(session);
1005
1239
  session.released = true;
1006
1240
  this.settlePermissions(session);
1007
1241
  this.released.set(session.sessionId, session);
@@ -1015,6 +1249,10 @@ export class AgentBridge {
1015
1249
  this.state = 'stopped';
1016
1250
  this.initResult = undefined;
1017
1251
  this.publishStatus();
1252
+ // The parked records are what keeps them resumable after a host restart —
1253
+ // persist now, while their transcripts (the `hasHistory` evidence) are
1254
+ // still the ones they were parked with.
1255
+ this.onSessionsDirty?.();
1018
1256
  }
1019
1257
  /** Detach one released session record for a resume attempt. */
1020
1258
  takeReleased(sessionId) {
@@ -1028,6 +1266,14 @@ export class AgentBridge {
1028
1266
  dropReleased(sessionId) {
1029
1267
  return this.released.delete(sessionId);
1030
1268
  }
1269
+ /**
1270
+ * Register a session record restored from disk as already parked: a host
1271
+ * restart killed every process, so it comes back exactly as `releaseAll`
1272
+ * would have left it — resumable via `session/load`, never live.
1273
+ */
1274
+ parkReleased(session) {
1275
+ this.released.set(session.sessionId, session);
1276
+ }
1031
1277
  get releasedList() {
1032
1278
  return [...this.released.values()];
1033
1279
  }
@@ -1097,6 +1343,12 @@ export class AgentBridge {
1097
1343
  if (session.closed)
1098
1344
  return;
1099
1345
  session.closed = true;
1346
+ // The session is going away: whatever background drain it announced ends
1347
+ // with it (a re-created session must not inherit a stale ambient).
1348
+ clearAmbientState(session);
1349
+ // ...and so does its prompt slot: nothing may keep rejecting a new prompt
1350
+ // for a record that is being deleted.
1351
+ session.promptInFlight = false;
1100
1352
  // Closing a session is activity of its own: restart the idle window here
1101
1353
  // rather than at the prompt that opened it.
1102
1354
  this.touch();
@@ -1169,6 +1421,7 @@ export class AgentBridge {
1169
1421
  this.settlePermissions(session);
1170
1422
  this.openSettleGate(session);
1171
1423
  session.activeToolCalls.clear();
1424
+ clearAmbientState(session);
1172
1425
  this.sessions.delete(session.sessionId);
1173
1426
  this.acpIndex.delete(session.acpSessionId);
1174
1427
  this.hub.broadcast({ type: 'session/closed', sessionId: session.sessionId, reason });
@@ -1440,6 +1693,61 @@ export class AgentBridge {
1440
1693
  // tool, which earns the wider watchdog window.
1441
1694
  session.lastAgentActivityAt = Date.now();
1442
1695
  const kind = params.update.sessionUpdate;
1696
+ const meta = codebuddyMetaOf(params.update);
1697
+ // A RETIRED drain that speaks again is alive, not retired. Un-retire it
1698
+ // BEFORE this update is classified and rebuild the ambient record: the run
1699
+ // may never re-announce itself, yet the next prompt folded into it must
1700
+ // still be recognised as steered and receive the drain's text. A
1701
+ // re-announcement below does the same for its id.
1702
+ if (meta?.requestId !== undefined && isRetiredAmbientId(session, meta.requestId)) {
1703
+ reviveAmbientId(session, meta.requestId);
1704
+ session.ambient = {
1705
+ requestId: meta.requestId,
1706
+ startedAt: session.ambient?.startedAt ?? Date.now(),
1707
+ lastActivityAt: Date.now(),
1708
+ sawOutput: session.ambient?.sawOutput ?? false,
1709
+ };
1710
+ }
1711
+ if (meta?.unsolicitedTurn !== undefined) {
1712
+ // The agent started a background turn on its own: remember it so a
1713
+ // prompt the agent folds into it (answered instantly, no conversation
1714
+ // request id) can be kept open until the drain speaks. Announced once per
1715
+ // request id — a repeated announcement is the same run, not news.
1716
+ const run = meta.unsolicitedTurn;
1717
+ // A re-announcement also revives a drain whose wait already closed: the
1718
+ // same run is alive, and the notice de-dupe set keeps it silent.
1719
+ reviveAmbientId(session, run.requestId);
1720
+ session.ambient = { requestId: run.requestId, startedAt: Date.now(), lastActivityAt: Date.now(), sawOutput: false };
1721
+ const announced = session.ambientNotices ?? new Set();
1722
+ session.ambientNotices = announced;
1723
+ if (!announced.has(run.requestId)) {
1724
+ announced.add(run.requestId);
1725
+ // The de-dupe set outlives individual turns (a hard ceiling keeps the
1726
+ // drain record), so it is bounded: evict the oldest ids first.
1727
+ while (announced.size > AMBIENT_NOTICE_MAX) {
1728
+ const oldest = announced.values().next().value;
1729
+ if (oldest === undefined)
1730
+ break;
1731
+ announced.delete(oldest);
1732
+ }
1733
+ this.hub.broadcast({
1734
+ type: 'agent/notice',
1735
+ agentId: session.agentId,
1736
+ level: 'info',
1737
+ 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.`,
1738
+ });
1739
+ }
1740
+ }
1741
+ if (session.ambient !== undefined
1742
+ && meta?.requestId === session.ambient.requestId) {
1743
+ // The run is alive: refresh the staleness clock on ANY update it owns,
1744
+ // not just prose (a tool-heavy stretch is still the drain working).
1745
+ session.ambient.lastActivityAt = Date.now();
1746
+ if ((kind === 'agent_message_chunk' || kind === 'agent_thought_chunk')
1747
+ && chunkTextOf(params.update.content) !== '') {
1748
+ session.ambient.sawOutput = true;
1749
+ }
1750
+ }
1443
1751
  if (kind === 'tool_call' || kind === 'tool_call_update') {
1444
1752
  const { toolCallId, status } = params.update;
1445
1753
  if (status === 'completed' || status === 'failed')
@@ -1500,6 +1808,129 @@ export class AgentBridge {
1500
1808
  }
1501
1809
  }
1502
1810
  }
1811
+ /** Read the CodeBuddy `_meta` keys off an update or a `{ _meta }` wrapper. */
1812
+ function codebuddyMetaOf(source) {
1813
+ if (typeof source !== 'object' || source === null)
1814
+ return undefined;
1815
+ const row = source;
1816
+ const rawMeta = row._meta;
1817
+ if (typeof rawMeta !== 'object' || rawMeta === null)
1818
+ return undefined;
1819
+ const meta = rawMeta;
1820
+ const out = {};
1821
+ const requestId = meta['codebuddy.ai/requestId'];
1822
+ if (typeof requestId === 'string' && requestId !== '')
1823
+ out.requestId = requestId;
1824
+ const conversationRequestId = meta['codebuddy.ai/conversationRequestId'];
1825
+ if (typeof conversationRequestId === 'string' && conversationRequestId !== '') {
1826
+ out.conversationRequestId = conversationRequestId;
1827
+ }
1828
+ const toolName = meta['codebuddy.ai/toolName'];
1829
+ if (typeof toolName === 'string' && toolName !== '')
1830
+ out.toolName = toolName;
1831
+ const parentToolCallId = meta['codebuddy.ai/parentToolCallId'];
1832
+ if (typeof parentToolCallId === 'string' && parentToolCallId !== '')
1833
+ out.parentToolCallId = parentToolCallId;
1834
+ // Booleans, read as booleans only: a string "true" is not the declared shape
1835
+ // and guessing from it would make another agent's unrelated key look detached.
1836
+ if (meta['codebuddy.ai/isBackground'] === true)
1837
+ out.isBackground = true;
1838
+ if (meta['codebuddy.ai/isSubagent'] === true)
1839
+ out.isSubagent = true;
1840
+ const unsolicited = meta['codebuddy.ai/unsolicitedTurn'];
1841
+ if (typeof unsolicited === 'object' && unsolicited !== null) {
1842
+ const run = unsolicited;
1843
+ if (typeof run.requestId === 'string' && run.requestId !== '') {
1844
+ out.unsolicitedTurn = {
1845
+ requestId: run.requestId,
1846
+ ...(typeof run.reason === 'string' && run.reason !== '' ? { reason: run.reason } : {}),
1847
+ };
1848
+ }
1849
+ }
1850
+ const phase = meta['codebuddy.ai/agentPhase'];
1851
+ if (typeof phase === 'object' && phase !== null) {
1852
+ const tick = phase;
1853
+ if (typeof tick.phase === 'string' && tick.phase !== '')
1854
+ out.agentPhase = { phase: tick.phase };
1855
+ }
1856
+ return Object.keys(out).length === 0 ? undefined : out;
1857
+ }
1858
+ /**
1859
+ * Remember a background drain this session stopped waiting on. A quiet close
1860
+ * drops the drain record (so an ordinary next turn is not held open for it),
1861
+ * but the run may revive afterwards; keeping its id lets every later update be
1862
+ * recognized as the drain's (`isRetiredAmbientId`) instead of being adopted as
1863
+ * the next turn's own. Bounded, oldest first — an id this far back names a run
1864
+ * no late update can plausibly still carry. Re-retiring an id refreshes its
1865
+ * timestamp and moves it to the end.
1866
+ */
1867
+ function retireAmbientId(session, requestId) {
1868
+ const retired = session.retiredAmbients ?? [];
1869
+ const existing = retired.findIndex(entry => entry.requestId === requestId);
1870
+ if (existing >= 0)
1871
+ retired.splice(existing, 1);
1872
+ retired.push({ requestId, retiredAt: Date.now() });
1873
+ while (retired.length > RETIRED_AMBIENT_MAX)
1874
+ retired.shift();
1875
+ session.retiredAmbients = retired;
1876
+ }
1877
+ /** Whether this request id names a drain the session already stopped waiting on. */
1878
+ function isRetiredAmbientId(session, requestId) {
1879
+ return requestId !== undefined
1880
+ && session.retiredAmbients?.some(entry => entry.requestId === requestId) === true;
1881
+ }
1882
+ /**
1883
+ * Whether the session retired a drain recently enough that a prompt answered
1884
+ * with no ambient record and no conversation id may have been folded into it.
1885
+ * A quiet close is only evidence WE stopped waiting, so the next instant
1886
+ * answer is ambiguous; past this horizon the drain is treated as gone and the
1887
+ * ordinary zero-output warning applies.
1888
+ */
1889
+ function hasRecentRetiredAmbient(session, withinMs) {
1890
+ const retired = session.retiredAmbients;
1891
+ if (retired === undefined || retired.length === 0)
1892
+ return false;
1893
+ const cutoff = Date.now() - withinMs;
1894
+ return retired.some(entry => entry.retiredAt >= cutoff);
1895
+ }
1896
+ /**
1897
+ * The retired drain spoke again (an update or a re-announcement carried its
1898
+ * id): it is alive, not retired. The caller rebuilds {@link LiveSession.ambient}
1899
+ * so the next prompt folded into it is recognised as steered and its text can
1900
+ * reach the user's turn; only while the id stays retired does its text remain
1901
+ * transcript-only.
1902
+ */
1903
+ function reviveAmbientId(session, requestId) {
1904
+ const retired = session.retiredAmbients;
1905
+ if (retired === undefined)
1906
+ return;
1907
+ const index = retired.findIndex(entry => entry.requestId === requestId);
1908
+ if (index < 0)
1909
+ return;
1910
+ retired.splice(index, 1);
1911
+ if (retired.length === 0)
1912
+ session.retiredAmbients = undefined;
1913
+ }
1914
+ /**
1915
+ * Forget every background-drain trace a session carried. Called at each
1916
+ * lifecycle boundary (close / release / resume / force reset): a fresh
1917
+ * agent-side state neither inherits a stale drain nor the ids of drains that
1918
+ * belonged to the old one.
1919
+ */
1920
+ function clearAmbientState(session) {
1921
+ session.ambient = undefined;
1922
+ session.ambientNotices?.clear();
1923
+ session.retiredAmbients = undefined;
1924
+ }
1925
+ /**
1926
+ * The closing line of a turn the agent deferred to its OWN background run: the
1927
+ * answer exists, but in the ACP transcript view rather than in this bubble.
1928
+ * Shared by the ambient wait and the detached-worker hold, so both say the
1929
+ * same thing in the same words.
1930
+ */
1931
+ function backgroundDeferralLine(agentId) {
1932
+ 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`;
1933
+ }
1503
1934
  /** Human-facing identity of an ACP tool call (the approval headline). */
1504
1935
  /**
1505
1936
  * Human-facing name of the tool an ask is about, for the chat line and the
@@ -1591,12 +2022,69 @@ function snapshot(session) {
1591
2022
  ...(session.availableCommands !== undefined ? { availableCommands: session.availableCommands } : {}),
1592
2023
  ...(session.plan !== undefined ? { plan: session.plan } : {}),
1593
2024
  ...(session.usage !== undefined ? { usage: session.usage } : {}),
2025
+ ...(session.burst !== undefined ? { burst: session.burst } : {}),
1594
2026
  running: session.running,
1595
2027
  closed: session.closed,
1596
2028
  ...(session.released ? { released: true } : {}),
2029
+ ...(session.hasHistory === true ? { hasHistory: true } : {}),
1597
2030
  seq: session.transcript.head,
1598
2031
  };
1599
2032
  }
2033
+ /**
2034
+ * The `fork` half of one `session/chat-list` row: the host's verdict on
2035
+ * forking this session's ACP agent. The capability half is the agent's LAST
2036
+ * KNOWN bit (see `canForkNow`); the state half is the session's own, released
2037
+ * checked before running (a parked record is never running). The browser
2038
+ * renders its branch button from this verdict and never reads a capability
2039
+ * bit itself.
2040
+ */
2041
+ function forkRowInfo(session, capability) {
2042
+ if (!capability)
2043
+ return { fork: { supported: false, reason: 'unsupported' } };
2044
+ if (session.released)
2045
+ return { fork: { supported: false, reason: 'released' } };
2046
+ if (session.running || session.settled !== undefined)
2047
+ return { fork: { supported: false, reason: 'busy' } };
2048
+ return { fork: { supported: true } };
2049
+ }
2050
+ function persistedChatSession(session) {
2051
+ return {
2052
+ sessionId: session.sessionId,
2053
+ acpSessionId: session.acpSessionId,
2054
+ agentId: session.agentId,
2055
+ dshSessionId: session.dshSessionId,
2056
+ cwd: session.cwd,
2057
+ ...(session.title === undefined ? {} : { title: session.title }),
2058
+ ...(session.modelId === undefined ? {} : { modelId: session.modelId }),
2059
+ ...(session.reasoningEffort === undefined ? {} : { reasoningEffort: session.reasoningEffort }),
2060
+ ...(session.modes === undefined ? {} : { modes: session.modes }),
2061
+ ...(session.usage === undefined ? {} : { usage: session.usage }),
2062
+ ...(session.burst === undefined ? {} : { burst: session.burst }),
2063
+ ...(session.hasHistory === true || session.transcript.since(0).length > 0 ? { hasHistory: true } : {}),
2064
+ };
2065
+ }
2066
+ /** Tolerant shape check for a `modes` value read back from disk. */
2067
+ function isPersistedModes(value) {
2068
+ if (value === null || typeof value !== 'object')
2069
+ return false;
2070
+ const modes = value;
2071
+ return typeof modes.currentModeId === 'string' && Array.isArray(modes.availableModes);
2072
+ }
2073
+ /** Tolerant shape check for a `usage` value read back from disk. */
2074
+ function isPersistedUsage(value) {
2075
+ if (value === null || typeof value !== 'object')
2076
+ return false;
2077
+ const usage = value;
2078
+ return typeof usage.used === 'number' && typeof usage.size === 'number';
2079
+ }
2080
+ /** Tolerant shape check for a `burst` aggregate read back from disk. */
2081
+ function isPersistedBurst(value) {
2082
+ if (value === null || typeof value !== 'object')
2083
+ return false;
2084
+ const burst = value;
2085
+ return typeof burst.outputTokens === 'number' && burst.outputTokens >= 0
2086
+ && typeof burst.burstMs === 'number' && burst.burstMs >= 0;
2087
+ }
1600
2088
  /**
1601
2089
  * The DSH chat session's OWN workspace, when one is resolvable: the directory
1602
2090
  * the user selected in the DSH sidebar, which the host reports back as the
@@ -2275,8 +2763,40 @@ export function releaseAllToolOutputWaiters() {
2275
2763
  for (const waiter of [...set])
2276
2764
  waiter(undefined);
2277
2765
  }
2278
- /** ACP `usage_update` is context occupancy, not request tokens; host usage is a 4-chars/token OUTPUT estimate (tool args excluded). */
2279
- const OUTPUT_CHARS_PER_TOKEN = 4;
2766
+ /** How often a running turn's live burst aggregate is pushed to the browser. */
2767
+ const TPS_PUSH_INTERVAL_MS = 1000;
2768
+ /**
2769
+ * Bounds on the updates withheld while a turn's own request id is still
2770
+ * unknown (see `TurnStreamController.bufferAmbientUpdate`). The buffer exists
2771
+ * only for the response window of one prompt, but a chatty drain could stream
2772
+ * for a while inside it; over the bound the OLDEST entries are dropped — the
2773
+ * transcript still has every one of them.
2774
+ */
2775
+ const PENDING_AMBIENT_MAX_ENTRIES = 200;
2776
+ const PENDING_AMBIENT_MAX_CHARS = 64 * 1024;
2777
+ /**
2778
+ * Bound on {@link LiveSession.ambientNotices}. The set now outlives the turn
2779
+ * that announced a drain (a hard ceiling keeps the record, and a repeated
2780
+ * announcement of the same run must stay silent), so it needs a ceiling of its
2781
+ * own for a chatty agent. Oldest ids are evicted first; an id that far back is
2782
+ * a run nobody is waiting on anymore.
2783
+ */
2784
+ const AMBIENT_NOTICE_MAX = 64;
2785
+ /**
2786
+ * Bound on {@link LiveSession.retiredAmbients}. The ids only exist to catch a
2787
+ * drain that speaks again after its wait closed, and the timestamp lets the
2788
+ * next silent close recognise a recent fold — a short-horizon concern, so a
2789
+ * small ring is enough; the oldest is evicted first.
2790
+ */
2791
+ const RETIRED_AMBIENT_MAX = 32;
2792
+ /**
2793
+ * Floor for the "recently retired" lookback the zero-output fallback uses
2794
+ * (twice `ambientQuietMs`). Only test-sized quiet windows fall below it; a
2795
+ * production window (10s) always wins. Without the floor a 250ms quiet window
2796
+ * would leave a 500ms lookback, too tight to survive a loaded CI scheduler
2797
+ * between one turn ending and the next prompt's response.
2798
+ */
2799
+ const RETIRED_AMBIENT_RECENT_MIN_MS = 1_000;
2280
2800
  /**
2281
2801
  * The state of ONE ACP prompt, shared across every driver step of the turn.
2282
2802
  *
@@ -2296,13 +2816,111 @@ class TurnStreamController {
2296
2816
  rollbackFirstTurn;
2297
2817
  segment;
2298
2818
  settled = false;
2819
+ /** Whether the turn's closing work already ran (see `closeTurn`). */
2820
+ closedTurn = false;
2299
2821
  disposed = false;
2300
2822
  seenCalls = new Set();
2301
2823
  announcedKinds = new Map();
2302
2824
  /** Narrative lines (permission asks) that arrived while no segment was open. */
2303
2825
  pendingProse = [];
2304
2826
  sawVisibleBlock = false;
2827
+ /**
2828
+ * Whether THIS turn streamed its own prose (text or reasoning), i.e. an
2829
+ * answer that is not the background drain's. Tool cards do not count — a
2830
+ * drain that only ran tools delivered no words to the conversation.
2831
+ */
2832
+ sawAgentProse = false;
2833
+ /**
2834
+ * Whether any `agent_message_chunk` text reached the plane. Reasoning does
2835
+ * not count: a steer whose drain only thought is still a turn with no answer
2836
+ * text, and must say so when it closes.
2837
+ */
2838
+ sawVisibleText = false;
2839
+ /**
2840
+ * The CodeBuddy request id this turn has adopted as its own (from the
2841
+ * prompt RESPONSE's `conversationRequestId`, or from the first update that
2842
+ * named one). Used to keep a background drain's prose out of the user's
2843
+ * answer; `undefined` means "unknown" and nothing is split (fail-open).
2844
+ */
2845
+ turnRequestId;
2846
+ /**
2847
+ * Updates the session's background drain already streamed while this turn's
2848
+ * OWN request id was still unknown. They are NOT folded on arrival: a drain
2849
+ * tick that lands before the prompt response could otherwise teach the turn
2850
+ * the drain's id (`noteTurnRequestId`) and mix the drain's text into the
2851
+ * user's answer. Withheld here (cursor already advanced — the transcript
2852
+ * keeps a copy) until the response decides what this turn is:
2853
+ * - a normal turn (response carries a conversation id) discards them — they
2854
+ * were never this turn's answer;
2855
+ * - a steered turn flushes them into the segment, in order, once
2856
+ * `awaitAmbient` marks the segment ambient (tool cards suppressed).
2857
+ */
2858
+ pendingAmbientUpdates = [];
2859
+ pendingAmbientChars = 0;
2860
+ /**
2861
+ * Whether the bounded buffer dropped at least one update. The flush then
2862
+ * says so in the conversation instead of silently losing the front of the
2863
+ * drain's output (constraint 29: ACP payloads are never dropped silently).
2864
+ */
2865
+ pendingAmbientTruncated = false;
2866
+ /** The ambient wait in progress, if the agent folded this prompt into a drain. */
2867
+ awaitingAmbient;
2868
+ /**
2869
+ * The prompt response named no conversation turn while a drain this session
2870
+ * retired RECENTLY may still be running without re-announcing. The turn still
2871
+ * settles at once (a drain record that no update revived is not waited on),
2872
+ * but its zero-output report says the turn may have been folded into that run
2873
+ * instead of blaming the agent and suggesting a resend.
2874
+ */
2875
+ recentRetiredDrain = false;
2876
+ /** The outcome of a settle whose closing work is waiting one macrotask (see `settle`). */
2877
+ pendingOutcome;
2878
+ closeScheduled = false;
2305
2879
  cursorSeq;
2880
+ /**
2881
+ * Burst tracker for this turn, fed from the LIVE hub frames only. Segment
2882
+ * replay must not touch it: every event is already counted once when it
2883
+ * arrived, and replay would double the numerator and the gaps.
2884
+ */
2885
+ tps = new TurnTpsTracker();
2886
+ tpsSeq;
2887
+ lastTpsPushAt = 0;
2888
+ /**
2889
+ * The ambient quiet/ceiling windows, read once from the manager: they are
2890
+ * deployment tuning, not per-tick facts, and small test values must be stable
2891
+ * for the whole turn (see {@link BridgeTuning.ambientQuietMs} for the
2892
+ * measured CodeBuddy tick cadence these defaults come from).
2893
+ */
2894
+ ambientTuning;
2895
+ /**
2896
+ * The detached workers this turn dispatched (see {@link BackgroundDispatch}),
2897
+ * by `toolCallId`, recorded from the wire while the turn is still streaming —
2898
+ * typically in the first step, before the prompt response lands. A turn may
2899
+ * dispatch several, and the hold waits for ALL of them.
2900
+ */
2901
+ dispatches = new Map();
2902
+ /** The detached-worker hold in progress, once a segment carries it. */
2903
+ backgroundHold;
2904
+ /**
2905
+ * A hold parked because the prompt response landed while NO driver segment
2906
+ * was open (the host is between steps, about to execute the Agent card). It
2907
+ * begins when the next `startSegment` opens — or closes after a bounded
2908
+ * grace so a host that never opens one does not hang the turn.
2909
+ */
2910
+ pendingBackgroundHold;
2911
+ /**
2912
+ * The hold's own windows, read once at construction like the ambient pair:
2913
+ * `taskMaxMs` is the worker phase's ceiling, `graceMs` the parked hold's
2914
+ * bounded wait for a driver step (see {@link BridgeTuning.backgroundTaskMaxMs}
2915
+ * and {@link BridgeTuning.backgroundParkedGraceMs}).
2916
+ */
2917
+ backgroundTuning;
2918
+ /**
2919
+ * When this controller was built. Only a drain announced AFTER this moment
2920
+ * may be adopted by the hold: an `ambient` record that predates the turn is
2921
+ * some earlier run's, and folding its text would mix two answers.
2922
+ */
2923
+ constructedAt;
2306
2924
  constructor(manager, hub, session, turnId,
2307
2925
  /** Releases the agent-side turn slot (the `beginTurn` settler; idempotent). */
2308
2926
  endTurn, rollbackFirstTurn) {
@@ -2314,6 +2932,10 @@ class TurnStreamController {
2314
2932
  this.rollbackFirstTurn = rollbackFirstTurn;
2315
2933
  this.turnSeqAtStart = session.turnSeq;
2316
2934
  this.cursorSeq = session.transcript.head;
2935
+ this.tpsSeq = session.transcript.head;
2936
+ this.constructedAt = Date.now();
2937
+ this.ambientTuning = manager.ambientTuning;
2938
+ this.backgroundTuning = manager.backgroundTuning;
2317
2939
  this.unsubscribe = hub.subscribe(event => this.onEvent(event));
2318
2940
  }
2319
2941
  unsubscribe;
@@ -2343,6 +2965,12 @@ class TurnStreamController {
2343
2965
  * consuming it, so the next segment replays it again.
2344
2966
  */
2345
2967
  startSegment() {
2968
+ // A step opening on an already settled turn (the response landed a tick
2969
+ // ago and the tail drain is still scheduled) closes that turn FIRST: the
2970
+ // step must read a settled controller and the session must not stay
2971
+ // `running` across the macrotask boundary — the host's next prompt would be
2972
+ // refused as busy for no reason.
2973
+ this.flushScheduledClose();
2346
2974
  // A step opening is the HOST handing the turn back to the agent — this
2347
2975
  // runs for the first step and for each continuation after the host
2348
2976
  // executed a card. The watchdog measures the stretch the AGENT holds the
@@ -2358,6 +2986,15 @@ class TurnStreamController {
2358
2986
  outputChars: 0,
2359
2987
  activeToolCall: undefined,
2360
2988
  hasCard: false,
2989
+ // A segment opened DURING an ambient wait (never the steered first step,
2990
+ // which marks its own) inherits the suppression: the drain's tools still
2991
+ // stay transcript-only until the turn closes. A detached-worker hold
2992
+ // suppresses its cards for the same reason, and a PARKED hold must
2993
+ // suppress from the first replayed update on — the drain's TaskOutput
2994
+ // arrives in exactly that step.
2995
+ ambient: this.awaitingAmbient !== undefined
2996
+ || this.backgroundHold !== undefined
2997
+ || this.pendingBackgroundHold !== undefined,
2361
2998
  finished: false,
2362
2999
  };
2363
3000
  this.segment = seg;
@@ -2365,7 +3002,16 @@ class TurnStreamController {
2365
3002
  for (const entry of entries) {
2366
3003
  if (seg.finished)
2367
3004
  break;
2368
- if (!this.foldInto(seg, entry.update))
3005
+ const update = entry.update;
3006
+ // The same split the live path makes: a background drain's prose is not
3007
+ // this turn's answer, even when it landed while no step was open. The
3008
+ // record (tool outputs) still goes in; the cursor moves past it.
3009
+ if (this.isForeignAmbientText(update)) {
3010
+ this.recordWithoutSegment(update);
3011
+ this.cursorSeq = entry.seq;
3012
+ continue;
3013
+ }
3014
+ if (!this.foldInto(seg, update, entry.seq))
2369
3015
  break;
2370
3016
  this.cursorSeq = entry.seq;
2371
3017
  }
@@ -2375,20 +3021,785 @@ class TurnStreamController {
2375
3021
  const lines = this.pendingProse.splice(0, this.pendingProse.length).join('');
2376
3022
  this.emitProse(seg, 'reasoning', lines);
2377
3023
  }
2378
- if (this.settled)
2379
- this.finishAfterSettle(seg);
2380
- return seg;
2381
- }
2382
- /** Finish the segment on air (if any). Used by cancel paths and teardown. */
2383
- finishActive(reason) {
2384
- if (this.segment !== undefined)
2385
- this.finishSegment(this.segment, reason);
3024
+ const parkedHold = this.pendingBackgroundHold;
3025
+ if (parkedHold !== undefined && !seg.finished) {
3026
+ // The host opened the continuation step for the Agent card: the hold
3027
+ // finally has a driver step to speak the drain's answer into.
3028
+ this.adoptBackgroundHold(seg, parkedHold);
3029
+ }
3030
+ if (this.settled)
3031
+ this.finishAfterSettle(seg);
3032
+ return seg;
3033
+ }
3034
+ /** Finish the segment on air (if any). Used by cancel paths and teardown. */
3035
+ finishActive(reason) {
3036
+ if (this.segment !== undefined)
3037
+ this.finishSegment(this.segment, reason);
3038
+ }
3039
+ /** Whether this turn already streamed prose of its own (not a drain's). */
3040
+ get hasAgentProse() {
3041
+ return this.sawAgentProse;
3042
+ }
3043
+ /**
3044
+ * Keep the turn open for a background drain the agent folded our prompt into.
3045
+ *
3046
+ * THE SHAPE: the agent answered `session/prompt` at once (CodeBuddy returns
3047
+ * `end_turn` in ~14ms when it merges the message into a drain it is already
3048
+ * running) and the real answer streams afterwards under the DRAIN's request
3049
+ * id. Closing on the response would cut the turn before its text exists —
3050
+ * which is exactly the bug this state exists for. So:
3051
+ *
3052
+ * - the agent-side prompt slot is released (`endTurn`: the response HAS
3053
+ * landed) but the turn is NOT settled: `settled` means "the turn is over",
3054
+ * and it is not — no `turn/end`, no `running = false`, no segment close;
3055
+ * - the segment keeps folding the session's updates, with cards suppressed
3056
+ * (see `SegmentState.ambient`): the host must not execute a background
3057
+ * tool for a turn whose answer has not begun;
3058
+ * - any update on the session resets the quiet clock, and the drain's end is
3059
+ * its silence (a `session_end` is rejected by the SDK's schema and never
3060
+ * reaches us — never wait for one); a hard ceiling closes what never ends.
3061
+ */
3062
+ awaitAmbient(requestId, stopReason) {
3063
+ if (this.settled || this.disposed || this.awaitingAmbient !== undefined)
3064
+ return;
3065
+ const seg = this.segment;
3066
+ if (seg === undefined || seg.finished) {
3067
+ // No live driver step to speak into: nothing can be folded, so close the
3068
+ // turn the ordinary way rather than holding state that renders nothing.
3069
+ this.clearPendingAmbient();
3070
+ this.settle({ kind: 'stop', stopReason });
3071
+ return;
3072
+ }
3073
+ seg.ambient = true;
3074
+ // The response landed: the agent no longer holds a prompt for this turn.
3075
+ this.endTurn();
3076
+ // The drain may have spoken BEFORE the response was judged. Those updates
3077
+ // were withheld while this turn's own request id was unknowable (see
3078
+ // `pendingAmbientUpdates`); the turn IS the drain, so they are its answer —
3079
+ // folded in arrival order, now that `seg.ambient` suppresses their cards.
3080
+ this.flushPendingAmbient(seg);
3081
+ if (seg.finished) {
3082
+ this.settle({ kind: 'stop', stopReason });
3083
+ return;
3084
+ }
3085
+ const wait = {
3086
+ requestId,
3087
+ quietTimer: setTimeout(() => { this.onAmbientQuiet(wait); }, this.ambientTuning.quietMs),
3088
+ hardTimer: setTimeout(() => { this.onAmbientHard(wait); }, this.ambientTuning.maxMs),
3089
+ stopReason,
3090
+ };
3091
+ wait.quietTimer.unref?.();
3092
+ wait.hardTimer.unref?.();
3093
+ this.awaitingAmbient = wait;
3094
+ }
3095
+ /**
3096
+ * The prompt response named no conversation turn while a drain the session
3097
+ * retired recently may still be running (and did not re-announce): the turn
3098
+ * cannot be held open, but a zero-output report must not blame the agent.
3099
+ * See `hasRecentRetiredAmbient` for the window.
3100
+ */
3101
+ noteRecentRetiredDrain() {
3102
+ this.recentRetiredDrain = true;
3103
+ }
3104
+ /**
3105
+ * Whether this turn dispatched a detached worker whose answer is still
3106
+ * outstanding. Read when the prompt response lands: such a turn is NOT over
3107
+ * even though the agent answered it (see {@link holdForBackgroundDispatch}).
3108
+ *
3109
+ * Two ways a dispatch keeps the turn open:
3110
+ * - it has not reported back yet (the worker is still running), or
3111
+ * - it DID report — before the response, even — but it was structurally
3112
+ * detached and emitted worker updates: the drain that carries its report
3113
+ * is still coming.
3114
+ * A synchronous `Agent` / `Task` call (completed, no `isBackground`, no
3115
+ * worker updates) matches neither and closes normally.
3116
+ */
3117
+ get dispatchedDetachedWorker() {
3118
+ for (const dispatch of this.dispatches.values()) {
3119
+ if (!dispatch.completed)
3120
+ return true;
3121
+ if (dispatch.detached && dispatch.sawWorkerUpdate)
3122
+ return true;
3123
+ }
3124
+ return false;
3125
+ }
3126
+ /** The hold this turn is inside — adopted, or parked waiting for a step. */
3127
+ activeBackgroundHold() {
3128
+ return this.backgroundHold ?? this.pendingBackgroundHold;
3129
+ }
3130
+ /** Whether every KNOWN dispatch has reported (no dispatch at all counts as done). */
3131
+ allDispatchesCompleted() {
3132
+ for (const dispatch of this.dispatches.values()) {
3133
+ if (!dispatch.completed)
3134
+ return false;
3135
+ }
3136
+ return true;
3137
+ }
3138
+ /**
3139
+ * Hold the turn open for the detached worker(s) its `Agent` tool calls
3140
+ * dispatched — the second controlled ambient-family exception (constraint
3141
+ * 23), not a new idle watchdog.
3142
+ *
3143
+ * THE SHAPE (measured against CodeBuddy 2.179.0, multitask on): the
3144
+ * coordinator opens an `Agent` call that hands work to a background worker,
3145
+ * says "已派…", and the `session/prompt` RESPONSE arrives normally while the
3146
+ * worker keeps running. The worker's own updates (requestId B +
3147
+ * `parentToolCallId`) never touch this session's message; when it finishes,
3148
+ * the agent starts a background DRAIN (requestId A — the response's own
3149
+ * `conversationRequestId`) whose steps carry the report and the final answer.
3150
+ * Nothing tells the client the worker is running: no `session_end` (the SDK's
3151
+ * schema rejects it) and an `agentPhase` idle tick even fires BETWEEN the
3152
+ * worker and the drain. So the response cannot close the turn.
3153
+ *
3154
+ * The hold:
3155
+ * - releases the agent-side prompt slot (`endTurn`: the response landed)
3156
+ * while keeping `running` and the driver step open;
3157
+ * - marks the segment background, so EVERY tool call for the rest of the
3158
+ * hold — the workers', the drain's TaskOutput — stays transcript-only and
3159
+ * the final message carries text with no card in front of it;
3160
+ * - waits in two phases: the worker phase only has the
3161
+ * {@link BridgeTuning.backgroundTaskMaxMs} ceiling (a detached worker goes
3162
+ * silent for tens of seconds while it works) and ends only when ALL known
3163
+ * dispatches reported, after which the drain phase uses the ambient
3164
+ * windows.
3165
+ */
3166
+ holdForBackgroundDispatch(stopReason, requestId) {
3167
+ if (this.settled || this.disposed || this.closedTurn || this.stale)
3168
+ return;
3169
+ if (this.activeBackgroundHold() !== undefined)
3170
+ return;
3171
+ const hold = {
3172
+ stopReason,
3173
+ requestId,
3174
+ acceptedIds: new Set(),
3175
+ phase: 'worker',
3176
+ answerChars: 0,
3177
+ floorSeq: this.session.transcript.head,
3178
+ retired: false,
3179
+ };
3180
+ if (requestId !== undefined)
3181
+ hold.acceptedIds.add(requestId);
3182
+ // A drain announced while this turn was ALREADY on air belongs to it (the
3183
+ // announcement may have named a different id than the response). One that
3184
+ // predates the turn is some earlier run's, and folding its text would mix
3185
+ // two answers.
3186
+ const announced = this.session.ambient;
3187
+ if (announced !== undefined && announced.startedAt >= this.constructedAt) {
3188
+ hold.acceptedIds.add(announced.requestId);
3189
+ if (hold.requestId === undefined)
3190
+ hold.requestId = announced.requestId;
3191
+ }
3192
+ // The response landed: the agent no longer holds a prompt for this turn.
3193
+ this.endTurn();
3194
+ hold.hardTimer = setTimeout(() => { this.onBackgroundHard(hold); }, this.backgroundTuning.taskMaxMs);
3195
+ hold.hardTimer.unref?.();
3196
+ const seg = this.segment;
3197
+ if (seg !== undefined && !seg.finished) {
3198
+ this.adoptBackgroundHold(seg, hold);
3199
+ }
3200
+ else {
3201
+ // The response landed BETWEEN driver steps: the host is about to execute
3202
+ // the Agent card and open its continuation step. Park the hold for that
3203
+ // step, with a bounded wait — a host that never opens one must not hang
3204
+ // the turn forever.
3205
+ hold.graceTimer = setTimeout(() => { this.onBackgroundGraceExpired(hold); }, this.backgroundTuning.graceMs);
3206
+ hold.graceTimer.unref?.();
3207
+ this.pendingBackgroundHold = hold;
3208
+ }
3209
+ // A FAST detached worker may already have reported (even before the
3210
+ // response): with every known dispatch complete there is no worker phase
3211
+ // left to wait out, so the drain phase starts now.
3212
+ if (this.allDispatchesCompleted())
3213
+ this.startBackgroundDrain(hold);
3214
+ }
3215
+ /** Give a (possibly parked) hold a driver segment: cards off for the rest of it. */
3216
+ adoptBackgroundHold(seg, hold) {
3217
+ if (hold.graceTimer !== undefined) {
3218
+ clearTimeout(hold.graceTimer);
3219
+ hold.graceTimer = undefined;
3220
+ }
3221
+ if (this.pendingBackgroundHold === hold)
3222
+ this.pendingBackgroundHold = undefined;
3223
+ seg.ambient = true;
3224
+ this.backgroundHold = hold;
3225
+ // The drain may have spoken BEFORE the response was judged. Those updates
3226
+ // were withheld while this turn's own id was unknown
3227
+ // (`pendingAmbientUpdates`); only the runs THIS hold owns are its answer —
3228
+ // another run's withheld text is dropped (the transcript keeps it).
3229
+ this.flushBackgroundHoldUpdates(seg, hold);
3230
+ // The parked grace owns the wait while no step is on air; now that one is,
3231
+ // a hold whose drain phase already began arms its quiet clock.
3232
+ if (hold.phase === 'drain')
3233
+ this.armBackgroundQuiet(hold);
3234
+ }
3235
+ /**
3236
+ * The worker phase is over once EVERY known dispatch reported: the drain
3237
+ * that carries their reports is (about to be) running, so the existing
3238
+ * ambient windows take over, measured from HERE.
3239
+ *
3240
+ * An announced run is accepted immediately, but while any dispatch is still
3241
+ * incomplete it only records the id and switches the hold's primary run —
3242
+ * arming the quiet clock there would let a drain that spoke early cut the
3243
+ * turn off before its other worker ever reports.
3244
+ */
3245
+ startBackgroundDrain(hold, requestId) {
3246
+ if (hold !== this.backgroundHold && hold !== this.pendingBackgroundHold)
3247
+ return;
3248
+ if (requestId !== undefined) {
3249
+ hold.acceptedIds.add(requestId);
3250
+ hold.requestId = requestId;
3251
+ }
3252
+ if (!this.allDispatchesCompleted())
3253
+ return;
3254
+ if (hold.phase === 'drain') {
3255
+ if (this.backgroundHold === hold)
3256
+ this.armBackgroundQuiet(hold);
3257
+ return;
3258
+ }
3259
+ hold.phase = 'drain';
3260
+ if (hold.hardTimer !== undefined)
3261
+ clearTimeout(hold.hardTimer);
3262
+ hold.hardTimer = setTimeout(() => { this.onBackgroundHard(hold); }, this.ambientTuning.maxMs);
3263
+ hold.hardTimer.unref?.();
3264
+ // A parked hold belongs to the grace until a driver step opens; a hold
3265
+ // with a step arms its quiet clock now.
3266
+ if (this.backgroundHold === hold)
3267
+ this.armBackgroundQuiet(hold);
3268
+ }
3269
+ /**
3270
+ * A dispatch that lands while the drain phase already began means the
3271
+ * coordinator handed out NEW work: the worker phase is back, and with it the
3272
+ * absence of a quiet window.
3273
+ */
3274
+ enterBackgroundWorkerPhase(hold) {
3275
+ if (hold.phase === 'worker')
3276
+ return;
3277
+ hold.phase = 'worker';
3278
+ if (hold.quietTimer !== undefined) {
3279
+ clearTimeout(hold.quietTimer);
3280
+ hold.quietTimer = undefined;
3281
+ }
3282
+ if (hold.hardTimer !== undefined)
3283
+ clearTimeout(hold.hardTimer);
3284
+ hold.hardTimer = setTimeout(() => { this.onBackgroundHard(hold); }, this.backgroundTuning.taskMaxMs);
3285
+ hold.hardTimer.unref?.();
3286
+ }
3287
+ /** Push the drain phase's quiet clock out: every update is evidence of work. */
3288
+ armBackgroundQuiet(hold) {
3289
+ if (hold.quietTimer !== undefined)
3290
+ clearTimeout(hold.quietTimer);
3291
+ hold.quietTimer = setTimeout(() => { this.onBackgroundQuiet(hold); }, this.ambientTuning.quietMs);
3292
+ hold.quietTimer.unref?.();
3293
+ }
3294
+ /**
3295
+ * Any session update restarts the drain phase's quiet clock. Only an
3296
+ * ADOPTED hold has one: a parked hold's wait belongs to its grace timer, and
3297
+ * the worker phase never has a clock at all.
3298
+ */
3299
+ noteBackgroundHoldActivity() {
3300
+ const hold = this.backgroundHold;
3301
+ if (hold === undefined || hold.phase !== 'drain')
3302
+ return;
3303
+ this.armBackgroundQuiet(hold);
3304
+ }
3305
+ /** The drained run went quiet: the turn ends with the stop reason the response gave. */
3306
+ onBackgroundQuiet(hold) {
3307
+ if (this.backgroundHold !== hold || hold.phase !== 'drain')
3308
+ return;
3309
+ this.closeBackgroundHold(hold, { hard: false });
3310
+ }
3311
+ /** The hold outlived its ceiling (worker or drain): the run is still going. */
3312
+ onBackgroundHard(hold) {
3313
+ if (hold !== this.backgroundHold && hold !== this.pendingBackgroundHold)
3314
+ return;
3315
+ this.closeBackgroundHold(hold, { hard: true });
3316
+ }
3317
+ /**
3318
+ * No driver step opened inside the grace: the host is not coming back for
3319
+ * this turn's Agent card. Close it the way a quiet ambient ending does — the
3320
+ * deferral line, never the zero-output blame — and keep ONE drain record:
3321
+ * the worker may still report into a run no turn is waiting on.
3322
+ *
3323
+ * A parked hold has NO segment by definition, so the chat plane has nowhere
3324
+ * to put the deferral line: it goes to the settings log (`agent/notice`)
3325
+ * instead of being parked on prose nobody will read.
3326
+ */
3327
+ onBackgroundGraceExpired(hold) {
3328
+ if (this.pendingBackgroundHold !== hold)
3329
+ return;
3330
+ this.pendingBackgroundHold = undefined;
3331
+ this.clearBackgroundHoldTimers(hold);
3332
+ const line = hold.answerChars > 0 ? undefined : backgroundDeferralLine(this.session.agentId);
3333
+ this.releaseBackgroundHoldIds(hold, { hard: true });
3334
+ if (line !== undefined)
3335
+ this.broadcastBackgroundDeferral(line);
3336
+ this.closeTurn({ kind: 'stop', stopReason: hold.stopReason }, { line, keepAmbient: true, requestId: hold.requestId });
3337
+ }
3338
+ /**
3339
+ * Close a detached-worker hold exactly once. A turn that folded NO answer
3340
+ * text from its held runs says where the rest of the output will land (the
3341
+ * deferral wording, never the silent-failure blame); the parent's interim
3342
+ * acknowledgement does not count as that answer.
3343
+ *
3344
+ * Ownership of the drain ids at close (`releaseBackgroundHoldIds`): a quiet
3345
+ * close retires every accepted id and drops the session record; a hard or
3346
+ * grace close keeps exactly ONE record, because the run is provably still
3347
+ * going and a later update or re-announcement must revive it normally.
3348
+ */
3349
+ closeBackgroundHold(hold, options) {
3350
+ if (this.pendingBackgroundHold === hold)
3351
+ this.pendingBackgroundHold = undefined;
3352
+ else if (this.backgroundHold === hold)
3353
+ this.backgroundHold = undefined;
3354
+ else
3355
+ return;
3356
+ this.clearBackgroundHoldTimers(hold);
3357
+ const line = hold.answerChars > 0 ? undefined : backgroundDeferralLine(this.session.agentId);
3358
+ this.releaseBackgroundHoldIds(hold, { hard: options.hard });
3359
+ const onAir = this.segment !== undefined && !this.segment.finished;
3360
+ // With no segment on air the closing line has no chat bubble to land in:
3361
+ // log it, so the fact is recorded rather than silently dropped.
3362
+ if (line !== undefined && !onAir)
3363
+ this.broadcastBackgroundDeferral(line);
3364
+ this.closeTurn({ kind: 'stop', stopReason: hold.stopReason }, { line, keepAmbient: options.hard, requestId: hold.requestId });
3365
+ }
3366
+ /** Write a hold's deferral line into the settings log (`agent/notice`). */
3367
+ broadcastBackgroundDeferral(line) {
3368
+ this.hub.broadcast({
3369
+ type: 'agent/notice',
3370
+ agentId: this.session.agentId,
3371
+ level: 'info',
3372
+ text: line.trim(),
3373
+ });
3374
+ }
3375
+ /** Drop a hold's clocks. The hold object itself stays for its `retired` mark. */
3376
+ clearBackgroundHoldTimers(hold) {
3377
+ if (hold.quietTimer !== undefined)
3378
+ clearTimeout(hold.quietTimer);
3379
+ if (hold.hardTimer !== undefined)
3380
+ clearTimeout(hold.hardTimer);
3381
+ if (hold.graceTimer !== undefined)
3382
+ clearTimeout(hold.graceTimer);
3383
+ hold.quietTimer = undefined;
3384
+ hold.hardTimer = undefined;
3385
+ hold.graceTimer = undefined;
3386
+ }
3387
+ /**
3388
+ * Drop every hold clock (teardown / a forced ending). `retire` also gives up
3389
+ * every accepted run id — a SUCCESSOR turn must never fold the abandoned
3390
+ * hold's drain or worker text into its own answer.
3391
+ */
3392
+ clearBackgroundHold(options) {
3393
+ const pending = this.pendingBackgroundHold;
3394
+ if (pending !== undefined) {
3395
+ this.pendingBackgroundHold = undefined;
3396
+ this.clearBackgroundHoldTimers(pending);
3397
+ if (options?.retire === true)
3398
+ this.releaseBackgroundHoldIds(pending, { hard: false });
3399
+ }
3400
+ const hold = this.backgroundHold;
3401
+ if (hold === undefined)
3402
+ return;
3403
+ this.backgroundHold = undefined;
3404
+ this.clearBackgroundHoldTimers(hold);
3405
+ if (options?.retire === true)
3406
+ this.releaseBackgroundHoldIds(hold, { hard: false });
3407
+ }
3408
+ /**
3409
+ * Settle what happens to the session's drain records when a hold closes.
3410
+ *
3411
+ * `hard: false` (quiet close, cancel, teardown): the run is no longer waited
3412
+ * on, so EVERY accepted id is retired — its late text stays transcript-only
3413
+ * for whatever turn runs next — and the session record is dropped if it
3414
+ * names one of them.
3415
+ *
3416
+ * `hard: true` (ceiling / parked grace): the run is provably still going.
3417
+ * Exactly ONE record survives — the session's current drain if it has one,
3418
+ * else the hold's primary run — with its activity clock refreshed; the other
3419
+ * accepted ids are retired. A hold whose primary id had no session record
3420
+ * CREATES one, so a successor's `noteTurnRequestId` cannot adopt that id.
3421
+ * Every accepted id therefore ends up either retired or owned by
3422
+ * `session.ambient`; never both unowned and unretired.
3423
+ */
3424
+ releaseBackgroundHoldIds(hold, options) {
3425
+ if (hold.retired)
3426
+ return;
3427
+ hold.retired = true;
3428
+ if (!options.hard) {
3429
+ for (const id of hold.acceptedIds)
3430
+ retireAmbientId(this.session, id);
3431
+ if (this.session.ambient !== undefined && hold.acceptedIds.has(this.session.ambient.requestId)) {
3432
+ this.session.ambient = undefined;
3433
+ }
3434
+ return;
3435
+ }
3436
+ const survivor = this.session.ambient?.requestId ?? hold.requestId;
3437
+ for (const id of hold.acceptedIds) {
3438
+ if (id === survivor)
3439
+ continue;
3440
+ retireAmbientId(this.session, id);
3441
+ }
3442
+ if (survivor === undefined)
3443
+ return;
3444
+ const now = Date.now();
3445
+ if (this.session.ambient?.requestId === survivor) {
3446
+ this.session.ambient.lastActivityAt = now;
3447
+ }
3448
+ else {
3449
+ this.session.ambient = {
3450
+ requestId: survivor,
3451
+ startedAt: now,
3452
+ lastActivityAt: now,
3453
+ sawOutput: hold.answerChars > 0,
3454
+ };
3455
+ }
3456
+ // The surviving run is alive, not retired (an earlier wait of this turn
3457
+ // may have retired it before it spoke again).
3458
+ reviveAmbientId(this.session, survivor);
3459
+ }
3460
+ /**
3461
+ * Fold the withheld drain updates that belong to this hold into its segment.
3462
+ * The rest of the buffer was some OTHER run's text: it is dropped here (the
3463
+ * transcript keeps it), never leaked into this turn's answer.
3464
+ */
3465
+ flushBackgroundHoldUpdates(seg, hold) {
3466
+ if (this.pendingAmbientUpdates.length === 0) {
3467
+ this.pendingAmbientTruncated = false;
3468
+ return;
3469
+ }
3470
+ const pending = this.pendingAmbientUpdates.splice(0, this.pendingAmbientUpdates.length);
3471
+ this.pendingAmbientChars = 0;
3472
+ const truncated = this.pendingAmbientTruncated;
3473
+ this.pendingAmbientTruncated = false;
3474
+ const accepted = pending.filter(entry => {
3475
+ const requestId = codebuddyMetaOf(entry.update)?.requestId;
3476
+ return requestId !== undefined && hold.acceptedIds.has(requestId);
3477
+ });
3478
+ if (accepted.length === 0)
3479
+ return;
3480
+ if (truncated && !seg.finished) {
3481
+ // The front of the drain's output was evicted from the bounded buffer:
3482
+ // say so rather than letting the turn quietly start mid-story.
3483
+ this.emitProse(seg, 'text', '\n[dsh-ntr] background output was truncated; the ACP transcript view still has every update.\n');
3484
+ }
3485
+ for (const entry of accepted) {
3486
+ if (seg.finished)
3487
+ break;
3488
+ this.foldInto(seg, entry.update, entry.seq);
3489
+ // The buffered drain text arrived BEFORE the hold, but it IS the held
3490
+ // run's answer: below the hold's floor it was not counted by `foldInto`,
3491
+ // so count it here (the floor exists to skip the parent's earlier ack,
3492
+ // not the drain's own words).
3493
+ if (entry.seq <= hold.floorSeq
3494
+ && String(entry.update.sessionUpdate ?? '') === 'agent_message_chunk') {
3495
+ hold.answerChars += chunkTextOf(entry.update.content).length;
3496
+ }
3497
+ }
3498
+ }
3499
+ /**
3500
+ * Follow the detached-worker signals of one live update.
3501
+ *
3502
+ * Only CodeBuddy sends them (`codebuddyMetaOf` answers `undefined` for every
3503
+ * other agent, so nothing here can fire for them):
3504
+ * - an `Agent` / `Task` `tool_call` records the dispatch — the FIRST send
3505
+ * needs that CodeBuddy shape;
3506
+ * - the SAME call re-sent with `isBackground: true`, or any update naming it
3507
+ * as its `parentToolCallId`, marks the work DETACHED;
3508
+ * - that call reporting completed / failed is read BY ID: the real
3509
+ * completion frame carries NO `_meta` at all, so requiring the CodeBuddy
3510
+ * shape here would miss every real completion;
3511
+ * - any update carrying `parentToolCallId` is the worker's OWN turn — it
3512
+ * proves the worker alive but never belongs to this session's answer;
3513
+ * - an `unsolicitedTurn` announcement is the drain that carries the report.
3514
+ */
3515
+ trackBackgroundDispatch(update) {
3516
+ if (this.closedTurn)
3517
+ return;
3518
+ const meta = codebuddyMetaOf(update);
3519
+ const kind = String(update.sessionUpdate ?? '');
3520
+ const row = update;
3521
+ const toolCallId = typeof row.toolCallId === 'string' ? row.toolCallId.trim() : '';
3522
+ const status = typeof row.status === 'string' ? row.status : '';
3523
+ const completedStatus = status === 'completed' || status === 'failed';
3524
+ if ((kind === 'tool_call' || kind === 'tool_call_update') && toolCallId !== '') {
3525
+ const known = this.dispatches.get(toolCallId);
3526
+ if (known !== undefined) {
3527
+ // A known dispatch: the id is the whole evidence (see the doc above).
3528
+ if (status !== '')
3529
+ known.status = status;
3530
+ if (meta?.isBackground === true)
3531
+ known.detached = true;
3532
+ if (completedStatus && !known.completed) {
3533
+ known.completed = true;
3534
+ this.noteDispatchProgress();
3535
+ }
3536
+ }
3537
+ else if (meta !== undefined
3538
+ && (meta.toolName === 'Agent' || meta.toolName === 'Task')
3539
+ && (kind === 'tool_call' || meta.isBackground === true)) {
3540
+ // A NEW dispatch needs the CodeBuddy shape. A turn may dispatch more
3541
+ // than one worker; the hold waits for all of them.
3542
+ const dispatch = {
3543
+ toolCallId,
3544
+ status,
3545
+ completed: completedStatus,
3546
+ detached: meta.isBackground === true,
3547
+ sawWorkerUpdate: false,
3548
+ };
3549
+ this.dispatches.set(toolCallId, dispatch);
3550
+ if (!dispatch.completed) {
3551
+ const held = this.activeBackgroundHold();
3552
+ // A dispatch registered after the drain phase began means new work:
3553
+ // the worker phase (and its absence of a quiet clock) is back.
3554
+ if (held !== undefined)
3555
+ this.enterBackgroundWorkerPhase(held);
3556
+ }
3557
+ }
3558
+ }
3559
+ if (meta?.parentToolCallId !== undefined) {
3560
+ const parent = this.dispatches.get(meta.parentToolCallId);
3561
+ if (parent !== undefined) {
3562
+ parent.detached = true;
3563
+ parent.sawWorkerUpdate = true;
3564
+ }
3565
+ }
3566
+ if (meta?.unsolicitedTurn !== undefined) {
3567
+ const held = this.activeBackgroundHold();
3568
+ if (held !== undefined)
3569
+ this.startBackgroundDrain(held, meta.unsolicitedTurn.requestId);
3570
+ }
3571
+ }
3572
+ /**
3573
+ * A dispatch just reported: once the LAST one has, the drain phase starts.
3574
+ * Until then the turn stays in its worker phase, with no quiet clock.
3575
+ */
3576
+ noteDispatchProgress() {
3577
+ if (!this.allDispatchesCompleted())
3578
+ return;
3579
+ const held = this.activeBackgroundHold();
3580
+ if (held !== undefined)
3581
+ this.startBackgroundDrain(held);
3582
+ }
3583
+ /** Any session update is evidence the drain still works: push the quiet clock out. */
3584
+ noteAmbientActivity() {
3585
+ const wait = this.awaitingAmbient;
3586
+ if (wait === undefined)
3587
+ return;
3588
+ clearTimeout(wait.quietTimer);
3589
+ wait.quietTimer = setTimeout(() => { this.onAmbientQuiet(wait); }, this.ambientTuning.quietMs);
3590
+ wait.quietTimer.unref?.();
3591
+ }
3592
+ /** The drain went quiet: the turn ends with the stop reason the response gave. */
3593
+ onAmbientQuiet(wait) {
3594
+ if (this.awaitingAmbient !== wait)
3595
+ return;
3596
+ this.finishAmbient(wait);
3597
+ }
3598
+ /** The drain outlived the ceiling: say where the rest will land, then close. */
3599
+ onAmbientHard(wait) {
3600
+ if (this.awaitingAmbient !== wait)
3601
+ return;
3602
+ const seg = this.segment;
3603
+ if (seg !== undefined && !seg.finished) {
3604
+ 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`);
3605
+ }
3606
+ // The ceiling is evidence the drain is STILL RUNNING (unlike a quiet close),
3607
+ // so the ending keeps the session's drain record; see `closeTurn`.
3608
+ wait.hard = true;
3609
+ // The hard explanation already says the turn was cut short; the deferred
3610
+ // line would only repeat it in different words.
3611
+ this.finishAmbient(wait, { announceDeferral: false });
3612
+ }
3613
+ /**
3614
+ * Close an ambient wait exactly once. A turn that produced no answer TEXT
3615
+ * says so: the drain's output is in the ACP transcript view, and this
3616
+ * bubble must not be empty (the zero-output report, reworded for a turn that
3617
+ * was deferred rather than failed).
3618
+ */
3619
+ finishAmbient(wait, options) {
3620
+ if (this.awaitingAmbient !== wait)
3621
+ return;
3622
+ const line = options?.announceDeferral === false || this.sawVisibleText
3623
+ ? undefined
3624
+ : backgroundDeferralLine(this.session.agentId);
3625
+ this.closeTurn({ kind: 'stop', stopReason: wait.stopReason }, { line, keepAmbient: wait.hard === true, requestId: wait.requestId });
3626
+ }
3627
+ /** Drop the ambient wait's clocks (the turn is being closed by someone else). */
3628
+ clearAmbientWait() {
3629
+ const wait = this.awaitingAmbient;
3630
+ if (wait === undefined)
3631
+ return;
3632
+ this.awaitingAmbient = undefined;
3633
+ clearTimeout(wait.quietTimer);
3634
+ clearTimeout(wait.hardTimer);
3635
+ }
3636
+ /**
3637
+ * Withhold one background-drain update until the prompt response says what
3638
+ * this turn is. The cursor advances past it anyway: it is already in the
3639
+ * transcript, and replaying it into a later segment would emit it twice.
3640
+ * Bounded (see `PENDING_AMBIENT_MAX_*`) — the transcript still keeps every
3641
+ * entry the buffer drops.
3642
+ */
3643
+ bufferAmbientUpdate(seq, update) {
3644
+ const chars = JSON.stringify(update).length;
3645
+ this.pendingAmbientUpdates.push({ seq, update, chars });
3646
+ this.pendingAmbientChars += chars;
3647
+ while (this.pendingAmbientUpdates.length > PENDING_AMBIENT_MAX_ENTRIES
3648
+ || (this.pendingAmbientChars > PENDING_AMBIENT_MAX_CHARS && this.pendingAmbientUpdates.length > 1)) {
3649
+ const dropped = this.pendingAmbientUpdates.shift();
3650
+ if (dropped === undefined)
3651
+ break;
3652
+ this.pendingAmbientChars -= dropped.chars;
3653
+ this.pendingAmbientTruncated = true;
3654
+ }
3655
+ }
3656
+ /** Fold the withheld drain updates into the now-ambient segment, once, in order. */
3657
+ flushPendingAmbient(seg) {
3658
+ if (this.pendingAmbientUpdates.length === 0) {
3659
+ this.pendingAmbientTruncated = false;
3660
+ return;
3661
+ }
3662
+ const truncated = this.pendingAmbientTruncated;
3663
+ this.pendingAmbientTruncated = false;
3664
+ const pending = this.pendingAmbientUpdates.splice(0, this.pendingAmbientUpdates.length);
3665
+ this.pendingAmbientChars = 0;
3666
+ if (truncated && !seg.finished) {
3667
+ // The front of the drain's output was evicted from the bounded buffer:
3668
+ // say so rather than letting the turn quietly start mid-story.
3669
+ this.emitProse(seg, 'text', '\n[dsh-ntr] background output was truncated; the ACP transcript view still has every update.\n');
3670
+ }
3671
+ for (const entry of pending) {
3672
+ if (seg.finished)
3673
+ break;
3674
+ this.foldInto(seg, entry.update, entry.seq);
3675
+ }
3676
+ }
3677
+ /** Drop withheld drain updates: this turn is not the drain, or it is closing. */
3678
+ clearPendingAmbient() {
3679
+ this.pendingAmbientUpdates.length = 0;
3680
+ this.pendingAmbientChars = 0;
3681
+ this.pendingAmbientTruncated = false;
3682
+ }
3683
+ /** The agent settled its `session/prompt`. Ends the turn's streaming face. */
3684
+ settle(outcome) {
3685
+ if (this.settled)
3686
+ return;
3687
+ // `settled` is claimed NOW (a second settle, a cancel or a watchdog that
3688
+ // races the tail drain must find the turn already spoken for), but the
3689
+ // CLOSING work waits one macrotask: the SDK dispatches `session/update`
3690
+ // notifications independently of the `session/prompt` RESPONSE, so the
3691
+ // agent's last chunks can still be queued when this lands (see
3692
+ // `settleTranscript`). Only what already reached the transcript is read —
3693
+ // one macrotask, never an open-ended wait.
3694
+ this.settled = true;
3695
+ this.clearAmbientWait();
3696
+ this.clearPendingAmbient();
3697
+ // An outside ending (watchdog, cancel) abandons a detached-worker hold: its
3698
+ // run is no longer this turn's, so its id is retired before the close.
3699
+ this.clearBackgroundHold({ retire: true });
3700
+ this.pendingOutcome = outcome;
3701
+ this.closeScheduled = true;
3702
+ setImmediate(() => { this.flushScheduledClose(); });
3703
+ }
3704
+ /**
3705
+ * Force the local end of this turn — the cancel fallback and any watchdog
3706
+ * that reached the controller. Runs the SAME closing work as a normal settle
3707
+ * (`closeTurn`): the ambient clocks stop, the on-air segment closes (so the
3708
+ * driver step parked on its queue ends), exactly one `turn/end` goes out
3709
+ * with THIS turn's id, the session stops running and the prompt slot is
3710
+ * released. Idempotent: a turn that already closed ignores the call.
3711
+ */
3712
+ forceFinalize(outcome) {
3713
+ if (this.stale || this.closedTurn)
3714
+ return;
3715
+ this.clearAmbientWait();
3716
+ // A forced ending abandons a detached-worker hold: retire its run's id so
3717
+ // its late words stay transcript-only for the next turn.
3718
+ this.clearBackgroundHold({ retire: true });
3719
+ // A settle's deferred tail read is moot once this close runs: the turn is
3720
+ // over now, and the pending macrotask must not drain anything into it.
3721
+ this.pendingOutcome = undefined;
3722
+ this.closeScheduled = false;
3723
+ this.closeTurn(outcome);
3724
+ }
3725
+ /**
3726
+ * Run the deferred closing work now, when a driver step is about to open on
3727
+ * an already settled turn: the continuation step must see the closed turn
3728
+ * (and the session NOT left `running` across the macrotask boundary).
3729
+ * Idempotent — the scheduled tick then finds nothing left to do.
3730
+ */
3731
+ flushScheduledClose() {
3732
+ if (!this.closeScheduled)
3733
+ return;
3734
+ this.closeScheduled = false;
3735
+ const outcome = this.pendingOutcome;
3736
+ this.pendingOutcome = undefined;
3737
+ if (outcome === undefined)
3738
+ return;
3739
+ // A DISPOSE that raced this queued close cleared it (see `dispose`), so a
3740
+ // close still scheduled here belongs to a settle that landed AFTER the
3741
+ // stream was retired (a crash rejects its prompt only after the teardown).
3742
+ // That turn still ends through its own queue and real id — the teardown's
3743
+ // releaseAll frame carried an empty id.
3744
+ this.drainTail();
3745
+ this.closeTurn(outcome);
3746
+ }
3747
+ /**
3748
+ * Fold the notifications that reached the transcript but not the segment:
3749
+ * the tail a response raced past. Stops AT a cutting event without consuming
3750
+ * it (same rule as `startSegment`) — its next segment is not this turn's.
3751
+ */
3752
+ drainTail() {
3753
+ // The deferred read belongs to the turn that scheduled it. If the turn was
3754
+ // retired or a NEWER turn already began (turnSeq moved on) in this one
3755
+ // macrotask window, everything past the cursor is the successor's: folding
3756
+ // it here would leak its text into this turn's driver step.
3757
+ if (this.stale)
3758
+ return;
3759
+ const seg = this.segment;
3760
+ if (seg === undefined || seg.finished)
3761
+ return;
3762
+ for (const entry of this.session.transcript.since(this.cursorSeq)) {
3763
+ if (seg.finished)
3764
+ break;
3765
+ const update = entry.update;
3766
+ // The same split the live path makes: a background drain's text in the
3767
+ // tail is not this turn's answer. Record what the registry needs (tool
3768
+ // outputs), move the cursor past it, and do NOT fold it — otherwise the
3769
+ // deferred read smuggles the drain's prose into a normal turn.
3770
+ if (this.isForeignAmbientText(update)) {
3771
+ this.recordWithoutSegment(update);
3772
+ this.cursorSeq = entry.seq;
3773
+ continue;
3774
+ }
3775
+ if (!this.foldInto(seg, update, entry.seq))
3776
+ break;
3777
+ this.cursorSeq = entry.seq;
3778
+ }
2386
3779
  }
2387
- /** The agent settled its `session/prompt`. Ends the turn's streaming face. */
2388
- settle(outcome) {
2389
- if (this.settled)
3780
+ /**
3781
+ * End the turn exactly once: burst metrics, ONE `turn/end`, the provisional
3782
+ * lock, the closing text, the segment, the session flags, the prompt slot.
3783
+ *
3784
+ * `ambient.line` marks a controlled ambient ending: the given line closes
3785
+ * the segment when the turn carried no answer text, instead of the generic
3786
+ * zero-output warning (which would blame the agent for a turn the drain
3787
+ * simply had not finished yet). `ambient.keepAmbient` says the ending was the
3788
+ * CEILING, not silence: see the drain-record comment below.
3789
+ */
3790
+ closeTurn(outcome, ambient) {
3791
+ if (this.closedTurn)
2390
3792
  return;
3793
+ this.closedTurn = true;
3794
+ // A closed turn is a settled turn, whichever path got here first (an
3795
+ // ambient ending does not go through `settle`).
2391
3796
  this.settled = true;
3797
+ this.clearAmbientWait();
3798
+ this.clearPendingAmbient();
3799
+ // Whoever closed this turn already decided what happens to the held
3800
+ // run's record (`closeBackgroundHold` retired or kept it); only the clocks
3801
+ // are still ours to drop.
3802
+ this.clearBackgroundHold();
2392
3803
  // A late answer to a SUPERSEDED turn still has to close this controller's
2393
3804
  // queue (a continuation step may be parked on it) and its settle gate —
2394
3805
  // but it must not touch the session the NEWER turn now owns, nor release
@@ -2400,6 +3811,41 @@ class TurnStreamController {
2400
3811
  ? { kind: 'error', failure: { message: outcome.message, code: outcome.code ?? 'INTERNAL' } }
2401
3812
  : { kind: 'stop' };
2402
3813
  if (current) {
3814
+ // An ambient ending that came from SILENCE retires the drain record with
3815
+ // its turn: a record that outlives the wait would make the NEXT instant
3816
+ // answer with no conversation request id look steered (see
3817
+ // `awaitAmbient`), holding a normal turn open for a drain this one
3818
+ // already finished waiting on. A HARD ceiling ending is the opposite
3819
+ // evidence — the drain was still ticking when the turn was cut — so the
3820
+ // record SURVIVES (activity clock refreshed; `ambientStaleMs` expires it
3821
+ // lazily) precisely so the next prompt folded into the same drain is
3822
+ // still recognised as steered. `ambientNotices` is never cleared here:
3823
+ // a repeated announcement of the same run must stay silent, and the set
3824
+ // is bounded where entries are added.
3825
+ if (ambient !== undefined) {
3826
+ if (ambient.keepAmbient === true) {
3827
+ if (this.session.ambient !== undefined)
3828
+ this.session.ambient.lastActivityAt = Date.now();
3829
+ }
3830
+ else {
3831
+ // Silence closed this wait, but silence is not proof the run ended: a
3832
+ // queued message or a long tool can make the drain speak again. So
3833
+ // RETIRE its id before dropping the record — a revived run's late
3834
+ // text must stay in the ACP transcript view instead of teaching the
3835
+ // NEXT turn the drain's id and mixing the drain's answer into the
3836
+ // user's (a double answer). The trade-off is deliberate: a next
3837
+ // prompt the agent folds into that same revived drain may not receive
3838
+ // the drain's text on the chat plane (transcript-only); a double
3839
+ // answer is worse.
3840
+ if (ambient.requestId !== undefined)
3841
+ retireAmbientId(this.session, ambient.requestId);
3842
+ this.session.ambient = undefined;
3843
+ }
3844
+ }
3845
+ // The turn's burst aggregate must be visible before the turn is marked
3846
+ // over: the final `turn/metrics` lands first, then `turn/end` flips the
3847
+ // session/pill state. Persisting and broadcasting share one value.
3848
+ this.finalizeTps();
2403
3849
  if (outcome.kind === 'cancelled') {
2404
3850
  this.hub.broadcast({ type: 'turn/end', sessionId: this.session.sessionId, turnId: this.turnId, stopReason: 'cancelled' });
2405
3851
  }
@@ -2411,20 +3857,31 @@ class TurnStreamController {
2411
3857
  }
2412
3858
  // The provisional lock settles BEFORE any synthesized warning below: the
2413
3859
  // warning is our text, not evidence that the first turn delivered anything.
3860
+ //
3861
+ // An AMBIENT ending ends the provisional phase WITHOUT a rollback: a
3862
+ // deferral to a background drain is not a failed first turn, the drain's
3863
+ // text still lands in the ACP transcript view, and rolling the lock back
3864
+ // would close the session under it — dropping exactly the output the
3865
+ // deferred turn promised. The lock therefore becomes permanent.
2414
3866
  if (this.session.provisionalLock === true) {
2415
3867
  this.session.provisionalLock = false;
2416
- if (!this.sawVisibleBlock)
3868
+ if (!this.sawVisibleBlock && ambient === undefined)
2417
3869
  this.rollbackFirstTurn(this.session);
2418
3870
  }
2419
3871
  }
2420
3872
  const seg = this.segment;
2421
3873
  if (seg !== undefined) {
2422
- if (current && outcome.kind === 'stop' && !this.sawVisibleBlock) {
3874
+ if (ambient !== undefined) {
3875
+ // The ambient line is OUR report, so it only appears when the drain
3876
+ // delivered no answer text of its own; reasoning alone does not count.
3877
+ if (ambient.line !== undefined && current)
3878
+ this.emitProse(seg, 'text', ambient.line);
3879
+ }
3880
+ else if (current && outcome.kind === 'stop' && !this.sawVisibleBlock) {
2423
3881
  // The silent-failure report: a normal stop with nothing the chat
2424
3882
  // plane renders. Aborted or failing turns need no extra hint; this
2425
3883
  // is specifically "protocol says success, the bubble is empty".
2426
- 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`;
2427
- this.emitProse(seg, 'text', line);
3884
+ this.emitProse(seg, 'text', this.zeroOutputLine(outcome.stopReason));
2428
3885
  }
2429
3886
  this.finishSegment(seg, reason);
2430
3887
  }
@@ -2439,25 +3896,60 @@ class TurnStreamController {
2439
3896
  }
2440
3897
  this.endTurn();
2441
3898
  // The controller stays indexed only while a continuation step can still
2442
- // come for its cards. A cancelled, failed, or card-less turn has none:
2443
- // retire it now; the next turn's `rememberTurnStream` would anyway.
3899
+ // come for its cards. A cancelled, failed, or card-less turn has none: retire
3900
+ // it now; the next turn's `rememberTurnStream` would anyway. An ambient turn
3901
+ // ALWAYS retires: its suppressed calls never open cards, so keeping the index
3902
+ // would pin the controller (and its session id) to a turn nobody can continue.
2444
3903
  if (!current)
2445
3904
  this.retire();
2446
- else if (outcome.kind !== 'stop' || this.seenCalls.size === 0)
3905
+ else if (ambient !== undefined || outcome.kind !== 'stop' || this.seenCalls.size === 0)
2447
3906
  this.dispose();
2448
3907
  }
2449
- /** Cancelled by the user: finish the on-air segment and release the turn's slots. */
3908
+ /**
3909
+ * Cancelled by the user: finish the on-air segment and release the turn's
3910
+ * slots. An AMBIENT turn has already released the prompt slot (its response
3911
+ * landed) and holds no segment the cancel fallback could close, so the
3912
+ * cancel closes the turn itself with the cancelled stop.
3913
+ */
2450
3914
  abortActiveSegment() {
2451
3915
  if (this.stale)
2452
3916
  return;
3917
+ this.clearPendingAmbient();
3918
+ if (this.closeScheduled) {
3919
+ // `settle` claimed the turn but queued its closing work for the next
3920
+ // macrotask. An abort landing inside that window must not let the
3921
+ // scheduled close broadcast the RESPONSE's stop reason while the segment
3922
+ // already finished `aborted`: drop the queued outcome and close NOW with
3923
+ // the cancelled one, so `turn/end` and the segment's finish agree.
3924
+ this.closeScheduled = false;
3925
+ this.pendingOutcome = undefined;
3926
+ this.closeTurn({ kind: 'cancelled' });
3927
+ return;
3928
+ }
3929
+ if (this.awaitingAmbient !== undefined) {
3930
+ this.settle({ kind: 'cancelled' });
3931
+ return;
3932
+ }
3933
+ if (this.backgroundHold !== undefined || this.pendingBackgroundHold !== undefined) {
3934
+ // A detached-worker hold is a turn whose prompt response already landed
3935
+ // (the slot is free) and whose segment is still open on purpose: the
3936
+ // cancel ends OUR face of it now, once, and the held run's id is retired
3937
+ // so its late text cannot join the next turn.
3938
+ this.clearBackgroundHold({ retire: true });
3939
+ this.settle({ kind: 'cancelled' });
3940
+ return;
3941
+ }
2453
3942
  this.finishActive({ kind: 'aborted', failure: { message: 'aborted', code: 'ABORTED' } });
2454
3943
  // The agent may take its time acknowledging the cancel, but OUR face of
2455
- // the turn ends now: the session stops running and a provisional first
2456
- // lock settles against what was actually shown. The turn SLOT stays
2457
- // claimed, though: the agent-side prompt is still in flight, and a second
3944
+ // the turn ends now: the session stops running, the HOST's prompt slot is
3945
+ // released (so a new prompt may claim it), and a provisional first lock
3946
+ // settles against what was actually shown. The AGENT-side gate (`settled`)
3947
+ // stays claimed: the prompt is still in flight over there, and a second
2458
3948
  // prompt sent into that window would be a concurrent `session/prompt` the
2459
- // protocol forbids — `settle` (or the cancel fallback) releases the slot.
3949
+ // protocol forbids — `claimTurn` waits it out, `settle` (or the cancel
3950
+ // fallback) opens it.
2460
3951
  this.session.running = false;
3952
+ this.session.promptInFlight = false;
2461
3953
  if (this.session.provisionalLock === true) {
2462
3954
  this.session.provisionalLock = false;
2463
3955
  if (!this.sawVisibleBlock)
@@ -2465,10 +3957,30 @@ class TurnStreamController {
2465
3957
  }
2466
3958
  releaseAllToolOutputWaiters();
2467
3959
  }
2468
- /** Stop observing and release the manager's index. Idempotent. */
3960
+ /**
3961
+ * Stop observing and release the manager's index. Idempotent.
3962
+ *
3963
+ * The segment is CLOSED here (aborted) before unsubscribing: a driver step
3964
+ * parked on its queue (`yield* segment.queue`) only ends when the queue
3965
+ * closes, and a controller replaced by the conversation's next turn would
3966
+ * otherwise leave that step hanging forever. An already finished segment is a
3967
+ * no-op, and the session's flags are left alone — a superseded turn must not
3968
+ * mark the newer turn's session idle.
3969
+ */
2469
3970
  dispose() {
2470
3971
  if (this.disposed)
2471
3972
  return;
3973
+ // A settle may have queued its closing work for the next macrotask: this
3974
+ // close owns the turn now, so that tick must not broadcast a second
3975
+ // `turn/end` (with this turn's real id) over the teardown.
3976
+ this.closeScheduled = false;
3977
+ this.pendingOutcome = undefined;
3978
+ this.finishActive({ kind: 'aborted', failure: { message: 'turn stream retired', code: 'RETIRED' } });
3979
+ this.clearAmbientWait();
3980
+ this.clearPendingAmbient();
3981
+ // A successor turn must never fold this hold's drain or worker text: the
3982
+ // abandoned run's id is retired before the subscription goes away.
3983
+ this.clearBackgroundHold({ retire: true });
2472
3984
  this.retire();
2473
3985
  releaseAllToolOutputWaiters();
2474
3986
  }
@@ -2532,12 +4044,24 @@ class TurnStreamController {
2532
4044
  else {
2533
4045
  // A settled turn that never showed anything: the zero-output warning the
2534
4046
  // plane would have gotten from a single-stream turn (constraint 23).
2535
- 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`;
4047
+ const line = this.zeroOutputLine();
2536
4048
  if (seg.index < 0)
2537
4049
  this.emitProse(seg, 'text', line);
2538
4050
  this.finishSegment(seg, { kind: 'stop' });
2539
4051
  }
2540
4052
  }
4053
+ /**
4054
+ * The "protocol says success, the bubble is empty" line. When a recently
4055
+ * retired drain may have taken the prompt without re-announcing itself, say
4056
+ * that instead of blaming the agent: the output exists in the transcript.
4057
+ */
4058
+ zeroOutputLine(stopReason) {
4059
+ if (this.recentRetiredDrain) {
4060
+ 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`;
4061
+ }
4062
+ const reason = stopReason === undefined ? '' : ` (stop reason: ${stopReason})`;
4063
+ 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`;
4064
+ }
2541
4065
  onEvent(event) {
2542
4066
  if (this.disposed)
2543
4067
  return;
@@ -2546,9 +4070,50 @@ class TurnStreamController {
2546
4070
  return;
2547
4071
  if (row.type === 'session/update') {
2548
4072
  const typed = event;
4073
+ // Any update — a `usage_update` / phase tick included — says the
4074
+ // background drain is still working, so the ambient quiet clock restarts.
4075
+ this.noteAmbientActivity();
4076
+ // A detached-worker hold's drain phase has the same rule; its worker
4077
+ // phase never does (see `BridgeTuning.backgroundTaskMaxMs`).
4078
+ this.noteBackgroundHoldActivity();
4079
+ const update = typed.update;
4080
+ // The dispatch / worker / announcement bookkeeping runs BEFORE the cursor
4081
+ // guard: those are turn-level facts, not segment content, and an update
4082
+ // withheld by the ambient buffer must still keep them.
4083
+ this.trackBackgroundDispatch(update);
2549
4084
  if (typed.seq <= this.cursorSeq)
2550
4085
  return;
2551
- const update = typed.update;
4086
+ // A background drain's prose is not this turn's answer: keep it out of
4087
+ // the plane (the transcript still has it). Fail-open — nothing is split
4088
+ // until this turn has a request id of its own to compare against.
4089
+ this.noteTurnRequestId(update);
4090
+ if (this.isForeignAmbientText(update)) {
4091
+ this.recordWithoutSegment(update);
4092
+ // Advance past it only when nothing is left unconsumed behind it: the
4093
+ // event that CUT the previous segment is parked for the next one, and
4094
+ // jumping the cursor over it would lose its text for good. The replay
4095
+ // applies the same foreign split, so leaving this one behind is safe.
4096
+ if (typed.seq === this.cursorSeq + 1)
4097
+ this.cursorSeq = typed.seq;
4098
+ return;
4099
+ }
4100
+ // The turn's OWN request id is still unknown and this update belongs to
4101
+ // the background drain: folding it now could make the drain's tick teach
4102
+ // `noteTurnRequestId` the drain's id, mixing the drain's text into the
4103
+ // user's answer. Withhold it until the prompt response decides what this
4104
+ // turn is (see `pendingAmbientUpdates`); the cursor moves on because the
4105
+ // transcript already holds the entry.
4106
+ if (this.turnRequestId === undefined
4107
+ && this.awaitingAmbient === undefined
4108
+ && this.isAmbientOwned(update)) {
4109
+ this.bufferAmbientUpdate(typed.seq, update);
4110
+ this.cursorSeq = typed.seq;
4111
+ return;
4112
+ }
4113
+ // Live-only burst accounting runs BEFORE the segment cursor: prose that
4114
+ // arrives between segments still belongs to this turn's output, and its
4115
+ // gaps are real. Replay never reaches this path.
4116
+ this.trackTps(typed);
2552
4117
  const seg = this.segment;
2553
4118
  if (seg === undefined || seg.finished) {
2554
4119
  // Between segments: tool outputs still reach the shared registry (the
@@ -2561,18 +4126,81 @@ class TurnStreamController {
2561
4126
  }
2562
4127
  // The cursor advances only past events fully consumed into this
2563
4128
  // segment; the event that CUTS the segment belongs to the next one.
2564
- if (this.foldInto(seg, update))
4129
+ if (this.foldInto(seg, update, typed.seq))
2565
4130
  this.cursorSeq = typed.seq;
2566
4131
  }
2567
4132
  else if (row.type === 'permission/request') {
4133
+ this.tps.onBreak();
2568
4134
  const typed = event;
2569
4135
  this.pushProse(this.permissionLine(typed));
2570
4136
  }
2571
4137
  else if (row.type === 'permission/expired') {
4138
+ this.tps.onBreak();
2572
4139
  const typed = event;
2573
4140
  this.pushProse(this.expiredLine(typed));
2574
4141
  }
2575
4142
  }
4143
+ /** Feed one live update into the turn's burst tracker. Never called by replay. */
4144
+ trackTps(typed) {
4145
+ if (typed.seq <= this.tpsSeq)
4146
+ return;
4147
+ this.tpsSeq = typed.seq;
4148
+ const update = typed.update;
4149
+ const kind = String(update.sessionUpdate ?? '');
4150
+ if (kind === 'agent_thought_chunk' || kind === 'agent_message_chunk') {
4151
+ const text = chunkTextOf(update.content);
4152
+ if (text === '')
4153
+ return;
4154
+ this.tps.onProse(text, Date.now());
4155
+ this.pushLiveTps();
4156
+ }
4157
+ else if (kind === 'tool_call' || kind === 'tool_call_update') {
4158
+ this.tps.onBreak();
4159
+ }
4160
+ }
4161
+ /** Throttled live aggregate so the dock's speed moves during a long turn. */
4162
+ pushLiveTps() {
4163
+ const dshSessionId = this.session.dshSessionId;
4164
+ if (dshSessionId === undefined)
4165
+ return;
4166
+ const now = Date.now();
4167
+ if (now - this.lastTpsPushAt < TPS_PUSH_INTERVAL_MS)
4168
+ return;
4169
+ const snap = this.tps.snapshot();
4170
+ if (snap.outputTokens <= 0 || snap.burstMs <= 0)
4171
+ return;
4172
+ this.lastTpsPushAt = now;
4173
+ const base = this.session.burst ?? { outputTokens: 0, burstMs: 0 };
4174
+ this.hub.broadcast({
4175
+ type: 'turn/metrics',
4176
+ sessionId: this.session.sessionId,
4177
+ dshSessionId,
4178
+ turnId: this.turnId,
4179
+ outputTokens: base.outputTokens + snap.outputTokens,
4180
+ burstMs: base.burstMs + snap.burstMs,
4181
+ final: false,
4182
+ });
4183
+ }
4184
+ /** Close the turn's metrics: persist the measurable part, then broadcast it. */
4185
+ finalizeTps() {
4186
+ const dshSessionId = this.session.dshSessionId;
4187
+ if (dshSessionId === undefined)
4188
+ return;
4189
+ const snap = this.tps.snapshot();
4190
+ if (snap.outputTokens > 0 && snap.burstMs > 0) {
4191
+ this.manager.noteSessionBurst(this.session, snap.outputTokens, snap.burstMs);
4192
+ }
4193
+ const aggregate = this.session.burst ?? { outputTokens: 0, burstMs: 0 };
4194
+ this.hub.broadcast({
4195
+ type: 'turn/metrics',
4196
+ sessionId: this.session.sessionId,
4197
+ dshSessionId,
4198
+ turnId: this.turnId,
4199
+ outputTokens: aggregate.outputTokens,
4200
+ burstMs: aggregate.burstMs,
4201
+ final: true,
4202
+ });
4203
+ }
2576
4204
  /**
2577
4205
  * Put one narrative line into the turn's prose stream.
2578
4206
  *
@@ -2609,15 +4237,124 @@ class TurnStreamController {
2609
4237
  return;
2610
4238
  recordAcpToolOutput(callId, updateContentToText(update.content));
2611
4239
  }
4240
+ /**
4241
+ * Whether this update belongs to a background drain — the one the session is
4242
+ * waiting on, or one whose wait already closed (retired) but whose run can
4243
+ * still speak.
4244
+ */
4245
+ isAmbientOwned(update) {
4246
+ const requestId = codebuddyMetaOf(update)?.requestId;
4247
+ if (requestId === undefined)
4248
+ return false;
4249
+ return requestId === this.session.ambient?.requestId
4250
+ || isRetiredAmbientId(this.session, requestId);
4251
+ }
4252
+ /** Learn this turn's own CodeBuddy request id from the first update naming one. */
4253
+ noteTurnRequestId(update) {
4254
+ if (this.turnRequestId !== undefined)
4255
+ return;
4256
+ const meta = codebuddyMetaOf(update);
4257
+ // A detached worker's update names A WORKER, never this turn: adopting it
4258
+ // would let the worker's own turn pass as the session's answer.
4259
+ if (meta?.parentToolCallId !== undefined)
4260
+ return;
4261
+ // While a hold runs, the only ids this turn owns are the drain ids it
4262
+ // accepted (see `BackgroundHold.acceptedIds`) — and those belong to the
4263
+ // background run, never to a second "own turn id" that would outlive the
4264
+ // hold. Nothing else may be adopted.
4265
+ if (this.activeBackgroundHold() !== undefined)
4266
+ return;
4267
+ const requestId = meta?.requestId;
4268
+ // A background drain's update names THE DRAIN, never this turn: adopting it
4269
+ // would make `isForeignAmbientText` pass the drain's own text through. A
4270
+ // RETIRED drain (its wait closed on silence, the run revived) is the same
4271
+ // trap — that id belongs to the background run whichever turn is on air.
4272
+ if (requestId === undefined || requestId === this.session.ambient?.requestId)
4273
+ return;
4274
+ if (isRetiredAmbientId(this.session, requestId))
4275
+ return;
4276
+ this.turnRequestId = requestId;
4277
+ }
4278
+ /** Adopt the request id the prompt RESPONSE declared for this turn. */
4279
+ adoptTurnRequestId(requestId) {
4280
+ // A retired drain id is never this turn's own: adopting it would make the
4281
+ // background run's prose pass `isForeignAmbientText` as the answer. The
4282
+ // response still ends a normal turn, so the withheld drain buffer is
4283
+ // dropped either way (the transcript keeps it).
4284
+ if (!isRetiredAmbientId(this.session, requestId))
4285
+ this.turnRequestId = requestId;
4286
+ // A normal turn: whatever the drain streamed while the id was unknown was
4287
+ // never this turn's answer. Drop it (the transcript keeps it) — it must not
4288
+ // surface when a later segment replays or when the turn flushes anything.
4289
+ this.clearPendingAmbient();
4290
+ }
4291
+ /**
4292
+ * Prose of the session's background drain while this turn runs on its OWN
4293
+ * request id: it belongs to the background run, not to the user's answer.
4294
+ * Fail-open by design — an unknown turn id splits nothing, and a turn that
4295
+ * was itself folded into the drain keeps its prose.
4296
+ */
4297
+ isForeignAmbientText(update) {
4298
+ const meta = codebuddyMetaOf(update);
4299
+ const requestId = meta?.requestId;
4300
+ // A detached worker's own turn is never this session's answer, whichever
4301
+ // turn is on air (the fold-level guard lives in `foldInto` as well, for
4302
+ // the replay paths that never ask this question). This includes a worker
4303
+ // update that also names a drain id: its prose is the WORKER's.
4304
+ if (meta?.parentToolCallId !== undefined)
4305
+ return true;
4306
+ // A detached-worker hold owns the drain phase: any run it accepted (the
4307
+ // response's id, an announcement that named a different one) is this
4308
+ // turn's answer, and every OTHER id is background noise — whatever the
4309
+ // turn's own id says. Parked holds count: the response already owns them.
4310
+ const hold = this.activeBackgroundHold();
4311
+ if (hold !== undefined) {
4312
+ if (requestId === undefined)
4313
+ return false;
4314
+ return !hold.acceptedIds.has(requestId);
4315
+ }
4316
+ // The drain THIS turn is waiting on owns its text unconditionally: the
4317
+ // wait was opened for this id (a revived drain may still carry a retired
4318
+ // mark if the update raced the revival), so the whole steered answer must
4319
+ // reach the user instead of being filtered as background noise.
4320
+ if (requestId !== undefined && this.awaitingAmbient?.requestId === requestId)
4321
+ return false;
4322
+ // A RETIRED drain is foreign even though its record is gone: the quiet
4323
+ // close only means WE stopped waiting, not that the run ended. Its late
4324
+ // text must never join the turn now on air — even before that turn has
4325
+ // learned a request id of its own (the fail-open below does not apply to
4326
+ // an id known to be a background run).
4327
+ if (isRetiredAmbientId(this.session, requestId))
4328
+ return true;
4329
+ if (this.turnRequestId === undefined)
4330
+ return false;
4331
+ if (requestId === undefined || requestId === this.turnRequestId)
4332
+ return false;
4333
+ const ambient = this.session.ambient;
4334
+ return ambient !== undefined && requestId === ambient.requestId;
4335
+ }
2612
4336
  /**
2613
4337
  * The turn's whole fold, shared by live events and segment replay. Visible
2614
4338
  * non-tool events END a card-bearing segment instead of joining it — that is
2615
4339
  * the segmentation rule that puts each card between two assistant messages.
2616
4340
  * Returns whether the event was consumed into this segment: the cutting
2617
4341
  * event is left unconsumed (and thus behind the cursor) for the next one.
4342
+ *
4343
+ * `seq` is the update's transcript position when the caller has one: the
4344
+ * detached-worker hold uses it to count only the answer text that arrived
4345
+ * AFTER the hold began (a parked hold replays the parent's earlier ack).
2618
4346
  */
2619
- foldInto(seg, update) {
4347
+ foldInto(seg, update, seq) {
2620
4348
  const kind = String(update.sessionUpdate ?? '');
4349
+ // A DETACHED WORKER's own turn: its words and tools are the worker's, not
4350
+ // this session's answer. The transcript keeps them (the ACP view is where
4351
+ // the user can read them) and the shared tool-output registry still sees
4352
+ // the call, but nothing reaches this turn's message and no card opens for
4353
+ // the host to execute. This applies to EVERY turn, not just a hold.
4354
+ if (codebuddyMetaOf(update)?.parentToolCallId !== undefined) {
4355
+ this.noteToolRecord(update);
4356
+ return true;
4357
+ }
2621
4358
  if (kind === 'agent_thought_chunk' || kind === 'agent_message_chunk') {
2622
4359
  const text = chunkTextOf(update.content);
2623
4360
  if (text === '')
@@ -2627,11 +4364,36 @@ class TurnStreamController {
2627
4364
  return false;
2628
4365
  }
2629
4366
  seg.outputChars += text.length;
4367
+ // "The turn's own answer", as opposed to a background drain's prose that
4368
+ // happens to stream through the same session (see `isForeignAmbientText`).
4369
+ if (!this.isAmbientOwned(update))
4370
+ this.sawAgentProse = true;
4371
+ if (kind === 'agent_message_chunk') {
4372
+ this.sawVisibleText = true;
4373
+ // A hold's closing line asks whether ITS runs delivered answer text.
4374
+ // The parent's interim acknowledgement arrived before the hold and
4375
+ // already set `sawVisibleText`, so the hold keeps its own counter —
4376
+ // and only text that arrived after the hold began counts (a parked
4377
+ // hold replays that earlier ack from the transcript).
4378
+ const hold = this.activeBackgroundHold();
4379
+ if (hold !== undefined && (seq === undefined || seq > hold.floorSeq)) {
4380
+ hold.answerChars += text.length;
4381
+ }
4382
+ }
2630
4383
  this.emitProse(seg, kind === 'agent_thought_chunk' ? 'reasoning' : 'text', text);
2631
4384
  return true;
2632
4385
  }
2633
4386
  else if (kind === 'tool_call' || kind === 'tool_call_update') {
2634
4387
  this.noteToolRecord(update);
4388
+ if (seg.ambient || this.isAmbientOwned(update)) {
4389
+ // A background drain's tools stay in the transcript: opening a card
4390
+ // would make the host execute a tool and propose a continuation step
4391
+ // for a turn whose answer has not started yet. The ownership check
4392
+ // covers a NORMAL turn too — its own segment is not ambient, but a
4393
+ // drain can stream a tool call on the same session while it runs, and
4394
+ // that call is not this turn's work.
4395
+ return true;
4396
+ }
2635
4397
  const snapshot = acpToolCallFromUpdate(update);
2636
4398
  if (snapshot === undefined)
2637
4399
  return true;
@@ -2645,6 +4407,13 @@ class TurnStreamController {
2645
4407
  }
2646
4408
  if (this.seenCalls.has(snapshot.callId))
2647
4409
  return true; // a restatement of a card already settled
4410
+ // A BARE status-only update for a call this turn never announced is not
4411
+ // a card. The real wire sends the detached dispatch's completion exactly
4412
+ // that way (no `_meta`, no title, no input); a turn that begins after the
4413
+ // dispatch belongs to another turn's card, and opening one here would
4414
+ // make the host execute a phantom tool.
4415
+ if (kind === 'tool_call_update' && snapshot.name === '' && snapshot.argsRaw === '')
4416
+ return true;
2648
4417
  this.seenCalls.add(snapshot.callId);
2649
4418
  this.openToolCallBlock(seg, snapshot);
2650
4419
  return true;
@@ -2841,6 +4610,29 @@ export class BridgeManager {
2841
4610
  * write would fight with no way to tell which one is live.
2842
4611
  */
2843
4612
  deadlineOverride = {};
4613
+ /**
4614
+ * The idle-release window the settings page picked, in ms, persisted
4615
+ * with the agent registry. `undefined` means the deployment baseline runs.
4616
+ *
4617
+ * Merged OVER {@link baselineIdleTimeoutMs} exactly like
4618
+ * {@link deadlineOverride} over {@link baselineDeadline}: a UI pick is what
4619
+ * runs, and `cordis.patch.yml` becomes the value the reset button returns
4620
+ * to. Without this split the two sources would fight with no way to tell
4621
+ * which one is live.
4622
+ */
4623
+ idleReleaseOverride;
4624
+ /**
4625
+ * The window `cordis.patch.yml` names, when it names one. Kept apart
4626
+ * from the override for the same reason the deadline keeps its baseline
4627
+ * apart: the settings card has to be able to say "the file asks for
4628
+ * 10 minutes, the value set here wins".
4629
+ */
4630
+ configuredIdleTimeoutMs;
4631
+ /**
4632
+ * The idle-release window before any override — the tuning this manager was
4633
+ * constructed with (the deployment baseline, or the built-in 30 minutes).
4634
+ */
4635
+ baselineIdleTimeoutMs;
2844
4636
  /**
2845
4637
  * Tuning in force, defaults applied. The approval deadline is rewritten
2846
4638
  * IN PLACE by {@link applyDeadlineTuning}, which is why this is a live object
@@ -2862,6 +4654,32 @@ export class BridgeManager {
2862
4654
  turnStreamFor(dshSessionId) {
2863
4655
  return this.turnStreams.get(dshSessionId);
2864
4656
  }
4657
+ /**
4658
+ * The ambient windows a turn's controller reads once at construction (see
4659
+ * {@link BridgeTuning.ambientQuietMs}). Exposed as a plain snapshot so the
4660
+ * controller never pokes at the manager's tuning object, and tests can tune
4661
+ * the cadence through the constructor like every other window.
4662
+ */
4663
+ get ambientTuning() {
4664
+ return {
4665
+ quietMs: this.tuning.ambientQuietMs,
4666
+ maxMs: this.tuning.ambientMaxMs,
4667
+ };
4668
+ }
4669
+ /**
4670
+ * The detached-worker hold's windows, read once at controller construction
4671
+ * for the same reason as {@link ambientTuning}: `taskMaxMs` is the worker
4672
+ * phase's only clock (see {@link BridgeTuning.backgroundTaskMaxMs}) and
4673
+ * `graceMs` the bounded wait a PARKED hold gives the host to open its
4674
+ * continuation step (see {@link BridgeTuning.backgroundParkedGraceMs}). The
4675
+ * drain phase reuses {@link ambientTuning}.
4676
+ */
4677
+ get backgroundTuning() {
4678
+ return {
4679
+ taskMaxMs: this.tuning.backgroundTaskMaxMs,
4680
+ graceMs: this.tuning.backgroundParkedGraceMs,
4681
+ };
4682
+ }
2865
4683
  /** Index a turn's controller, retiring whatever turn it supersedes. */
2866
4684
  rememberTurnStream(dshSessionId, controller) {
2867
4685
  const previous = this.turnStreams.get(dshSessionId);
@@ -3002,6 +4820,7 @@ export class BridgeManager {
3002
4820
  // it. Everything else in `tuning` is fixed for the manager's lifetime, so
3003
4821
  // only this needs a snapshot.
3004
4822
  this.baselineDeadline = this.tuning.permissionTimeoutMs;
4823
+ this.baselineIdleTimeoutMs = this.tuning.idleTimeoutMs;
3005
4824
  this.baselineMode = this.tuning.permissionTimeoutMode;
3006
4825
  for (const def of defs) {
3007
4826
  this.defsMap.set(def.id, def);
@@ -3018,12 +4837,16 @@ export class BridgeManager {
3018
4837
  // A persisted deadline override must reach the tuning the bridges are built
3019
4838
  // from, not just the settings card's readout.
3020
4839
  this.applyDeadlineTuning();
4840
+ // The same for the idle-release window: a persisted override must
4841
+ // reach the tuning the bridges are built from, not just the
4842
+ // settings card's readout.
4843
+ this.applyIdleReleaseTuning();
3021
4844
  // The request-path rewrite falls back to this default for a conversation
3022
4845
  // the user never bound explicitly — the same agent the composer chip shows.
3023
4846
  routeBindingRegistry().defaultModel = this.defaultAgent();
3024
4847
  }
3025
- makeBridge(def, onConnectionLost, onCatalog, onCommands) {
3026
- return new AgentBridge(def, this.hub, this.transcriptMax, onConnectionLost, onCatalog, onCommands, this.tuning, () => this.sessionWorkspaceResolver);
4848
+ makeBridge(def, onConnectionLost, onCatalog, onCommands, onSessionsDirty, onForkCapability) {
4849
+ return new AgentBridge(def, this.hub, this.transcriptMax, onConnectionLost, onCatalog, onCommands, this.tuning, () => this.sessionWorkspaceResolver, onSessionsDirty, onForkCapability);
3027
4850
  }
3028
4851
  /**
3029
4852
  * Every outstanding ask, across all agents, with the view the browser was
@@ -3047,6 +4870,12 @@ export class BridgeManager {
3047
4870
  setSessionWorkspaceResolver(resolver) {
3048
4871
  this.sessionWorkspaceResolver = resolver;
3049
4872
  }
4873
+ /**
4874
+ * Set the attachment service or context used to resolve image attachments in user prompts.
4875
+ */
4876
+ setAttachmentResolver(resolver) {
4877
+ this.attachmentResolver = resolver;
4878
+ }
3050
4879
  /**
3051
4880
  * Wire the ACP-mode probe: given a DSH session id, report whether that
3052
4881
  * session runs the ACP agent preset. The host half reads it back from the
@@ -3166,11 +4995,85 @@ export class BridgeManager {
3166
4995
  ...(session.reasoningEffort === undefined ? {} : { reasoningEffort: session.reasoningEffort }),
3167
4996
  ...(session.modes === undefined ? {} : { modes: session.modes }),
3168
4997
  ...(session.availableCommands === undefined ? {} : { availableCommands: session.availableCommands }),
4998
+ ...(session.usage === undefined ? {} : { usage: session.usage }),
4999
+ ...(session.burst === undefined ? {} : { burst: session.burst }),
3169
5000
  ...(session.released ? { released: true } : {}),
5001
+ ...(session.running ? { running: true } : {}),
5002
+ ...forkRowInfo(session, this.canForkNow(session.agentId)),
3170
5003
  });
3171
5004
  }
3172
5005
  return out;
3173
5006
  }
5007
+ /**
5008
+ * Adopt the ACP side of a branched conversation: the browser has already
5009
+ * asked the HOST to fork the DSH session tree (`sessions.fork`), and this
5010
+ * runs the agent's own `session/fork` of the parent conversation's live ACP
5011
+ * session so the child actually remembers the parent context — then binds
5012
+ * the child to it (the fourth session-creation point, with every discipline
5013
+ * the other three carry).
5014
+ *
5015
+ * Failure is deliberately all-or-nothing: nothing is written for the child
5016
+ * until the agent has answered, so a failed adopt leaves the child an
5017
+ * ordinary copied-history conversation whose first message starts a fresh
5018
+ * `session/new` (today's behavior) — never a half-bound record.
5019
+ *
5020
+ * @param childDshSessionId - the DSH session the host fork created.
5021
+ * @param parentDshSessionId - the conversation being branched; its ACP
5022
+ * session must be LIVE on this connection (a released parent has no
5023
+ * process to fork from — the browser blocks the click, this is the hard
5024
+ * stop behind it).
5025
+ */
5026
+ async adoptForkedSession(childDshSessionId, parentDshSessionId) {
5027
+ // One conversation, one agent (the lock). The parent's LIVE session is
5028
+ // the fork source; released or in-flight-settling parents are refused —
5029
+ // forking a parked record would silently resume it, and forking mid-turn
5030
+ // would fork an agent state the parent turn is still changing.
5031
+ const parent = [...this.dshSessionMap.values()].find(session => session.dshSessionId === parentDshSessionId && !session.closed && !session.released);
5032
+ if (parent === undefined) {
5033
+ throw new AcpError('parent_not_live', 'acp: the conversation to branch has no live ACP session; send a message to resume it first');
5034
+ }
5035
+ if (parent.running || parent.settled !== undefined) {
5036
+ throw new AcpError('turn_in_flight', 'acp: the conversation to branch is running a turn; wait for it to finish');
5037
+ }
5038
+ const agentId = parent.agentId;
5039
+ const childKey = `${childDshSessionId}:${agentId}`;
5040
+ if (this.dshSessionMap.has(childKey)) {
5041
+ throw new AcpError('id_conflict', 'acp: the forked conversation is already bound to an ACP session');
5042
+ }
5043
+ // Capability gate BEFORE any request: an agent that never advertised
5044
+ // `session/fork` must not be asked (it would answer an unknown-method
5045
+ // error at best). Live init wins, last-known falls back — the same
5046
+ // verdict the browser's row already showed.
5047
+ if (!this.canForkNow(agentId)) {
5048
+ throw new AcpError('fork_unsupported', `acp: agent "${agentId}" does not advertise the session/fork capability`);
5049
+ }
5050
+ const session = await this.bridge(agentId).forkSession(parent, childDshSessionId);
5051
+ // The fork answered — the child is real on the agent side. From here the
5052
+ // binding is written through the SAME paths as any chat session: one
5053
+ // choice record (a copy of the parent's, locked — a branched conversation
5054
+ // belongs to its agent from birth), the route pin, and persistence.
5055
+ this.dshSessionMap.set(childKey, session);
5056
+ const parentChoice = this.chatAgentIntent.get(parentDshSessionId);
5057
+ this.patchChatChoice(childDshSessionId, {
5058
+ agentId,
5059
+ locked: true,
5060
+ ...(parentChoice?.modelId === undefined ? {} : { modelId: parentChoice.modelId }),
5061
+ ...(parentChoice?.effort === undefined ? {} : { effort: parentChoice.effort }),
5062
+ ...(parentChoice?.mode === undefined ? {} : { mode: parentChoice.mode }),
5063
+ ...(parentChoice?.extraConfigs === undefined ? {} : { extraConfigs: { ...parentChoice.extraConfigs } }),
5064
+ });
5065
+ // The fourth creation point follows the same discipline as `createSession`
5066
+ // and `doResumeSession`: apply the recorded pick to the fresh session, so
5067
+ // mode and extra configs (which only this path applies) cannot drift from
5068
+ // what the composer shows — a forked session starts on the agent-side
5069
+ // state the fork copied, and the record must match what runs.
5070
+ await this.applyChatChoice(session);
5071
+ // Re-publish so the browser's list and the composer's occupancy ring see
5072
+ // the child immediately (with its just-bound choice mirrors).
5073
+ this.hub.broadcast({ type: 'session/meta', sessionId: session.sessionId, session: snapshot(session) });
5074
+ this.persistAgents();
5075
+ return { sessionId: session.sessionId, acpSessionId: session.acpSessionId };
5076
+ }
3174
5077
  /**
3175
5078
  * Bind one DSH chat session to an ACP agent.
3176
5079
  *
@@ -3428,12 +5331,13 @@ export class BridgeManager {
3428
5331
  for (const session of this.dshSessionMap.values()) {
3429
5332
  if (session.dshSessionId !== dshSessionId || session.agentId !== agentId)
3430
5333
  continue;
5334
+ session.choice = choice;
5335
+ session.modelId = modelId;
3431
5336
  if (session.released)
3432
5337
  continue;
3433
5338
  await this.alignSessionConfig(session, choice, session.reasoningEffort);
3434
- session.choice = choice;
3435
- session.modelId = modelId;
3436
5339
  }
5340
+ this.persistAgents();
3437
5341
  }
3438
5342
  /**
3439
5343
  * Switch the reasoning effort for one chat session.
@@ -3458,14 +5362,18 @@ export class BridgeManager {
3458
5362
  // session is skipped here and re-aligned by `applyChatChoice` on resume.
3459
5363
  this.patchChatChoice(dshSessionId, { ...(agentId === undefined ? {} : { agentId }), effort });
3460
5364
  for (const session of this.dshSessionMap.values()) {
3461
- if (session.dshSessionId !== dshSessionId || session.released)
5365
+ if (session.dshSessionId !== dshSessionId)
3462
5366
  continue;
3463
5367
  if (agentId !== undefined && session.agentId !== agentId)
3464
5368
  continue;
5369
+ session.reasoningEffort = effort;
5370
+ if (session.released)
5371
+ continue;
3465
5372
  const aligned = await this.alignSessionConfig(session, session.choice, effort);
3466
5373
  // Same rule as the model axis: a dropped level is no longer "in force".
3467
5374
  session.reasoningEffort = aligned.effortStale ? undefined : effort;
3468
5375
  }
5376
+ this.persistAgents();
3469
5377
  }
3470
5378
  /**
3471
5379
  * Set one of the agent's EXTRA config options for one chat session (the
@@ -3658,9 +5566,15 @@ export class BridgeManager {
3658
5566
  return views;
3659
5567
  }
3660
5568
  /**
3661
- * Switch one chat-bound session's ACP mode. A released session is resumed
3662
- * first (`session/load`), matching the "send to recover" promise — the mode
3663
- * switch is itself a reason to bring the agent back.
5569
+ * Switch one chat-bound session's ACP mode.
5570
+ *
5571
+ * A released session is deliberately NOT resumed for it (user decision,
5572
+ * 2026-10-30 — the composer's axes are "what the next turn should use", and
5573
+ * the process comes back only when the user sends a message or hits the
5574
+ * released banner's resume button). The pick is recorded and
5575
+ * `applyChatChoice` applies it on resume, exactly like `setChatSessionModel`
5576
+ * / `setChatSessionEffort` / `setChatSessionConfig`; only a LIVE session is
5577
+ * told immediately (`session/set_mode`).
3664
5578
  */
3665
5579
  async setChatSessionMode(dshSessionId, modeId, agentId) {
3666
5580
  if (agentId !== undefined) {
@@ -3690,15 +5604,62 @@ export class BridgeManager {
3690
5604
  if (target.closed) {
3691
5605
  throw new AcpError('unknown_session', `acp: the ACP session bound to chat session ${dshSessionId} is closed`);
3692
5606
  }
5607
+ // A PARKED session gets the same record-only treatment (its agent is its
5608
+ // own `agentId`, whatever the caller passed — the selection above already
5609
+ // matched it). Resuming here was the one composer axis that broke ranks
5610
+ // with the others, and it woke a process the user had explicitly parked.
5611
+ if (target.released) {
5612
+ this.patchChatChoice(dshSessionId, { agentId: target.agentId, mode: modeId });
5613
+ if (target.modes !== undefined) {
5614
+ target.modes = { ...target.modes, currentModeId: modeId };
5615
+ }
5616
+ this.persistAgents();
5617
+ return;
5618
+ }
3693
5619
  const bridge = this.bridge(target.agentId);
3694
- const session = target.released ? await bridge.resumeSession(target) : target;
3695
- await bridge.setMode(session, modeId);
5620
+ await bridge.setMode(target, modeId);
3696
5621
  // Persisted only AFTER the agent accepted it: a mode the agent rejected must
3697
5622
  // not come back on the next restore. This is the same contract the model and
3698
5623
  // thought level follow (user decision, 2026-10-18 — every control in the
3699
5624
  // bottom bar remembers its last choice, not just the thought level).
3700
5625
  this.patchChatChoice(dshSessionId, { agentId: target.agentId, mode: modeId });
3701
5626
  }
5627
+ /**
5628
+ * Bring one chat conversation's released session back on a fresh process on
5629
+ * the USER's explicit gesture (the "恢复进程" banner) — the same work the
5630
+ * next prompt performs lazily (`getOrCreateDshSession`'s released branch),
5631
+ * minus the create path: a banner button must never materialize a session
5632
+ * the conversation has not had yet. A live target passes through untouched,
5633
+ * so a double click (or a click racing the next message) spawns nothing.
5634
+ */
5635
+ async resumeChatSession(dshSessionId) {
5636
+ const candidates = [...this.dshSessionMap.values()].filter(session => session.dshSessionId === dshSessionId && !session.closed);
5637
+ // The session the NEXT message would use — the bound agent's — is the one
5638
+ // to bring back; the first candidate is the fallback for a conversation
5639
+ // whose binding has drifted (same selection `setChatSessionMode` makes
5640
+ // without an explicit agentId).
5641
+ const target = candidates.find(session => session.agentId === this.chatAgentFor(dshSessionId)) ?? candidates[0];
5642
+ if (target === undefined) {
5643
+ throw new AcpError('unknown_session', `acp: no ACP session is bound to chat session ${dshSessionId}`);
5644
+ }
5645
+ if (!target.released)
5646
+ return;
5647
+ const bridge = this.bridge(target.agentId);
5648
+ const resumed = await bridge.resumeSession(target);
5649
+ // Chat-side rebind + pick re-application, verbatim the lazy path's: the
5650
+ // freshly loaded session comes back at the agent's defaults, so the
5651
+ // conversation's model / thought level must be re-applied or the composer
5652
+ // would show one thing and the process run another (see
5653
+ // `getOrCreateDshSession`).
5654
+ resumed.dshSessionId = dshSessionId;
5655
+ this.lockChatAgent(dshSessionId, target.agentId);
5656
+ if (this.chatAgentIntent.has(dshSessionId))
5657
+ this.syncConversationRoute(dshSessionId);
5658
+ else
5659
+ routeBindingRegistry().conversations.set(dshSessionId, resumed.modelId ?? target.agentId);
5660
+ await this.applyChatChoice(resumed);
5661
+ resumed.provisionalLock = false;
5662
+ }
3702
5663
  /**
3703
5664
  * Wire the harness's preset roster (`ctx.agentPresets`) into `presetStatus`.
3704
5665
  *
@@ -3889,7 +5850,22 @@ export class BridgeManager {
3889
5850
  catch {
3890
5851
  this.deadlineOverride = {};
3891
5852
  }
5853
+ // The settings page's idle-release pick. Re-validated on every boot
5854
+ // because the state file is user- and version-editable: an
5855
+ // unreadable value must not poison the window, and a value the
5856
+ // validator accepts must be the value that runs.
5857
+ try {
5858
+ const raw = parsed.idleRelease;
5859
+ const value = raw?.idleTimeoutMs;
5860
+ this.idleReleaseOverride = typeof value === 'number' && IDLE_RELEASE_MS.includes(value)
5861
+ ? value
5862
+ : undefined;
5863
+ }
5864
+ catch {
5865
+ this.idleReleaseOverride = undefined;
5866
+ }
3892
5867
  this.loadPersistedChoices(parsed.conversations);
5868
+ this.loadPersistedChatSessions(parsed.chatSessions);
3893
5869
  }
3894
5870
  }
3895
5871
  catch {
@@ -3933,11 +5909,33 @@ export class BridgeManager {
3933
5909
  for (const dshSessionId of this.chatAgentIntent.keys())
3934
5910
  this.syncConversationRoute(dshSessionId);
3935
5911
  }
5912
+ /**
5913
+ * Fold one settled ACP turn's measurable burst into the session aggregate and
5914
+ * persist it. A turn with no measured inter-chunk gap contributes neither
5915
+ * side: adding its tokens with zero time would inflate the session rate.
5916
+ */
5917
+ noteSessionBurst(session, outputTokens, burstMs) {
5918
+ if (outputTokens <= 0 || burstMs <= 0)
5919
+ return;
5920
+ const base = session.burst ?? { outputTokens: 0, burstMs: 0 };
5921
+ session.burst = {
5922
+ outputTokens: base.outputTokens + outputTokens,
5923
+ burstMs: base.burstMs + burstMs,
5924
+ };
5925
+ this.persistAgents();
5926
+ }
3936
5927
  persistAgents() {
3937
5928
  const filePath = this.getStoragePath();
3938
5929
  if (!filePath)
3939
5930
  return;
3940
5931
  try {
5932
+ // The chat-bound session records themselves: a host restart kills the
5933
+ // processes but not the agent-side sessions, so restoring these as
5934
+ // PARKED keeps a conversation resumable (`session/load`) instead of
5935
+ // its next message silently starting a context-less new one.
5936
+ const chatSessions = [...this.dshSessionMap.values()]
5937
+ .filter(session => session.dshSessionId !== undefined && !session.closed)
5938
+ .map(persistedChatSession);
3941
5939
  const payload = {
3942
5940
  version: 2,
3943
5941
  ...(this.defaultAgentId === undefined ? {} : { defaultAgentId: this.defaultAgentId }),
@@ -3948,12 +5946,18 @@ export class BridgeManager {
3948
5946
  ...(Object.keys(this.deadlineOverride).length === 0
3949
5947
  ? {}
3950
5948
  : { approvalDeadlines: { ...this.deadlineOverride } }),
5949
+ // The settings page's idle-release pick. Absent while none was
5950
+ // ever written, so a fresh deployment's state file stays as it was.
5951
+ ...(this.idleReleaseOverride === undefined
5952
+ ? {}
5953
+ : { idleRelease: { idleTimeoutMs: this.idleReleaseOverride } }),
3951
5954
  // The per-conversation picks: agent + model + thought level + mode +
3952
5955
  // extra config values chosen in the composer, including for
3953
5956
  // conversations with no live session yet.
3954
5957
  ...(this.chatAgentIntent.size === 0
3955
5958
  ? {}
3956
5959
  : { conversations: Object.fromEntries(this.chatAgentIntent) }),
5960
+ ...(chatSessions.length === 0 ? {} : { chatSessions }),
3957
5961
  };
3958
5962
  writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8');
3959
5963
  }
@@ -3961,8 +5965,78 @@ export class BridgeManager {
3961
5965
  // Storage persist failure is non-fatal
3962
5966
  }
3963
5967
  }
5968
+ /**
5969
+ * Restore chat session records from disk as PARKED sessions.
5970
+ *
5971
+ * Every process died with the host, so nothing comes back live — but the
5972
+ * agent-side sessions did not. Restoring each record as `released` is what
5973
+ * makes the released banner, its resume button and the "next message
5974
+ * resumes it" promise survive a restart (the alternative is the next
5975
+ * message quietly starting a NEW, context-less session — constraint 10's
5976
+ * "released ≠ destroyed" lost at the process boundary). Tolerant, one row
5977
+ * at a time: a single corrupt entry must not cost every other conversation
5978
+ * its context. Records whose agent is no longer configured are dropped —
5979
+ * there is nothing to resume ON, and the next write omits them.
5980
+ */
5981
+ loadPersistedChatSessions(raw) {
5982
+ if (!Array.isArray(raw))
5983
+ return;
5984
+ for (const row of raw) {
5985
+ if (row === null || typeof row !== 'object')
5986
+ continue;
5987
+ const record = row;
5988
+ const { sessionId, acpSessionId, agentId, dshSessionId, cwd } = record;
5989
+ if (typeof sessionId !== 'string' || sessionId === '')
5990
+ continue;
5991
+ if (typeof acpSessionId !== 'string' || acpSessionId === '')
5992
+ continue;
5993
+ if (typeof agentId !== 'string' || agentId === '')
5994
+ continue;
5995
+ if (typeof dshSessionId !== 'string' || dshSessionId === '')
5996
+ continue;
5997
+ if (typeof cwd !== 'string')
5998
+ continue;
5999
+ if (!this.defsMap.has(agentId))
6000
+ continue;
6001
+ const { title, modelId, reasoningEffort, modes, usage, burst, hasHistory } = record;
6002
+ const session = {
6003
+ sessionId,
6004
+ acpSessionId,
6005
+ agentId,
6006
+ dshSessionId,
6007
+ cwd,
6008
+ ...(typeof title === 'string' && title !== '' ? { title } : {}),
6009
+ ...(typeof modelId === 'string' && modelId !== ''
6010
+ ? { modelId, ...(splitAdapterModelId(modelId).choice === undefined ? {} : { choice: splitAdapterModelId(modelId).choice }) }
6011
+ : {}),
6012
+ ...(typeof reasoningEffort === 'string' && reasoningEffort !== '' ? { reasoningEffort } : {}),
6013
+ ...(isPersistedModes(modes) ? { modes } : {}),
6014
+ ...(isPersistedUsage(usage) ? { usage } : {}),
6015
+ ...(isPersistedBurst(burst) ? { burst } : {}),
6016
+ ...(hasHistory === true ? { hasHistory: true } : {}),
6017
+ running: false,
6018
+ turnSeq: 0,
6019
+ closed: false,
6020
+ released: true,
6021
+ lastAgentActivityAt: Date.now(),
6022
+ activeToolCalls: new Set(),
6023
+ transcript: new Transcript(this.transcriptMax),
6024
+ };
6025
+ this.dshSessionMap.set(`${dshSessionId}:${agentId}`, session);
6026
+ this.bridge(agentId).parkReleased(session);
6027
+ }
6028
+ }
3964
6029
  // ---- Model catalog ----
3965
6030
  catalogs = new Map();
6031
+ /**
6032
+ * Each agent's LAST KNOWN `sessionCapabilities.fork` bit, persisted with the
6033
+ * model catalog. A live `initialize` result outranks it whenever one exists
6034
+ * (see {@link canForkNow}) — this copy answers when the process is gone
6035
+ * (a released session's branch button must not flash into "supported" just
6036
+ * because `initResult` died with it, nor read `view().agentCapabilities`,
6037
+ * which a release clears).
6038
+ */
6039
+ forkCaps = new Map();
3966
6040
  /**
3967
6041
  * The slash commands each agent declared in its most recent session, cached
3968
6042
  * per agent and persisted alongside the model catalog.
@@ -4035,6 +6109,10 @@ export class BridgeManager {
4035
6109
  if (typeof raw !== 'object' || raw === null)
4036
6110
  continue;
4037
6111
  const row = raw;
6112
+ // The persisted fork capability: a LAST KNOWN bit, loaded before any
6113
+ // bridge can report a fresh one (a fresh `initialize` overwrites it).
6114
+ if (typeof row.fork === 'boolean')
6115
+ this.forkCaps.set(agentId, row.fork);
4038
6116
  // Commands and the model catalog are learned independently (a session
4039
6117
  // may declare one before the other), so either half alone is a valid row.
4040
6118
  if (Array.isArray(row.commands)) {
@@ -4068,6 +6146,12 @@ export class BridgeManager {
4068
6146
  for (const [agentId, entry] of this.commandCache) {
4069
6147
  agents[agentId] = { ...(agents[agentId] ?? {}), commands: entry.commands, commandsUpdatedAt: entry.updatedAt };
4070
6148
  }
6149
+ // The LAST KNOWN fork capability rides the same file: it outlives the
6150
+ // agent process, which is exactly when the browser needs it (see
6151
+ // `canForkNow`).
6152
+ for (const [agentId, supported] of this.forkCaps) {
6153
+ agents[agentId] = { ...(agents[agentId] ?? {}), fork: supported };
6154
+ }
4071
6155
  writeFileSync(filePath, JSON.stringify({ version: BridgeManager.CATALOG_SCHEMA, agents }, null, 2), 'utf-8');
4072
6156
  }
4073
6157
  catch {
@@ -4162,6 +6246,32 @@ export class BridgeManager {
4162
6246
  // first `/`, when there is still no live session to ask.
4163
6247
  this.hub.broadcast({ type: 'catalog/updated', agentId, updatedAt: entry.updatedAt });
4164
6248
  }
6249
+ /**
6250
+ * Record a fresh `initialize`'s `sessionCapabilities.fork` verdict (the
6251
+ * bridge reports it right after the handshake). Persisted with the model
6252
+ * catalog; no broadcast — the browser learns it through `session/chat-list`
6253
+ * rows, and its branch button deliberately runs on a last-known basis (the
6254
+ * agent's own next `initialize` is the refresh).
6255
+ */
6256
+ noteForkCapability(agentId, supported) {
6257
+ if (this.forkCaps.get(agentId) === supported)
6258
+ return;
6259
+ this.forkCaps.set(agentId, supported);
6260
+ this.persistCatalogs();
6261
+ }
6262
+ /**
6263
+ * Whether an agent's fork can be attempted RIGHT NOW: a live `initialize`
6264
+ * answer wins (the process is up, its handshake is current); only when the
6265
+ * process is gone does the LAST KNOWN persisted bit answer. `false` covers
6266
+ * "never seen" — an unknown agent simply never gets a fork attempt.
6267
+ */
6268
+ canForkNow(agentId) {
6269
+ const bridge = this.bridges.get(agentId);
6270
+ if (bridge?.initResult !== undefined) {
6271
+ return bridge.initResult.agentCapabilities?.sessionCapabilities?.fork != null;
6272
+ }
6273
+ return this.forkCaps.get(agentId) === true;
6274
+ }
4165
6275
  /**
4166
6276
  * Append models this observation newly introduced to the agent's selection.
4167
6277
  *
@@ -4602,6 +6712,10 @@ export class BridgeManager {
4602
6712
  this.absorbCatalog(observedAgentId, configOptions);
4603
6713
  }, (observedAgentId, commands) => {
4604
6714
  this.absorbCommands(observedAgentId, commands);
6715
+ }, () => {
6716
+ this.persistAgents();
6717
+ }, (observedAgentId, supported) => {
6718
+ this.noteForkCapability(observedAgentId, supported);
4605
6719
  });
4606
6720
  this.bridges.set(agentId, bridge);
4607
6721
  }
@@ -4760,11 +6874,15 @@ export class BridgeManager {
4760
6874
  }
4761
6875
  if (routeBindingRegistry().defaultModel === agentId)
4762
6876
  routeBindingRegistry().defaultModel = undefined;
4763
- // Both maps are independent (either may be missing), so delete both before
4764
- // deciding whether the file needs rewriting.
6877
+ // All three maps are independent (any may be missing), so delete all
6878
+ // before deciding whether the file needs rewriting. The fork capability
6879
+ // goes with the agent: a same-id rebuild must not inherit the OLD agent's
6880
+ // "supported" and click into a `fork_unsupported` wall before its own
6881
+ // first `initialize` answers.
4765
6882
  const droppedCatalog = this.catalogs.delete(agentId);
4766
6883
  const droppedCommands = this.commandCache.delete(agentId);
4767
- if (droppedCatalog || droppedCommands)
6884
+ const droppedFork = this.forkCaps.delete(agentId);
6885
+ if (droppedCatalog || droppedCommands || droppedFork)
4768
6886
  this.persistCatalogs();
4769
6887
  this.persistAgents();
4770
6888
  this.hub.broadcast({
@@ -4811,7 +6929,7 @@ export class BridgeManager {
4811
6929
  const session = await this.bridge(agentId).createSession(cwd);
4812
6930
  return { sessionId: session.sessionId };
4813
6931
  }
4814
- async prompt(sessionId, text) {
6932
+ async prompt(sessionId, prompt) {
4815
6933
  let located = await this.locateOrResume(sessionId);
4816
6934
  // The watchdog abandoned a previous turn here and the agent may still be
4817
6935
  // holding its prompt slot on the old process: reclaim BEFORE sending, or
@@ -4830,8 +6948,36 @@ export class BridgeManager {
4830
6948
  located = await this.locateOrResume(sessionId);
4831
6949
  }
4832
6950
  const { bridge, session } = located;
4833
- if (session.running)
6951
+ // The agent prompt slot is SHARED with chat turns: `running` alone misses
6952
+ // a chat turn whose cancellation already released it (`running` false,
6953
+ // `promptInFlight` still naming the agent-side turn), and a live chat
6954
+ // controller means a continuation step may resume the SAME agent turn.
6955
+ // A controller that already SETTLED (kept only so its card's continuation
6956
+ // can still stream) has given the agent slot back: `/acp` may use it, and
6957
+ // advancing `turnSeq` retires it — the behaviour the stale-controller
6958
+ // watchdog case pins.
6959
+ const chatController = session.turnController;
6960
+ if (session.running
6961
+ || session.promptInFlight === true
6962
+ || (chatController !== undefined && !chatController.stale && !chatController.hasSettled)) {
4834
6963
  throw new AcpError('turn_in_flight', 'acp: this session already has a prompt in flight');
6964
+ }
6965
+ const blocks = typeof prompt === 'string'
6966
+ ? [{ type: 'text', text: prompt }]
6967
+ : prompt;
6968
+ if (blocks.length === 0) {
6969
+ throw new AcpError('empty_prompt', 'acp: prompt cannot be empty');
6970
+ }
6971
+ const hasImages = blocks.some(b => b.type === 'image');
6972
+ if (hasImages && bridge.initResult?.agentCapabilities?.promptCapabilities?.image !== true) {
6973
+ throw new AcpError('image_unsupported', `acp: agent "${bridge.def.id}" does not support image input`);
6974
+ }
6975
+ // Claim the SAME slot the chat path claims (`claimTurn` + `beginTurn`): an
6976
+ // `/acp` turn in flight must make the next chat prompt wait, and vice
6977
+ // versa. The settler is idempotent and identity-guarded; the turn still
6978
+ // ends through `turn/end` below, so chat and RPC turns share one gate
6979
+ // without sharing a stream controller.
6980
+ const endTurn = bridge.beginTurn(session);
4835
6981
  session.running = true;
4836
6982
  session.turnSeq += 1;
4837
6983
  const turnGen = session.turnSeq;
@@ -4842,9 +6988,12 @@ export class BridgeManager {
4842
6988
  session.reclaimedTurnSeq = undefined;
4843
6989
  this.armStallTimer();
4844
6990
  const turnId = randomUUID();
4845
- this.hub.broadcast({ type: 'turn/start', sessionId, turnId, text });
4846
- void bridge.runPrompt(session, text, turnId)
4847
- .then(stopReason => {
6991
+ const promptText = typeof prompt === 'string'
6992
+ ? prompt
6993
+ : blocks.filter(b => b.type === 'text').map(b => b.text).join('\n');
6994
+ this.hub.broadcast({ type: 'turn/start', sessionId, turnId, text: promptText });
6995
+ void bridge.runPrompt(session, blocks, turnId, turnGen)
6996
+ .then(result => {
4848
6997
  // The reclaim already ended this turn locally; the answer it caused is
4849
6998
  // that same turn reporting back, not a second turn end for the caller.
4850
6999
  if (session.reclaimedTurnSeq === turnGen) {
@@ -4855,8 +7004,11 @@ export class BridgeManager {
4855
7004
  if (session.turnSeq !== turnGen)
4856
7005
  return;
4857
7006
  this.clearOrphanPrompt(session, turnGen);
7007
+ // Release the shared slot BEFORE the terminal frame: a listener that
7008
+ // sends the next prompt on `turn/end` must not read a stale claim.
7009
+ endTurn();
4858
7010
  session.running = false;
4859
- this.hub.broadcast({ type: 'turn/end', sessionId, turnId, stopReason });
7011
+ this.hub.broadcast({ type: 'turn/end', sessionId, turnId, stopReason: result.stopReason });
4860
7012
  })
4861
7013
  .catch((error) => {
4862
7014
  if (session.reclaimedTurnSeq === turnGen) {
@@ -4874,6 +7026,7 @@ export class BridgeManager {
4874
7026
  return;
4875
7027
  }
4876
7028
  this.clearOrphanPrompt(session, turnGen);
7029
+ endTurn();
4877
7030
  session.running = false;
4878
7031
  this.hub.broadcast({
4879
7032
  type: 'turn/end',
@@ -4881,7 +7034,10 @@ export class BridgeManager {
4881
7034
  turnId,
4882
7035
  error: describeError(error),
4883
7036
  });
4884
- });
7037
+ })
7038
+ // Every outcome — normal answer, failure, reclaim early-return, or a
7039
+ // close's rejection — releases the shared prompt slot exactly once.
7040
+ .finally(() => { endTurn(); });
4885
7041
  return { turnId };
4886
7042
  }
4887
7043
  /** The late answer landed (or failed): the turn is no longer an orphan. */
@@ -5004,6 +7160,23 @@ export class BridgeManager {
5004
7160
  this.tagHaloSetting = validateTagHalo(halo);
5005
7161
  this.persistAgents();
5006
7162
  }
7163
+ /**
7164
+ * Declare the deployment baseline for the idle-release window — the value
7165
+ * `cordis.patch.yml` names, passed RAW (only when it names one).
7166
+ *
7167
+ * Kept separate from the validated tuning for the same reason the
7168
+ * deadline keeps its baseline apart: "the file says 10 minutes" and
7169
+ * "the file says nothing, so 30 minutes applies" are different facts.
7170
+ * The first is the reset target and shows as the fact being overridden;
7171
+ * the second is a built-in default. Called once by the host entry,
7172
+ * before any bridge exists.
7173
+ */
7174
+ setConfiguredIdleRelease(idleTimeoutMs) {
7175
+ this.configuredIdleTimeoutMs = idleTimeoutMs;
7176
+ if (idleTimeoutMs !== undefined)
7177
+ this.baselineIdleTimeoutMs = idleTimeoutMs;
7178
+ this.applyIdleReleaseTuning();
7179
+ }
5007
7180
  /**
5008
7181
  * Declare the deployment baseline for the approval deadline — the values
5009
7182
  * `cordis.patch.yml` names, passed RAW (only the keys it actually contains).
@@ -5051,6 +7224,56 @@ export class BridgeManager {
5051
7224
  deadlines.configuredMode = this.configuredMode;
5052
7225
  return deadlines;
5053
7226
  }
7227
+ /**
7228
+ * The idle-release window in force, plus the facts that explain a value the
7229
+ * user did not pick: the deployment baseline an override is outranking.
7230
+ *
7231
+ * `defaultIdleTimeoutMs` is the window a RESET returns to — the deployment
7232
+ * baseline when the config file names one, else the built-in default. It is
7233
+ * NOT a hardcoded constant: a deployment that names 10 minutes must reset
7234
+ * to 10 minutes, not to the built-in 30.
7235
+ *
7236
+ * The config file's own number is reported only when the file names one, so
7237
+ * the settings card can stay silent about a plain default.
7238
+ */
7239
+ idleRelease() {
7240
+ const release = {
7241
+ idleTimeoutMs: this.idleTimeoutMs(),
7242
+ defaultIdleTimeoutMs: this.baselineIdleTimeoutMs,
7243
+ };
7244
+ if (this.configuredIdleTimeoutMs !== undefined)
7245
+ release.configuredIdleTimeoutMs = this.configuredIdleTimeoutMs;
7246
+ return release;
7247
+ }
7248
+ /**
7249
+ * Write the idle-release window, or drop the persisted override with
7250
+ * `reset` so the deployment baseline applies again.
7251
+ *
7252
+ * HOT: the rewrite reaches every live bridge, so a user changing the window
7253
+ * never has to kill a running session to see it take effect.
7254
+ */
7255
+ async setIdleRelease(idleTimeoutMs, reset = false) {
7256
+ this.idleReleaseOverride = reset ? undefined : idleTimeoutMs;
7257
+ this.applyIdleReleaseTuning();
7258
+ this.persistAgents();
7259
+ }
7260
+ /**
7261
+ * Recompute the idle-release window from override → deployment baseline, and
7262
+ * push the result to every live bridge.
7263
+ */
7264
+ applyIdleReleaseTuning() {
7265
+ const window = this.idleTimeoutMs();
7266
+ this.tuning.idleTimeoutMs = window;
7267
+ for (const bridge of this.bridges.values())
7268
+ bridge.applyIdleRelease(window);
7269
+ }
7270
+ /**
7271
+ * The idle-release window that runs, in ms: the settings page's pick, else
7272
+ * the deployment baseline.
7273
+ */
7274
+ idleTimeoutMs() {
7275
+ return this.idleReleaseOverride ?? this.baselineIdleTimeoutMs;
7276
+ }
5054
7277
  /**
5055
7278
  * Write the approval deadline, or the mode (omitted keys keep their value),
5056
7279
  * or drop the persisted override with `reset` so the config-file baseline —
@@ -5108,13 +7331,21 @@ export class BridgeManager {
5108
7331
  chatAgentIntent = new Map();
5109
7332
  /** Drop every chat-keyed record pointing at one host session (close/stop paths). */
5110
7333
  forgetDshSession(sessionId) {
7334
+ let dropped = false;
5111
7335
  for (const [key, session] of this.dshSessionMap) {
5112
- if (session.sessionId === sessionId)
7336
+ if (session.sessionId === sessionId) {
5113
7337
  this.dshSessionMap.delete(key);
7338
+ dropped = true;
7339
+ }
5114
7340
  }
7341
+ // The disk record goes with it: a closed session is destroyed, not parked,
7342
+ // and must not come back on the next boot (constraint 10).
7343
+ if (dropped)
7344
+ this.persistAgents();
5115
7345
  }
5116
7346
  sessionWorkspaceResolver;
5117
7347
  acpModeResolver;
7348
+ attachmentResolver;
5118
7349
  /** Cached `chatSessionAcpMode` answers, keyed by DSH chat session id. */
5119
7350
  acpModeCache = new Map();
5120
7351
  /** In-flight cold reads behind `chatSessionAcpModeAnswer`, keyed by session id. */
@@ -5265,6 +7496,9 @@ export class BridgeManager {
5265
7496
  // A RESUMED session had context before this call; a lock written here is
5266
7497
  // not provisional even if the conversation record was missing it.
5267
7498
  resumed.provisionalLock = false;
7499
+ // The load refreshed the record's mirrors (modes / usage / hasHistory);
7500
+ // persist so a later restart restores what this resume saw.
7501
+ this.persistAgents();
5268
7502
  return resumed;
5269
7503
  }
5270
7504
  // A chat session runs where its DSH session lives, not in the plugin's
@@ -5314,6 +7548,9 @@ export class BridgeManager {
5314
7548
  // conversation back (a failed first attempt must not trap it).
5315
7549
  session.provisionalLock = this.lockChatAgent(dshSessionId, agentId);
5316
7550
  }
7551
+ // The record is on disk from here: a host restart restores it as parked
7552
+ // instead of the next message starting a context-less new session.
7553
+ this.persistAgents();
5317
7554
  return session;
5318
7555
  }
5319
7556
  /**
@@ -5485,8 +7722,39 @@ export class BridgeManager {
5485
7722
  // Forward the person's own words: the host folds its context sections
5486
7723
  // (AGENTS.md digest, skill catalog, runtime snapshot) after the claimed user
5487
7724
  // batch precisely so a consumer can tell them apart by `source` — see
5488
- // `selectForwardPrompt` and AGENTS.md rule 16.
5489
- const selection = selectForwardPrompt(options.messages ?? []);
7725
+ const workspaceRoot = chatWorkspaceRoot(this.sessionWorkspaceResolver, options.sessionId);
7726
+ const defaultReadFile = (p) => {
7727
+ try {
7728
+ if (existsSync(p))
7729
+ return readFileSync(p);
7730
+ if (workspaceRoot !== undefined && !isAbsolutePath(p)) {
7731
+ const joined = join(workspaceRoot, p);
7732
+ if (existsSync(joined))
7733
+ return readFileSync(joined);
7734
+ }
7735
+ }
7736
+ catch { }
7737
+ return undefined;
7738
+ };
7739
+ const resolver = this.attachmentResolver;
7740
+ const attachmentContext = resolver !== undefined && typeof resolver === 'object' && resolver !== null
7741
+ ? Object.assign(Object.create(resolver), {
7742
+ readFile: (p) => {
7743
+ if (typeof resolver.readFile === 'function') {
7744
+ try {
7745
+ const res = resolver.readFile(p);
7746
+ if (res !== undefined)
7747
+ return res;
7748
+ }
7749
+ catch { }
7750
+ }
7751
+ return defaultReadFile(p);
7752
+ },
7753
+ })
7754
+ : {
7755
+ readFile: defaultReadFile,
7756
+ };
7757
+ const selection = selectForwardPrompt(options.messages ?? [], attachmentContext);
5490
7758
  if (selection.kind === 'no-user-input') {
5491
7759
  // An injected-context wake: nothing the user said, nothing to send.
5492
7760
  yield { type: 'finish', reason: { kind: 'stop' } };
@@ -5551,15 +7819,31 @@ export class BridgeManager {
5551
7819
  };
5552
7820
  return;
5553
7821
  }
7822
+ const hasImages = (selection.images?.length ?? 0) > 0 || (selection.content?.some(b => b.type === 'image') ?? false);
7823
+ if (hasImages && bridge.initResult?.agentCapabilities?.promptCapabilities?.image !== true) {
7824
+ if (session.provisionalLock === true)
7825
+ this.rollbackFirstTurn(session);
7826
+ yield {
7827
+ type: 'finish',
7828
+ reason: {
7829
+ kind: 'error',
7830
+ failure: {
7831
+ message: `acp: agent "${agentId}" does not support image input`,
7832
+ code: 'image_unsupported',
7833
+ },
7834
+ },
7835
+ };
7836
+ return;
7837
+ }
5554
7838
  // One prompt slot per session, and a turn the user aborted still occupies
5555
7839
  // the AGENT's slot until it acknowledges the cancel (see `claimTurn`).
5556
7840
  const claim = await bridge.claimTurn(session);
5557
7841
  if (!claim.ok) {
5558
- // A first turn that never got the prompt slot delivered nothing either:
5559
- // give the conversation back instead of leaving it locked to an agent
5560
- // that refused the only message it was asked to serve.
5561
- if (session.provisionalLock === true)
5562
- this.rollbackFirstTurn(session);
7842
+ // Never roll the provisional lock back here: a failed claim means ANOTHER
7843
+ // prompt holds the slot (or the agent has not settled it), and that turn
7844
+ // owns the conversation's lock. Closing the session would kill a turn
7845
+ // that is actively streaming — the refusal already tells the user to
7846
+ // retry.
5563
7847
  yield {
5564
7848
  type: 'finish',
5565
7849
  reason: {
@@ -5691,8 +7975,11 @@ export class BridgeManager {
5691
7975
  // next turn has to respect. Settlement reaches the controller, which
5692
7976
  // releases the slot (`endTurn`) whether the segment on air is the first
5693
7977
  // or a continuation one.
5694
- bridge.runPrompt(session, promptText, turnId)
5695
- .then(stopReason => {
7978
+ const promptBlocks = selection.content && selection.content.length > 0
7979
+ ? selection.content
7980
+ : [{ type: 'text', text: promptText }];
7981
+ bridge.runPrompt(session, promptBlocks, turnId, turnGen)
7982
+ .then(result => {
5696
7983
  // The reclaim already ended this turn locally, and the close it
5697
7984
  // performed rejected this very request: the late answer is that same
5698
7985
  // turn, not a second one to broadcast.
@@ -5700,9 +7987,76 @@ export class BridgeManager {
5700
7987
  session.reclaimedTurnSeq = undefined;
5701
7988
  return;
5702
7989
  }
5703
- controller.settle(stopReason === 'cancelled'
7990
+ const responseMeta = codebuddyMetaOf({ _meta: result.meta });
7991
+ // A DETACHED-WORKER HOLD: this turn's `Agent` call handed real work
7992
+ // to a background worker that has not produced its answer yet, yet
7993
+ // the agent answered the prompt normally (CodeBuddy says "已派…" and
7994
+ // ends its own turn). Closing here loses the only answer the turn
7995
+ // will ever produce — the worker's report streams into this same
7996
+ // session later, as a background drain. Hold the turn open until
7997
+ // every dispatch reports and the drain goes quiet (see
7998
+ // `holdForBackgroundDispatch`); the prompt slot is released there.
7999
+ //
8000
+ // The decision comes BEFORE `adoptTurnRequestId`: adopting the id
8001
+ // would clear the updates the drain streamed while this turn's own id
8002
+ // was unknown, and those are exactly the ones the hold must fold.
8003
+ const holdDispatch = controller.dispatchedDetachedWorker
8004
+ && !session.released
8005
+ && !session.closed
8006
+ && result.stopReason !== 'cancelled';
8007
+ if (!holdDispatch && responseMeta?.conversationRequestId !== undefined) {
8008
+ // The response names the conversation turn it queued: an instant
8009
+ // answer that still carries one is a real (if silent) turn, and its
8010
+ // prose can be told apart from a background drain's.
8011
+ controller.adoptTurnRequestId(responseMeta.conversationRequestId);
8012
+ }
8013
+ // An announced drain that has shown NO sign of life for the staleness
8014
+ // window is over, even though nothing said so (CodeBuddy never sends
8015
+ // `session_end`). Keeping the record would make a later instant answer
8016
+ // with no conversation request id look steered, and hold a normal turn
8017
+ // open against a drain that is never coming back. RETIRE the id
8018
+ // first, so a late update or a response naming it is never adopted as
8019
+ // this session's own turn; then forget the record (the notice de-dupe
8020
+ // set with it) so this response settles normally.
8021
+ if (session.ambient !== undefined
8022
+ && Date.now() - session.ambient.lastActivityAt > this.tuning.ambientStaleMs) {
8023
+ retireAmbientId(session, session.ambient.requestId);
8024
+ session.ambient = undefined;
8025
+ session.ambientNotices?.clear();
8026
+ }
8027
+ if (holdDispatch) {
8028
+ // The response may name one run while an announcement named
8029
+ // another; the hold accepts both (see `BackgroundHold.acceptedIds`).
8030
+ controller.holdForBackgroundDispatch(result.stopReason, responseMeta?.conversationRequestId ?? responseMeta?.requestId);
8031
+ return;
8032
+ }
8033
+ // An instant answer with NO conversation request id, while the agent
8034
+ // announced a background drain this turn has not spoken into: the
8035
+ // prompt was folded into that drain (CodeBuddy's multitask). The
8036
+ // response is a formality — keep the turn open until the drain speaks.
8037
+ if (session.ambient !== undefined
8038
+ && !session.released
8039
+ && !session.closed
8040
+ && result.stopReason !== 'cancelled'
8041
+ && responseMeta?.conversationRequestId === undefined
8042
+ && !controller.hasAgentProse) {
8043
+ controller.awaitAmbient(session.ambient.requestId, result.stopReason);
8044
+ return;
8045
+ }
8046
+ // No ambient record to wait on, but a drain this session stopped
8047
+ // waiting on was retired moments ago: the run may have taken this
8048
+ // prompt WITHOUT re-announcing itself. The turn cannot be held open
8049
+ // (nothing names the drain), but a silent close must say the output
8050
+ // may be in the transcript instead of blaming the agent.
8051
+ if (session.ambient === undefined
8052
+ && result.stopReason !== 'cancelled'
8053
+ && responseMeta?.conversationRequestId === undefined
8054
+ && hasRecentRetiredAmbient(session, Math.max(this.tuning.ambientQuietMs * 2, RETIRED_AMBIENT_RECENT_MIN_MS))) {
8055
+ controller.noteRecentRetiredDrain();
8056
+ }
8057
+ controller.settle(result.stopReason === 'cancelled'
5704
8058
  ? { kind: 'cancelled' }
5705
- : { kind: 'stop', stopReason });
8059
+ : { kind: 'stop', stopReason: result.stopReason });
5706
8060
  })
5707
8061
  .catch((err) => {
5708
8062
  if (session.reclaimedTurnSeq === turnGen) {