dsh-ntr 0.3.2 → 0.3.4

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.
package/src/bridge.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  PROTOCOL_VERSION,
21
21
  RequestError,
22
22
  type ClientConnection,
23
+ type ContentBlock,
23
24
  type InitializeResponse,
24
25
  type McpServer,
25
26
  type McpServerHttp,
@@ -39,6 +40,7 @@ import {
39
40
  acpToolCallFromUpdate,
40
41
  buildAdapterModelId,
41
42
  childEnv,
43
+ hasUnambiguousModelSelector,
42
44
  isAcpToolContinuation,
43
45
  isUnderPath,
44
46
  mergeCatalogShape,
@@ -52,6 +54,7 @@ import {
52
54
  resolveFsPath,
53
55
  resolveSessionCwd,
54
56
  selectForwardPrompt,
57
+ isAbsolutePath,
55
58
  acpPresetStatusFromRoster,
56
59
  splitAdapterModelId,
57
60
  updateContentToText,
@@ -74,6 +77,7 @@ import type {
74
77
  AcpIdleRelease, ApprovalDeadlineKey, ApprovalDeadlinePatch, PermissionOutcome, PermissionOptionView, PermissionTimeoutMode, PluginUpdateState, PreparedAdapterCall, SessionView, StreamChunk,
75
78
  } from './protocol.ts'
76
79
  import { DEFAULT_IDLE_TIMEOUT_MS, DEFAULT_TAG_HALO, IDLE_RELEASE_MS } from './protocol.ts'
80
+ import { OUTPUT_CHARS_PER_TOKEN, TurnTpsTracker } from './tps.ts'
77
81
  import { UpdateService } from './update.ts'
78
82
 
79
83
  /**
@@ -134,7 +138,30 @@ interface LiveSession {
134
138
  availableCommands?: SessionView['availableCommands']
135
139
  plan?: unknown[]
136
140
  usage?: SessionView['usage']
141
+ /**
142
+ * The plugin's own measurable burst aggregate over this ACP session's turns
143
+ * (see `src/tps.ts`). Persisted with the chat-session record so the dock's
144
+ * rate survives a host restart; absent until a turn contributes a measurable
145
+ * burst (>= 1 inter-chunk gap).
146
+ */
147
+ burst?: SessionView['burst']
137
148
  running: boolean
149
+ /**
150
+ * The HOST's prompt slot for this session: true from the moment a turn
151
+ * starts until the agent answers it (or the host aborts the turn, or the
152
+ * turn is discarded). Together with the {@link settled} gate — which STAYS
153
+ * claimed after an abort until the agent acknowledges the cancel — this is
154
+ * what `claimTurn` arbitrates, NOT `running`.
155
+ *
156
+ * The three are deliberately different:
157
+ * - an ambient wait keeps `running` true (the background drain is still the
158
+ * session's work, so nothing may release the process) while this slot is
159
+ * already FREE (the agent answered; only the drain keeps talking);
160
+ * - an aborted turn drops THIS flag at once (our face of the turn ends) but
161
+ * leaves `settled` in place, so the next prompt still waits out
162
+ * `turnSettleMs` for the agent instead of racing a concurrent prompt.
163
+ */
164
+ promptInFlight?: boolean
138
165
  closed: boolean
139
166
  /** The agent process died; the session awaits a `session/load` resume. */
140
167
  released: boolean
@@ -244,9 +271,73 @@ interface LiveSession {
244
271
  * the controller's own `retire` when it still points at it.
245
272
  */
246
273
  turnController?: TurnStreamController
274
+ /**
275
+ * A background turn the agent started on its OWN initiative (CodeBuddy's
276
+ * multitask drains: a sub-agent completion notice starts a run the client
277
+ * never asked for). Its first `session_info_update` announces the request id
278
+ * every later update of that run carries in `_meta`; a `session/prompt`
279
+ * answered instantly with no conversation request id was folded into this
280
+ * drain and its real answer arrives as the drain's updates.
281
+ *
282
+ * Only lifetime events clear it (close / release / resume / stop): a drain
283
+ * may outlive the turn that was steered into it, and clearing on a normal
284
+ * settle would misclassify the NEXT steered prompt.
285
+ */
286
+ ambient?: AmbientTurn
287
+ /**
288
+ * Request ids whose `agent/notice` already went out, so a repeated
289
+ * announcement of the same background run is not logged twice.
290
+ */
291
+ ambientNotices?: Set<string>
292
+ /**
293
+ * Background drains this session already STOPPED waiting on (an ambient wait
294
+ * that closed on silence, or a record that outlived `ambientStaleMs`). The
295
+ * record is gone from {@link ambient}, but the drain itself may still be
296
+ * running: a queued message or a long tool makes it speak again while the
297
+ * NEXT turn is on air. Without this list the late text would teach that turn
298
+ * the drain's id (`noteTurnRequestId` / `adoptTurnRequestId`) and fold the
299
+ * background run's answer into the user's answer (a double answer).
300
+ *
301
+ * An update or re-announcement carrying a listed id REVIVES it
302
+ * (`reviveAmbientId` + a rebuilt {@link ambient}): the run is alive, so its
303
+ * next steered prompt must receive the drain's text. Only while the record
304
+ * stays retired is its text transcript-only. Timestamps let the zero-output
305
+ * report recognise a prompt folded into a recently quieted drain
306
+ * (`hasRecentRetiredAmbient`). Bounded ({@link RETIRED_AMBIENT_MAX}), oldest
307
+ * evicted first; every lifecycle boundary clears the list
308
+ * (`clearAmbientState`).
309
+ */
310
+ retiredAmbients?: RetiredAmbient[]
247
311
  transcript: Transcript
248
312
  }
249
313
 
314
+ /** One unsolicited (background) turn the agent is running, as announced in `_meta`. */
315
+ interface AmbientTurn {
316
+ /** `codebuddy.ai/requestId` of the drain; every update of the run carries it. */
317
+ requestId: string
318
+ startedAt: number
319
+ /**
320
+ * When this drain last showed any sign of life (announcement or an update
321
+ * carrying its request id). {@link BridgeTuning.ambientStaleMs} expires the
322
+ * record from here, so a drain that died silently does not capture a later
323
+ * normal turn as steered.
324
+ */
325
+ lastActivityAt: number
326
+ /** Whether the drain already streamed prose (text or thought) into the transcript. */
327
+ sawOutput: boolean
328
+ }
329
+
330
+ /** A drain id the session stopped waiting on, and when it was retired. */
331
+ interface RetiredAmbient {
332
+ requestId: string
333
+ /**
334
+ * When the wait was retired. `hasRecentRetiredAmbient` uses it to tell a
335
+ * prompt folded into a just-quieted drain from one that ended on its own;
336
+ * `adoptTurnRequestId` / `noteTurnRequestId` ignore the id at any age.
337
+ */
338
+ retiredAt: number
339
+ }
340
+
250
341
  /** Reliability tuning, threaded from `AcpPluginConfig` through the manager. */
251
342
  export interface BridgeTuning {
252
343
  startupTimeoutMs?: number
@@ -341,6 +432,65 @@ export interface BridgeTuning {
341
432
  * probe-only process has no stake in. `0` disables it.
342
433
  */
343
434
  probeIdleTimeoutMs?: number
435
+ /**
436
+ * How long an ambient wait (see `TurnStreamController.awaitAmbient`) stays
437
+ * open after the LAST session update before the turn closes itself.
438
+ *
439
+ * A drain's end is its silence — the SDK's schema rejects the `session_end`
440
+ * CodeBuddy never sends anyway — but "silence" must be measured in the
441
+ * agent's own cadence, not in a chat-app-paced one. Measured against
442
+ * CodeBuddy 2.179.0: the gaps between drain ticks (the
443
+ * `model_requesting`→`model_streaming` hand-off of a real model call) run
444
+ * 1.0–1.4s, so any window below that closes the turn BEFORE the answer
445
+ * exists. 10s leaves room for two slow hand-offs without holding the turn
446
+ * open for a drain that actually finished.
447
+ *
448
+ * Deliberately NOT keyed on an `agentPhase` idle flag: a drain goes idle
449
+ * between queued messages too, so that signal closes the turn in the middle
450
+ * of its own work.
451
+ */
452
+ ambientQuietMs?: number
453
+ /**
454
+ * The hard ceiling on ONE ambient wait: a background run that keeps ticking
455
+ * for minutes without finishing must not hold the conversation's turn open
456
+ * forever. At the ceiling the turn says where the rest of the output will
457
+ * land and closes.
458
+ */
459
+ ambientMaxMs?: number
460
+ /**
461
+ * The hard ceiling on the WORKER phase of a detached-worker hold (see
462
+ * `TurnStreamController.holdForBackgroundDispatch`): the turn dispatched one
463
+ * or more background workers through `Agent` tool calls, the agent answered
464
+ * the prompt, and at least one worker has not reported back yet. The worker
465
+ * phase has NO quiet window on purpose — measured against CodeBuddy 2.179.0,
466
+ * a detached worker's own updates are 13–24s apart while it is perfectly
467
+ * alive — so only EVERY known dispatch reporting (completed / failed) or
468
+ * this ceiling can end it; a drain announcement is recorded but arms no
469
+ * clock before then. Once they have all reported, the drain's own
470
+ * `ambientQuietMs` / `ambientMaxMs` windows take over.
471
+ *
472
+ * Default 600000 ms (10 minutes).
473
+ */
474
+ backgroundTaskMaxMs?: number
475
+ /**
476
+ * How long a PARKED detached-worker hold may wait for the host to open the
477
+ * driver step that carries the worker's report. The prompt response landed
478
+ * between two steps (the host is about to execute the `Agent` card), and a
479
+ * host that never opens the continuation step must not hang the turn. The
480
+ * host normally opens it at once, so this is a safety bound, not a cadence —
481
+ * deliberately far larger than one model hand-off.
482
+ *
483
+ * Default 30000 ms (30 seconds).
484
+ */
485
+ backgroundParkedGraceMs?: number
486
+ /**
487
+ * How long an announced background drain may go without ANY update before a
488
+ * later instant prompt stops treating itself as folded into it. A drain that
489
+ * went silent for minutes is over even though nothing told us; keeping the
490
+ * record would misclassify the next normal turn as steered. Checked lazily
491
+ * when a prompt response arrives (see `streamPrompt`), never on a timer.
492
+ */
493
+ ambientStaleMs?: number
344
494
  }
345
495
 
346
496
  /**
@@ -426,6 +576,11 @@ const DEFAULT_TUNING: Required<BridgeTuning> = {
426
576
  turnStallTickMs: 30_000,
427
577
  idleTimeoutMs: DEFAULT_IDLE_TIMEOUT_MS,
428
578
  probeIdleTimeoutMs: 300_000,
579
+ ambientQuietMs: 10_000,
580
+ ambientMaxMs: 120_000,
581
+ backgroundTaskMaxMs: 600_000,
582
+ backgroundParkedGraceMs: 30_000,
583
+ ambientStaleMs: 300_000,
429
584
  }
430
585
 
431
586
  type BridgeState = 'stopped' | 'starting' | 'ready' | 'error'
@@ -698,11 +853,16 @@ export class AgentBridge {
698
853
  * same session — forbidden by the protocol and rejected (or worse, mishandled)
699
854
  * by the agent. Waiting turns a protocol error into a short delay.
700
855
  *
856
+ * The gate is {@link LiveSession.promptInFlight} + the settle promise, NOT
857
+ * {@link LiveSession.running}: an ambient turn keeps `running` true for the
858
+ * whole drain while its prompt slot is already free, so `running` would
859
+ * refuse every message the user sends during a background task.
860
+ *
701
861
  * @param session - the session asking for the slot.
702
862
  * @returns whether the slot was claimed, and why not when it was not.
703
863
  */
704
864
  async claimTurn(session: LiveSession): Promise<{ ok: true } | { ok: false; reason: 'busy' | 'unsettled' }> {
705
- if (session.running) return { ok: false, reason: 'busy' }
865
+ if (this.promptSlotHeld(session)) return { ok: false, reason: 'busy' }
706
866
  const pending = session.settled
707
867
  if (pending === undefined) return { ok: true }
708
868
  let timer: ReturnType<typeof setTimeout> | undefined
@@ -720,13 +880,21 @@ export class AgentBridge {
720
880
  // The agent never acknowledged: sending now would start a concurrent turn,
721
881
  // so report it honestly instead of handing the agent two prompts at once.
722
882
  if (session.settled !== undefined) return { ok: false, reason: 'unsettled' }
723
- if (session.running) return { ok: false, reason: 'busy' }
883
+ // Re-checked after the wait: another turn may have claimed the slot while
884
+ // this one was waiting on the gate.
885
+ if (this.promptSlotHeld(session)) return { ok: false, reason: 'busy' }
724
886
  return { ok: true }
725
887
  }
726
888
 
889
+ /** Whether a turn currently holds the agent prompt slot (see `promptInFlight`). */
890
+ private promptSlotHeld(session: LiveSession): boolean {
891
+ return session.promptInFlight === true
892
+ }
893
+
727
894
  /**
728
- * Mark the agent-side turn as in flight and hand back the settler its stream
729
- * owner must call once the agent is actually done with it.
895
+ * Mark the agent-side turn as in flight — claim the agent prompt slot and
896
+ * open the settle gate — and hand back the settler its stream owner must
897
+ * call once the agent is actually done with it.
730
898
  *
731
899
  * @param session - the session whose turn is starting.
732
900
  * @returns the settler (idempotent; also used by connection loss).
@@ -736,11 +904,16 @@ export class AgentBridge {
736
904
  const settled = new Promise<void>(resolve => { settle = resolve })
737
905
  session.settled = settled
738
906
  session.settledResolve = settle
907
+ session.promptInFlight = true
739
908
  this.touch()
740
909
  return () => {
910
+ // Identity-guarded: only THIS turn's settler may release the slot. A
911
+ // superseded turn's late settler must not clear the flag a NEWER turn
912
+ // has already claimed (its settle gate was replaced with a new promise).
741
913
  if (session.settled === settled) {
742
914
  session.settled = undefined
743
915
  session.settledResolve = undefined
916
+ session.promptInFlight = false
744
917
  }
745
918
  settle()
746
919
  // The turn is over: a fresh window starts here rather than at the prompt
@@ -752,12 +925,14 @@ export class AgentBridge {
752
925
  /**
753
926
  * Open a session's settle gate without owning its turn stream: the turn's
754
927
  * stream is gone (a resume, a release) but the next prompt still waits on
755
- * this promise, and nothing is left that could ever resolve it.
928
+ * this promise, and nothing is left that could ever resolve it. The prompt
929
+ * slot itself goes with it: whatever held it is gone too.
756
930
  */
757
931
  private openSettleGate(session: LiveSession): void {
758
932
  const settle = session.settledResolve
759
933
  session.settled = undefined
760
934
  session.settledResolve = undefined
935
+ session.promptInFlight = false
761
936
  settle?.()
762
937
  }
763
938
 
@@ -1257,25 +1432,67 @@ export class AgentBridge {
1257
1432
  * {@link BridgeTuning.staleCancelRetryDelayMs}, out-waiting the cleanup
1258
1433
  * window, bounded by {@link BridgeTuning.staleCancelMaxRetries}; a genuinely
1259
1434
  * requested cancel (`cancelRequestedAtTurn` still names this generation)
1260
- * returns as-is.
1435
+ * returns as-is — checked BOTH before the delay and after it, so a second
1436
+ * stop click inside the retry window does not resend the prompt the user
1437
+ * just stopped.
1438
+ *
1439
+ * `turnGen` is the {@link LiveSession.turnSeq} this prompt was sent for. A
1440
+ * stale-cancel resend is only allowed while that generation still owns the
1441
+ * session: once a successor turn began (the cancel fallback finalized this
1442
+ * one and the user moved on), resending would race a second `session/prompt`
1443
+ * into the successor's turn. The late answer is returned as-is instead.
1444
+ *
1445
+ * The RESPONSE's `_meta` travels with the stop reason: it is the only place
1446
+ * an agent says whether the prompt actually queued a conversation turn (see
1447
+ * `CodebuddyMeta.conversationRequestId`), which decides whether an instant
1448
+ * answer was the turn ending or a fold into a background drain.
1261
1449
  */
1262
- async runPrompt(session: LiveSession, text: string, turnId: string): Promise<string> {
1450
+ async runPrompt(session: LiveSession, prompt: string | ContentBlock[], turnId: string, turnGen: number): Promise<{ stopReason: string; meta?: Record<string, unknown> }> {
1263
1451
  const conn = this.conn
1264
1452
  if (conn === undefined || session.closed || session.released) throw new AcpError('session_closed', 'acp: session is closed')
1265
1453
  this.touch()
1454
+
1455
+ const blocks: ContentBlock[] = typeof prompt === 'string'
1456
+ ? [{ type: 'text', text: prompt }]
1457
+ : prompt
1458
+
1459
+ if (blocks.length === 0) {
1460
+ throw new AcpError('empty_prompt', 'acp: prompt cannot be empty')
1461
+ }
1462
+
1463
+ const hasImages = blocks.some(b => b.type === 'image')
1464
+ if (hasImages && this.initResult?.agentCapabilities?.promptCapabilities?.image !== true) {
1465
+ throw new AcpError('image_unsupported', `acp: agent "${this.def.id}" does not support image input`)
1466
+ }
1467
+
1266
1468
  for (let attempt = 0; ; attempt += 1) {
1267
1469
  // The response is activity too: the idle window must start at the turn's
1268
1470
  // END, not at the prompt that may have started a long time ago.
1269
1471
  const result = await conn.agent.request(methods.agent.session.prompt, {
1270
1472
  sessionId: session.acpSessionId,
1271
- prompt: [{ type: 'text', text }],
1473
+ prompt: blocks,
1272
1474
  }).finally(() => { this.touch() })
1475
+ const answered = () => ({
1476
+ stopReason: result.stopReason,
1477
+ ...(result._meta !== undefined && result._meta !== null ? { meta: result._meta } : {}),
1478
+ })
1273
1479
  if (result.stopReason !== 'cancelled' || session.cancelRequestedAtTurn === session.turnSeq) {
1274
- return result.stopReason
1480
+ return answered()
1275
1481
  }
1276
- if (attempt >= this.staleCancelMaxRetries) return result.stopReason
1482
+ // The turn this answer belongs to no longer owns the session (a cancel
1483
+ // fallback finalized it and a successor began): resending would fire a
1484
+ // second `session/prompt` into the successor's turn.
1485
+ if (session.turnSeq !== turnGen) return answered()
1486
+ if (attempt >= this.staleCancelMaxRetries) return answered()
1277
1487
  await new Promise(resolve => setTimeout(resolve, this.staleCancelRetryDelayMs))
1278
1488
  if (session.closed || this.conn !== conn) throw new AcpError('session_closed', 'acp: session is closed')
1489
+ // Re-check after the delay: the successor may have started while waiting.
1490
+ if (session.turnSeq !== turnGen) return answered()
1491
+ // ...and a SECOND stop during the delay names this same generation. The
1492
+ // user already said stop, so the stale-cancel answer is not a swallow to
1493
+ // out-wait anymore: resending would make the agent run the prompt the
1494
+ // user just stopped (the double-click case). Return as-is.
1495
+ if (session.cancelRequestedAtTurn === session.turnSeq) return answered()
1279
1496
  this.touch()
1280
1497
  }
1281
1498
  }
@@ -1326,16 +1543,30 @@ export class AgentBridge {
1326
1543
 
1327
1544
  /**
1328
1545
  * Force the local end of one turn whose agent never acknowledged it: clear
1329
- * `running`, open the settle gate, and broadcast the turn end with `turnId:
1330
- * ''` (there is no agent response to attribute it to). Shared by the cancel
1546
+ * `running`, open the settle gate, and end the turn. Shared by the cancel
1331
1547
  * fallback and the stall watchdog.
1332
1548
  *
1549
+ * A live controller owns the turn's streaming face — its segment queue, its
1550
+ * ambient clocks and its `turn/end` id. Ending the turn behind its back
1551
+ * would leave the driver step parked on an open queue, and the controller's
1552
+ * own timers would later broadcast a SECOND `turn/end` for a turn already
1553
+ * closed. So when the session still points at a current controller, the
1554
+ * force goes THROUGH it (`forceFinalize`), which runs the same closing work
1555
+ * as a normal settle exactly once. Only a turn with no live controller (an
1556
+ * `/acp`-RPC turn, or one already retired) falls back to the local broadcast
1557
+ * with `turnId: ''` — there is no stream whose id could be named.
1558
+ *
1333
1559
  * Generation-guarded: a turn that already ended — or that a later turn
1334
1560
  * superseded — is not this call's to finalize. Returns whether it acted; a
1335
1561
  * `false` means the caller was late and must leave the session alone.
1336
1562
  */
1337
1563
  finalizeTurn(session: LiveSession, turnGen: number, message: string): boolean {
1338
1564
  if (session.closed || session.turnSeq !== turnGen) return false
1565
+ const controller = session.turnController
1566
+ if (controller !== undefined && !controller.stale) {
1567
+ controller.forceFinalize({ kind: 'error', message })
1568
+ return true
1569
+ }
1339
1570
  // Nothing is in flight anymore (the turn settled on its own): nothing to
1340
1571
  // finalize. A still-pending settle gate counts as in flight even though
1341
1572
  // the stream is gone — that gate is what the next prompt waits on.
@@ -1403,10 +1634,12 @@ export class AgentBridge {
1403
1634
  session.closed = false
1404
1635
  session.released = false
1405
1636
  // A resume is a fresh agent-side state: nothing from the dead process's
1406
- // turn survives it, including the settle gate and the stall markers.
1637
+ // turn survives it, including the settle gate and the stall markers. A
1638
+ // background drain belonged to that process too.
1407
1639
  session.orphanPromptTurnSeq = undefined
1408
1640
  session.reclaimedTurnSeq = undefined
1409
1641
  session.activeToolCalls.clear()
1642
+ clearAmbientState(session)
1410
1643
  session.lastAgentActivityAt = Date.now()
1411
1644
  this.openSettleGate(session)
1412
1645
  // Route replay updates into the fresh transcript while the load request
@@ -1469,8 +1702,10 @@ export class AgentBridge {
1469
1702
  // would make the next prompt wait for an answer that can no longer come.
1470
1703
  this.openSettleGate(session)
1471
1704
  // No call from the dead process can still be running: drop the tool set
1472
- // so a resumed turn starts on the plain window.
1705
+ // so a resumed turn starts on the plain window. A background drain died
1706
+ // with it, and a stale ambient would misclassify the NEXT prompt.
1473
1707
  session.activeToolCalls.clear()
1708
+ clearAmbientState(session)
1474
1709
  session.released = true
1475
1710
  this.settlePermissions(session)
1476
1711
  this.released.set(session.sessionId, session)
@@ -1581,6 +1816,12 @@ export class AgentBridge {
1581
1816
  async closeSession(session: LiveSession, reason: 'closed' | 'agent-stopped' | 'agent-died'): Promise<void> {
1582
1817
  if (session.closed) return
1583
1818
  session.closed = true
1819
+ // The session is going away: whatever background drain it announced ends
1820
+ // with it (a re-created session must not inherit a stale ambient).
1821
+ clearAmbientState(session)
1822
+ // ...and so does its prompt slot: nothing may keep rejecting a new prompt
1823
+ // for a record that is being deleted.
1824
+ session.promptInFlight = false
1584
1825
  // Closing a session is activity of its own: restart the idle window here
1585
1826
  // rather than at the prompt that opened it.
1586
1827
  this.touch()
@@ -1655,6 +1896,7 @@ export class AgentBridge {
1655
1896
  this.settlePermissions(session)
1656
1897
  this.openSettleGate(session)
1657
1898
  session.activeToolCalls.clear()
1899
+ clearAmbientState(session)
1658
1900
  this.sessions.delete(session.sessionId)
1659
1901
  this.acpIndex.delete(session.acpSessionId)
1660
1902
  this.hub.broadcast({ type: 'session/closed', sessionId: session.sessionId, reason })
@@ -1937,6 +2179,60 @@ export class AgentBridge {
1937
2179
  // tool, which earns the wider watchdog window.
1938
2180
  session.lastAgentActivityAt = Date.now()
1939
2181
  const kind = params.update.sessionUpdate
2182
+ const meta = codebuddyMetaOf(params.update)
2183
+ // A RETIRED drain that speaks again is alive, not retired. Un-retire it
2184
+ // BEFORE this update is classified and rebuild the ambient record: the run
2185
+ // may never re-announce itself, yet the next prompt folded into it must
2186
+ // still be recognised as steered and receive the drain's text. A
2187
+ // re-announcement below does the same for its id.
2188
+ if (meta?.requestId !== undefined && isRetiredAmbientId(session, meta.requestId)) {
2189
+ reviveAmbientId(session, meta.requestId)
2190
+ session.ambient = {
2191
+ requestId: meta.requestId,
2192
+ startedAt: session.ambient?.startedAt ?? Date.now(),
2193
+ lastActivityAt: Date.now(),
2194
+ sawOutput: session.ambient?.sawOutput ?? false,
2195
+ }
2196
+ }
2197
+ if (meta?.unsolicitedTurn !== undefined) {
2198
+ // The agent started a background turn on its own: remember it so a
2199
+ // prompt the agent folds into it (answered instantly, no conversation
2200
+ // request id) can be kept open until the drain speaks. Announced once per
2201
+ // request id — a repeated announcement is the same run, not news.
2202
+ const run = meta.unsolicitedTurn
2203
+ // A re-announcement also revives a drain whose wait already closed: the
2204
+ // same run is alive, and the notice de-dupe set keeps it silent.
2205
+ reviveAmbientId(session, run.requestId)
2206
+ session.ambient = { requestId: run.requestId, startedAt: Date.now(), lastActivityAt: Date.now(), sawOutput: false }
2207
+ const announced = session.ambientNotices ?? new Set<string>()
2208
+ session.ambientNotices = announced
2209
+ if (!announced.has(run.requestId)) {
2210
+ announced.add(run.requestId)
2211
+ // The de-dupe set outlives individual turns (a hard ceiling keeps the
2212
+ // drain record), so it is bounded: evict the oldest ids first.
2213
+ while (announced.size > AMBIENT_NOTICE_MAX) {
2214
+ const oldest = announced.values().next().value
2215
+ if (oldest === undefined) break
2216
+ announced.delete(oldest)
2217
+ }
2218
+ this.hub.broadcast({
2219
+ type: 'agent/notice',
2220
+ agentId: session.agentId,
2221
+ level: 'info',
2222
+ 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.`,
2223
+ })
2224
+ }
2225
+ }
2226
+ if (session.ambient !== undefined
2227
+ && meta?.requestId === session.ambient.requestId) {
2228
+ // The run is alive: refresh the staleness clock on ANY update it owns,
2229
+ // not just prose (a tool-heavy stretch is still the drain working).
2230
+ session.ambient.lastActivityAt = Date.now()
2231
+ if ((kind === 'agent_message_chunk' || kind === 'agent_thought_chunk')
2232
+ && chunkTextOf((params.update as { content?: unknown }).content) !== '') {
2233
+ session.ambient.sawOutput = true
2234
+ }
2235
+ }
1940
2236
  if (kind === 'tool_call' || kind === 'tool_call_update') {
1941
2237
  const { toolCallId, status } = params.update as { toolCallId: string; status?: string | null }
1942
2238
  if (status === 'completed' || status === 'failed') session.activeToolCalls.delete(toolCallId)
@@ -1994,6 +2290,178 @@ export class AgentBridge {
1994
2290
  }
1995
2291
  }
1996
2292
 
2293
+ /**
2294
+ * The CodeBuddy `_meta` facts this bridge reads, when the object carries them.
2295
+ *
2296
+ * CodeBuddy runs "background drains": a sub-agent completion notice starts a
2297
+ * turn the client never prompted, announced by a `session_info_update` whose
2298
+ * `_meta["codebuddy.ai/unsolicitedTurn"]` names the run. Every update of that
2299
+ * run then carries `_meta["codebuddy.ai/requestId"]`, and a `session/prompt`
2300
+ * answered instantly (with no `codebuddy.ai/conversationRequestId` on the
2301
+ * RESPONSE) had its message folded into the drain — the real answer streams
2302
+ * afterwards, under the drain's request id.
2303
+ *
2304
+ * The same run also explains a DETACHED WORKER: `_meta["codebuddy.ai/toolName"]`
2305
+ * names the `Agent` call that dispatched it, the call is re-sent with
2306
+ * `_meta["codebuddy.ai/isBackground"] = true` once the worker runs off-turn, and
2307
+ * every update the worker emits carries
2308
+ * `_meta["codebuddy.ai/parentToolCallId"]` naming that call. Those updates are
2309
+ * the worker's own turn — they belong in the transcript, not in the session's
2310
+ * assistant message. The completion of the dispatch call itself carries NO
2311
+ * `_meta` at all, so a known call's status is read from its id.
2312
+ *
2313
+ * No other agent sends these keys: an object without them yields `undefined`,
2314
+ * so every existing path is untouched. Values are only read when they have the
2315
+ * declared shape (never guessed from a message body).
2316
+ */
2317
+ interface CodebuddyMeta {
2318
+ /** The run this update belongs to. */
2319
+ requestId?: string
2320
+ /** Set on the `session_info_update` that STARTS a background turn. */
2321
+ unsolicitedTurn?: { requestId: string; reason?: string }
2322
+ /** The agent's own phase heartbeat (`{ phase }`), a liveness tick. */
2323
+ agentPhase?: { phase: string }
2324
+ /** Set on a `session/prompt` RESPONSE that really queued a conversation turn. */
2325
+ conversationRequestId?: string
2326
+ /**
2327
+ * The tool a `tool_call` names, when the agent omits `title`. `Agent` (or
2328
+ * `Task`) means the call DISPATCHED a detached worker: the call stays in
2329
+ * progress while the worker runs on its own.
2330
+ */
2331
+ toolName?: string
2332
+ /**
2333
+ * Set on every update a DETACHED WORKER emits (prose, tools, its report):
2334
+ * it names the `Agent` tool call that dispatched the worker. Such an update
2335
+ * belongs to the worker's own turn, never to this session's answer.
2336
+ */
2337
+ parentToolCallId?: string
2338
+ /**
2339
+ * Set on the dispatch call RE-SENT after the worker started
2340
+ * (`codebuddy.ai/isBackground: true`): the call handed its work to a worker
2341
+ * that keeps running off-turn, which is the structural "detached" signal.
2342
+ * The first send of the same call does not carry it.
2343
+ */
2344
+ isBackground?: boolean
2345
+ /** `codebuddy.ai/isSubagent` — the same re-send declares the worker a sub-agent. */
2346
+ isSubagent?: boolean
2347
+ }
2348
+
2349
+ /** Read the CodeBuddy `_meta` keys off an update or a `{ _meta }` wrapper. */
2350
+ function codebuddyMetaOf(source: unknown): CodebuddyMeta | undefined {
2351
+ if (typeof source !== 'object' || source === null) return undefined
2352
+ const row = source as Record<string, unknown>
2353
+ const rawMeta = row._meta
2354
+ if (typeof rawMeta !== 'object' || rawMeta === null) return undefined
2355
+ const meta = rawMeta as Record<string, unknown>
2356
+ const out: CodebuddyMeta = {}
2357
+ const requestId = meta['codebuddy.ai/requestId']
2358
+ if (typeof requestId === 'string' && requestId !== '') out.requestId = requestId
2359
+ const conversationRequestId = meta['codebuddy.ai/conversationRequestId']
2360
+ if (typeof conversationRequestId === 'string' && conversationRequestId !== '') {
2361
+ out.conversationRequestId = conversationRequestId
2362
+ }
2363
+ const toolName = meta['codebuddy.ai/toolName']
2364
+ if (typeof toolName === 'string' && toolName !== '') out.toolName = toolName
2365
+ const parentToolCallId = meta['codebuddy.ai/parentToolCallId']
2366
+ if (typeof parentToolCallId === 'string' && parentToolCallId !== '') out.parentToolCallId = parentToolCallId
2367
+ // Booleans, read as booleans only: a string "true" is not the declared shape
2368
+ // and guessing from it would make another agent's unrelated key look detached.
2369
+ if (meta['codebuddy.ai/isBackground'] === true) out.isBackground = true
2370
+ if (meta['codebuddy.ai/isSubagent'] === true) out.isSubagent = true
2371
+ const unsolicited = meta['codebuddy.ai/unsolicitedTurn']
2372
+ if (typeof unsolicited === 'object' && unsolicited !== null) {
2373
+ const run = unsolicited as Record<string, unknown>
2374
+ if (typeof run.requestId === 'string' && run.requestId !== '') {
2375
+ out.unsolicitedTurn = {
2376
+ requestId: run.requestId,
2377
+ ...(typeof run.reason === 'string' && run.reason !== '' ? { reason: run.reason } : {}),
2378
+ }
2379
+ }
2380
+ }
2381
+ const phase = meta['codebuddy.ai/agentPhase']
2382
+ if (typeof phase === 'object' && phase !== null) {
2383
+ const tick = phase as Record<string, unknown>
2384
+ if (typeof tick.phase === 'string' && tick.phase !== '') out.agentPhase = { phase: tick.phase }
2385
+ }
2386
+ return Object.keys(out).length === 0 ? undefined : out
2387
+ }
2388
+
2389
+ /**
2390
+ * Remember a background drain this session stopped waiting on. A quiet close
2391
+ * drops the drain record (so an ordinary next turn is not held open for it),
2392
+ * but the run may revive afterwards; keeping its id lets every later update be
2393
+ * recognized as the drain's (`isRetiredAmbientId`) instead of being adopted as
2394
+ * the next turn's own. Bounded, oldest first — an id this far back names a run
2395
+ * no late update can plausibly still carry. Re-retiring an id refreshes its
2396
+ * timestamp and moves it to the end.
2397
+ */
2398
+ function retireAmbientId(session: LiveSession, requestId: string): void {
2399
+ const retired = session.retiredAmbients ?? []
2400
+ const existing = retired.findIndex(entry => entry.requestId === requestId)
2401
+ if (existing >= 0) retired.splice(existing, 1)
2402
+ retired.push({ requestId, retiredAt: Date.now() })
2403
+ while (retired.length > RETIRED_AMBIENT_MAX) retired.shift()
2404
+ session.retiredAmbients = retired
2405
+ }
2406
+
2407
+ /** Whether this request id names a drain the session already stopped waiting on. */
2408
+ function isRetiredAmbientId(session: LiveSession, requestId: string | undefined): boolean {
2409
+ return requestId !== undefined
2410
+ && session.retiredAmbients?.some(entry => entry.requestId === requestId) === true
2411
+ }
2412
+
2413
+ /**
2414
+ * Whether the session retired a drain recently enough that a prompt answered
2415
+ * with no ambient record and no conversation id may have been folded into it.
2416
+ * A quiet close is only evidence WE stopped waiting, so the next instant
2417
+ * answer is ambiguous; past this horizon the drain is treated as gone and the
2418
+ * ordinary zero-output warning applies.
2419
+ */
2420
+ function hasRecentRetiredAmbient(session: LiveSession, withinMs: number): boolean {
2421
+ const retired = session.retiredAmbients
2422
+ if (retired === undefined || retired.length === 0) return false
2423
+ const cutoff = Date.now() - withinMs
2424
+ return retired.some(entry => entry.retiredAt >= cutoff)
2425
+ }
2426
+
2427
+ /**
2428
+ * The retired drain spoke again (an update or a re-announcement carried its
2429
+ * id): it is alive, not retired. The caller rebuilds {@link LiveSession.ambient}
2430
+ * so the next prompt folded into it is recognised as steered and its text can
2431
+ * reach the user's turn; only while the id stays retired does its text remain
2432
+ * transcript-only.
2433
+ */
2434
+ function reviveAmbientId(session: LiveSession, requestId: string): void {
2435
+ const retired = session.retiredAmbients
2436
+ if (retired === undefined) return
2437
+ const index = retired.findIndex(entry => entry.requestId === requestId)
2438
+ if (index < 0) return
2439
+ retired.splice(index, 1)
2440
+ if (retired.length === 0) session.retiredAmbients = undefined
2441
+ }
2442
+
2443
+ /**
2444
+ * Forget every background-drain trace a session carried. Called at each
2445
+ * lifecycle boundary (close / release / resume / force reset): a fresh
2446
+ * agent-side state neither inherits a stale drain nor the ids of drains that
2447
+ * belonged to the old one.
2448
+ */
2449
+ function clearAmbientState(session: LiveSession): void {
2450
+ session.ambient = undefined
2451
+ session.ambientNotices?.clear()
2452
+ session.retiredAmbients = undefined
2453
+ }
2454
+
2455
+ /**
2456
+ * The closing line of a turn the agent deferred to its OWN background run: the
2457
+ * answer exists, but in the ACP transcript view rather than in this bubble.
2458
+ * Shared by the ambient wait and the detached-worker hold, so both say the
2459
+ * same thing in the same words.
2460
+ */
2461
+ function backgroundDeferralLine(agentId: string): string {
2462
+ 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`
2463
+ }
2464
+
1997
2465
  /** Human-facing identity of an ACP tool call (the approval headline). */
1998
2466
  /**
1999
2467
  * Human-facing name of the tool an ask is about, for the chat line and the
@@ -2085,6 +2553,7 @@ function snapshot(session: LiveSession): SessionView {
2085
2553
  ...(session.availableCommands !== undefined ? { availableCommands: session.availableCommands } : {}),
2086
2554
  ...(session.plan !== undefined ? { plan: session.plan } : {}),
2087
2555
  ...(session.usage !== undefined ? { usage: session.usage } : {}),
2556
+ ...(session.burst !== undefined ? { burst: session.burst } : {}),
2088
2557
  running: session.running,
2089
2558
  closed: session.closed,
2090
2559
  ...(session.released ? { released: true } : {}),
@@ -2127,6 +2596,7 @@ interface PersistedChatSession {
2127
2596
  reasoningEffort?: string
2128
2597
  modes?: SessionView['modes']
2129
2598
  usage?: SessionView['usage']
2599
+ burst?: SessionView['burst']
2130
2600
  hasHistory?: boolean
2131
2601
  }
2132
2602
 
@@ -2142,6 +2612,7 @@ function persistedChatSession(session: LiveSession): PersistedChatSession {
2142
2612
  ...(session.reasoningEffort === undefined ? {} : { reasoningEffort: session.reasoningEffort }),
2143
2613
  ...(session.modes === undefined ? {} : { modes: session.modes }),
2144
2614
  ...(session.usage === undefined ? {} : { usage: session.usage }),
2615
+ ...(session.burst === undefined ? {} : { burst: session.burst }),
2145
2616
  ...(session.hasHistory === true || session.transcript.since(0).length > 0 ? { hasHistory: true } : {}),
2146
2617
  }
2147
2618
  }
@@ -2160,6 +2631,14 @@ function isPersistedUsage(value: unknown): value is SessionView['usage'] {
2160
2631
  return typeof usage.used === 'number' && typeof usage.size === 'number'
2161
2632
  }
2162
2633
 
2634
+ /** Tolerant shape check for a `burst` aggregate read back from disk. */
2635
+ function isPersistedBurst(value: unknown): value is NonNullable<SessionView['burst']> {
2636
+ if (value === null || typeof value !== 'object') return false
2637
+ const burst = value as { outputTokens?: unknown; burstMs?: unknown }
2638
+ return typeof burst.outputTokens === 'number' && burst.outputTokens >= 0
2639
+ && typeof burst.burstMs === 'number' && burst.burstMs >= 0
2640
+ }
2641
+
2163
2642
  /**
2164
2643
  * The DSH chat session's OWN workspace, when one is resolvable: the directory
2165
2644
  * the user selected in the DSH sidebar, which the host reports back as the
@@ -2925,12 +3404,17 @@ interface SegmentState {
2925
3404
  activeToolCall: AcpToolCallSnapshot | undefined
2926
3405
  /** Whether this segment opened at least one tool-call block. */
2927
3406
  hasCard: boolean
3407
+ /**
3408
+ * The segment belongs to an ambient wait: the agent folded our prompt into a
3409
+ * background drain it started itself. Tool traffic is recorded (so the
3410
+ * transcript and any parked tool-output waiter get it) but opens NO card —
3411
+ * a card would make the host loop execute a tool for a turn whose answer has
3412
+ * not even started streaming yet.
3413
+ */
3414
+ ambient: boolean
2928
3415
  finished: boolean
2929
3416
  }
2930
3417
 
2931
- /** ACP `usage_update` is context occupancy, not request tokens; host usage is a 4-chars/token OUTPUT estimate (tool args excluded). */
2932
- const OUTPUT_CHARS_PER_TOKEN = 4
2933
-
2934
3418
  /** How one segment's stream ends, in `StreamChunk.finish` terms. */
2935
3419
  type SegmentFinishReason =
2936
3420
  | { kind: 'stop' }
@@ -2943,6 +3427,153 @@ type TurnSettle =
2943
3427
  | { kind: 'cancelled' }
2944
3428
  | { kind: 'error'; message: string; code?: string }
2945
3429
 
3430
+ /** How often a running turn's live burst aggregate is pushed to the browser. */
3431
+ const TPS_PUSH_INTERVAL_MS = 1000
3432
+
3433
+ /**
3434
+ * Bounds on the updates withheld while a turn's own request id is still
3435
+ * unknown (see `TurnStreamController.bufferAmbientUpdate`). The buffer exists
3436
+ * only for the response window of one prompt, but a chatty drain could stream
3437
+ * for a while inside it; over the bound the OLDEST entries are dropped — the
3438
+ * transcript still has every one of them.
3439
+ */
3440
+ const PENDING_AMBIENT_MAX_ENTRIES = 200
3441
+ const PENDING_AMBIENT_MAX_CHARS = 64 * 1024
3442
+
3443
+ /**
3444
+ * Bound on {@link LiveSession.ambientNotices}. The set now outlives the turn
3445
+ * that announced a drain (a hard ceiling keeps the record, and a repeated
3446
+ * announcement of the same run must stay silent), so it needs a ceiling of its
3447
+ * own for a chatty agent. Oldest ids are evicted first; an id that far back is
3448
+ * a run nobody is waiting on anymore.
3449
+ */
3450
+ const AMBIENT_NOTICE_MAX = 64
3451
+
3452
+ /**
3453
+ * Bound on {@link LiveSession.retiredAmbients}. The ids only exist to catch a
3454
+ * drain that speaks again after its wait closed, and the timestamp lets the
3455
+ * next silent close recognise a recent fold — a short-horizon concern, so a
3456
+ * small ring is enough; the oldest is evicted first.
3457
+ */
3458
+ const RETIRED_AMBIENT_MAX = 32
3459
+
3460
+ /**
3461
+ * Floor for the "recently retired" lookback the zero-output fallback uses
3462
+ * (twice `ambientQuietMs`). Only test-sized quiet windows fall below it; a
3463
+ * production window (10s) always wins. Without the floor a 250ms quiet window
3464
+ * would leave a 500ms lookback, too tight to survive a loaded CI scheduler
3465
+ * between one turn ending and the next prompt's response.
3466
+ */
3467
+ const RETIRED_AMBIENT_RECENT_MIN_MS = 1_000
3468
+
3469
+ /** One ambient wait's clocks and the stop reason it will close the turn with. */
3470
+ interface AmbientWait {
3471
+ /** The drain's request id: the turn is waiting on this run, not on its own. */
3472
+ requestId: string
3473
+ quietTimer: ReturnType<typeof setTimeout>
3474
+ hardTimer: ReturnType<typeof setTimeout>
3475
+ /** The stop reason the agent's `session/prompt` response carried. */
3476
+ stopReason: string
3477
+ /**
3478
+ * Whether the CEILING closed this wait (the drain was still ticking) rather
3479
+ * than silence. A hard ending must keep the session's drain record: the next
3480
+ * prompt may be folded into the same run, and it is still alive.
3481
+ */
3482
+ hard?: boolean
3483
+ }
3484
+
3485
+ /** An update withheld from the segment until the turn's own request id is known. */
3486
+ interface PendingAmbientUpdate {
3487
+ seq: number
3488
+ update: Record<string, unknown>
3489
+ /** `JSON.stringify(update).length`, kept so eviction need not re-serialize. */
3490
+ chars: number
3491
+ }
3492
+
3493
+ /**
3494
+ * The `Agent` tool call that handed work to a DETACHED worker.
3495
+ *
3496
+ * CodeBuddy 2.179.0 with multitask on: the coordinator opens an `Agent` call
3497
+ * (`_meta["codebuddy.ai/toolName"] = "Agent"`), says "已派…", and ENDS ITS OWN
3498
+ * PROMPT normally while the worker keeps running off-turn. The call itself
3499
+ * stays `in_progress` until the worker reports, and only then does the agent
3500
+ * start the background drain that carries the worker's report into the
3501
+ * session. A turn that closed at its prompt response therefore lost the only
3502
+ * answer it was ever going to produce; this record is what the controller
3503
+ * holds the turn open for.
3504
+ */
3505
+ interface BackgroundDispatch {
3506
+ /** The `Agent` call's id — the card that stays on the plane. */
3507
+ toolCallId: string
3508
+ /** The last status the agent declared for the dispatch call itself. */
3509
+ status: string
3510
+ /** Whether the call already reported completed / failed. */
3511
+ completed: boolean
3512
+ /**
3513
+ * Whether the work runs DETACHED, read from the wire's own structure: the
3514
+ * call re-sent with `_meta["codebuddy.ai/isBackground"] = true`, or any
3515
+ * update naming this call as its `parentToolCallId`. A synchronous
3516
+ * `Agent` / `Task` call carries neither, so it can never make the turn wait
3517
+ * after its own answer.
3518
+ */
3519
+ detached: boolean
3520
+ /** Whether any update came FROM the worker (`codebuddy.ai/parentToolCallId`). */
3521
+ sawWorkerUpdate: boolean
3522
+ }
3523
+
3524
+ /**
3525
+ * A turn held open for the detached worker its `Agent` call dispatched.
3526
+ *
3527
+ * Two phases, because their liveness signals differ:
3528
+ * - `worker`: at least one worker runs off-turn and may be silent for tens of
3529
+ * seconds between model calls — NO quiet timer, only
3530
+ * {@link BridgeTuning.backgroundTaskMaxMs}. It ends when EVERY known dispatch
3531
+ * reported (completed / failed); a drain announcement is recorded meanwhile
3532
+ * but arms no clock.
3533
+ * - `drain`: every dispatch reported and the background turn that carries
3534
+ * their report is (or is about to start) — the existing ambient windows
3535
+ * apply (`ambientQuietMs` quiet, reset by every update; `ambientMaxMs`
3536
+ * ceiling from the switch).
3537
+ */
3538
+ interface BackgroundHold {
3539
+ /** The stop reason the prompt RESPONSE carried (what the turn closes with). */
3540
+ stopReason: string
3541
+ /** The run this hold currently waits on: the response's id or the last announcement. */
3542
+ requestId?: string
3543
+ /**
3544
+ * Every drain id this hold may fold text from: the response's
3545
+ * `conversationRequestId` / `requestId` plus every announced id. A run
3546
+ * announced before the response may name a different id than the response —
3547
+ * both are this turn's answer, and anything else stays background noise.
3548
+ */
3549
+ acceptedIds: Set<string>
3550
+ phase: 'worker' | 'drain'
3551
+ /**
3552
+ * Agent MESSAGE text folded into this hold. The parent's interim
3553
+ * acknowledgement ("已派…") landed before the hold and is not the drained
3554
+ * run's answer: a hold that never received that answer must still say where
3555
+ * it will appear.
3556
+ */
3557
+ answerChars: number
3558
+ /**
3559
+ * The transcript head when the hold began. Text at or below it arrived
3560
+ * before the hold — a parked hold replays the parent's ack from there — so
3561
+ * it is not counted in {@link answerChars}; the drain updates withheld for
3562
+ * the response are the exception and are counted explicitly when flushed.
3563
+ */
3564
+ floorSeq: number
3565
+ quietTimer?: ReturnType<typeof setTimeout>
3566
+ hardTimer?: ReturnType<typeof setTimeout>
3567
+ /**
3568
+ * The bounded wait for a driver step to speak into. Only while the hold is
3569
+ * PARKED (`pendingBackgroundHold`): the response landed between steps, and
3570
+ * the host is expected to open the continuation step for the Agent card.
3571
+ */
3572
+ graceTimer?: ReturnType<typeof setTimeout>
3573
+ /** Whether the run's ids were already released (a close, cancel or teardown). */
3574
+ retired: boolean
3575
+ }
3576
+
2946
3577
  /**
2947
3578
  * The state of ONE ACP prompt, shared across every driver step of the turn.
2948
3579
  *
@@ -2956,13 +3587,111 @@ type TurnSettle =
2956
3587
  class TurnStreamController {
2957
3588
  private segment: SegmentState | undefined
2958
3589
  private settled = false
3590
+ /** Whether the turn's closing work already ran (see `closeTurn`). */
3591
+ private closedTurn = false
2959
3592
  private disposed = false
2960
3593
  private readonly seenCalls = new Set<string>()
2961
3594
  private readonly announcedKinds = new Map<string, string>()
2962
3595
  /** Narrative lines (permission asks) that arrived while no segment was open. */
2963
3596
  private readonly pendingProse: string[] = []
2964
3597
  sawVisibleBlock = false
3598
+ /**
3599
+ * Whether THIS turn streamed its own prose (text or reasoning), i.e. an
3600
+ * answer that is not the background drain's. Tool cards do not count — a
3601
+ * drain that only ran tools delivered no words to the conversation.
3602
+ */
3603
+ private sawAgentProse = false
3604
+ /**
3605
+ * Whether any `agent_message_chunk` text reached the plane. Reasoning does
3606
+ * not count: a steer whose drain only thought is still a turn with no answer
3607
+ * text, and must say so when it closes.
3608
+ */
3609
+ private sawVisibleText = false
3610
+ /**
3611
+ * The CodeBuddy request id this turn has adopted as its own (from the
3612
+ * prompt RESPONSE's `conversationRequestId`, or from the first update that
3613
+ * named one). Used to keep a background drain's prose out of the user's
3614
+ * answer; `undefined` means "unknown" and nothing is split (fail-open).
3615
+ */
3616
+ private turnRequestId?: string
3617
+ /**
3618
+ * Updates the session's background drain already streamed while this turn's
3619
+ * OWN request id was still unknown. They are NOT folded on arrival: a drain
3620
+ * tick that lands before the prompt response could otherwise teach the turn
3621
+ * the drain's id (`noteTurnRequestId`) and mix the drain's text into the
3622
+ * user's answer. Withheld here (cursor already advanced — the transcript
3623
+ * keeps a copy) until the response decides what this turn is:
3624
+ * - a normal turn (response carries a conversation id) discards them — they
3625
+ * were never this turn's answer;
3626
+ * - a steered turn flushes them into the segment, in order, once
3627
+ * `awaitAmbient` marks the segment ambient (tool cards suppressed).
3628
+ */
3629
+ private readonly pendingAmbientUpdates: PendingAmbientUpdate[] = []
3630
+ private pendingAmbientChars = 0
3631
+ /**
3632
+ * Whether the bounded buffer dropped at least one update. The flush then
3633
+ * says so in the conversation instead of silently losing the front of the
3634
+ * drain's output (constraint 29: ACP payloads are never dropped silently).
3635
+ */
3636
+ private pendingAmbientTruncated = false
3637
+ /** The ambient wait in progress, if the agent folded this prompt into a drain. */
3638
+ private awaitingAmbient?: AmbientWait
3639
+ /**
3640
+ * The prompt response named no conversation turn while a drain this session
3641
+ * retired RECENTLY may still be running without re-announcing. The turn still
3642
+ * settles at once (a drain record that no update revived is not waited on),
3643
+ * but its zero-output report says the turn may have been folded into that run
3644
+ * instead of blaming the agent and suggesting a resend.
3645
+ */
3646
+ private recentRetiredDrain = false
3647
+ /** The outcome of a settle whose closing work is waiting one macrotask (see `settle`). */
3648
+ private pendingOutcome?: TurnSettle
3649
+ private closeScheduled = false
2965
3650
  private cursorSeq: number
3651
+ /**
3652
+ * Burst tracker for this turn, fed from the LIVE hub frames only. Segment
3653
+ * replay must not touch it: every event is already counted once when it
3654
+ * arrived, and replay would double the numerator and the gaps.
3655
+ */
3656
+ private readonly tps = new TurnTpsTracker()
3657
+ private tpsSeq: number
3658
+ private lastTpsPushAt = 0
3659
+ /**
3660
+ * The ambient quiet/ceiling windows, read once from the manager: they are
3661
+ * deployment tuning, not per-tick facts, and small test values must be stable
3662
+ * for the whole turn (see {@link BridgeTuning.ambientQuietMs} for the
3663
+ * measured CodeBuddy tick cadence these defaults come from).
3664
+ */
3665
+ private readonly ambientTuning: { quietMs: number; maxMs: number }
3666
+ /**
3667
+ * The detached workers this turn dispatched (see {@link BackgroundDispatch}),
3668
+ * by `toolCallId`, recorded from the wire while the turn is still streaming —
3669
+ * typically in the first step, before the prompt response lands. A turn may
3670
+ * dispatch several, and the hold waits for ALL of them.
3671
+ */
3672
+ private readonly dispatches = new Map<string, BackgroundDispatch>()
3673
+ /** The detached-worker hold in progress, once a segment carries it. */
3674
+ private backgroundHold?: BackgroundHold
3675
+ /**
3676
+ * A hold parked because the prompt response landed while NO driver segment
3677
+ * was open (the host is between steps, about to execute the Agent card). It
3678
+ * begins when the next `startSegment` opens — or closes after a bounded
3679
+ * grace so a host that never opens one does not hang the turn.
3680
+ */
3681
+ private pendingBackgroundHold?: BackgroundHold
3682
+ /**
3683
+ * The hold's own windows, read once at construction like the ambient pair:
3684
+ * `taskMaxMs` is the worker phase's ceiling, `graceMs` the parked hold's
3685
+ * bounded wait for a driver step (see {@link BridgeTuning.backgroundTaskMaxMs}
3686
+ * and {@link BridgeTuning.backgroundParkedGraceMs}).
3687
+ */
3688
+ private readonly backgroundTuning: { taskMaxMs: number; graceMs: number }
3689
+ /**
3690
+ * When this controller was built. Only a drain announced AFTER this moment
3691
+ * may be adopted by the hold: an `ambient` record that predates the turn is
3692
+ * some earlier run's, and folding its text would mix two answers.
3693
+ */
3694
+ private readonly constructedAt: number
2966
3695
 
2967
3696
  constructor(
2968
3697
  private readonly manager: BridgeManager,
@@ -2975,6 +3704,10 @@ class TurnStreamController {
2975
3704
  ) {
2976
3705
  this.turnSeqAtStart = session.turnSeq
2977
3706
  this.cursorSeq = session.transcript.head
3707
+ this.tpsSeq = session.transcript.head
3708
+ this.constructedAt = Date.now()
3709
+ this.ambientTuning = manager.ambientTuning
3710
+ this.backgroundTuning = manager.backgroundTuning
2978
3711
  this.unsubscribe = hub.subscribe(event => this.onEvent(event))
2979
3712
  }
2980
3713
 
@@ -3010,6 +3743,12 @@ class TurnStreamController {
3010
3743
  * consuming it, so the next segment replays it again.
3011
3744
  */
3012
3745
  startSegment(): SegmentState {
3746
+ // A step opening on an already settled turn (the response landed a tick
3747
+ // ago and the tail drain is still scheduled) closes that turn FIRST: the
3748
+ // step must read a settled controller and the session must not stay
3749
+ // `running` across the macrotask boundary — the host's next prompt would be
3750
+ // refused as busy for no reason.
3751
+ this.flushScheduledClose()
3013
3752
  // A step opening is the HOST handing the turn back to the agent — this
3014
3753
  // runs for the first step and for each continuation after the host
3015
3754
  // executed a card. The watchdog measures the stretch the AGENT holds the
@@ -3025,13 +3764,31 @@ class TurnStreamController {
3025
3764
  outputChars: 0,
3026
3765
  activeToolCall: undefined,
3027
3766
  hasCard: false,
3767
+ // A segment opened DURING an ambient wait (never the steered first step,
3768
+ // which marks its own) inherits the suppression: the drain's tools still
3769
+ // stay transcript-only until the turn closes. A detached-worker hold
3770
+ // suppresses its cards for the same reason, and a PARKED hold must
3771
+ // suppress from the first replayed update on — the drain's TaskOutput
3772
+ // arrives in exactly that step.
3773
+ ambient: this.awaitingAmbient !== undefined
3774
+ || this.backgroundHold !== undefined
3775
+ || this.pendingBackgroundHold !== undefined,
3028
3776
  finished: false,
3029
3777
  }
3030
3778
  this.segment = seg
3031
3779
  const entries = this.session.transcript.since(this.cursorSeq)
3032
3780
  for (const entry of entries) {
3033
3781
  if (seg.finished) break
3034
- if (!this.foldInto(seg, entry.update as Record<string, unknown>)) break
3782
+ const update = entry.update as Record<string, unknown>
3783
+ // The same split the live path makes: a background drain's prose is not
3784
+ // this turn's answer, even when it landed while no step was open. The
3785
+ // record (tool outputs) still goes in; the cursor moves past it.
3786
+ if (this.isForeignAmbientText(update)) {
3787
+ this.recordWithoutSegment(update)
3788
+ this.cursorSeq = entry.seq
3789
+ continue
3790
+ }
3791
+ if (!this.foldInto(seg, update, entry.seq)) break
3035
3792
  this.cursorSeq = entry.seq
3036
3793
  }
3037
3794
  if (!seg.finished && this.pendingProse.length > 0) {
@@ -3040,6 +3797,12 @@ class TurnStreamController {
3040
3797
  const lines = this.pendingProse.splice(0, this.pendingProse.length).join('')
3041
3798
  this.emitProse(seg, 'reasoning', lines)
3042
3799
  }
3800
+ const parkedHold = this.pendingBackgroundHold
3801
+ if (parkedHold !== undefined && !seg.finished) {
3802
+ // The host opened the continuation step for the Agent card: the hold
3803
+ // finally has a driver step to speak the drain's answer into.
3804
+ this.adoptBackgroundHold(seg, parkedHold)
3805
+ }
3043
3806
  if (this.settled) this.finishAfterSettle(seg)
3044
3807
  return seg
3045
3808
  }
@@ -3049,10 +3812,745 @@ class TurnStreamController {
3049
3812
  if (this.segment !== undefined) this.finishSegment(this.segment, reason)
3050
3813
  }
3051
3814
 
3815
+ /** Whether this turn already streamed prose of its own (not a drain's). */
3816
+ get hasAgentProse(): boolean {
3817
+ return this.sawAgentProse
3818
+ }
3819
+
3820
+ /**
3821
+ * Keep the turn open for a background drain the agent folded our prompt into.
3822
+ *
3823
+ * THE SHAPE: the agent answered `session/prompt` at once (CodeBuddy returns
3824
+ * `end_turn` in ~14ms when it merges the message into a drain it is already
3825
+ * running) and the real answer streams afterwards under the DRAIN's request
3826
+ * id. Closing on the response would cut the turn before its text exists —
3827
+ * which is exactly the bug this state exists for. So:
3828
+ *
3829
+ * - the agent-side prompt slot is released (`endTurn`: the response HAS
3830
+ * landed) but the turn is NOT settled: `settled` means "the turn is over",
3831
+ * and it is not — no `turn/end`, no `running = false`, no segment close;
3832
+ * - the segment keeps folding the session's updates, with cards suppressed
3833
+ * (see `SegmentState.ambient`): the host must not execute a background
3834
+ * tool for a turn whose answer has not begun;
3835
+ * - any update on the session resets the quiet clock, and the drain's end is
3836
+ * its silence (a `session_end` is rejected by the SDK's schema and never
3837
+ * reaches us — never wait for one); a hard ceiling closes what never ends.
3838
+ */
3839
+ awaitAmbient(requestId: string, stopReason: string): void {
3840
+ if (this.settled || this.disposed || this.awaitingAmbient !== undefined) return
3841
+ const seg = this.segment
3842
+ if (seg === undefined || seg.finished) {
3843
+ // No live driver step to speak into: nothing can be folded, so close the
3844
+ // turn the ordinary way rather than holding state that renders nothing.
3845
+ this.clearPendingAmbient()
3846
+ this.settle({ kind: 'stop', stopReason })
3847
+ return
3848
+ }
3849
+ seg.ambient = true
3850
+ // The response landed: the agent no longer holds a prompt for this turn.
3851
+ this.endTurn()
3852
+ // The drain may have spoken BEFORE the response was judged. Those updates
3853
+ // were withheld while this turn's own request id was unknowable (see
3854
+ // `pendingAmbientUpdates`); the turn IS the drain, so they are its answer —
3855
+ // folded in arrival order, now that `seg.ambient` suppresses their cards.
3856
+ this.flushPendingAmbient(seg)
3857
+ if (seg.finished) {
3858
+ this.settle({ kind: 'stop', stopReason })
3859
+ return
3860
+ }
3861
+ const wait: AmbientWait = {
3862
+ requestId,
3863
+ quietTimer: setTimeout(() => { this.onAmbientQuiet(wait) }, this.ambientTuning.quietMs),
3864
+ hardTimer: setTimeout(() => { this.onAmbientHard(wait) }, this.ambientTuning.maxMs),
3865
+ stopReason,
3866
+ }
3867
+ wait.quietTimer.unref?.()
3868
+ wait.hardTimer.unref?.()
3869
+ this.awaitingAmbient = wait
3870
+ }
3871
+
3872
+ /**
3873
+ * The prompt response named no conversation turn while a drain the session
3874
+ * retired recently may still be running (and did not re-announce): the turn
3875
+ * cannot be held open, but a zero-output report must not blame the agent.
3876
+ * See `hasRecentRetiredAmbient` for the window.
3877
+ */
3878
+ noteRecentRetiredDrain(): void {
3879
+ this.recentRetiredDrain = true
3880
+ }
3881
+
3882
+ /**
3883
+ * Whether this turn dispatched a detached worker whose answer is still
3884
+ * outstanding. Read when the prompt response lands: such a turn is NOT over
3885
+ * even though the agent answered it (see {@link holdForBackgroundDispatch}).
3886
+ *
3887
+ * Two ways a dispatch keeps the turn open:
3888
+ * - it has not reported back yet (the worker is still running), or
3889
+ * - it DID report — before the response, even — but it was structurally
3890
+ * detached and emitted worker updates: the drain that carries its report
3891
+ * is still coming.
3892
+ * A synchronous `Agent` / `Task` call (completed, no `isBackground`, no
3893
+ * worker updates) matches neither and closes normally.
3894
+ */
3895
+ get dispatchedDetachedWorker(): boolean {
3896
+ for (const dispatch of this.dispatches.values()) {
3897
+ if (!dispatch.completed) return true
3898
+ if (dispatch.detached && dispatch.sawWorkerUpdate) return true
3899
+ }
3900
+ return false
3901
+ }
3902
+
3903
+ /** The hold this turn is inside — adopted, or parked waiting for a step. */
3904
+ private activeBackgroundHold(): BackgroundHold | undefined {
3905
+ return this.backgroundHold ?? this.pendingBackgroundHold
3906
+ }
3907
+
3908
+ /** Whether every KNOWN dispatch has reported (no dispatch at all counts as done). */
3909
+ private allDispatchesCompleted(): boolean {
3910
+ for (const dispatch of this.dispatches.values()) {
3911
+ if (!dispatch.completed) return false
3912
+ }
3913
+ return true
3914
+ }
3915
+
3916
+ /**
3917
+ * Hold the turn open for the detached worker(s) its `Agent` tool calls
3918
+ * dispatched — the second controlled ambient-family exception (constraint
3919
+ * 23), not a new idle watchdog.
3920
+ *
3921
+ * THE SHAPE (measured against CodeBuddy 2.179.0, multitask on): the
3922
+ * coordinator opens an `Agent` call that hands work to a background worker,
3923
+ * says "已派…", and the `session/prompt` RESPONSE arrives normally while the
3924
+ * worker keeps running. The worker's own updates (requestId B +
3925
+ * `parentToolCallId`) never touch this session's message; when it finishes,
3926
+ * the agent starts a background DRAIN (requestId A — the response's own
3927
+ * `conversationRequestId`) whose steps carry the report and the final answer.
3928
+ * Nothing tells the client the worker is running: no `session_end` (the SDK's
3929
+ * schema rejects it) and an `agentPhase` idle tick even fires BETWEEN the
3930
+ * worker and the drain. So the response cannot close the turn.
3931
+ *
3932
+ * The hold:
3933
+ * - releases the agent-side prompt slot (`endTurn`: the response landed)
3934
+ * while keeping `running` and the driver step open;
3935
+ * - marks the segment background, so EVERY tool call for the rest of the
3936
+ * hold — the workers', the drain's TaskOutput — stays transcript-only and
3937
+ * the final message carries text with no card in front of it;
3938
+ * - waits in two phases: the worker phase only has the
3939
+ * {@link BridgeTuning.backgroundTaskMaxMs} ceiling (a detached worker goes
3940
+ * silent for tens of seconds while it works) and ends only when ALL known
3941
+ * dispatches reported, after which the drain phase uses the ambient
3942
+ * windows.
3943
+ */
3944
+ holdForBackgroundDispatch(stopReason: string, requestId?: string): void {
3945
+ if (this.settled || this.disposed || this.closedTurn || this.stale) return
3946
+ if (this.activeBackgroundHold() !== undefined) return
3947
+ const hold: BackgroundHold = {
3948
+ stopReason,
3949
+ requestId,
3950
+ acceptedIds: new Set<string>(),
3951
+ phase: 'worker',
3952
+ answerChars: 0,
3953
+ floorSeq: this.session.transcript.head,
3954
+ retired: false,
3955
+ }
3956
+ if (requestId !== undefined) hold.acceptedIds.add(requestId)
3957
+ // A drain announced while this turn was ALREADY on air belongs to it (the
3958
+ // announcement may have named a different id than the response). One that
3959
+ // predates the turn is some earlier run's, and folding its text would mix
3960
+ // two answers.
3961
+ const announced = this.session.ambient
3962
+ if (announced !== undefined && announced.startedAt >= this.constructedAt) {
3963
+ hold.acceptedIds.add(announced.requestId)
3964
+ if (hold.requestId === undefined) hold.requestId = announced.requestId
3965
+ }
3966
+ // The response landed: the agent no longer holds a prompt for this turn.
3967
+ this.endTurn()
3968
+ hold.hardTimer = setTimeout(() => { this.onBackgroundHard(hold) }, this.backgroundTuning.taskMaxMs)
3969
+ hold.hardTimer.unref?.()
3970
+ const seg = this.segment
3971
+ if (seg !== undefined && !seg.finished) {
3972
+ this.adoptBackgroundHold(seg, hold)
3973
+ } else {
3974
+ // The response landed BETWEEN driver steps: the host is about to execute
3975
+ // the Agent card and open its continuation step. Park the hold for that
3976
+ // step, with a bounded wait — a host that never opens one must not hang
3977
+ // the turn forever.
3978
+ hold.graceTimer = setTimeout(() => { this.onBackgroundGraceExpired(hold) }, this.backgroundTuning.graceMs)
3979
+ hold.graceTimer.unref?.()
3980
+ this.pendingBackgroundHold = hold
3981
+ }
3982
+ // A FAST detached worker may already have reported (even before the
3983
+ // response): with every known dispatch complete there is no worker phase
3984
+ // left to wait out, so the drain phase starts now.
3985
+ if (this.allDispatchesCompleted()) this.startBackgroundDrain(hold)
3986
+ }
3987
+
3988
+ /** Give a (possibly parked) hold a driver segment: cards off for the rest of it. */
3989
+ private adoptBackgroundHold(seg: SegmentState, hold: BackgroundHold): void {
3990
+ if (hold.graceTimer !== undefined) {
3991
+ clearTimeout(hold.graceTimer)
3992
+ hold.graceTimer = undefined
3993
+ }
3994
+ if (this.pendingBackgroundHold === hold) this.pendingBackgroundHold = undefined
3995
+ seg.ambient = true
3996
+ this.backgroundHold = hold
3997
+ // The drain may have spoken BEFORE the response was judged. Those updates
3998
+ // were withheld while this turn's own id was unknown
3999
+ // (`pendingAmbientUpdates`); only the runs THIS hold owns are its answer —
4000
+ // another run's withheld text is dropped (the transcript keeps it).
4001
+ this.flushBackgroundHoldUpdates(seg, hold)
4002
+ // The parked grace owns the wait while no step is on air; now that one is,
4003
+ // a hold whose drain phase already began arms its quiet clock.
4004
+ if (hold.phase === 'drain') this.armBackgroundQuiet(hold)
4005
+ }
4006
+
4007
+ /**
4008
+ * The worker phase is over once EVERY known dispatch reported: the drain
4009
+ * that carries their reports is (about to be) running, so the existing
4010
+ * ambient windows take over, measured from HERE.
4011
+ *
4012
+ * An announced run is accepted immediately, but while any dispatch is still
4013
+ * incomplete it only records the id and switches the hold's primary run —
4014
+ * arming the quiet clock there would let a drain that spoke early cut the
4015
+ * turn off before its other worker ever reports.
4016
+ */
4017
+ private startBackgroundDrain(hold: BackgroundHold, requestId?: string): void {
4018
+ if (hold !== this.backgroundHold && hold !== this.pendingBackgroundHold) return
4019
+ if (requestId !== undefined) {
4020
+ hold.acceptedIds.add(requestId)
4021
+ hold.requestId = requestId
4022
+ }
4023
+ if (!this.allDispatchesCompleted()) return
4024
+ if (hold.phase === 'drain') {
4025
+ if (this.backgroundHold === hold) this.armBackgroundQuiet(hold)
4026
+ return
4027
+ }
4028
+ hold.phase = 'drain'
4029
+ if (hold.hardTimer !== undefined) clearTimeout(hold.hardTimer)
4030
+ hold.hardTimer = setTimeout(() => { this.onBackgroundHard(hold) }, this.ambientTuning.maxMs)
4031
+ hold.hardTimer.unref?.()
4032
+ // A parked hold belongs to the grace until a driver step opens; a hold
4033
+ // with a step arms its quiet clock now.
4034
+ if (this.backgroundHold === hold) this.armBackgroundQuiet(hold)
4035
+ }
4036
+
4037
+ /**
4038
+ * A dispatch that lands while the drain phase already began means the
4039
+ * coordinator handed out NEW work: the worker phase is back, and with it the
4040
+ * absence of a quiet window.
4041
+ */
4042
+ private enterBackgroundWorkerPhase(hold: BackgroundHold): void {
4043
+ if (hold.phase === 'worker') return
4044
+ hold.phase = 'worker'
4045
+ if (hold.quietTimer !== undefined) {
4046
+ clearTimeout(hold.quietTimer)
4047
+ hold.quietTimer = undefined
4048
+ }
4049
+ if (hold.hardTimer !== undefined) clearTimeout(hold.hardTimer)
4050
+ hold.hardTimer = setTimeout(() => { this.onBackgroundHard(hold) }, this.backgroundTuning.taskMaxMs)
4051
+ hold.hardTimer.unref?.()
4052
+ }
4053
+
4054
+ /** Push the drain phase's quiet clock out: every update is evidence of work. */
4055
+ private armBackgroundQuiet(hold: BackgroundHold): void {
4056
+ if (hold.quietTimer !== undefined) clearTimeout(hold.quietTimer)
4057
+ hold.quietTimer = setTimeout(() => { this.onBackgroundQuiet(hold) }, this.ambientTuning.quietMs)
4058
+ hold.quietTimer.unref?.()
4059
+ }
4060
+
4061
+ /**
4062
+ * Any session update restarts the drain phase's quiet clock. Only an
4063
+ * ADOPTED hold has one: a parked hold's wait belongs to its grace timer, and
4064
+ * the worker phase never has a clock at all.
4065
+ */
4066
+ private noteBackgroundHoldActivity(): void {
4067
+ const hold = this.backgroundHold
4068
+ if (hold === undefined || hold.phase !== 'drain') return
4069
+ this.armBackgroundQuiet(hold)
4070
+ }
4071
+
4072
+ /** The drained run went quiet: the turn ends with the stop reason the response gave. */
4073
+ private onBackgroundQuiet(hold: BackgroundHold): void {
4074
+ if (this.backgroundHold !== hold || hold.phase !== 'drain') return
4075
+ this.closeBackgroundHold(hold, { hard: false })
4076
+ }
4077
+
4078
+ /** The hold outlived its ceiling (worker or drain): the run is still going. */
4079
+ private onBackgroundHard(hold: BackgroundHold): void {
4080
+ if (hold !== this.backgroundHold && hold !== this.pendingBackgroundHold) return
4081
+ this.closeBackgroundHold(hold, { hard: true })
4082
+ }
4083
+
4084
+ /**
4085
+ * No driver step opened inside the grace: the host is not coming back for
4086
+ * this turn's Agent card. Close it the way a quiet ambient ending does — the
4087
+ * deferral line, never the zero-output blame — and keep ONE drain record:
4088
+ * the worker may still report into a run no turn is waiting on.
4089
+ *
4090
+ * A parked hold has NO segment by definition, so the chat plane has nowhere
4091
+ * to put the deferral line: it goes to the settings log (`agent/notice`)
4092
+ * instead of being parked on prose nobody will read.
4093
+ */
4094
+ private onBackgroundGraceExpired(hold: BackgroundHold): void {
4095
+ if (this.pendingBackgroundHold !== hold) return
4096
+ this.pendingBackgroundHold = undefined
4097
+ this.clearBackgroundHoldTimers(hold)
4098
+ const line = hold.answerChars > 0 ? undefined : backgroundDeferralLine(this.session.agentId)
4099
+ this.releaseBackgroundHoldIds(hold, { hard: true })
4100
+ if (line !== undefined) this.broadcastBackgroundDeferral(line)
4101
+ this.closeTurn({ kind: 'stop', stopReason: hold.stopReason }, { line, keepAmbient: true, requestId: hold.requestId })
4102
+ }
4103
+
4104
+ /**
4105
+ * Close a detached-worker hold exactly once. A turn that folded NO answer
4106
+ * text from its held runs says where the rest of the output will land (the
4107
+ * deferral wording, never the silent-failure blame); the parent's interim
4108
+ * acknowledgement does not count as that answer.
4109
+ *
4110
+ * Ownership of the drain ids at close (`releaseBackgroundHoldIds`): a quiet
4111
+ * close retires every accepted id and drops the session record; a hard or
4112
+ * grace close keeps exactly ONE record, because the run is provably still
4113
+ * going and a later update or re-announcement must revive it normally.
4114
+ */
4115
+ private closeBackgroundHold(hold: BackgroundHold, options: { hard: boolean }): void {
4116
+ if (this.pendingBackgroundHold === hold) this.pendingBackgroundHold = undefined
4117
+ else if (this.backgroundHold === hold) this.backgroundHold = undefined
4118
+ else return
4119
+ this.clearBackgroundHoldTimers(hold)
4120
+ const line = hold.answerChars > 0 ? undefined : backgroundDeferralLine(this.session.agentId)
4121
+ this.releaseBackgroundHoldIds(hold, { hard: options.hard })
4122
+ const onAir = this.segment !== undefined && !this.segment.finished
4123
+ // With no segment on air the closing line has no chat bubble to land in:
4124
+ // log it, so the fact is recorded rather than silently dropped.
4125
+ if (line !== undefined && !onAir) this.broadcastBackgroundDeferral(line)
4126
+ this.closeTurn(
4127
+ { kind: 'stop', stopReason: hold.stopReason },
4128
+ { line, keepAmbient: options.hard, requestId: hold.requestId },
4129
+ )
4130
+ }
4131
+
4132
+ /** Write a hold's deferral line into the settings log (`agent/notice`). */
4133
+ private broadcastBackgroundDeferral(line: string): void {
4134
+ this.hub.broadcast({
4135
+ type: 'agent/notice',
4136
+ agentId: this.session.agentId,
4137
+ level: 'info',
4138
+ text: line.trim(),
4139
+ })
4140
+ }
4141
+
4142
+ /** Drop a hold's clocks. The hold object itself stays for its `retired` mark. */
4143
+ private clearBackgroundHoldTimers(hold: BackgroundHold): void {
4144
+ if (hold.quietTimer !== undefined) clearTimeout(hold.quietTimer)
4145
+ if (hold.hardTimer !== undefined) clearTimeout(hold.hardTimer)
4146
+ if (hold.graceTimer !== undefined) clearTimeout(hold.graceTimer)
4147
+ hold.quietTimer = undefined
4148
+ hold.hardTimer = undefined
4149
+ hold.graceTimer = undefined
4150
+ }
4151
+
4152
+ /**
4153
+ * Drop every hold clock (teardown / a forced ending). `retire` also gives up
4154
+ * every accepted run id — a SUCCESSOR turn must never fold the abandoned
4155
+ * hold's drain or worker text into its own answer.
4156
+ */
4157
+ private clearBackgroundHold(options?: { retire?: boolean }): void {
4158
+ const pending = this.pendingBackgroundHold
4159
+ if (pending !== undefined) {
4160
+ this.pendingBackgroundHold = undefined
4161
+ this.clearBackgroundHoldTimers(pending)
4162
+ if (options?.retire === true) this.releaseBackgroundHoldIds(pending, { hard: false })
4163
+ }
4164
+ const hold = this.backgroundHold
4165
+ if (hold === undefined) return
4166
+ this.backgroundHold = undefined
4167
+ this.clearBackgroundHoldTimers(hold)
4168
+ if (options?.retire === true) this.releaseBackgroundHoldIds(hold, { hard: false })
4169
+ }
4170
+
4171
+ /**
4172
+ * Settle what happens to the session's drain records when a hold closes.
4173
+ *
4174
+ * `hard: false` (quiet close, cancel, teardown): the run is no longer waited
4175
+ * on, so EVERY accepted id is retired — its late text stays transcript-only
4176
+ * for whatever turn runs next — and the session record is dropped if it
4177
+ * names one of them.
4178
+ *
4179
+ * `hard: true` (ceiling / parked grace): the run is provably still going.
4180
+ * Exactly ONE record survives — the session's current drain if it has one,
4181
+ * else the hold's primary run — with its activity clock refreshed; the other
4182
+ * accepted ids are retired. A hold whose primary id had no session record
4183
+ * CREATES one, so a successor's `noteTurnRequestId` cannot adopt that id.
4184
+ * Every accepted id therefore ends up either retired or owned by
4185
+ * `session.ambient`; never both unowned and unretired.
4186
+ */
4187
+ private releaseBackgroundHoldIds(hold: BackgroundHold, options: { hard: boolean }): void {
4188
+ if (hold.retired) return
4189
+ hold.retired = true
4190
+ if (!options.hard) {
4191
+ for (const id of hold.acceptedIds) retireAmbientId(this.session, id)
4192
+ if (this.session.ambient !== undefined && hold.acceptedIds.has(this.session.ambient.requestId)) {
4193
+ this.session.ambient = undefined
4194
+ }
4195
+ return
4196
+ }
4197
+ const survivor = this.session.ambient?.requestId ?? hold.requestId
4198
+ for (const id of hold.acceptedIds) {
4199
+ if (id === survivor) continue
4200
+ retireAmbientId(this.session, id)
4201
+ }
4202
+ if (survivor === undefined) return
4203
+ const now = Date.now()
4204
+ if (this.session.ambient?.requestId === survivor) {
4205
+ this.session.ambient.lastActivityAt = now
4206
+ } else {
4207
+ this.session.ambient = {
4208
+ requestId: survivor,
4209
+ startedAt: now,
4210
+ lastActivityAt: now,
4211
+ sawOutput: hold.answerChars > 0,
4212
+ }
4213
+ }
4214
+ // The surviving run is alive, not retired (an earlier wait of this turn
4215
+ // may have retired it before it spoke again).
4216
+ reviveAmbientId(this.session, survivor)
4217
+ }
4218
+
4219
+ /**
4220
+ * Fold the withheld drain updates that belong to this hold into its segment.
4221
+ * The rest of the buffer was some OTHER run's text: it is dropped here (the
4222
+ * transcript keeps it), never leaked into this turn's answer.
4223
+ */
4224
+ private flushBackgroundHoldUpdates(seg: SegmentState, hold: BackgroundHold): void {
4225
+ if (this.pendingAmbientUpdates.length === 0) {
4226
+ this.pendingAmbientTruncated = false
4227
+ return
4228
+ }
4229
+ const pending = this.pendingAmbientUpdates.splice(0, this.pendingAmbientUpdates.length)
4230
+ this.pendingAmbientChars = 0
4231
+ const truncated = this.pendingAmbientTruncated
4232
+ this.pendingAmbientTruncated = false
4233
+ const accepted = pending.filter(entry => {
4234
+ const requestId = codebuddyMetaOf(entry.update)?.requestId
4235
+ return requestId !== undefined && hold.acceptedIds.has(requestId)
4236
+ })
4237
+ if (accepted.length === 0) return
4238
+ if (truncated && !seg.finished) {
4239
+ // The front of the drain's output was evicted from the bounded buffer:
4240
+ // say so rather than letting the turn quietly start mid-story.
4241
+ this.emitProse(seg, 'text', '\n[dsh-ntr] background output was truncated; the ACP transcript view still has every update.\n')
4242
+ }
4243
+ for (const entry of accepted) {
4244
+ if (seg.finished) break
4245
+ this.foldInto(seg, entry.update, entry.seq)
4246
+ // The buffered drain text arrived BEFORE the hold, but it IS the held
4247
+ // run's answer: below the hold's floor it was not counted by `foldInto`,
4248
+ // so count it here (the floor exists to skip the parent's earlier ack,
4249
+ // not the drain's own words).
4250
+ if (entry.seq <= hold.floorSeq
4251
+ && String(entry.update.sessionUpdate ?? '') === 'agent_message_chunk') {
4252
+ hold.answerChars += chunkTextOf(entry.update.content).length
4253
+ }
4254
+ }
4255
+ }
4256
+
4257
+ /**
4258
+ * Follow the detached-worker signals of one live update.
4259
+ *
4260
+ * Only CodeBuddy sends them (`codebuddyMetaOf` answers `undefined` for every
4261
+ * other agent, so nothing here can fire for them):
4262
+ * - an `Agent` / `Task` `tool_call` records the dispatch — the FIRST send
4263
+ * needs that CodeBuddy shape;
4264
+ * - the SAME call re-sent with `isBackground: true`, or any update naming it
4265
+ * as its `parentToolCallId`, marks the work DETACHED;
4266
+ * - that call reporting completed / failed is read BY ID: the real
4267
+ * completion frame carries NO `_meta` at all, so requiring the CodeBuddy
4268
+ * shape here would miss every real completion;
4269
+ * - any update carrying `parentToolCallId` is the worker's OWN turn — it
4270
+ * proves the worker alive but never belongs to this session's answer;
4271
+ * - an `unsolicitedTurn` announcement is the drain that carries the report.
4272
+ */
4273
+ private trackBackgroundDispatch(update: Record<string, unknown>): void {
4274
+ if (this.closedTurn) return
4275
+ const meta = codebuddyMetaOf(update)
4276
+ const kind = String(update.sessionUpdate ?? '')
4277
+ const row = update as { toolCallId?: unknown; status?: unknown }
4278
+ const toolCallId = typeof row.toolCallId === 'string' ? row.toolCallId.trim() : ''
4279
+ const status = typeof row.status === 'string' ? row.status : ''
4280
+ const completedStatus = status === 'completed' || status === 'failed'
4281
+ if ((kind === 'tool_call' || kind === 'tool_call_update') && toolCallId !== '') {
4282
+ const known = this.dispatches.get(toolCallId)
4283
+ if (known !== undefined) {
4284
+ // A known dispatch: the id is the whole evidence (see the doc above).
4285
+ if (status !== '') known.status = status
4286
+ if (meta?.isBackground === true) known.detached = true
4287
+ if (completedStatus && !known.completed) {
4288
+ known.completed = true
4289
+ this.noteDispatchProgress()
4290
+ }
4291
+ } else if (meta !== undefined
4292
+ && (meta.toolName === 'Agent' || meta.toolName === 'Task')
4293
+ && (kind === 'tool_call' || meta.isBackground === true)) {
4294
+ // A NEW dispatch needs the CodeBuddy shape. A turn may dispatch more
4295
+ // than one worker; the hold waits for all of them.
4296
+ const dispatch: BackgroundDispatch = {
4297
+ toolCallId,
4298
+ status,
4299
+ completed: completedStatus,
4300
+ detached: meta.isBackground === true,
4301
+ sawWorkerUpdate: false,
4302
+ }
4303
+ this.dispatches.set(toolCallId, dispatch)
4304
+ if (!dispatch.completed) {
4305
+ const held = this.activeBackgroundHold()
4306
+ // A dispatch registered after the drain phase began means new work:
4307
+ // the worker phase (and its absence of a quiet clock) is back.
4308
+ if (held !== undefined) this.enterBackgroundWorkerPhase(held)
4309
+ }
4310
+ }
4311
+ }
4312
+ if (meta?.parentToolCallId !== undefined) {
4313
+ const parent = this.dispatches.get(meta.parentToolCallId)
4314
+ if (parent !== undefined) {
4315
+ parent.detached = true
4316
+ parent.sawWorkerUpdate = true
4317
+ }
4318
+ }
4319
+ if (meta?.unsolicitedTurn !== undefined) {
4320
+ const held = this.activeBackgroundHold()
4321
+ if (held !== undefined) this.startBackgroundDrain(held, meta.unsolicitedTurn.requestId)
4322
+ }
4323
+ }
4324
+
4325
+ /**
4326
+ * A dispatch just reported: once the LAST one has, the drain phase starts.
4327
+ * Until then the turn stays in its worker phase, with no quiet clock.
4328
+ */
4329
+ private noteDispatchProgress(): void {
4330
+ if (!this.allDispatchesCompleted()) return
4331
+ const held = this.activeBackgroundHold()
4332
+ if (held !== undefined) this.startBackgroundDrain(held)
4333
+ }
4334
+
4335
+ /** Any session update is evidence the drain still works: push the quiet clock out. */
4336
+ private noteAmbientActivity(): void {
4337
+ const wait = this.awaitingAmbient
4338
+ if (wait === undefined) return
4339
+ clearTimeout(wait.quietTimer)
4340
+ wait.quietTimer = setTimeout(() => { this.onAmbientQuiet(wait) }, this.ambientTuning.quietMs)
4341
+ wait.quietTimer.unref?.()
4342
+ }
4343
+
4344
+ /** The drain went quiet: the turn ends with the stop reason the response gave. */
4345
+ private onAmbientQuiet(wait: AmbientWait): void {
4346
+ if (this.awaitingAmbient !== wait) return
4347
+ this.finishAmbient(wait)
4348
+ }
4349
+
4350
+ /** The drain outlived the ceiling: say where the rest will land, then close. */
4351
+ private onAmbientHard(wait: AmbientWait): void {
4352
+ if (this.awaitingAmbient !== wait) return
4353
+ const seg = this.segment
4354
+ if (seg !== undefined && !seg.finished) {
4355
+ 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`)
4356
+ }
4357
+ // The ceiling is evidence the drain is STILL RUNNING (unlike a quiet close),
4358
+ // so the ending keeps the session's drain record; see `closeTurn`.
4359
+ wait.hard = true
4360
+ // The hard explanation already says the turn was cut short; the deferred
4361
+ // line would only repeat it in different words.
4362
+ this.finishAmbient(wait, { announceDeferral: false })
4363
+ }
4364
+
4365
+ /**
4366
+ * Close an ambient wait exactly once. A turn that produced no answer TEXT
4367
+ * says so: the drain's output is in the ACP transcript view, and this
4368
+ * bubble must not be empty (the zero-output report, reworded for a turn that
4369
+ * was deferred rather than failed).
4370
+ */
4371
+ private finishAmbient(wait: AmbientWait, options?: { announceDeferral?: boolean }): void {
4372
+ if (this.awaitingAmbient !== wait) return
4373
+ const line = options?.announceDeferral === false || this.sawVisibleText
4374
+ ? undefined
4375
+ : backgroundDeferralLine(this.session.agentId)
4376
+ this.closeTurn(
4377
+ { kind: 'stop', stopReason: wait.stopReason },
4378
+ { line, keepAmbient: wait.hard === true, requestId: wait.requestId },
4379
+ )
4380
+ }
4381
+
4382
+ /** Drop the ambient wait's clocks (the turn is being closed by someone else). */
4383
+ private clearAmbientWait(): void {
4384
+ const wait = this.awaitingAmbient
4385
+ if (wait === undefined) return
4386
+ this.awaitingAmbient = undefined
4387
+ clearTimeout(wait.quietTimer)
4388
+ clearTimeout(wait.hardTimer)
4389
+ }
4390
+
4391
+ /**
4392
+ * Withhold one background-drain update until the prompt response says what
4393
+ * this turn is. The cursor advances past it anyway: it is already in the
4394
+ * transcript, and replaying it into a later segment would emit it twice.
4395
+ * Bounded (see `PENDING_AMBIENT_MAX_*`) — the transcript still keeps every
4396
+ * entry the buffer drops.
4397
+ */
4398
+ private bufferAmbientUpdate(seq: number, update: Record<string, unknown>): void {
4399
+ const chars = JSON.stringify(update).length
4400
+ this.pendingAmbientUpdates.push({ seq, update, chars })
4401
+ this.pendingAmbientChars += chars
4402
+ while (this.pendingAmbientUpdates.length > PENDING_AMBIENT_MAX_ENTRIES
4403
+ || (this.pendingAmbientChars > PENDING_AMBIENT_MAX_CHARS && this.pendingAmbientUpdates.length > 1)) {
4404
+ const dropped = this.pendingAmbientUpdates.shift()
4405
+ if (dropped === undefined) break
4406
+ this.pendingAmbientChars -= dropped.chars
4407
+ this.pendingAmbientTruncated = true
4408
+ }
4409
+ }
4410
+
4411
+ /** Fold the withheld drain updates into the now-ambient segment, once, in order. */
4412
+ private flushPendingAmbient(seg: SegmentState): void {
4413
+ if (this.pendingAmbientUpdates.length === 0) {
4414
+ this.pendingAmbientTruncated = false
4415
+ return
4416
+ }
4417
+ const truncated = this.pendingAmbientTruncated
4418
+ this.pendingAmbientTruncated = false
4419
+ const pending = this.pendingAmbientUpdates.splice(0, this.pendingAmbientUpdates.length)
4420
+ this.pendingAmbientChars = 0
4421
+ if (truncated && !seg.finished) {
4422
+ // The front of the drain's output was evicted from the bounded buffer:
4423
+ // say so rather than letting the turn quietly start mid-story.
4424
+ this.emitProse(seg, 'text', '\n[dsh-ntr] background output was truncated; the ACP transcript view still has every update.\n')
4425
+ }
4426
+ for (const entry of pending) {
4427
+ if (seg.finished) break
4428
+ this.foldInto(seg, entry.update, entry.seq)
4429
+ }
4430
+ }
4431
+
4432
+ /** Drop withheld drain updates: this turn is not the drain, or it is closing. */
4433
+ private clearPendingAmbient(): void {
4434
+ this.pendingAmbientUpdates.length = 0
4435
+ this.pendingAmbientChars = 0
4436
+ this.pendingAmbientTruncated = false
4437
+ }
4438
+
3052
4439
  /** The agent settled its `session/prompt`. Ends the turn's streaming face. */
3053
4440
  settle(outcome: TurnSettle): void {
3054
4441
  if (this.settled) return
4442
+ // `settled` is claimed NOW (a second settle, a cancel or a watchdog that
4443
+ // races the tail drain must find the turn already spoken for), but the
4444
+ // CLOSING work waits one macrotask: the SDK dispatches `session/update`
4445
+ // notifications independently of the `session/prompt` RESPONSE, so the
4446
+ // agent's last chunks can still be queued when this lands (see
4447
+ // `settleTranscript`). Only what already reached the transcript is read —
4448
+ // one macrotask, never an open-ended wait.
4449
+ this.settled = true
4450
+ this.clearAmbientWait()
4451
+ this.clearPendingAmbient()
4452
+ // An outside ending (watchdog, cancel) abandons a detached-worker hold: its
4453
+ // run is no longer this turn's, so its id is retired before the close.
4454
+ this.clearBackgroundHold({ retire: true })
4455
+ this.pendingOutcome = outcome
4456
+ this.closeScheduled = true
4457
+ setImmediate(() => { this.flushScheduledClose() })
4458
+ }
4459
+
4460
+ /**
4461
+ * Force the local end of this turn — the cancel fallback and any watchdog
4462
+ * that reached the controller. Runs the SAME closing work as a normal settle
4463
+ * (`closeTurn`): the ambient clocks stop, the on-air segment closes (so the
4464
+ * driver step parked on its queue ends), exactly one `turn/end` goes out
4465
+ * with THIS turn's id, the session stops running and the prompt slot is
4466
+ * released. Idempotent: a turn that already closed ignores the call.
4467
+ */
4468
+ forceFinalize(outcome: TurnSettle): void {
4469
+ if (this.stale || this.closedTurn) return
4470
+ this.clearAmbientWait()
4471
+ // A forced ending abandons a detached-worker hold: retire its run's id so
4472
+ // its late words stay transcript-only for the next turn.
4473
+ this.clearBackgroundHold({ retire: true })
4474
+ // A settle's deferred tail read is moot once this close runs: the turn is
4475
+ // over now, and the pending macrotask must not drain anything into it.
4476
+ this.pendingOutcome = undefined
4477
+ this.closeScheduled = false
4478
+ this.closeTurn(outcome)
4479
+ }
4480
+
4481
+ /**
4482
+ * Run the deferred closing work now, when a driver step is about to open on
4483
+ * an already settled turn: the continuation step must see the closed turn
4484
+ * (and the session NOT left `running` across the macrotask boundary).
4485
+ * Idempotent — the scheduled tick then finds nothing left to do.
4486
+ */
4487
+ private flushScheduledClose(): void {
4488
+ if (!this.closeScheduled) return
4489
+ this.closeScheduled = false
4490
+ const outcome = this.pendingOutcome
4491
+ this.pendingOutcome = undefined
4492
+ if (outcome === undefined) return
4493
+ // A DISPOSE that raced this queued close cleared it (see `dispose`), so a
4494
+ // close still scheduled here belongs to a settle that landed AFTER the
4495
+ // stream was retired (a crash rejects its prompt only after the teardown).
4496
+ // That turn still ends through its own queue and real id — the teardown's
4497
+ // releaseAll frame carried an empty id.
4498
+ this.drainTail()
4499
+ this.closeTurn(outcome)
4500
+ }
4501
+
4502
+ /**
4503
+ * Fold the notifications that reached the transcript but not the segment:
4504
+ * the tail a response raced past. Stops AT a cutting event without consuming
4505
+ * it (same rule as `startSegment`) — its next segment is not this turn's.
4506
+ */
4507
+ private drainTail(): void {
4508
+ // The deferred read belongs to the turn that scheduled it. If the turn was
4509
+ // retired or a NEWER turn already began (turnSeq moved on) in this one
4510
+ // macrotask window, everything past the cursor is the successor's: folding
4511
+ // it here would leak its text into this turn's driver step.
4512
+ if (this.stale) return
4513
+ const seg = this.segment
4514
+ if (seg === undefined || seg.finished) return
4515
+ for (const entry of this.session.transcript.since(this.cursorSeq)) {
4516
+ if (seg.finished) break
4517
+ const update = entry.update as Record<string, unknown>
4518
+ // The same split the live path makes: a background drain's text in the
4519
+ // tail is not this turn's answer. Record what the registry needs (tool
4520
+ // outputs), move the cursor past it, and do NOT fold it — otherwise the
4521
+ // deferred read smuggles the drain's prose into a normal turn.
4522
+ if (this.isForeignAmbientText(update)) {
4523
+ this.recordWithoutSegment(update)
4524
+ this.cursorSeq = entry.seq
4525
+ continue
4526
+ }
4527
+ if (!this.foldInto(seg, update, entry.seq)) break
4528
+ this.cursorSeq = entry.seq
4529
+ }
4530
+ }
4531
+
4532
+ /**
4533
+ * End the turn exactly once: burst metrics, ONE `turn/end`, the provisional
4534
+ * lock, the closing text, the segment, the session flags, the prompt slot.
4535
+ *
4536
+ * `ambient.line` marks a controlled ambient ending: the given line closes
4537
+ * the segment when the turn carried no answer text, instead of the generic
4538
+ * zero-output warning (which would blame the agent for a turn the drain
4539
+ * simply had not finished yet). `ambient.keepAmbient` says the ending was the
4540
+ * CEILING, not silence: see the drain-record comment below.
4541
+ */
4542
+ private closeTurn(outcome: TurnSettle, ambient?: { line?: string; keepAmbient?: boolean; requestId?: string }): void {
4543
+ if (this.closedTurn) return
4544
+ this.closedTurn = true
4545
+ // A closed turn is a settled turn, whichever path got here first (an
4546
+ // ambient ending does not go through `settle`).
3055
4547
  this.settled = true
4548
+ this.clearAmbientWait()
4549
+ this.clearPendingAmbient()
4550
+ // Whoever closed this turn already decided what happens to the held
4551
+ // run's record (`closeBackgroundHold` retired or kept it); only the clocks
4552
+ // are still ours to drop.
4553
+ this.clearBackgroundHold()
3056
4554
  // A late answer to a SUPERSEDED turn still has to close this controller's
3057
4555
  // queue (a continuation step may be parked on it) and its settle gate —
3058
4556
  // but it must not touch the session the NEWER turn now owns, nor release
@@ -3064,6 +4562,38 @@ class TurnStreamController {
3064
4562
  ? { kind: 'error', failure: { message: outcome.message, code: outcome.code ?? 'INTERNAL' } }
3065
4563
  : { kind: 'stop' }
3066
4564
  if (current) {
4565
+ // An ambient ending that came from SILENCE retires the drain record with
4566
+ // its turn: a record that outlives the wait would make the NEXT instant
4567
+ // answer with no conversation request id look steered (see
4568
+ // `awaitAmbient`), holding a normal turn open for a drain this one
4569
+ // already finished waiting on. A HARD ceiling ending is the opposite
4570
+ // evidence — the drain was still ticking when the turn was cut — so the
4571
+ // record SURVIVES (activity clock refreshed; `ambientStaleMs` expires it
4572
+ // lazily) precisely so the next prompt folded into the same drain is
4573
+ // still recognised as steered. `ambientNotices` is never cleared here:
4574
+ // a repeated announcement of the same run must stay silent, and the set
4575
+ // is bounded where entries are added.
4576
+ if (ambient !== undefined) {
4577
+ if (ambient.keepAmbient === true) {
4578
+ if (this.session.ambient !== undefined) this.session.ambient.lastActivityAt = Date.now()
4579
+ } else {
4580
+ // Silence closed this wait, but silence is not proof the run ended: a
4581
+ // queued message or a long tool can make the drain speak again. So
4582
+ // RETIRE its id before dropping the record — a revived run's late
4583
+ // text must stay in the ACP transcript view instead of teaching the
4584
+ // NEXT turn the drain's id and mixing the drain's answer into the
4585
+ // user's (a double answer). The trade-off is deliberate: a next
4586
+ // prompt the agent folds into that same revived drain may not receive
4587
+ // the drain's text on the chat plane (transcript-only); a double
4588
+ // answer is worse.
4589
+ if (ambient.requestId !== undefined) retireAmbientId(this.session, ambient.requestId)
4590
+ this.session.ambient = undefined
4591
+ }
4592
+ }
4593
+ // The turn's burst aggregate must be visible before the turn is marked
4594
+ // over: the final `turn/metrics` lands first, then `turn/end` flips the
4595
+ // session/pill state. Persisting and broadcasting share one value.
4596
+ this.finalizeTps()
3067
4597
  if (outcome.kind === 'cancelled') {
3068
4598
  this.hub.broadcast({ type: 'turn/end', sessionId: this.session.sessionId, turnId: this.turnId, stopReason: 'cancelled' })
3069
4599
  } else if (outcome.kind === 'error') {
@@ -3073,19 +4603,28 @@ class TurnStreamController {
3073
4603
  }
3074
4604
  // The provisional lock settles BEFORE any synthesized warning below: the
3075
4605
  // warning is our text, not evidence that the first turn delivered anything.
4606
+ //
4607
+ // An AMBIENT ending ends the provisional phase WITHOUT a rollback: a
4608
+ // deferral to a background drain is not a failed first turn, the drain's
4609
+ // text still lands in the ACP transcript view, and rolling the lock back
4610
+ // would close the session under it — dropping exactly the output the
4611
+ // deferred turn promised. The lock therefore becomes permanent.
3076
4612
  if (this.session.provisionalLock === true) {
3077
4613
  this.session.provisionalLock = false
3078
- if (!this.sawVisibleBlock) this.rollbackFirstTurn(this.session)
4614
+ if (!this.sawVisibleBlock && ambient === undefined) this.rollbackFirstTurn(this.session)
3079
4615
  }
3080
4616
  }
3081
4617
  const seg = this.segment
3082
4618
  if (seg !== undefined) {
3083
- if (current && outcome.kind === 'stop' && !this.sawVisibleBlock) {
4619
+ if (ambient !== undefined) {
4620
+ // The ambient line is OUR report, so it only appears when the drain
4621
+ // delivered no answer text of its own; reasoning alone does not count.
4622
+ if (ambient.line !== undefined && current) this.emitProse(seg, 'text', ambient.line)
4623
+ } else if (current && outcome.kind === 'stop' && !this.sawVisibleBlock) {
3084
4624
  // The silent-failure report: a normal stop with nothing the chat
3085
4625
  // plane renders. Aborted or failing turns need no extra hint; this
3086
4626
  // is specifically "protocol says success, the bubble is empty".
3087
- 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`
3088
- this.emitProse(seg, 'text', line)
4627
+ this.emitProse(seg, 'text', this.zeroOutputLine(outcome.stopReason))
3089
4628
  }
3090
4629
  this.finishSegment(seg, reason)
3091
4630
  }
@@ -3100,23 +4639,58 @@ class TurnStreamController {
3100
4639
  }
3101
4640
  this.endTurn()
3102
4641
  // The controller stays indexed only while a continuation step can still
3103
- // come for its cards. A cancelled, failed, or card-less turn has none:
3104
- // retire it now; the next turn's `rememberTurnStream` would anyway.
4642
+ // come for its cards. A cancelled, failed, or card-less turn has none: retire
4643
+ // it now; the next turn's `rememberTurnStream` would anyway. An ambient turn
4644
+ // ALWAYS retires: its suppressed calls never open cards, so keeping the index
4645
+ // would pin the controller (and its session id) to a turn nobody can continue.
3105
4646
  if (!current) this.retire()
3106
- else if (outcome.kind !== 'stop' || this.seenCalls.size === 0) this.dispose()
4647
+ else if (ambient !== undefined || outcome.kind !== 'stop' || this.seenCalls.size === 0) this.dispose()
3107
4648
  }
3108
4649
 
3109
- /** Cancelled by the user: finish the on-air segment and release the turn's slots. */
4650
+ /**
4651
+ * Cancelled by the user: finish the on-air segment and release the turn's
4652
+ * slots. An AMBIENT turn has already released the prompt slot (its response
4653
+ * landed) and holds no segment the cancel fallback could close, so the
4654
+ * cancel closes the turn itself with the cancelled stop.
4655
+ */
3110
4656
  abortActiveSegment(): void {
3111
4657
  if (this.stale) return
4658
+ this.clearPendingAmbient()
4659
+ if (this.closeScheduled) {
4660
+ // `settle` claimed the turn but queued its closing work for the next
4661
+ // macrotask. An abort landing inside that window must not let the
4662
+ // scheduled close broadcast the RESPONSE's stop reason while the segment
4663
+ // already finished `aborted`: drop the queued outcome and close NOW with
4664
+ // the cancelled one, so `turn/end` and the segment's finish agree.
4665
+ this.closeScheduled = false
4666
+ this.pendingOutcome = undefined
4667
+ this.closeTurn({ kind: 'cancelled' })
4668
+ return
4669
+ }
4670
+ if (this.awaitingAmbient !== undefined) {
4671
+ this.settle({ kind: 'cancelled' })
4672
+ return
4673
+ }
4674
+ if (this.backgroundHold !== undefined || this.pendingBackgroundHold !== undefined) {
4675
+ // A detached-worker hold is a turn whose prompt response already landed
4676
+ // (the slot is free) and whose segment is still open on purpose: the
4677
+ // cancel ends OUR face of it now, once, and the held run's id is retired
4678
+ // so its late text cannot join the next turn.
4679
+ this.clearBackgroundHold({ retire: true })
4680
+ this.settle({ kind: 'cancelled' })
4681
+ return
4682
+ }
3112
4683
  this.finishActive({ kind: 'aborted', failure: { message: 'aborted', code: 'ABORTED' } })
3113
4684
  // The agent may take its time acknowledging the cancel, but OUR face of
3114
- // the turn ends now: the session stops running and a provisional first
3115
- // lock settles against what was actually shown. The turn SLOT stays
3116
- // claimed, though: the agent-side prompt is still in flight, and a second
4685
+ // the turn ends now: the session stops running, the HOST's prompt slot is
4686
+ // released (so a new prompt may claim it), and a provisional first lock
4687
+ // settles against what was actually shown. The AGENT-side gate (`settled`)
4688
+ // stays claimed: the prompt is still in flight over there, and a second
3117
4689
  // prompt sent into that window would be a concurrent `session/prompt` the
3118
- // protocol forbids — `settle` (or the cancel fallback) releases the slot.
4690
+ // protocol forbids — `claimTurn` waits it out, `settle` (or the cancel
4691
+ // fallback) opens it.
3119
4692
  this.session.running = false
4693
+ this.session.promptInFlight = false
3120
4694
  if (this.session.provisionalLock === true) {
3121
4695
  this.session.provisionalLock = false
3122
4696
  if (!this.sawVisibleBlock) this.rollbackFirstTurn(this.session)
@@ -3124,9 +4698,29 @@ class TurnStreamController {
3124
4698
  releaseAllToolOutputWaiters()
3125
4699
  }
3126
4700
 
3127
- /** Stop observing and release the manager's index. Idempotent. */
4701
+ /**
4702
+ * Stop observing and release the manager's index. Idempotent.
4703
+ *
4704
+ * The segment is CLOSED here (aborted) before unsubscribing: a driver step
4705
+ * parked on its queue (`yield* segment.queue`) only ends when the queue
4706
+ * closes, and a controller replaced by the conversation's next turn would
4707
+ * otherwise leave that step hanging forever. An already finished segment is a
4708
+ * no-op, and the session's flags are left alone — a superseded turn must not
4709
+ * mark the newer turn's session idle.
4710
+ */
3128
4711
  dispose(): void {
3129
4712
  if (this.disposed) return
4713
+ // A settle may have queued its closing work for the next macrotask: this
4714
+ // close owns the turn now, so that tick must not broadcast a second
4715
+ // `turn/end` (with this turn's real id) over the teardown.
4716
+ this.closeScheduled = false
4717
+ this.pendingOutcome = undefined
4718
+ this.finishActive({ kind: 'aborted', failure: { message: 'turn stream retired', code: 'RETIRED' } })
4719
+ this.clearAmbientWait()
4720
+ this.clearPendingAmbient()
4721
+ // A successor turn must never fold this hold's drain or worker text: the
4722
+ // abandoned run's id is retired before the subscription goes away.
4723
+ this.clearBackgroundHold({ retire: true })
3130
4724
  this.retire()
3131
4725
  releaseAllToolOutputWaiters()
3132
4726
  }
@@ -3187,20 +4781,73 @@ class TurnStreamController {
3187
4781
  } else {
3188
4782
  // A settled turn that never showed anything: the zero-output warning the
3189
4783
  // plane would have gotten from a single-stream turn (constraint 23).
3190
- 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`
4784
+ const line = this.zeroOutputLine()
3191
4785
  if (seg.index < 0) this.emitProse(seg, 'text', line)
3192
4786
  this.finishSegment(seg, { kind: 'stop' })
3193
4787
  }
3194
4788
  }
3195
4789
 
4790
+ /**
4791
+ * The "protocol says success, the bubble is empty" line. When a recently
4792
+ * retired drain may have taken the prompt without re-announcing itself, say
4793
+ * that instead of blaming the agent: the output exists in the transcript.
4794
+ */
4795
+ private zeroOutputLine(stopReason?: string): string {
4796
+ if (this.recentRetiredDrain) {
4797
+ 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`
4798
+ }
4799
+ const reason = stopReason === undefined ? '' : ` (stop reason: ${stopReason})`
4800
+ 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`
4801
+ }
4802
+
3196
4803
  private onEvent(event: unknown): void {
3197
4804
  if (this.disposed) return
3198
4805
  const row = event as { type?: string; sessionId?: string }
3199
4806
  if (row.sessionId !== undefined && row.sessionId !== this.session.sessionId) return
3200
4807
  if (row.type === 'session/update') {
3201
4808
  const typed = event as { sessionId: string; seq: number; update: unknown }
3202
- if (typed.seq <= this.cursorSeq) return
4809
+ // Any update — a `usage_update` / phase tick included — says the
4810
+ // background drain is still working, so the ambient quiet clock restarts.
4811
+ this.noteAmbientActivity()
4812
+ // A detached-worker hold's drain phase has the same rule; its worker
4813
+ // phase never does (see `BridgeTuning.backgroundTaskMaxMs`).
4814
+ this.noteBackgroundHoldActivity()
3203
4815
  const update = typed.update as Record<string, unknown>
4816
+ // The dispatch / worker / announcement bookkeeping runs BEFORE the cursor
4817
+ // guard: those are turn-level facts, not segment content, and an update
4818
+ // withheld by the ambient buffer must still keep them.
4819
+ this.trackBackgroundDispatch(update)
4820
+ if (typed.seq <= this.cursorSeq) return
4821
+ // A background drain's prose is not this turn's answer: keep it out of
4822
+ // the plane (the transcript still has it). Fail-open — nothing is split
4823
+ // until this turn has a request id of its own to compare against.
4824
+ this.noteTurnRequestId(update)
4825
+ if (this.isForeignAmbientText(update)) {
4826
+ this.recordWithoutSegment(update)
4827
+ // Advance past it only when nothing is left unconsumed behind it: the
4828
+ // event that CUT the previous segment is parked for the next one, and
4829
+ // jumping the cursor over it would lose its text for good. The replay
4830
+ // applies the same foreign split, so leaving this one behind is safe.
4831
+ if (typed.seq === this.cursorSeq + 1) this.cursorSeq = typed.seq
4832
+ return
4833
+ }
4834
+ // The turn's OWN request id is still unknown and this update belongs to
4835
+ // the background drain: folding it now could make the drain's tick teach
4836
+ // `noteTurnRequestId` the drain's id, mixing the drain's text into the
4837
+ // user's answer. Withhold it until the prompt response decides what this
4838
+ // turn is (see `pendingAmbientUpdates`); the cursor moves on because the
4839
+ // transcript already holds the entry.
4840
+ if (this.turnRequestId === undefined
4841
+ && this.awaitingAmbient === undefined
4842
+ && this.isAmbientOwned(update)) {
4843
+ this.bufferAmbientUpdate(typed.seq, update)
4844
+ this.cursorSeq = typed.seq
4845
+ return
4846
+ }
4847
+ // Live-only burst accounting runs BEFORE the segment cursor: prose that
4848
+ // arrives between segments still belongs to this turn's output, and its
4849
+ // gaps are real. Replay never reaches this path.
4850
+ this.trackTps(typed)
3204
4851
  const seg = this.segment
3205
4852
  if (seg === undefined || seg.finished) {
3206
4853
  // Between segments: tool outputs still reach the shared registry (the
@@ -3213,16 +4860,75 @@ class TurnStreamController {
3213
4860
  }
3214
4861
  // The cursor advances only past events fully consumed into this
3215
4862
  // segment; the event that CUTS the segment belongs to the next one.
3216
- if (this.foldInto(seg, update)) this.cursorSeq = typed.seq
4863
+ if (this.foldInto(seg, update, typed.seq)) this.cursorSeq = typed.seq
3217
4864
  } else if (row.type === 'permission/request') {
4865
+ this.tps.onBreak()
3218
4866
  const typed = event as Extract<AcpEvent, { type: 'permission/request' }>
3219
4867
  this.pushProse(this.permissionLine(typed))
3220
4868
  } else if (row.type === 'permission/expired') {
4869
+ this.tps.onBreak()
3221
4870
  const typed = event as { toolCall: unknown; timeoutMs: number; outcome: PermissionOutcome; optionName?: string }
3222
4871
  this.pushProse(this.expiredLine(typed))
3223
4872
  }
3224
4873
  }
3225
4874
 
4875
+ /** Feed one live update into the turn's burst tracker. Never called by replay. */
4876
+ private trackTps(typed: { seq: number; update: unknown }): void {
4877
+ if (typed.seq <= this.tpsSeq) return
4878
+ this.tpsSeq = typed.seq
4879
+ const update = typed.update as Record<string, unknown>
4880
+ const kind = String(update.sessionUpdate ?? '')
4881
+ if (kind === 'agent_thought_chunk' || kind === 'agent_message_chunk') {
4882
+ const text = chunkTextOf(update.content)
4883
+ if (text === '') return
4884
+ this.tps.onProse(text, Date.now())
4885
+ this.pushLiveTps()
4886
+ } else if (kind === 'tool_call' || kind === 'tool_call_update') {
4887
+ this.tps.onBreak()
4888
+ }
4889
+ }
4890
+
4891
+ /** Throttled live aggregate so the dock's speed moves during a long turn. */
4892
+ private pushLiveTps(): void {
4893
+ const dshSessionId = this.session.dshSessionId
4894
+ if (dshSessionId === undefined) return
4895
+ const now = Date.now()
4896
+ if (now - this.lastTpsPushAt < TPS_PUSH_INTERVAL_MS) return
4897
+ const snap = this.tps.snapshot()
4898
+ if (snap.outputTokens <= 0 || snap.burstMs <= 0) return
4899
+ this.lastTpsPushAt = now
4900
+ const base = this.session.burst ?? { outputTokens: 0, burstMs: 0 }
4901
+ this.hub.broadcast({
4902
+ type: 'turn/metrics',
4903
+ sessionId: this.session.sessionId,
4904
+ dshSessionId,
4905
+ turnId: this.turnId,
4906
+ outputTokens: base.outputTokens + snap.outputTokens,
4907
+ burstMs: base.burstMs + snap.burstMs,
4908
+ final: false,
4909
+ })
4910
+ }
4911
+
4912
+ /** Close the turn's metrics: persist the measurable part, then broadcast it. */
4913
+ private finalizeTps(): void {
4914
+ const dshSessionId = this.session.dshSessionId
4915
+ if (dshSessionId === undefined) return
4916
+ const snap = this.tps.snapshot()
4917
+ if (snap.outputTokens > 0 && snap.burstMs > 0) {
4918
+ this.manager.noteSessionBurst(this.session, snap.outputTokens, snap.burstMs)
4919
+ }
4920
+ const aggregate = this.session.burst ?? { outputTokens: 0, burstMs: 0 }
4921
+ this.hub.broadcast({
4922
+ type: 'turn/metrics',
4923
+ sessionId: this.session.sessionId,
4924
+ dshSessionId,
4925
+ turnId: this.turnId,
4926
+ outputTokens: aggregate.outputTokens,
4927
+ burstMs: aggregate.burstMs,
4928
+ final: true,
4929
+ })
4930
+ }
4931
+
3226
4932
  /**
3227
4933
  * Put one narrative line into the turn's prose stream.
3228
4934
  *
@@ -3259,15 +4965,115 @@ class TurnStreamController {
3259
4965
  recordAcpToolOutput(callId, updateContentToText(update.content))
3260
4966
  }
3261
4967
 
4968
+ /**
4969
+ * Whether this update belongs to a background drain — the one the session is
4970
+ * waiting on, or one whose wait already closed (retired) but whose run can
4971
+ * still speak.
4972
+ */
4973
+ private isAmbientOwned(update: Record<string, unknown>): boolean {
4974
+ const requestId = codebuddyMetaOf(update)?.requestId
4975
+ if (requestId === undefined) return false
4976
+ return requestId === this.session.ambient?.requestId
4977
+ || isRetiredAmbientId(this.session, requestId)
4978
+ }
4979
+
4980
+ /** Learn this turn's own CodeBuddy request id from the first update naming one. */
4981
+ private noteTurnRequestId(update: Record<string, unknown>): void {
4982
+ if (this.turnRequestId !== undefined) return
4983
+ const meta = codebuddyMetaOf(update)
4984
+ // A detached worker's update names A WORKER, never this turn: adopting it
4985
+ // would let the worker's own turn pass as the session's answer.
4986
+ if (meta?.parentToolCallId !== undefined) return
4987
+ // While a hold runs, the only ids this turn owns are the drain ids it
4988
+ // accepted (see `BackgroundHold.acceptedIds`) — and those belong to the
4989
+ // background run, never to a second "own turn id" that would outlive the
4990
+ // hold. Nothing else may be adopted.
4991
+ if (this.activeBackgroundHold() !== undefined) return
4992
+ const requestId = meta?.requestId
4993
+ // A background drain's update names THE DRAIN, never this turn: adopting it
4994
+ // would make `isForeignAmbientText` pass the drain's own text through. A
4995
+ // RETIRED drain (its wait closed on silence, the run revived) is the same
4996
+ // trap — that id belongs to the background run whichever turn is on air.
4997
+ if (requestId === undefined || requestId === this.session.ambient?.requestId) return
4998
+ if (isRetiredAmbientId(this.session, requestId)) return
4999
+ this.turnRequestId = requestId
5000
+ }
5001
+
5002
+ /** Adopt the request id the prompt RESPONSE declared for this turn. */
5003
+ adoptTurnRequestId(requestId: string): void {
5004
+ // A retired drain id is never this turn's own: adopting it would make the
5005
+ // background run's prose pass `isForeignAmbientText` as the answer. The
5006
+ // response still ends a normal turn, so the withheld drain buffer is
5007
+ // dropped either way (the transcript keeps it).
5008
+ if (!isRetiredAmbientId(this.session, requestId)) this.turnRequestId = requestId
5009
+ // A normal turn: whatever the drain streamed while the id was unknown was
5010
+ // never this turn's answer. Drop it (the transcript keeps it) — it must not
5011
+ // surface when a later segment replays or when the turn flushes anything.
5012
+ this.clearPendingAmbient()
5013
+ }
5014
+
5015
+ /**
5016
+ * Prose of the session's background drain while this turn runs on its OWN
5017
+ * request id: it belongs to the background run, not to the user's answer.
5018
+ * Fail-open by design — an unknown turn id splits nothing, and a turn that
5019
+ * was itself folded into the drain keeps its prose.
5020
+ */
5021
+ private isForeignAmbientText(update: Record<string, unknown>): boolean {
5022
+ const meta = codebuddyMetaOf(update)
5023
+ const requestId = meta?.requestId
5024
+ // A detached worker's own turn is never this session's answer, whichever
5025
+ // turn is on air (the fold-level guard lives in `foldInto` as well, for
5026
+ // the replay paths that never ask this question). This includes a worker
5027
+ // update that also names a drain id: its prose is the WORKER's.
5028
+ if (meta?.parentToolCallId !== undefined) return true
5029
+ // A detached-worker hold owns the drain phase: any run it accepted (the
5030
+ // response's id, an announcement that named a different one) is this
5031
+ // turn's answer, and every OTHER id is background noise — whatever the
5032
+ // turn's own id says. Parked holds count: the response already owns them.
5033
+ const hold = this.activeBackgroundHold()
5034
+ if (hold !== undefined) {
5035
+ if (requestId === undefined) return false
5036
+ return !hold.acceptedIds.has(requestId)
5037
+ }
5038
+ // The drain THIS turn is waiting on owns its text unconditionally: the
5039
+ // wait was opened for this id (a revived drain may still carry a retired
5040
+ // mark if the update raced the revival), so the whole steered answer must
5041
+ // reach the user instead of being filtered as background noise.
5042
+ if (requestId !== undefined && this.awaitingAmbient?.requestId === requestId) return false
5043
+ // A RETIRED drain is foreign even though its record is gone: the quiet
5044
+ // close only means WE stopped waiting, not that the run ended. Its late
5045
+ // text must never join the turn now on air — even before that turn has
5046
+ // learned a request id of its own (the fail-open below does not apply to
5047
+ // an id known to be a background run).
5048
+ if (isRetiredAmbientId(this.session, requestId)) return true
5049
+ if (this.turnRequestId === undefined) return false
5050
+ if (requestId === undefined || requestId === this.turnRequestId) return false
5051
+ const ambient = this.session.ambient
5052
+ return ambient !== undefined && requestId === ambient.requestId
5053
+ }
5054
+
3262
5055
  /**
3263
5056
  * The turn's whole fold, shared by live events and segment replay. Visible
3264
5057
  * non-tool events END a card-bearing segment instead of joining it — that is
3265
5058
  * the segmentation rule that puts each card between two assistant messages.
3266
5059
  * Returns whether the event was consumed into this segment: the cutting
3267
5060
  * event is left unconsumed (and thus behind the cursor) for the next one.
5061
+ *
5062
+ * `seq` is the update's transcript position when the caller has one: the
5063
+ * detached-worker hold uses it to count only the answer text that arrived
5064
+ * AFTER the hold began (a parked hold replays the parent's earlier ack).
3268
5065
  */
3269
- private foldInto(seg: SegmentState, update: Record<string, unknown>): boolean {
5066
+ private foldInto(seg: SegmentState, update: Record<string, unknown>, seq?: number): boolean {
3270
5067
  const kind = String(update.sessionUpdate ?? '')
5068
+ // A DETACHED WORKER's own turn: its words and tools are the worker's, not
5069
+ // this session's answer. The transcript keeps them (the ACP view is where
5070
+ // the user can read them) and the shared tool-output registry still sees
5071
+ // the call, but nothing reaches this turn's message and no card opens for
5072
+ // the host to execute. This applies to EVERY turn, not just a hold.
5073
+ if (codebuddyMetaOf(update)?.parentToolCallId !== undefined) {
5074
+ this.noteToolRecord(update)
5075
+ return true
5076
+ }
3271
5077
  if (kind === 'agent_thought_chunk' || kind === 'agent_message_chunk') {
3272
5078
  const text = chunkTextOf(update.content)
3273
5079
  if (text === '') return true
@@ -3276,10 +5082,34 @@ class TurnStreamController {
3276
5082
  return false
3277
5083
  }
3278
5084
  seg.outputChars += text.length
5085
+ // "The turn's own answer", as opposed to a background drain's prose that
5086
+ // happens to stream through the same session (see `isForeignAmbientText`).
5087
+ if (!this.isAmbientOwned(update)) this.sawAgentProse = true
5088
+ if (kind === 'agent_message_chunk') {
5089
+ this.sawVisibleText = true
5090
+ // A hold's closing line asks whether ITS runs delivered answer text.
5091
+ // The parent's interim acknowledgement arrived before the hold and
5092
+ // already set `sawVisibleText`, so the hold keeps its own counter —
5093
+ // and only text that arrived after the hold began counts (a parked
5094
+ // hold replays that earlier ack from the transcript).
5095
+ const hold = this.activeBackgroundHold()
5096
+ if (hold !== undefined && (seq === undefined || seq > hold.floorSeq)) {
5097
+ hold.answerChars += text.length
5098
+ }
5099
+ }
3279
5100
  this.emitProse(seg, kind === 'agent_thought_chunk' ? 'reasoning' : 'text', text)
3280
5101
  return true
3281
5102
  } else if (kind === 'tool_call' || kind === 'tool_call_update') {
3282
5103
  this.noteToolRecord(update)
5104
+ if (seg.ambient || this.isAmbientOwned(update)) {
5105
+ // A background drain's tools stay in the transcript: opening a card
5106
+ // would make the host execute a tool and propose a continuation step
5107
+ // for a turn whose answer has not started yet. The ownership check
5108
+ // covers a NORMAL turn too — its own segment is not ambient, but a
5109
+ // drain can stream a tool call on the same session while it runs, and
5110
+ // that call is not this turn's work.
5111
+ return true
5112
+ }
3283
5113
  const snapshot = acpToolCallFromUpdate(update)
3284
5114
  if (snapshot === undefined) return true
3285
5115
  if (seg.activeType === 'tool-call' && seg.activeToolCall?.callId === snapshot.callId) {
@@ -3291,6 +5121,12 @@ class TurnStreamController {
3291
5121
  return true
3292
5122
  }
3293
5123
  if (this.seenCalls.has(snapshot.callId)) return true // a restatement of a card already settled
5124
+ // A BARE status-only update for a call this turn never announced is not
5125
+ // a card. The real wire sends the detached dispatch's completion exactly
5126
+ // that way (no `_meta`, no title, no input); a turn that begins after the
5127
+ // dispatch belongs to another turn's card, and opening one here would
5128
+ // make the host execute a phantom tool.
5129
+ if (kind === 'tool_call_update' && snapshot.name === '' && snapshot.argsRaw === '') return true
3294
5130
  this.seenCalls.add(snapshot.callId)
3295
5131
  this.openToolCallBlock(seg, snapshot)
3296
5132
  return true
@@ -3524,6 +5360,34 @@ export class BridgeManager {
3524
5360
  return this.turnStreams.get(dshSessionId)
3525
5361
  }
3526
5362
 
5363
+ /**
5364
+ * The ambient windows a turn's controller reads once at construction (see
5365
+ * {@link BridgeTuning.ambientQuietMs}). Exposed as a plain snapshot so the
5366
+ * controller never pokes at the manager's tuning object, and tests can tune
5367
+ * the cadence through the constructor like every other window.
5368
+ */
5369
+ get ambientTuning(): { quietMs: number; maxMs: number } {
5370
+ return {
5371
+ quietMs: this.tuning.ambientQuietMs,
5372
+ maxMs: this.tuning.ambientMaxMs,
5373
+ }
5374
+ }
5375
+
5376
+ /**
5377
+ * The detached-worker hold's windows, read once at controller construction
5378
+ * for the same reason as {@link ambientTuning}: `taskMaxMs` is the worker
5379
+ * phase's only clock (see {@link BridgeTuning.backgroundTaskMaxMs}) and
5380
+ * `graceMs` the bounded wait a PARKED hold gives the host to open its
5381
+ * continuation step (see {@link BridgeTuning.backgroundParkedGraceMs}). The
5382
+ * drain phase reuses {@link ambientTuning}.
5383
+ */
5384
+ get backgroundTuning(): { taskMaxMs: number; graceMs: number } {
5385
+ return {
5386
+ taskMaxMs: this.tuning.backgroundTaskMaxMs,
5387
+ graceMs: this.tuning.backgroundParkedGraceMs,
5388
+ }
5389
+ }
5390
+
3527
5391
  /** Index a turn's controller, retiring whatever turn it supersedes. */
3528
5392
  rememberTurnStream(dshSessionId: string, controller: TurnStreamController): void {
3529
5393
  const previous = this.turnStreams.get(dshSessionId)
@@ -3722,6 +5586,13 @@ export class BridgeManager {
3722
5586
  this.sessionWorkspaceResolver = resolver
3723
5587
  }
3724
5588
 
5589
+ /**
5590
+ * Set the attachment service or context used to resolve image attachments in user prompts.
5591
+ */
5592
+ setAttachmentResolver(resolver?: unknown): void {
5593
+ this.attachmentResolver = resolver
5594
+ }
5595
+
3725
5596
  /**
3726
5597
  * Wire the ACP-mode probe: given a DSH session id, report whether that
3727
5598
  * session runs the ACP agent preset. The host half reads it back from the
@@ -3836,6 +5707,7 @@ export class BridgeManager {
3836
5707
  ...(session.modes === undefined ? {} : { modes: session.modes }),
3837
5708
  ...(session.availableCommands === undefined ? {} : { availableCommands: session.availableCommands }),
3838
5709
  ...(session.usage === undefined ? {} : { usage: session.usage }),
5710
+ ...(session.burst === undefined ? {} : { burst: session.burst }),
3839
5711
  ...(session.released ? { released: true } : {}),
3840
5712
  ...(session.running ? { running: true } : {}),
3841
5713
  ...forkRowInfo(session, this.canForkNow(session.agentId)),
@@ -4733,6 +6605,21 @@ export class BridgeManager {
4733
6605
  for (const dshSessionId of this.chatAgentIntent.keys()) this.syncConversationRoute(dshSessionId)
4734
6606
  }
4735
6607
 
6608
+ /**
6609
+ * Fold one settled ACP turn's measurable burst into the session aggregate and
6610
+ * persist it. A turn with no measured inter-chunk gap contributes neither
6611
+ * side: adding its tokens with zero time would inflate the session rate.
6612
+ */
6613
+ noteSessionBurst(session: LiveSession, outputTokens: number, burstMs: number): void {
6614
+ if (outputTokens <= 0 || burstMs <= 0) return
6615
+ const base = session.burst ?? { outputTokens: 0, burstMs: 0 }
6616
+ session.burst = {
6617
+ outputTokens: base.outputTokens + outputTokens,
6618
+ burstMs: base.burstMs + burstMs,
6619
+ }
6620
+ this.persistAgents()
6621
+ }
6622
+
4736
6623
  private persistAgents(): void {
4737
6624
  const filePath = this.getStoragePath()
4738
6625
  if (!filePath) return
@@ -4798,8 +6685,8 @@ export class BridgeManager {
4798
6685
  if (typeof dshSessionId !== 'string' || dshSessionId === '') continue
4799
6686
  if (typeof cwd !== 'string') continue
4800
6687
  if (!this.defsMap.has(agentId)) continue
4801
- const { title, modelId, reasoningEffort, modes, usage, hasHistory } = record as {
4802
- title?: unknown; modelId?: unknown; reasoningEffort?: unknown; modes?: unknown; usage?: unknown; hasHistory?: unknown
6688
+ const { title, modelId, reasoningEffort, modes, usage, burst, hasHistory } = record as {
6689
+ title?: unknown; modelId?: unknown; reasoningEffort?: unknown; modes?: unknown; usage?: unknown; burst?: unknown; hasHistory?: unknown
4803
6690
  }
4804
6691
  const session: LiveSession = {
4805
6692
  sessionId,
@@ -4814,6 +6701,7 @@ export class BridgeManager {
4814
6701
  ...(typeof reasoningEffort === 'string' && reasoningEffort !== '' ? { reasoningEffort } : {}),
4815
6702
  ...(isPersistedModes(modes) ? { modes } : {}),
4816
6703
  ...(isPersistedUsage(usage) ? { usage } : {}),
6704
+ ...(isPersistedBurst(burst) ? { burst } : {}),
4817
6705
  ...(hasHistory === true ? { hasHistory: true } : {}),
4818
6706
  running: false,
4819
6707
  turnSeq: 0,
@@ -5083,6 +6971,10 @@ export class BridgeManager {
5083
6971
  private autoExposeNewModels(agentId: string, next: AcpAgentCatalog, before: AcpAgentCatalog | undefined): boolean {
5084
6972
  const def = this.defsMap.get(agentId)
5085
6973
  if (def === undefined || def.autoDiscoverModels === false || before === undefined) return false
6974
+ // A different model-slot identity is a corrected reading, not newly added
6975
+ // models. Diffing across it would expose an entire real catalog the first
6976
+ // time a parser fix replaces a mislabelled slot (Qoder CN).
6977
+ if (before.model !== undefined && next.model !== undefined && before.model.configId !== next.model.configId) return false
5086
6978
  const known = new Set((before.model?.choices ?? []).map(choice => choice.value))
5087
6979
  const exposed = new Set(def.exposedModels ?? [])
5088
6980
  const added = (next.model?.choices ?? [])
@@ -5473,6 +7365,11 @@ export class BridgeManager {
5473
7365
  private staleModelChoice(session: LiveSession, modelChoice: string | undefined): string | undefined {
5474
7366
  const wanted = modelChoice !== undefined && modelChoice !== '' ? modelChoice : undefined
5475
7367
  if (wanted === undefined) return undefined
7368
+ // `category` is a UX hint, not identity: an agent may publish several
7369
+ // `category: 'model'` options (Qoder CN ships `reasoning_effort` that way)
7370
+ // and no explicit `model` id. In that case the selector is a guess, so a
7371
+ // "not offered" verdict would refuse a perfectly valid pick — fail open.
7372
+ if (!hasUnambiguousModelSelector(session.configOptions)) return undefined
5476
7373
  const model = parseCatalogOptions(session.configOptions).model
5477
7374
  if (model === undefined) return undefined
5478
7375
  if (model.currentValue === wanted) return undefined
@@ -5712,7 +7609,7 @@ export class BridgeManager {
5712
7609
  return { sessionId: session.sessionId }
5713
7610
  }
5714
7611
 
5715
- async prompt(sessionId: string, text: string): Promise<{ turnId: string }> {
7612
+ async prompt(sessionId: string, prompt: string | ContentBlock[]): Promise<{ turnId: string }> {
5716
7613
  let located = await this.locateOrResume(sessionId)
5717
7614
  // The watchdog abandoned a previous turn here and the agent may still be
5718
7615
  // holding its prompt slot on the old process: reclaim BEFORE sending, or
@@ -5730,7 +7627,40 @@ export class BridgeManager {
5730
7627
  located = await this.locateOrResume(sessionId)
5731
7628
  }
5732
7629
  const { bridge, session } = located
5733
- if (session.running) throw new AcpError('turn_in_flight', 'acp: this session already has a prompt in flight')
7630
+ // The agent prompt slot is SHARED with chat turns: `running` alone misses
7631
+ // a chat turn whose cancellation already released it (`running` false,
7632
+ // `promptInFlight` still naming the agent-side turn), and a live chat
7633
+ // controller means a continuation step may resume the SAME agent turn.
7634
+ // A controller that already SETTLED (kept only so its card's continuation
7635
+ // can still stream) has given the agent slot back: `/acp` may use it, and
7636
+ // advancing `turnSeq` retires it — the behaviour the stale-controller
7637
+ // watchdog case pins.
7638
+ const chatController = session.turnController
7639
+ if (session.running
7640
+ || session.promptInFlight === true
7641
+ || (chatController !== undefined && !chatController.stale && !chatController.hasSettled)) {
7642
+ throw new AcpError('turn_in_flight', 'acp: this session already has a prompt in flight')
7643
+ }
7644
+
7645
+ const blocks: ContentBlock[] = typeof prompt === 'string'
7646
+ ? [{ type: 'text', text: prompt }]
7647
+ : prompt
7648
+
7649
+ if (blocks.length === 0) {
7650
+ throw new AcpError('empty_prompt', 'acp: prompt cannot be empty')
7651
+ }
7652
+
7653
+ const hasImages = blocks.some(b => b.type === 'image')
7654
+ if (hasImages && bridge.initResult?.agentCapabilities?.promptCapabilities?.image !== true) {
7655
+ throw new AcpError('image_unsupported', `acp: agent "${bridge.def.id}" does not support image input`)
7656
+ }
7657
+
7658
+ // Claim the SAME slot the chat path claims (`claimTurn` + `beginTurn`): an
7659
+ // `/acp` turn in flight must make the next chat prompt wait, and vice
7660
+ // versa. The settler is idempotent and identity-guarded; the turn still
7661
+ // ends through `turn/end` below, so chat and RPC turns share one gate
7662
+ // without sharing a stream controller.
7663
+ const endTurn = bridge.beginTurn(session)
5734
7664
  session.running = true
5735
7665
  session.turnSeq += 1
5736
7666
  const turnGen = session.turnSeq
@@ -5741,9 +7671,12 @@ export class BridgeManager {
5741
7671
  session.reclaimedTurnSeq = undefined
5742
7672
  this.armStallTimer()
5743
7673
  const turnId = randomUUID()
5744
- this.hub.broadcast({ type: 'turn/start', sessionId, turnId, text })
5745
- void bridge.runPrompt(session, text, turnId)
5746
- .then(stopReason => {
7674
+ const promptText = typeof prompt === 'string'
7675
+ ? prompt
7676
+ : blocks.filter(b => b.type === 'text').map(b => (b as { text: string }).text).join('\n')
7677
+ this.hub.broadcast({ type: 'turn/start', sessionId, turnId, text: promptText })
7678
+ void bridge.runPrompt(session, blocks, turnId, turnGen)
7679
+ .then(result => {
5747
7680
  // The reclaim already ended this turn locally; the answer it caused is
5748
7681
  // that same turn reporting back, not a second turn end for the caller.
5749
7682
  if (session.reclaimedTurnSeq === turnGen) {
@@ -5753,8 +7686,11 @@ export class BridgeManager {
5753
7686
  // A superseded turn's late answer must not end the turn now on air.
5754
7687
  if (session.turnSeq !== turnGen) return
5755
7688
  this.clearOrphanPrompt(session, turnGen)
7689
+ // Release the shared slot BEFORE the terminal frame: a listener that
7690
+ // sends the next prompt on `turn/end` must not read a stale claim.
7691
+ endTurn()
5756
7692
  session.running = false
5757
- this.hub.broadcast({ type: 'turn/end', sessionId, turnId, stopReason })
7693
+ this.hub.broadcast({ type: 'turn/end', sessionId, turnId, stopReason: result.stopReason })
5758
7694
  })
5759
7695
  .catch((error: unknown) => {
5760
7696
  if (session.reclaimedTurnSeq === turnGen) {
@@ -5771,6 +7707,7 @@ export class BridgeManager {
5771
7707
  return
5772
7708
  }
5773
7709
  this.clearOrphanPrompt(session, turnGen)
7710
+ endTurn()
5774
7711
  session.running = false
5775
7712
  this.hub.broadcast({
5776
7713
  type: 'turn/end',
@@ -5779,6 +7716,9 @@ export class BridgeManager {
5779
7716
  error: describeError(error),
5780
7717
  })
5781
7718
  })
7719
+ // Every outcome — normal answer, failure, reclaim early-return, or a
7720
+ // close's rejection — releases the shared prompt slot exactly once.
7721
+ .finally(() => { endTurn() })
5782
7722
  return { turnId }
5783
7723
  }
5784
7724
 
@@ -6104,6 +8044,7 @@ export class BridgeManager {
6104
8044
  }
6105
8045
  private sessionWorkspaceResolver?: SessionWorkspaceResolver
6106
8046
  private acpModeResolver?: AcpModeResolver
8047
+ private attachmentResolver?: unknown
6107
8048
  /** Cached `chatSessionAcpMode` answers, keyed by DSH chat session id. */
6108
8049
  private readonly acpModeCache = new Map<string, boolean>()
6109
8050
  /** In-flight cold reads behind `chatSessionAcpModeAnswer`, keyed by session id. */
@@ -6378,6 +8319,47 @@ export class BridgeManager {
6378
8319
  return { ok: true, reset: true }
6379
8320
  }
6380
8321
 
8322
+ /**
8323
+ * 聊天插话的槽位回收:宿主把插话作为同一会话在飞回合的下一步送达,
8324
+ * 拒绝会让整条 host turn 失败。ACP 不能向运行中的 prompt 注入消息,
8325
+ * 所以按插话语义中止当前回合,等 agent 真正结算后再让调用方重发。
8326
+ *
8327
+ * 只处理 `claimTurn` 的 `busy`(agent 仍持有本会话 prompt);`unsettled`
8328
+ * 说明我们这一侧已经释放过槽位(用户按过停止),保持原样拒绝。
8329
+ *
8330
+ * - 控制器已 settle(响应已到,延后收口还没释放闸门):什么都不取消,只等闸门;
8331
+ * - agent 真在跑:走用户取消同一条管线(session/cancel + 结算权限 + cancel fallback),
8332
+ * 并立即关闭宿主这一侧的 segment,让闸门在 agent 应答/兜底后打开。
8333
+ */
8334
+ private async reclaimChatPrompt(
8335
+ session: LiveSession,
8336
+ controller: TurnStreamController,
8337
+ signal?: AbortSignal,
8338
+ ): Promise<'ok' | 'aborted'> {
8339
+ if (session.closed || session.released) return 'ok'
8340
+ if (!controller.hasSettled && session.promptInFlight === true) {
8341
+ await this.bridge(session.agentId).cancel(session)
8342
+ controller.abortActiveSegment()
8343
+ }
8344
+ return this.waitForSettleGate(session, signal)
8345
+ }
8346
+
8347
+ /** 等 agent 侧 prompt slot 真正释放;步骤被取消时立即返回 aborted。 */
8348
+ private waitForSettleGate(session: LiveSession, signal?: AbortSignal): Promise<'ok' | 'aborted'> {
8349
+ const pending = session.settled
8350
+ if (pending === undefined) return Promise.resolve('ok')
8351
+ if (signal?.aborted) return Promise.resolve('aborted')
8352
+ return new Promise(resolve => {
8353
+ const done = (value: 'ok' | 'aborted'): void => {
8354
+ signal?.removeEventListener('abort', onAbort)
8355
+ resolve(value)
8356
+ }
8357
+ const onAbort = (): void => { done('aborted') }
8358
+ signal?.addEventListener('abort', onAbort, { once: true })
8359
+ void pending.then(() => { done('ok') })
8360
+ })
8361
+ }
8362
+
6381
8363
  async *streamPrompt(options: GenerateOptions): AsyncIterable<StreamChunk> {
6382
8364
  // The plugin's own conversation↔agent mapping decides who serves the turn
6383
8365
  // (see `bindChatAgent`): the driver may arrive here with the previous
@@ -6469,8 +8451,36 @@ export class BridgeManager {
6469
8451
  // Forward the person's own words: the host folds its context sections
6470
8452
  // (AGENTS.md digest, skill catalog, runtime snapshot) after the claimed user
6471
8453
  // batch precisely so a consumer can tell them apart by `source` — see
6472
- // `selectForwardPrompt` and AGENTS.md rule 16.
6473
- const selection = selectForwardPrompt(options.messages ?? [])
8454
+ const workspaceRoot = chatWorkspaceRoot(this.sessionWorkspaceResolver, options.sessionId)
8455
+ const defaultReadFile = (p: string) => {
8456
+ try {
8457
+ if (existsSync(p)) return readFileSync(p)
8458
+ if (workspaceRoot !== undefined && !isAbsolutePath(p)) {
8459
+ const joined = join(workspaceRoot, p)
8460
+ if (existsSync(joined)) return readFileSync(joined)
8461
+ }
8462
+ } catch {}
8463
+ return undefined
8464
+ }
8465
+
8466
+ const resolver = this.attachmentResolver
8467
+ const attachmentContext = resolver !== undefined && typeof resolver === 'object' && resolver !== null
8468
+ ? Object.assign(Object.create(resolver), {
8469
+ readFile: (p: string) => {
8470
+ if (typeof (resolver as { readFile?: unknown }).readFile === 'function') {
8471
+ try {
8472
+ const res = (resolver as { readFile: (p: string) => unknown }).readFile(p)
8473
+ if (res !== undefined) return res
8474
+ } catch {}
8475
+ }
8476
+ return defaultReadFile(p)
8477
+ },
8478
+ })
8479
+ : {
8480
+ readFile: defaultReadFile,
8481
+ }
8482
+
8483
+ const selection = selectForwardPrompt(options.messages ?? [], attachmentContext)
6474
8484
  if (selection.kind === 'no-user-input') {
6475
8485
  // An injected-context wake: nothing the user said, nothing to send.
6476
8486
  yield { type: 'finish', reason: { kind: 'stop' } }
@@ -6537,29 +8547,101 @@ export class BridgeManager {
6537
8547
  return
6538
8548
  }
6539
8549
 
6540
- // One prompt slot per session, and a turn the user aborted still occupies
6541
- // the AGENT's slot until it acknowledges the cancel (see `claimTurn`).
6542
- const claim = await bridge.claimTurn(session)
6543
- if (!claim.ok) {
6544
- // A first turn that never got the prompt slot delivered nothing either:
6545
- // give the conversation back instead of leaving it locked to an agent
6546
- // that refused the only message it was asked to serve.
8550
+ const hasImages = (selection.images?.length ?? 0) > 0 || (selection.content?.some(b => b.type === 'image') ?? false)
8551
+ if (hasImages && bridge.initResult?.agentCapabilities?.promptCapabilities?.image !== true) {
6547
8552
  if (session.provisionalLock === true) this.rollbackFirstTurn(session)
6548
8553
  yield {
6549
8554
  type: 'finish',
6550
8555
  reason: {
6551
8556
  kind: 'error',
6552
8557
  failure: {
6553
- message: claim.reason === 'busy'
6554
- ? 'acp: this session already has a prompt in flight'
6555
- : 'acp: the agent is still finishing the previous (cancelled) turn; send again in a moment',
6556
- code: 'turn_in_flight',
8558
+ message: `acp: agent "${agentId}" does not support image input`,
8559
+ code: 'image_unsupported',
6557
8560
  },
6558
8561
  },
6559
8562
  }
6560
8563
  return
6561
8564
  }
6562
8565
 
8566
+ // One prompt slot per session, and a turn the user aborted still occupies
8567
+ // the AGENT's slot until it acknowledges the cancel (see `claimTurn`).
8568
+ let claim = await bridge.claimTurn(session)
8569
+ if (!claim.ok) {
8570
+ // A STEER ("插话"): the host delivers the user's mid-turn message as the
8571
+ // NEXT step of the SAME host turn, so the conversation's own ACP prompt
8572
+ // is still on air. Refusing would fail the whole host turn. ACP forbids
8573
+ // injecting into a running prompt, so the steer's real semantic — abort
8574
+ // the current turn, then send this one — is spelled cancel + wait +
8575
+ // re-claim here.
8576
+ //
8577
+ // Only `busy` can be a steer: it means the AGENT still holds OUR prompt
8578
+ // (`promptInFlight`). `unsettled` means our side already released the
8579
+ // slot (the person pressed stop, or an earlier steer cancelled): the
8580
+ // agent has not acknowledged yet, and quietly queueing the next message
8581
+ // behind a turn the user stopped is not this path's business — that
8582
+ // stays the honest `turn_in_flight` refusal below.
8583
+ //
8584
+ // Only the conversation's OWN in-flight turn qualifies (its live stream
8585
+ // controller, on this very session). A `/acp` RPC turn has no chat
8586
+ // controller, and another conversation never reaches this session, so
8587
+ // both keep the plain refusal: nothing may be cancelled behind a
8588
+ // caller's back.
8589
+ const chatController = claim.reason === 'busy' && options.sessionId !== undefined
8590
+ ? this.turnStreamFor(options.sessionId)
8591
+ : undefined
8592
+ if (options.sessionId !== undefined
8593
+ && chatController !== undefined
8594
+ && !chatController.stale
8595
+ && chatController.session === session) {
8596
+ const reclaimed = await this.reclaimChatPrompt(session, chatController, options.signal)
8597
+ if (reclaimed === 'aborted') {
8598
+ // The person pressed stop while the previous turn was winding down:
8599
+ // the steer is theirs to re-send, nothing goes to the agent.
8600
+ yield {
8601
+ type: 'finish',
8602
+ reason: { kind: 'aborted', failure: { message: 'aborted', code: 'ABORTED' } },
8603
+ }
8604
+ return
8605
+ }
8606
+ // A first turn that produced nothing rolled its provisional lock back
8607
+ // and CLOSED (and forgot) this session: the steer needs a fresh one.
8608
+ if (session.closed || session.released) {
8609
+ try {
8610
+ session = await this.getOrCreateDshSession(options.sessionId, agentId)
8611
+ } catch (err) {
8612
+ yield {
8613
+ type: 'finish',
8614
+ reason: { kind: 'error', failure: { message: describeError(err), code: 'session_init_failed' } },
8615
+ }
8616
+ return
8617
+ }
8618
+ }
8619
+ // Same bridge: the session may have been re-created, but the
8620
+ // conversation is still served by this agent's process.
8621
+ claim = await bridge.claimTurn(session)
8622
+ }
8623
+ if (!claim.ok) {
8624
+ // Never roll the provisional lock back here: a failed claim means
8625
+ // ANOTHER prompt holds the slot (or the agent has not settled it), and
8626
+ // that turn owns the conversation's lock. Closing the session would
8627
+ // kill a turn that is actively streaming — the refusal already tells
8628
+ // the user to retry.
8629
+ yield {
8630
+ type: 'finish',
8631
+ reason: {
8632
+ kind: 'error',
8633
+ failure: {
8634
+ message: claim.reason === 'busy'
8635
+ ? 'acp: this session already has a prompt in flight'
8636
+ : 'acp: the agent is still finishing the previous (cancelled) turn; send again in a moment',
8637
+ code: 'turn_in_flight',
8638
+ },
8639
+ },
8640
+ }
8641
+ return
8642
+ }
8643
+ }
8644
+
6563
8645
  // Same-agent model/effort switches ride the SAME ACP session (context
6564
8646
  // stays continuous); switching to another agent lands in a separate one
6565
8647
  // via the `${dshSessionId}:${agentId}` key in getOrCreateDshSession.
@@ -6682,8 +8764,12 @@ export class BridgeManager {
6682
8764
  // next turn has to respect. Settlement reaches the controller, which
6683
8765
  // releases the slot (`endTurn`) whether the segment on air is the first
6684
8766
  // or a continuation one.
6685
- bridge.runPrompt(session, promptText, turnId)
6686
- .then(stopReason => {
8767
+ const promptBlocks: ContentBlock[] = selection.content && selection.content.length > 0
8768
+ ? selection.content as ContentBlock[]
8769
+ : [{ type: 'text', text: promptText }]
8770
+
8771
+ bridge.runPrompt(session, promptBlocks, turnId, turnGen)
8772
+ .then(result => {
6687
8773
  // The reclaim already ended this turn locally, and the close it
6688
8774
  // performed rejected this very request: the late answer is that same
6689
8775
  // turn, not a second one to broadcast.
@@ -6691,9 +8777,79 @@ export class BridgeManager {
6691
8777
  session.reclaimedTurnSeq = undefined
6692
8778
  return
6693
8779
  }
6694
- controller.settle(stopReason === 'cancelled'
8780
+ const responseMeta = codebuddyMetaOf({ _meta: result.meta })
8781
+ // A DETACHED-WORKER HOLD: this turn's `Agent` call handed real work
8782
+ // to a background worker that has not produced its answer yet, yet
8783
+ // the agent answered the prompt normally (CodeBuddy says "已派…" and
8784
+ // ends its own turn). Closing here loses the only answer the turn
8785
+ // will ever produce — the worker's report streams into this same
8786
+ // session later, as a background drain. Hold the turn open until
8787
+ // every dispatch reports and the drain goes quiet (see
8788
+ // `holdForBackgroundDispatch`); the prompt slot is released there.
8789
+ //
8790
+ // The decision comes BEFORE `adoptTurnRequestId`: adopting the id
8791
+ // would clear the updates the drain streamed while this turn's own id
8792
+ // was unknown, and those are exactly the ones the hold must fold.
8793
+ const holdDispatch = controller.dispatchedDetachedWorker
8794
+ && !session.released
8795
+ && !session.closed
8796
+ && result.stopReason !== 'cancelled'
8797
+ if (!holdDispatch && responseMeta?.conversationRequestId !== undefined) {
8798
+ // The response names the conversation turn it queued: an instant
8799
+ // answer that still carries one is a real (if silent) turn, and its
8800
+ // prose can be told apart from a background drain's.
8801
+ controller.adoptTurnRequestId(responseMeta.conversationRequestId)
8802
+ }
8803
+ // An announced drain that has shown NO sign of life for the staleness
8804
+ // window is over, even though nothing said so (CodeBuddy never sends
8805
+ // `session_end`). Keeping the record would make a later instant answer
8806
+ // with no conversation request id look steered, and hold a normal turn
8807
+ // open against a drain that is never coming back. RETIRE the id
8808
+ // first, so a late update or a response naming it is never adopted as
8809
+ // this session's own turn; then forget the record (the notice de-dupe
8810
+ // set with it) so this response settles normally.
8811
+ if (session.ambient !== undefined
8812
+ && Date.now() - session.ambient.lastActivityAt > this.tuning.ambientStaleMs) {
8813
+ retireAmbientId(session, session.ambient.requestId)
8814
+ session.ambient = undefined
8815
+ session.ambientNotices?.clear()
8816
+ }
8817
+ if (holdDispatch) {
8818
+ // The response may name one run while an announcement named
8819
+ // another; the hold accepts both (see `BackgroundHold.acceptedIds`).
8820
+ controller.holdForBackgroundDispatch(
8821
+ result.stopReason,
8822
+ responseMeta?.conversationRequestId ?? responseMeta?.requestId,
8823
+ )
8824
+ return
8825
+ }
8826
+ // An instant answer with NO conversation request id, while the agent
8827
+ // announced a background drain this turn has not spoken into: the
8828
+ // prompt was folded into that drain (CodeBuddy's multitask). The
8829
+ // response is a formality — keep the turn open until the drain speaks.
8830
+ if (session.ambient !== undefined
8831
+ && !session.released
8832
+ && !session.closed
8833
+ && result.stopReason !== 'cancelled'
8834
+ && responseMeta?.conversationRequestId === undefined
8835
+ && !controller.hasAgentProse) {
8836
+ controller.awaitAmbient(session.ambient.requestId, result.stopReason)
8837
+ return
8838
+ }
8839
+ // No ambient record to wait on, but a drain this session stopped
8840
+ // waiting on was retired moments ago: the run may have taken this
8841
+ // prompt WITHOUT re-announcing itself. The turn cannot be held open
8842
+ // (nothing names the drain), but a silent close must say the output
8843
+ // may be in the transcript instead of blaming the agent.
8844
+ if (session.ambient === undefined
8845
+ && result.stopReason !== 'cancelled'
8846
+ && responseMeta?.conversationRequestId === undefined
8847
+ && hasRecentRetiredAmbient(session, Math.max(this.tuning.ambientQuietMs * 2, RETIRED_AMBIENT_RECENT_MIN_MS))) {
8848
+ controller.noteRecentRetiredDrain()
8849
+ }
8850
+ controller.settle(result.stopReason === 'cancelled'
6695
8851
  ? { kind: 'cancelled' }
6696
- : { kind: 'stop', stopReason })
8852
+ : { kind: 'stop', stopReason: result.stopReason })
6697
8853
  })
6698
8854
  .catch((err: unknown) => {
6699
8855
  if (session.reclaimedTurnSeq === turnGen) {