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/lib/bridge.js CHANGED
@@ -12,8 +12,9 @@ import { delimiter, dirname, join } from 'node:path';
12
12
  import { Writable, Readable } from 'node:stream';
13
13
  import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
14
14
  import { client, methods, ndJsonStream, PROTOCOL_VERSION, RequestError, } from '@agentclientprotocol/sdk';
15
- import { AcpError, EventHub, PermissionLedger, Transcript, acpToolCallDelta, acpToolCallFromUpdate, buildAdapterModelId, childEnv, isAcpToolContinuation, isUnderPath, mergeCatalogShape, parseCatalogOptions, usableConfigOptions, permissionRequestFrame, pickAutoOption, quoteCommandToken, randomUUID, resolveFsPath, resolveSessionCwd, selectForwardPrompt, acpPresetStatusFromRoster, splitAdapterModelId, updateContentToText, describeUnhandledUpdate, BOOKKEEPING_UPDATES, validateAgentDef, validateTagHalo, validateApprovalDeadlines, validatePermissionTimeoutMode, normalizeApprovalWindow, } from "./impl.js";
15
+ import { AcpError, EventHub, PermissionLedger, Transcript, acpToolCallDelta, acpToolCallFromUpdate, buildAdapterModelId, childEnv, hasUnambiguousModelSelector, isAcpToolContinuation, isUnderPath, mergeCatalogShape, parseCatalogOptions, usableConfigOptions, permissionRequestFrame, pickAutoOption, quoteCommandToken, randomUUID, resolveFsPath, resolveSessionCwd, selectForwardPrompt, isAbsolutePath, acpPresetStatusFromRoster, splitAdapterModelId, updateContentToText, describeUnhandledUpdate, BOOKKEEPING_UPDATES, validateAgentDef, validateTagHalo, validateApprovalDeadlines, validatePermissionTimeoutMode, normalizeApprovalWindow, } from "./impl.js";
16
16
  import { DEFAULT_IDLE_TIMEOUT_MS, DEFAULT_TAG_HALO, IDLE_RELEASE_MS } from "./protocol.js";
17
+ import { OUTPUT_CHARS_PER_TOKEN, TurnTpsTracker } from "./tps.js";
17
18
  /**
18
19
  * Version of this installed package, read from the shipped `package.json` —
19
20
  * one level above both `src/` at test time and `lib/` at runtime. The settings
@@ -106,6 +107,11 @@ const DEFAULT_TUNING = {
106
107
  turnStallTickMs: 30_000,
107
108
  idleTimeoutMs: DEFAULT_IDLE_TIMEOUT_MS,
108
109
  probeIdleTimeoutMs: 300_000,
110
+ ambientQuietMs: 10_000,
111
+ ambientMaxMs: 120_000,
112
+ backgroundTaskMaxMs: 600_000,
113
+ backgroundParkedGraceMs: 30_000,
114
+ ambientStaleMs: 300_000,
109
115
  };
110
116
  export class AgentBridge {
111
117
  def;
@@ -384,11 +390,16 @@ export class AgentBridge {
384
390
  * same session — forbidden by the protocol and rejected (or worse, mishandled)
385
391
  * by the agent. Waiting turns a protocol error into a short delay.
386
392
  *
393
+ * The gate is {@link LiveSession.promptInFlight} + the settle promise, NOT
394
+ * {@link LiveSession.running}: an ambient turn keeps `running` true for the
395
+ * whole drain while its prompt slot is already free, so `running` would
396
+ * refuse every message the user sends during a background task.
397
+ *
387
398
  * @param session - the session asking for the slot.
388
399
  * @returns whether the slot was claimed, and why not when it was not.
389
400
  */
390
401
  async claimTurn(session) {
391
- if (session.running)
402
+ if (this.promptSlotHeld(session))
392
403
  return { ok: false, reason: 'busy' };
393
404
  const pending = session.settled;
394
405
  if (pending === undefined)
@@ -411,13 +422,20 @@ export class AgentBridge {
411
422
  // so report it honestly instead of handing the agent two prompts at once.
412
423
  if (session.settled !== undefined)
413
424
  return { ok: false, reason: 'unsettled' };
414
- if (session.running)
425
+ // Re-checked after the wait: another turn may have claimed the slot while
426
+ // this one was waiting on the gate.
427
+ if (this.promptSlotHeld(session))
415
428
  return { ok: false, reason: 'busy' };
416
429
  return { ok: true };
417
430
  }
431
+ /** Whether a turn currently holds the agent prompt slot (see `promptInFlight`). */
432
+ promptSlotHeld(session) {
433
+ return session.promptInFlight === true;
434
+ }
418
435
  /**
419
- * Mark the agent-side turn as in flight and hand back the settler its stream
420
- * owner must call once the agent is actually done with it.
436
+ * Mark the agent-side turn as in flight — claim the agent prompt slot and
437
+ * open the settle gate — and hand back the settler its stream owner must
438
+ * call once the agent is actually done with it.
421
439
  *
422
440
  * @param session - the session whose turn is starting.
423
441
  * @returns the settler (idempotent; also used by connection loss).
@@ -427,11 +445,16 @@ export class AgentBridge {
427
445
  const settled = new Promise(resolve => { settle = resolve; });
428
446
  session.settled = settled;
429
447
  session.settledResolve = settle;
448
+ session.promptInFlight = true;
430
449
  this.touch();
431
450
  return () => {
451
+ // Identity-guarded: only THIS turn's settler may release the slot. A
452
+ // superseded turn's late settler must not clear the flag a NEWER turn
453
+ // has already claimed (its settle gate was replaced with a new promise).
432
454
  if (session.settled === settled) {
433
455
  session.settled = undefined;
434
456
  session.settledResolve = undefined;
457
+ session.promptInFlight = false;
435
458
  }
436
459
  settle();
437
460
  // The turn is over: a fresh window starts here rather than at the prompt
@@ -442,12 +465,14 @@ export class AgentBridge {
442
465
  /**
443
466
  * Open a session's settle gate without owning its turn stream: the turn's
444
467
  * stream is gone (a resume, a release) but the next prompt still waits on
445
- * this promise, and nothing is left that could ever resolve it.
468
+ * this promise, and nothing is left that could ever resolve it. The prompt
469
+ * slot itself goes with it: whatever held it is gone too.
446
470
  */
447
471
  openSettleGate(session) {
448
472
  const settle = session.settledResolve;
449
473
  session.settled = undefined;
450
474
  session.settledResolve = undefined;
475
+ session.promptInFlight = false;
451
476
  settle?.();
452
477
  }
453
478
  view() {
@@ -939,28 +964,69 @@ export class AgentBridge {
939
964
  * {@link BridgeTuning.staleCancelRetryDelayMs}, out-waiting the cleanup
940
965
  * window, bounded by {@link BridgeTuning.staleCancelMaxRetries}; a genuinely
941
966
  * requested cancel (`cancelRequestedAtTurn` still names this generation)
942
- * returns as-is.
967
+ * returns as-is — checked BOTH before the delay and after it, so a second
968
+ * stop click inside the retry window does not resend the prompt the user
969
+ * just stopped.
970
+ *
971
+ * `turnGen` is the {@link LiveSession.turnSeq} this prompt was sent for. A
972
+ * stale-cancel resend is only allowed while that generation still owns the
973
+ * session: once a successor turn began (the cancel fallback finalized this
974
+ * one and the user moved on), resending would race a second `session/prompt`
975
+ * into the successor's turn. The late answer is returned as-is instead.
976
+ *
977
+ * The RESPONSE's `_meta` travels with the stop reason: it is the only place
978
+ * an agent says whether the prompt actually queued a conversation turn (see
979
+ * `CodebuddyMeta.conversationRequestId`), which decides whether an instant
980
+ * answer was the turn ending or a fold into a background drain.
943
981
  */
944
- async runPrompt(session, text, turnId) {
982
+ async runPrompt(session, prompt, turnId, turnGen) {
945
983
  const conn = this.conn;
946
984
  if (conn === undefined || session.closed || session.released)
947
985
  throw new AcpError('session_closed', 'acp: session is closed');
948
986
  this.touch();
987
+ const blocks = typeof prompt === 'string'
988
+ ? [{ type: 'text', text: prompt }]
989
+ : prompt;
990
+ if (blocks.length === 0) {
991
+ throw new AcpError('empty_prompt', 'acp: prompt cannot be empty');
992
+ }
993
+ const hasImages = blocks.some(b => b.type === 'image');
994
+ if (hasImages && this.initResult?.agentCapabilities?.promptCapabilities?.image !== true) {
995
+ throw new AcpError('image_unsupported', `acp: agent "${this.def.id}" does not support image input`);
996
+ }
949
997
  for (let attempt = 0;; attempt += 1) {
950
998
  // The response is activity too: the idle window must start at the turn's
951
999
  // END, not at the prompt that may have started a long time ago.
952
1000
  const result = await conn.agent.request(methods.agent.session.prompt, {
953
1001
  sessionId: session.acpSessionId,
954
- prompt: [{ type: 'text', text }],
1002
+ prompt: blocks,
955
1003
  }).finally(() => { this.touch(); });
1004
+ const answered = () => ({
1005
+ stopReason: result.stopReason,
1006
+ ...(result._meta !== undefined && result._meta !== null ? { meta: result._meta } : {}),
1007
+ });
956
1008
  if (result.stopReason !== 'cancelled' || session.cancelRequestedAtTurn === session.turnSeq) {
957
- return result.stopReason;
1009
+ return answered();
958
1010
  }
1011
+ // The turn this answer belongs to no longer owns the session (a cancel
1012
+ // fallback finalized it and a successor began): resending would fire a
1013
+ // second `session/prompt` into the successor's turn.
1014
+ if (session.turnSeq !== turnGen)
1015
+ return answered();
959
1016
  if (attempt >= this.staleCancelMaxRetries)
960
- return result.stopReason;
1017
+ return answered();
961
1018
  await new Promise(resolve => setTimeout(resolve, this.staleCancelRetryDelayMs));
962
1019
  if (session.closed || this.conn !== conn)
963
1020
  throw new AcpError('session_closed', 'acp: session is closed');
1021
+ // Re-check after the delay: the successor may have started while waiting.
1022
+ if (session.turnSeq !== turnGen)
1023
+ return answered();
1024
+ // ...and a SECOND stop during the delay names this same generation. The
1025
+ // user already said stop, so the stale-cancel answer is not a swallow to
1026
+ // out-wait anymore: resending would make the agent run the prompt the
1027
+ // user just stopped (the double-click case). Return as-is.
1028
+ if (session.cancelRequestedAtTurn === session.turnSeq)
1029
+ return answered();
964
1030
  this.touch();
965
1031
  }
966
1032
  }
@@ -1006,10 +1072,19 @@ export class AgentBridge {
1006
1072
  }
1007
1073
  /**
1008
1074
  * Force the local end of one turn whose agent never acknowledged it: clear
1009
- * `running`, open the settle gate, and broadcast the turn end with `turnId:
1010
- * ''` (there is no agent response to attribute it to). Shared by the cancel
1075
+ * `running`, open the settle gate, and end the turn. Shared by the cancel
1011
1076
  * fallback and the stall watchdog.
1012
1077
  *
1078
+ * A live controller owns the turn's streaming face — its segment queue, its
1079
+ * ambient clocks and its `turn/end` id. Ending the turn behind its back
1080
+ * would leave the driver step parked on an open queue, and the controller's
1081
+ * own timers would later broadcast a SECOND `turn/end` for a turn already
1082
+ * closed. So when the session still points at a current controller, the
1083
+ * force goes THROUGH it (`forceFinalize`), which runs the same closing work
1084
+ * as a normal settle exactly once. Only a turn with no live controller (an
1085
+ * `/acp`-RPC turn, or one already retired) falls back to the local broadcast
1086
+ * with `turnId: ''` — there is no stream whose id could be named.
1087
+ *
1013
1088
  * Generation-guarded: a turn that already ended — or that a later turn
1014
1089
  * superseded — is not this call's to finalize. Returns whether it acted; a
1015
1090
  * `false` means the caller was late and must leave the session alone.
@@ -1017,6 +1092,11 @@ export class AgentBridge {
1017
1092
  finalizeTurn(session, turnGen, message) {
1018
1093
  if (session.closed || session.turnSeq !== turnGen)
1019
1094
  return false;
1095
+ const controller = session.turnController;
1096
+ if (controller !== undefined && !controller.stale) {
1097
+ controller.forceFinalize({ kind: 'error', message });
1098
+ return true;
1099
+ }
1020
1100
  // Nothing is in flight anymore (the turn settled on its own): nothing to
1021
1101
  // finalize. A still-pending settle gate counts as in flight even though
1022
1102
  // the stream is gone — that gate is what the next prompt waits on.
@@ -1085,10 +1165,12 @@ export class AgentBridge {
1085
1165
  session.closed = false;
1086
1166
  session.released = false;
1087
1167
  // A resume is a fresh agent-side state: nothing from the dead process's
1088
- // turn survives it, including the settle gate and the stall markers.
1168
+ // turn survives it, including the settle gate and the stall markers. A
1169
+ // background drain belonged to that process too.
1089
1170
  session.orphanPromptTurnSeq = undefined;
1090
1171
  session.reclaimedTurnSeq = undefined;
1091
1172
  session.activeToolCalls.clear();
1173
+ clearAmbientState(session);
1092
1174
  session.lastAgentActivityAt = Date.now();
1093
1175
  this.openSettleGate(session);
1094
1176
  // Route replay updates into the fresh transcript while the load request
@@ -1150,8 +1232,10 @@ export class AgentBridge {
1150
1232
  // would make the next prompt wait for an answer that can no longer come.
1151
1233
  this.openSettleGate(session);
1152
1234
  // No call from the dead process can still be running: drop the tool set
1153
- // so a resumed turn starts on the plain window.
1235
+ // so a resumed turn starts on the plain window. A background drain died
1236
+ // with it, and a stale ambient would misclassify the NEXT prompt.
1154
1237
  session.activeToolCalls.clear();
1238
+ clearAmbientState(session);
1155
1239
  session.released = true;
1156
1240
  this.settlePermissions(session);
1157
1241
  this.released.set(session.sessionId, session);
@@ -1259,6 +1343,12 @@ export class AgentBridge {
1259
1343
  if (session.closed)
1260
1344
  return;
1261
1345
  session.closed = true;
1346
+ // The session is going away: whatever background drain it announced ends
1347
+ // with it (a re-created session must not inherit a stale ambient).
1348
+ clearAmbientState(session);
1349
+ // ...and so does its prompt slot: nothing may keep rejecting a new prompt
1350
+ // for a record that is being deleted.
1351
+ session.promptInFlight = false;
1262
1352
  // Closing a session is activity of its own: restart the idle window here
1263
1353
  // rather than at the prompt that opened it.
1264
1354
  this.touch();
@@ -1331,6 +1421,7 @@ export class AgentBridge {
1331
1421
  this.settlePermissions(session);
1332
1422
  this.openSettleGate(session);
1333
1423
  session.activeToolCalls.clear();
1424
+ clearAmbientState(session);
1334
1425
  this.sessions.delete(session.sessionId);
1335
1426
  this.acpIndex.delete(session.acpSessionId);
1336
1427
  this.hub.broadcast({ type: 'session/closed', sessionId: session.sessionId, reason });
@@ -1602,6 +1693,61 @@ export class AgentBridge {
1602
1693
  // tool, which earns the wider watchdog window.
1603
1694
  session.lastAgentActivityAt = Date.now();
1604
1695
  const kind = params.update.sessionUpdate;
1696
+ const meta = codebuddyMetaOf(params.update);
1697
+ // A RETIRED drain that speaks again is alive, not retired. Un-retire it
1698
+ // BEFORE this update is classified and rebuild the ambient record: the run
1699
+ // may never re-announce itself, yet the next prompt folded into it must
1700
+ // still be recognised as steered and receive the drain's text. A
1701
+ // re-announcement below does the same for its id.
1702
+ if (meta?.requestId !== undefined && isRetiredAmbientId(session, meta.requestId)) {
1703
+ reviveAmbientId(session, meta.requestId);
1704
+ session.ambient = {
1705
+ requestId: meta.requestId,
1706
+ startedAt: session.ambient?.startedAt ?? Date.now(),
1707
+ lastActivityAt: Date.now(),
1708
+ sawOutput: session.ambient?.sawOutput ?? false,
1709
+ };
1710
+ }
1711
+ if (meta?.unsolicitedTurn !== undefined) {
1712
+ // The agent started a background turn on its own: remember it so a
1713
+ // prompt the agent folds into it (answered instantly, no conversation
1714
+ // request id) can be kept open until the drain speaks. Announced once per
1715
+ // request id — a repeated announcement is the same run, not news.
1716
+ const run = meta.unsolicitedTurn;
1717
+ // A re-announcement also revives a drain whose wait already closed: the
1718
+ // same run is alive, and the notice de-dupe set keeps it silent.
1719
+ reviveAmbientId(session, run.requestId);
1720
+ session.ambient = { requestId: run.requestId, startedAt: Date.now(), lastActivityAt: Date.now(), sawOutput: false };
1721
+ const announced = session.ambientNotices ?? new Set();
1722
+ session.ambientNotices = announced;
1723
+ if (!announced.has(run.requestId)) {
1724
+ announced.add(run.requestId);
1725
+ // The de-dupe set outlives individual turns (a hard ceiling keeps the
1726
+ // drain record), so it is bounded: evict the oldest ids first.
1727
+ while (announced.size > AMBIENT_NOTICE_MAX) {
1728
+ const oldest = announced.values().next().value;
1729
+ if (oldest === undefined)
1730
+ break;
1731
+ announced.delete(oldest);
1732
+ }
1733
+ this.hub.broadcast({
1734
+ type: 'agent/notice',
1735
+ agentId: session.agentId,
1736
+ level: 'info',
1737
+ text: `acp: agent "${this.def.id}" started a background task turn (${run.reason ?? 'unsolicited'}); its output will appear in the ACP transcript view, or in the turn that was waiting for it.`,
1738
+ });
1739
+ }
1740
+ }
1741
+ if (session.ambient !== undefined
1742
+ && meta?.requestId === session.ambient.requestId) {
1743
+ // The run is alive: refresh the staleness clock on ANY update it owns,
1744
+ // not just prose (a tool-heavy stretch is still the drain working).
1745
+ session.ambient.lastActivityAt = Date.now();
1746
+ if ((kind === 'agent_message_chunk' || kind === 'agent_thought_chunk')
1747
+ && chunkTextOf(params.update.content) !== '') {
1748
+ session.ambient.sawOutput = true;
1749
+ }
1750
+ }
1605
1751
  if (kind === 'tool_call' || kind === 'tool_call_update') {
1606
1752
  const { toolCallId, status } = params.update;
1607
1753
  if (status === 'completed' || status === 'failed')
@@ -1662,6 +1808,129 @@ export class AgentBridge {
1662
1808
  }
1663
1809
  }
1664
1810
  }
1811
+ /** Read the CodeBuddy `_meta` keys off an update or a `{ _meta }` wrapper. */
1812
+ function codebuddyMetaOf(source) {
1813
+ if (typeof source !== 'object' || source === null)
1814
+ return undefined;
1815
+ const row = source;
1816
+ const rawMeta = row._meta;
1817
+ if (typeof rawMeta !== 'object' || rawMeta === null)
1818
+ return undefined;
1819
+ const meta = rawMeta;
1820
+ const out = {};
1821
+ const requestId = meta['codebuddy.ai/requestId'];
1822
+ if (typeof requestId === 'string' && requestId !== '')
1823
+ out.requestId = requestId;
1824
+ const conversationRequestId = meta['codebuddy.ai/conversationRequestId'];
1825
+ if (typeof conversationRequestId === 'string' && conversationRequestId !== '') {
1826
+ out.conversationRequestId = conversationRequestId;
1827
+ }
1828
+ const toolName = meta['codebuddy.ai/toolName'];
1829
+ if (typeof toolName === 'string' && toolName !== '')
1830
+ out.toolName = toolName;
1831
+ const parentToolCallId = meta['codebuddy.ai/parentToolCallId'];
1832
+ if (typeof parentToolCallId === 'string' && parentToolCallId !== '')
1833
+ out.parentToolCallId = parentToolCallId;
1834
+ // Booleans, read as booleans only: a string "true" is not the declared shape
1835
+ // and guessing from it would make another agent's unrelated key look detached.
1836
+ if (meta['codebuddy.ai/isBackground'] === true)
1837
+ out.isBackground = true;
1838
+ if (meta['codebuddy.ai/isSubagent'] === true)
1839
+ out.isSubagent = true;
1840
+ const unsolicited = meta['codebuddy.ai/unsolicitedTurn'];
1841
+ if (typeof unsolicited === 'object' && unsolicited !== null) {
1842
+ const run = unsolicited;
1843
+ if (typeof run.requestId === 'string' && run.requestId !== '') {
1844
+ out.unsolicitedTurn = {
1845
+ requestId: run.requestId,
1846
+ ...(typeof run.reason === 'string' && run.reason !== '' ? { reason: run.reason } : {}),
1847
+ };
1848
+ }
1849
+ }
1850
+ const phase = meta['codebuddy.ai/agentPhase'];
1851
+ if (typeof phase === 'object' && phase !== null) {
1852
+ const tick = phase;
1853
+ if (typeof tick.phase === 'string' && tick.phase !== '')
1854
+ out.agentPhase = { phase: tick.phase };
1855
+ }
1856
+ return Object.keys(out).length === 0 ? undefined : out;
1857
+ }
1858
+ /**
1859
+ * Remember a background drain this session stopped waiting on. A quiet close
1860
+ * drops the drain record (so an ordinary next turn is not held open for it),
1861
+ * but the run may revive afterwards; keeping its id lets every later update be
1862
+ * recognized as the drain's (`isRetiredAmbientId`) instead of being adopted as
1863
+ * the next turn's own. Bounded, oldest first — an id this far back names a run
1864
+ * no late update can plausibly still carry. Re-retiring an id refreshes its
1865
+ * timestamp and moves it to the end.
1866
+ */
1867
+ function retireAmbientId(session, requestId) {
1868
+ const retired = session.retiredAmbients ?? [];
1869
+ const existing = retired.findIndex(entry => entry.requestId === requestId);
1870
+ if (existing >= 0)
1871
+ retired.splice(existing, 1);
1872
+ retired.push({ requestId, retiredAt: Date.now() });
1873
+ while (retired.length > RETIRED_AMBIENT_MAX)
1874
+ retired.shift();
1875
+ session.retiredAmbients = retired;
1876
+ }
1877
+ /** Whether this request id names a drain the session already stopped waiting on. */
1878
+ function isRetiredAmbientId(session, requestId) {
1879
+ return requestId !== undefined
1880
+ && session.retiredAmbients?.some(entry => entry.requestId === requestId) === true;
1881
+ }
1882
+ /**
1883
+ * Whether the session retired a drain recently enough that a prompt answered
1884
+ * with no ambient record and no conversation id may have been folded into it.
1885
+ * A quiet close is only evidence WE stopped waiting, so the next instant
1886
+ * answer is ambiguous; past this horizon the drain is treated as gone and the
1887
+ * ordinary zero-output warning applies.
1888
+ */
1889
+ function hasRecentRetiredAmbient(session, withinMs) {
1890
+ const retired = session.retiredAmbients;
1891
+ if (retired === undefined || retired.length === 0)
1892
+ return false;
1893
+ const cutoff = Date.now() - withinMs;
1894
+ return retired.some(entry => entry.retiredAt >= cutoff);
1895
+ }
1896
+ /**
1897
+ * The retired drain spoke again (an update or a re-announcement carried its
1898
+ * id): it is alive, not retired. The caller rebuilds {@link LiveSession.ambient}
1899
+ * so the next prompt folded into it is recognised as steered and its text can
1900
+ * reach the user's turn; only while the id stays retired does its text remain
1901
+ * transcript-only.
1902
+ */
1903
+ function reviveAmbientId(session, requestId) {
1904
+ const retired = session.retiredAmbients;
1905
+ if (retired === undefined)
1906
+ return;
1907
+ const index = retired.findIndex(entry => entry.requestId === requestId);
1908
+ if (index < 0)
1909
+ return;
1910
+ retired.splice(index, 1);
1911
+ if (retired.length === 0)
1912
+ session.retiredAmbients = undefined;
1913
+ }
1914
+ /**
1915
+ * Forget every background-drain trace a session carried. Called at each
1916
+ * lifecycle boundary (close / release / resume / force reset): a fresh
1917
+ * agent-side state neither inherits a stale drain nor the ids of drains that
1918
+ * belonged to the old one.
1919
+ */
1920
+ function clearAmbientState(session) {
1921
+ session.ambient = undefined;
1922
+ session.ambientNotices?.clear();
1923
+ session.retiredAmbients = undefined;
1924
+ }
1925
+ /**
1926
+ * The closing line of a turn the agent deferred to its OWN background run: the
1927
+ * answer exists, but in the ACP transcript view rather than in this bubble.
1928
+ * Shared by the ambient wait and the detached-worker hold, so both say the
1929
+ * same thing in the same words.
1930
+ */
1931
+ function backgroundDeferralLine(agentId) {
1932
+ return `\n[dsh-ntr] agent "${agentId}" deferred this turn to a background task run; its output is in the ACP transcript view. Send again after the background run finishes.\n`;
1933
+ }
1665
1934
  /** Human-facing identity of an ACP tool call (the approval headline). */
1666
1935
  /**
1667
1936
  * Human-facing name of the tool an ask is about, for the chat line and the
@@ -1753,6 +2022,7 @@ function snapshot(session) {
1753
2022
  ...(session.availableCommands !== undefined ? { availableCommands: session.availableCommands } : {}),
1754
2023
  ...(session.plan !== undefined ? { plan: session.plan } : {}),
1755
2024
  ...(session.usage !== undefined ? { usage: session.usage } : {}),
2025
+ ...(session.burst !== undefined ? { burst: session.burst } : {}),
1756
2026
  running: session.running,
1757
2027
  closed: session.closed,
1758
2028
  ...(session.released ? { released: true } : {}),
@@ -1789,6 +2059,7 @@ function persistedChatSession(session) {
1789
2059
  ...(session.reasoningEffort === undefined ? {} : { reasoningEffort: session.reasoningEffort }),
1790
2060
  ...(session.modes === undefined ? {} : { modes: session.modes }),
1791
2061
  ...(session.usage === undefined ? {} : { usage: session.usage }),
2062
+ ...(session.burst === undefined ? {} : { burst: session.burst }),
1792
2063
  ...(session.hasHistory === true || session.transcript.since(0).length > 0 ? { hasHistory: true } : {}),
1793
2064
  };
1794
2065
  }
@@ -1806,6 +2077,14 @@ function isPersistedUsage(value) {
1806
2077
  const usage = value;
1807
2078
  return typeof usage.used === 'number' && typeof usage.size === 'number';
1808
2079
  }
2080
+ /** Tolerant shape check for a `burst` aggregate read back from disk. */
2081
+ function isPersistedBurst(value) {
2082
+ if (value === null || typeof value !== 'object')
2083
+ return false;
2084
+ const burst = value;
2085
+ return typeof burst.outputTokens === 'number' && burst.outputTokens >= 0
2086
+ && typeof burst.burstMs === 'number' && burst.burstMs >= 0;
2087
+ }
1809
2088
  /**
1810
2089
  * The DSH chat session's OWN workspace, when one is resolvable: the directory
1811
2090
  * the user selected in the DSH sidebar, which the host reports back as the
@@ -2484,8 +2763,40 @@ export function releaseAllToolOutputWaiters() {
2484
2763
  for (const waiter of [...set])
2485
2764
  waiter(undefined);
2486
2765
  }
2487
- /** ACP `usage_update` is context occupancy, not request tokens; host usage is a 4-chars/token OUTPUT estimate (tool args excluded). */
2488
- const OUTPUT_CHARS_PER_TOKEN = 4;
2766
+ /** How often a running turn's live burst aggregate is pushed to the browser. */
2767
+ const TPS_PUSH_INTERVAL_MS = 1000;
2768
+ /**
2769
+ * Bounds on the updates withheld while a turn's own request id is still
2770
+ * unknown (see `TurnStreamController.bufferAmbientUpdate`). The buffer exists
2771
+ * only for the response window of one prompt, but a chatty drain could stream
2772
+ * for a while inside it; over the bound the OLDEST entries are dropped — the
2773
+ * transcript still has every one of them.
2774
+ */
2775
+ const PENDING_AMBIENT_MAX_ENTRIES = 200;
2776
+ const PENDING_AMBIENT_MAX_CHARS = 64 * 1024;
2777
+ /**
2778
+ * Bound on {@link LiveSession.ambientNotices}. The set now outlives the turn
2779
+ * that announced a drain (a hard ceiling keeps the record, and a repeated
2780
+ * announcement of the same run must stay silent), so it needs a ceiling of its
2781
+ * own for a chatty agent. Oldest ids are evicted first; an id that far back is
2782
+ * a run nobody is waiting on anymore.
2783
+ */
2784
+ const AMBIENT_NOTICE_MAX = 64;
2785
+ /**
2786
+ * Bound on {@link LiveSession.retiredAmbients}. The ids only exist to catch a
2787
+ * drain that speaks again after its wait closed, and the timestamp lets the
2788
+ * next silent close recognise a recent fold — a short-horizon concern, so a
2789
+ * small ring is enough; the oldest is evicted first.
2790
+ */
2791
+ const RETIRED_AMBIENT_MAX = 32;
2792
+ /**
2793
+ * Floor for the "recently retired" lookback the zero-output fallback uses
2794
+ * (twice `ambientQuietMs`). Only test-sized quiet windows fall below it; a
2795
+ * production window (10s) always wins. Without the floor a 250ms quiet window
2796
+ * would leave a 500ms lookback, too tight to survive a loaded CI scheduler
2797
+ * between one turn ending and the next prompt's response.
2798
+ */
2799
+ const RETIRED_AMBIENT_RECENT_MIN_MS = 1_000;
2489
2800
  /**
2490
2801
  * The state of ONE ACP prompt, shared across every driver step of the turn.
2491
2802
  *
@@ -2505,13 +2816,111 @@ class TurnStreamController {
2505
2816
  rollbackFirstTurn;
2506
2817
  segment;
2507
2818
  settled = false;
2819
+ /** Whether the turn's closing work already ran (see `closeTurn`). */
2820
+ closedTurn = false;
2508
2821
  disposed = false;
2509
2822
  seenCalls = new Set();
2510
2823
  announcedKinds = new Map();
2511
2824
  /** Narrative lines (permission asks) that arrived while no segment was open. */
2512
2825
  pendingProse = [];
2513
2826
  sawVisibleBlock = false;
2827
+ /**
2828
+ * Whether THIS turn streamed its own prose (text or reasoning), i.e. an
2829
+ * answer that is not the background drain's. Tool cards do not count — a
2830
+ * drain that only ran tools delivered no words to the conversation.
2831
+ */
2832
+ sawAgentProse = false;
2833
+ /**
2834
+ * Whether any `agent_message_chunk` text reached the plane. Reasoning does
2835
+ * not count: a steer whose drain only thought is still a turn with no answer
2836
+ * text, and must say so when it closes.
2837
+ */
2838
+ sawVisibleText = false;
2839
+ /**
2840
+ * The CodeBuddy request id this turn has adopted as its own (from the
2841
+ * prompt RESPONSE's `conversationRequestId`, or from the first update that
2842
+ * named one). Used to keep a background drain's prose out of the user's
2843
+ * answer; `undefined` means "unknown" and nothing is split (fail-open).
2844
+ */
2845
+ turnRequestId;
2846
+ /**
2847
+ * Updates the session's background drain already streamed while this turn's
2848
+ * OWN request id was still unknown. They are NOT folded on arrival: a drain
2849
+ * tick that lands before the prompt response could otherwise teach the turn
2850
+ * the drain's id (`noteTurnRequestId`) and mix the drain's text into the
2851
+ * user's answer. Withheld here (cursor already advanced — the transcript
2852
+ * keeps a copy) until the response decides what this turn is:
2853
+ * - a normal turn (response carries a conversation id) discards them — they
2854
+ * were never this turn's answer;
2855
+ * - a steered turn flushes them into the segment, in order, once
2856
+ * `awaitAmbient` marks the segment ambient (tool cards suppressed).
2857
+ */
2858
+ pendingAmbientUpdates = [];
2859
+ pendingAmbientChars = 0;
2860
+ /**
2861
+ * Whether the bounded buffer dropped at least one update. The flush then
2862
+ * says so in the conversation instead of silently losing the front of the
2863
+ * drain's output (constraint 29: ACP payloads are never dropped silently).
2864
+ */
2865
+ pendingAmbientTruncated = false;
2866
+ /** The ambient wait in progress, if the agent folded this prompt into a drain. */
2867
+ awaitingAmbient;
2868
+ /**
2869
+ * The prompt response named no conversation turn while a drain this session
2870
+ * retired RECENTLY may still be running without re-announcing. The turn still
2871
+ * settles at once (a drain record that no update revived is not waited on),
2872
+ * but its zero-output report says the turn may have been folded into that run
2873
+ * instead of blaming the agent and suggesting a resend.
2874
+ */
2875
+ recentRetiredDrain = false;
2876
+ /** The outcome of a settle whose closing work is waiting one macrotask (see `settle`). */
2877
+ pendingOutcome;
2878
+ closeScheduled = false;
2514
2879
  cursorSeq;
2880
+ /**
2881
+ * Burst tracker for this turn, fed from the LIVE hub frames only. Segment
2882
+ * replay must not touch it: every event is already counted once when it
2883
+ * arrived, and replay would double the numerator and the gaps.
2884
+ */
2885
+ tps = new TurnTpsTracker();
2886
+ tpsSeq;
2887
+ lastTpsPushAt = 0;
2888
+ /**
2889
+ * The ambient quiet/ceiling windows, read once from the manager: they are
2890
+ * deployment tuning, not per-tick facts, and small test values must be stable
2891
+ * for the whole turn (see {@link BridgeTuning.ambientQuietMs} for the
2892
+ * measured CodeBuddy tick cadence these defaults come from).
2893
+ */
2894
+ ambientTuning;
2895
+ /**
2896
+ * The detached workers this turn dispatched (see {@link BackgroundDispatch}),
2897
+ * by `toolCallId`, recorded from the wire while the turn is still streaming —
2898
+ * typically in the first step, before the prompt response lands. A turn may
2899
+ * dispatch several, and the hold waits for ALL of them.
2900
+ */
2901
+ dispatches = new Map();
2902
+ /** The detached-worker hold in progress, once a segment carries it. */
2903
+ backgroundHold;
2904
+ /**
2905
+ * A hold parked because the prompt response landed while NO driver segment
2906
+ * was open (the host is between steps, about to execute the Agent card). It
2907
+ * begins when the next `startSegment` opens — or closes after a bounded
2908
+ * grace so a host that never opens one does not hang the turn.
2909
+ */
2910
+ pendingBackgroundHold;
2911
+ /**
2912
+ * The hold's own windows, read once at construction like the ambient pair:
2913
+ * `taskMaxMs` is the worker phase's ceiling, `graceMs` the parked hold's
2914
+ * bounded wait for a driver step (see {@link BridgeTuning.backgroundTaskMaxMs}
2915
+ * and {@link BridgeTuning.backgroundParkedGraceMs}).
2916
+ */
2917
+ backgroundTuning;
2918
+ /**
2919
+ * When this controller was built. Only a drain announced AFTER this moment
2920
+ * may be adopted by the hold: an `ambient` record that predates the turn is
2921
+ * some earlier run's, and folding its text would mix two answers.
2922
+ */
2923
+ constructedAt;
2515
2924
  constructor(manager, hub, session, turnId,
2516
2925
  /** Releases the agent-side turn slot (the `beginTurn` settler; idempotent). */
2517
2926
  endTurn, rollbackFirstTurn) {
@@ -2523,6 +2932,10 @@ class TurnStreamController {
2523
2932
  this.rollbackFirstTurn = rollbackFirstTurn;
2524
2933
  this.turnSeqAtStart = session.turnSeq;
2525
2934
  this.cursorSeq = session.transcript.head;
2935
+ this.tpsSeq = session.transcript.head;
2936
+ this.constructedAt = Date.now();
2937
+ this.ambientTuning = manager.ambientTuning;
2938
+ this.backgroundTuning = manager.backgroundTuning;
2526
2939
  this.unsubscribe = hub.subscribe(event => this.onEvent(event));
2527
2940
  }
2528
2941
  unsubscribe;
@@ -2552,6 +2965,12 @@ class TurnStreamController {
2552
2965
  * consuming it, so the next segment replays it again.
2553
2966
  */
2554
2967
  startSegment() {
2968
+ // A step opening on an already settled turn (the response landed a tick
2969
+ // ago and the tail drain is still scheduled) closes that turn FIRST: the
2970
+ // step must read a settled controller and the session must not stay
2971
+ // `running` across the macrotask boundary — the host's next prompt would be
2972
+ // refused as busy for no reason.
2973
+ this.flushScheduledClose();
2555
2974
  // A step opening is the HOST handing the turn back to the agent — this
2556
2975
  // runs for the first step and for each continuation after the host
2557
2976
  // executed a card. The watchdog measures the stretch the AGENT holds the
@@ -2567,6 +2986,15 @@ class TurnStreamController {
2567
2986
  outputChars: 0,
2568
2987
  activeToolCall: undefined,
2569
2988
  hasCard: false,
2989
+ // A segment opened DURING an ambient wait (never the steered first step,
2990
+ // which marks its own) inherits the suppression: the drain's tools still
2991
+ // stay transcript-only until the turn closes. A detached-worker hold
2992
+ // suppresses its cards for the same reason, and a PARKED hold must
2993
+ // suppress from the first replayed update on — the drain's TaskOutput
2994
+ // arrives in exactly that step.
2995
+ ambient: this.awaitingAmbient !== undefined
2996
+ || this.backgroundHold !== undefined
2997
+ || this.pendingBackgroundHold !== undefined,
2570
2998
  finished: false,
2571
2999
  };
2572
3000
  this.segment = seg;
@@ -2574,7 +3002,16 @@ class TurnStreamController {
2574
3002
  for (const entry of entries) {
2575
3003
  if (seg.finished)
2576
3004
  break;
2577
- if (!this.foldInto(seg, entry.update))
3005
+ const update = entry.update;
3006
+ // The same split the live path makes: a background drain's prose is not
3007
+ // this turn's answer, even when it landed while no step was open. The
3008
+ // record (tool outputs) still goes in; the cursor moves past it.
3009
+ if (this.isForeignAmbientText(update)) {
3010
+ this.recordWithoutSegment(update);
3011
+ this.cursorSeq = entry.seq;
3012
+ continue;
3013
+ }
3014
+ if (!this.foldInto(seg, update, entry.seq))
2578
3015
  break;
2579
3016
  this.cursorSeq = entry.seq;
2580
3017
  }
@@ -2584,6 +3021,12 @@ class TurnStreamController {
2584
3021
  const lines = this.pendingProse.splice(0, this.pendingProse.length).join('');
2585
3022
  this.emitProse(seg, 'reasoning', lines);
2586
3023
  }
3024
+ const parkedHold = this.pendingBackgroundHold;
3025
+ if (parkedHold !== undefined && !seg.finished) {
3026
+ // The host opened the continuation step for the Agent card: the hold
3027
+ // finally has a driver step to speak the drain's answer into.
3028
+ this.adoptBackgroundHold(seg, parkedHold);
3029
+ }
2587
3030
  if (this.settled)
2588
3031
  this.finishAfterSettle(seg);
2589
3032
  return seg;
@@ -2593,11 +3036,770 @@ class TurnStreamController {
2593
3036
  if (this.segment !== undefined)
2594
3037
  this.finishSegment(this.segment, reason);
2595
3038
  }
3039
+ /** Whether this turn already streamed prose of its own (not a drain's). */
3040
+ get hasAgentProse() {
3041
+ return this.sawAgentProse;
3042
+ }
3043
+ /**
3044
+ * Keep the turn open for a background drain the agent folded our prompt into.
3045
+ *
3046
+ * THE SHAPE: the agent answered `session/prompt` at once (CodeBuddy returns
3047
+ * `end_turn` in ~14ms when it merges the message into a drain it is already
3048
+ * running) and the real answer streams afterwards under the DRAIN's request
3049
+ * id. Closing on the response would cut the turn before its text exists —
3050
+ * which is exactly the bug this state exists for. So:
3051
+ *
3052
+ * - the agent-side prompt slot is released (`endTurn`: the response HAS
3053
+ * landed) but the turn is NOT settled: `settled` means "the turn is over",
3054
+ * and it is not — no `turn/end`, no `running = false`, no segment close;
3055
+ * - the segment keeps folding the session's updates, with cards suppressed
3056
+ * (see `SegmentState.ambient`): the host must not execute a background
3057
+ * tool for a turn whose answer has not begun;
3058
+ * - any update on the session resets the quiet clock, and the drain's end is
3059
+ * its silence (a `session_end` is rejected by the SDK's schema and never
3060
+ * reaches us — never wait for one); a hard ceiling closes what never ends.
3061
+ */
3062
+ awaitAmbient(requestId, stopReason) {
3063
+ if (this.settled || this.disposed || this.awaitingAmbient !== undefined)
3064
+ return;
3065
+ const seg = this.segment;
3066
+ if (seg === undefined || seg.finished) {
3067
+ // No live driver step to speak into: nothing can be folded, so close the
3068
+ // turn the ordinary way rather than holding state that renders nothing.
3069
+ this.clearPendingAmbient();
3070
+ this.settle({ kind: 'stop', stopReason });
3071
+ return;
3072
+ }
3073
+ seg.ambient = true;
3074
+ // The response landed: the agent no longer holds a prompt for this turn.
3075
+ this.endTurn();
3076
+ // The drain may have spoken BEFORE the response was judged. Those updates
3077
+ // were withheld while this turn's own request id was unknowable (see
3078
+ // `pendingAmbientUpdates`); the turn IS the drain, so they are its answer —
3079
+ // folded in arrival order, now that `seg.ambient` suppresses their cards.
3080
+ this.flushPendingAmbient(seg);
3081
+ if (seg.finished) {
3082
+ this.settle({ kind: 'stop', stopReason });
3083
+ return;
3084
+ }
3085
+ const wait = {
3086
+ requestId,
3087
+ quietTimer: setTimeout(() => { this.onAmbientQuiet(wait); }, this.ambientTuning.quietMs),
3088
+ hardTimer: setTimeout(() => { this.onAmbientHard(wait); }, this.ambientTuning.maxMs),
3089
+ stopReason,
3090
+ };
3091
+ wait.quietTimer.unref?.();
3092
+ wait.hardTimer.unref?.();
3093
+ this.awaitingAmbient = wait;
3094
+ }
3095
+ /**
3096
+ * The prompt response named no conversation turn while a drain the session
3097
+ * retired recently may still be running (and did not re-announce): the turn
3098
+ * cannot be held open, but a zero-output report must not blame the agent.
3099
+ * See `hasRecentRetiredAmbient` for the window.
3100
+ */
3101
+ noteRecentRetiredDrain() {
3102
+ this.recentRetiredDrain = true;
3103
+ }
3104
+ /**
3105
+ * Whether this turn dispatched a detached worker whose answer is still
3106
+ * outstanding. Read when the prompt response lands: such a turn is NOT over
3107
+ * even though the agent answered it (see {@link holdForBackgroundDispatch}).
3108
+ *
3109
+ * Two ways a dispatch keeps the turn open:
3110
+ * - it has not reported back yet (the worker is still running), or
3111
+ * - it DID report — before the response, even — but it was structurally
3112
+ * detached and emitted worker updates: the drain that carries its report
3113
+ * is still coming.
3114
+ * A synchronous `Agent` / `Task` call (completed, no `isBackground`, no
3115
+ * worker updates) matches neither and closes normally.
3116
+ */
3117
+ get dispatchedDetachedWorker() {
3118
+ for (const dispatch of this.dispatches.values()) {
3119
+ if (!dispatch.completed)
3120
+ return true;
3121
+ if (dispatch.detached && dispatch.sawWorkerUpdate)
3122
+ return true;
3123
+ }
3124
+ return false;
3125
+ }
3126
+ /** The hold this turn is inside — adopted, or parked waiting for a step. */
3127
+ activeBackgroundHold() {
3128
+ return this.backgroundHold ?? this.pendingBackgroundHold;
3129
+ }
3130
+ /** Whether every KNOWN dispatch has reported (no dispatch at all counts as done). */
3131
+ allDispatchesCompleted() {
3132
+ for (const dispatch of this.dispatches.values()) {
3133
+ if (!dispatch.completed)
3134
+ return false;
3135
+ }
3136
+ return true;
3137
+ }
3138
+ /**
3139
+ * Hold the turn open for the detached worker(s) its `Agent` tool calls
3140
+ * dispatched — the second controlled ambient-family exception (constraint
3141
+ * 23), not a new idle watchdog.
3142
+ *
3143
+ * THE SHAPE (measured against CodeBuddy 2.179.0, multitask on): the
3144
+ * coordinator opens an `Agent` call that hands work to a background worker,
3145
+ * says "已派…", and the `session/prompt` RESPONSE arrives normally while the
3146
+ * worker keeps running. The worker's own updates (requestId B +
3147
+ * `parentToolCallId`) never touch this session's message; when it finishes,
3148
+ * the agent starts a background DRAIN (requestId A — the response's own
3149
+ * `conversationRequestId`) whose steps carry the report and the final answer.
3150
+ * Nothing tells the client the worker is running: no `session_end` (the SDK's
3151
+ * schema rejects it) and an `agentPhase` idle tick even fires BETWEEN the
3152
+ * worker and the drain. So the response cannot close the turn.
3153
+ *
3154
+ * The hold:
3155
+ * - releases the agent-side prompt slot (`endTurn`: the response landed)
3156
+ * while keeping `running` and the driver step open;
3157
+ * - marks the segment background, so EVERY tool call for the rest of the
3158
+ * hold — the workers', the drain's TaskOutput — stays transcript-only and
3159
+ * the final message carries text with no card in front of it;
3160
+ * - waits in two phases: the worker phase only has the
3161
+ * {@link BridgeTuning.backgroundTaskMaxMs} ceiling (a detached worker goes
3162
+ * silent for tens of seconds while it works) and ends only when ALL known
3163
+ * dispatches reported, after which the drain phase uses the ambient
3164
+ * windows.
3165
+ */
3166
+ holdForBackgroundDispatch(stopReason, requestId) {
3167
+ if (this.settled || this.disposed || this.closedTurn || this.stale)
3168
+ return;
3169
+ if (this.activeBackgroundHold() !== undefined)
3170
+ return;
3171
+ const hold = {
3172
+ stopReason,
3173
+ requestId,
3174
+ acceptedIds: new Set(),
3175
+ phase: 'worker',
3176
+ answerChars: 0,
3177
+ floorSeq: this.session.transcript.head,
3178
+ retired: false,
3179
+ };
3180
+ if (requestId !== undefined)
3181
+ hold.acceptedIds.add(requestId);
3182
+ // A drain announced while this turn was ALREADY on air belongs to it (the
3183
+ // announcement may have named a different id than the response). One that
3184
+ // predates the turn is some earlier run's, and folding its text would mix
3185
+ // two answers.
3186
+ const announced = this.session.ambient;
3187
+ if (announced !== undefined && announced.startedAt >= this.constructedAt) {
3188
+ hold.acceptedIds.add(announced.requestId);
3189
+ if (hold.requestId === undefined)
3190
+ hold.requestId = announced.requestId;
3191
+ }
3192
+ // The response landed: the agent no longer holds a prompt for this turn.
3193
+ this.endTurn();
3194
+ hold.hardTimer = setTimeout(() => { this.onBackgroundHard(hold); }, this.backgroundTuning.taskMaxMs);
3195
+ hold.hardTimer.unref?.();
3196
+ const seg = this.segment;
3197
+ if (seg !== undefined && !seg.finished) {
3198
+ this.adoptBackgroundHold(seg, hold);
3199
+ }
3200
+ else {
3201
+ // The response landed BETWEEN driver steps: the host is about to execute
3202
+ // the Agent card and open its continuation step. Park the hold for that
3203
+ // step, with a bounded wait — a host that never opens one must not hang
3204
+ // the turn forever.
3205
+ hold.graceTimer = setTimeout(() => { this.onBackgroundGraceExpired(hold); }, this.backgroundTuning.graceMs);
3206
+ hold.graceTimer.unref?.();
3207
+ this.pendingBackgroundHold = hold;
3208
+ }
3209
+ // A FAST detached worker may already have reported (even before the
3210
+ // response): with every known dispatch complete there is no worker phase
3211
+ // left to wait out, so the drain phase starts now.
3212
+ if (this.allDispatchesCompleted())
3213
+ this.startBackgroundDrain(hold);
3214
+ }
3215
+ /** Give a (possibly parked) hold a driver segment: cards off for the rest of it. */
3216
+ adoptBackgroundHold(seg, hold) {
3217
+ if (hold.graceTimer !== undefined) {
3218
+ clearTimeout(hold.graceTimer);
3219
+ hold.graceTimer = undefined;
3220
+ }
3221
+ if (this.pendingBackgroundHold === hold)
3222
+ this.pendingBackgroundHold = undefined;
3223
+ seg.ambient = true;
3224
+ this.backgroundHold = hold;
3225
+ // The drain may have spoken BEFORE the response was judged. Those updates
3226
+ // were withheld while this turn's own id was unknown
3227
+ // (`pendingAmbientUpdates`); only the runs THIS hold owns are its answer —
3228
+ // another run's withheld text is dropped (the transcript keeps it).
3229
+ this.flushBackgroundHoldUpdates(seg, hold);
3230
+ // The parked grace owns the wait while no step is on air; now that one is,
3231
+ // a hold whose drain phase already began arms its quiet clock.
3232
+ if (hold.phase === 'drain')
3233
+ this.armBackgroundQuiet(hold);
3234
+ }
3235
+ /**
3236
+ * The worker phase is over once EVERY known dispatch reported: the drain
3237
+ * that carries their reports is (about to be) running, so the existing
3238
+ * ambient windows take over, measured from HERE.
3239
+ *
3240
+ * An announced run is accepted immediately, but while any dispatch is still
3241
+ * incomplete it only records the id and switches the hold's primary run —
3242
+ * arming the quiet clock there would let a drain that spoke early cut the
3243
+ * turn off before its other worker ever reports.
3244
+ */
3245
+ startBackgroundDrain(hold, requestId) {
3246
+ if (hold !== this.backgroundHold && hold !== this.pendingBackgroundHold)
3247
+ return;
3248
+ if (requestId !== undefined) {
3249
+ hold.acceptedIds.add(requestId);
3250
+ hold.requestId = requestId;
3251
+ }
3252
+ if (!this.allDispatchesCompleted())
3253
+ return;
3254
+ if (hold.phase === 'drain') {
3255
+ if (this.backgroundHold === hold)
3256
+ this.armBackgroundQuiet(hold);
3257
+ return;
3258
+ }
3259
+ hold.phase = 'drain';
3260
+ if (hold.hardTimer !== undefined)
3261
+ clearTimeout(hold.hardTimer);
3262
+ hold.hardTimer = setTimeout(() => { this.onBackgroundHard(hold); }, this.ambientTuning.maxMs);
3263
+ hold.hardTimer.unref?.();
3264
+ // A parked hold belongs to the grace until a driver step opens; a hold
3265
+ // with a step arms its quiet clock now.
3266
+ if (this.backgroundHold === hold)
3267
+ this.armBackgroundQuiet(hold);
3268
+ }
3269
+ /**
3270
+ * A dispatch that lands while the drain phase already began means the
3271
+ * coordinator handed out NEW work: the worker phase is back, and with it the
3272
+ * absence of a quiet window.
3273
+ */
3274
+ enterBackgroundWorkerPhase(hold) {
3275
+ if (hold.phase === 'worker')
3276
+ return;
3277
+ hold.phase = 'worker';
3278
+ if (hold.quietTimer !== undefined) {
3279
+ clearTimeout(hold.quietTimer);
3280
+ hold.quietTimer = undefined;
3281
+ }
3282
+ if (hold.hardTimer !== undefined)
3283
+ clearTimeout(hold.hardTimer);
3284
+ hold.hardTimer = setTimeout(() => { this.onBackgroundHard(hold); }, this.backgroundTuning.taskMaxMs);
3285
+ hold.hardTimer.unref?.();
3286
+ }
3287
+ /** Push the drain phase's quiet clock out: every update is evidence of work. */
3288
+ armBackgroundQuiet(hold) {
3289
+ if (hold.quietTimer !== undefined)
3290
+ clearTimeout(hold.quietTimer);
3291
+ hold.quietTimer = setTimeout(() => { this.onBackgroundQuiet(hold); }, this.ambientTuning.quietMs);
3292
+ hold.quietTimer.unref?.();
3293
+ }
3294
+ /**
3295
+ * Any session update restarts the drain phase's quiet clock. Only an
3296
+ * ADOPTED hold has one: a parked hold's wait belongs to its grace timer, and
3297
+ * the worker phase never has a clock at all.
3298
+ */
3299
+ noteBackgroundHoldActivity() {
3300
+ const hold = this.backgroundHold;
3301
+ if (hold === undefined || hold.phase !== 'drain')
3302
+ return;
3303
+ this.armBackgroundQuiet(hold);
3304
+ }
3305
+ /** The drained run went quiet: the turn ends with the stop reason the response gave. */
3306
+ onBackgroundQuiet(hold) {
3307
+ if (this.backgroundHold !== hold || hold.phase !== 'drain')
3308
+ return;
3309
+ this.closeBackgroundHold(hold, { hard: false });
3310
+ }
3311
+ /** The hold outlived its ceiling (worker or drain): the run is still going. */
3312
+ onBackgroundHard(hold) {
3313
+ if (hold !== this.backgroundHold && hold !== this.pendingBackgroundHold)
3314
+ return;
3315
+ this.closeBackgroundHold(hold, { hard: true });
3316
+ }
3317
+ /**
3318
+ * No driver step opened inside the grace: the host is not coming back for
3319
+ * this turn's Agent card. Close it the way a quiet ambient ending does — the
3320
+ * deferral line, never the zero-output blame — and keep ONE drain record:
3321
+ * the worker may still report into a run no turn is waiting on.
3322
+ *
3323
+ * A parked hold has NO segment by definition, so the chat plane has nowhere
3324
+ * to put the deferral line: it goes to the settings log (`agent/notice`)
3325
+ * instead of being parked on prose nobody will read.
3326
+ */
3327
+ onBackgroundGraceExpired(hold) {
3328
+ if (this.pendingBackgroundHold !== hold)
3329
+ return;
3330
+ this.pendingBackgroundHold = undefined;
3331
+ this.clearBackgroundHoldTimers(hold);
3332
+ const line = hold.answerChars > 0 ? undefined : backgroundDeferralLine(this.session.agentId);
3333
+ this.releaseBackgroundHoldIds(hold, { hard: true });
3334
+ if (line !== undefined)
3335
+ this.broadcastBackgroundDeferral(line);
3336
+ this.closeTurn({ kind: 'stop', stopReason: hold.stopReason }, { line, keepAmbient: true, requestId: hold.requestId });
3337
+ }
3338
+ /**
3339
+ * Close a detached-worker hold exactly once. A turn that folded NO answer
3340
+ * text from its held runs says where the rest of the output will land (the
3341
+ * deferral wording, never the silent-failure blame); the parent's interim
3342
+ * acknowledgement does not count as that answer.
3343
+ *
3344
+ * Ownership of the drain ids at close (`releaseBackgroundHoldIds`): a quiet
3345
+ * close retires every accepted id and drops the session record; a hard or
3346
+ * grace close keeps exactly ONE record, because the run is provably still
3347
+ * going and a later update or re-announcement must revive it normally.
3348
+ */
3349
+ closeBackgroundHold(hold, options) {
3350
+ if (this.pendingBackgroundHold === hold)
3351
+ this.pendingBackgroundHold = undefined;
3352
+ else if (this.backgroundHold === hold)
3353
+ this.backgroundHold = undefined;
3354
+ else
3355
+ return;
3356
+ this.clearBackgroundHoldTimers(hold);
3357
+ const line = hold.answerChars > 0 ? undefined : backgroundDeferralLine(this.session.agentId);
3358
+ this.releaseBackgroundHoldIds(hold, { hard: options.hard });
3359
+ const onAir = this.segment !== undefined && !this.segment.finished;
3360
+ // With no segment on air the closing line has no chat bubble to land in:
3361
+ // log it, so the fact is recorded rather than silently dropped.
3362
+ if (line !== undefined && !onAir)
3363
+ this.broadcastBackgroundDeferral(line);
3364
+ this.closeTurn({ kind: 'stop', stopReason: hold.stopReason }, { line, keepAmbient: options.hard, requestId: hold.requestId });
3365
+ }
3366
+ /** Write a hold's deferral line into the settings log (`agent/notice`). */
3367
+ broadcastBackgroundDeferral(line) {
3368
+ this.hub.broadcast({
3369
+ type: 'agent/notice',
3370
+ agentId: this.session.agentId,
3371
+ level: 'info',
3372
+ text: line.trim(),
3373
+ });
3374
+ }
3375
+ /** Drop a hold's clocks. The hold object itself stays for its `retired` mark. */
3376
+ clearBackgroundHoldTimers(hold) {
3377
+ if (hold.quietTimer !== undefined)
3378
+ clearTimeout(hold.quietTimer);
3379
+ if (hold.hardTimer !== undefined)
3380
+ clearTimeout(hold.hardTimer);
3381
+ if (hold.graceTimer !== undefined)
3382
+ clearTimeout(hold.graceTimer);
3383
+ hold.quietTimer = undefined;
3384
+ hold.hardTimer = undefined;
3385
+ hold.graceTimer = undefined;
3386
+ }
3387
+ /**
3388
+ * Drop every hold clock (teardown / a forced ending). `retire` also gives up
3389
+ * every accepted run id — a SUCCESSOR turn must never fold the abandoned
3390
+ * hold's drain or worker text into its own answer.
3391
+ */
3392
+ clearBackgroundHold(options) {
3393
+ const pending = this.pendingBackgroundHold;
3394
+ if (pending !== undefined) {
3395
+ this.pendingBackgroundHold = undefined;
3396
+ this.clearBackgroundHoldTimers(pending);
3397
+ if (options?.retire === true)
3398
+ this.releaseBackgroundHoldIds(pending, { hard: false });
3399
+ }
3400
+ const hold = this.backgroundHold;
3401
+ if (hold === undefined)
3402
+ return;
3403
+ this.backgroundHold = undefined;
3404
+ this.clearBackgroundHoldTimers(hold);
3405
+ if (options?.retire === true)
3406
+ this.releaseBackgroundHoldIds(hold, { hard: false });
3407
+ }
3408
+ /**
3409
+ * Settle what happens to the session's drain records when a hold closes.
3410
+ *
3411
+ * `hard: false` (quiet close, cancel, teardown): the run is no longer waited
3412
+ * on, so EVERY accepted id is retired — its late text stays transcript-only
3413
+ * for whatever turn runs next — and the session record is dropped if it
3414
+ * names one of them.
3415
+ *
3416
+ * `hard: true` (ceiling / parked grace): the run is provably still going.
3417
+ * Exactly ONE record survives — the session's current drain if it has one,
3418
+ * else the hold's primary run — with its activity clock refreshed; the other
3419
+ * accepted ids are retired. A hold whose primary id had no session record
3420
+ * CREATES one, so a successor's `noteTurnRequestId` cannot adopt that id.
3421
+ * Every accepted id therefore ends up either retired or owned by
3422
+ * `session.ambient`; never both unowned and unretired.
3423
+ */
3424
+ releaseBackgroundHoldIds(hold, options) {
3425
+ if (hold.retired)
3426
+ return;
3427
+ hold.retired = true;
3428
+ if (!options.hard) {
3429
+ for (const id of hold.acceptedIds)
3430
+ retireAmbientId(this.session, id);
3431
+ if (this.session.ambient !== undefined && hold.acceptedIds.has(this.session.ambient.requestId)) {
3432
+ this.session.ambient = undefined;
3433
+ }
3434
+ return;
3435
+ }
3436
+ const survivor = this.session.ambient?.requestId ?? hold.requestId;
3437
+ for (const id of hold.acceptedIds) {
3438
+ if (id === survivor)
3439
+ continue;
3440
+ retireAmbientId(this.session, id);
3441
+ }
3442
+ if (survivor === undefined)
3443
+ return;
3444
+ const now = Date.now();
3445
+ if (this.session.ambient?.requestId === survivor) {
3446
+ this.session.ambient.lastActivityAt = now;
3447
+ }
3448
+ else {
3449
+ this.session.ambient = {
3450
+ requestId: survivor,
3451
+ startedAt: now,
3452
+ lastActivityAt: now,
3453
+ sawOutput: hold.answerChars > 0,
3454
+ };
3455
+ }
3456
+ // The surviving run is alive, not retired (an earlier wait of this turn
3457
+ // may have retired it before it spoke again).
3458
+ reviveAmbientId(this.session, survivor);
3459
+ }
3460
+ /**
3461
+ * Fold the withheld drain updates that belong to this hold into its segment.
3462
+ * The rest of the buffer was some OTHER run's text: it is dropped here (the
3463
+ * transcript keeps it), never leaked into this turn's answer.
3464
+ */
3465
+ flushBackgroundHoldUpdates(seg, hold) {
3466
+ if (this.pendingAmbientUpdates.length === 0) {
3467
+ this.pendingAmbientTruncated = false;
3468
+ return;
3469
+ }
3470
+ const pending = this.pendingAmbientUpdates.splice(0, this.pendingAmbientUpdates.length);
3471
+ this.pendingAmbientChars = 0;
3472
+ const truncated = this.pendingAmbientTruncated;
3473
+ this.pendingAmbientTruncated = false;
3474
+ const accepted = pending.filter(entry => {
3475
+ const requestId = codebuddyMetaOf(entry.update)?.requestId;
3476
+ return requestId !== undefined && hold.acceptedIds.has(requestId);
3477
+ });
3478
+ if (accepted.length === 0)
3479
+ return;
3480
+ if (truncated && !seg.finished) {
3481
+ // The front of the drain's output was evicted from the bounded buffer:
3482
+ // say so rather than letting the turn quietly start mid-story.
3483
+ this.emitProse(seg, 'text', '\n[dsh-ntr] background output was truncated; the ACP transcript view still has every update.\n');
3484
+ }
3485
+ for (const entry of accepted) {
3486
+ if (seg.finished)
3487
+ break;
3488
+ this.foldInto(seg, entry.update, entry.seq);
3489
+ // The buffered drain text arrived BEFORE the hold, but it IS the held
3490
+ // run's answer: below the hold's floor it was not counted by `foldInto`,
3491
+ // so count it here (the floor exists to skip the parent's earlier ack,
3492
+ // not the drain's own words).
3493
+ if (entry.seq <= hold.floorSeq
3494
+ && String(entry.update.sessionUpdate ?? '') === 'agent_message_chunk') {
3495
+ hold.answerChars += chunkTextOf(entry.update.content).length;
3496
+ }
3497
+ }
3498
+ }
3499
+ /**
3500
+ * Follow the detached-worker signals of one live update.
3501
+ *
3502
+ * Only CodeBuddy sends them (`codebuddyMetaOf` answers `undefined` for every
3503
+ * other agent, so nothing here can fire for them):
3504
+ * - an `Agent` / `Task` `tool_call` records the dispatch — the FIRST send
3505
+ * needs that CodeBuddy shape;
3506
+ * - the SAME call re-sent with `isBackground: true`, or any update naming it
3507
+ * as its `parentToolCallId`, marks the work DETACHED;
3508
+ * - that call reporting completed / failed is read BY ID: the real
3509
+ * completion frame carries NO `_meta` at all, so requiring the CodeBuddy
3510
+ * shape here would miss every real completion;
3511
+ * - any update carrying `parentToolCallId` is the worker's OWN turn — it
3512
+ * proves the worker alive but never belongs to this session's answer;
3513
+ * - an `unsolicitedTurn` announcement is the drain that carries the report.
3514
+ */
3515
+ trackBackgroundDispatch(update) {
3516
+ if (this.closedTurn)
3517
+ return;
3518
+ const meta = codebuddyMetaOf(update);
3519
+ const kind = String(update.sessionUpdate ?? '');
3520
+ const row = update;
3521
+ const toolCallId = typeof row.toolCallId === 'string' ? row.toolCallId.trim() : '';
3522
+ const status = typeof row.status === 'string' ? row.status : '';
3523
+ const completedStatus = status === 'completed' || status === 'failed';
3524
+ if ((kind === 'tool_call' || kind === 'tool_call_update') && toolCallId !== '') {
3525
+ const known = this.dispatches.get(toolCallId);
3526
+ if (known !== undefined) {
3527
+ // A known dispatch: the id is the whole evidence (see the doc above).
3528
+ if (status !== '')
3529
+ known.status = status;
3530
+ if (meta?.isBackground === true)
3531
+ known.detached = true;
3532
+ if (completedStatus && !known.completed) {
3533
+ known.completed = true;
3534
+ this.noteDispatchProgress();
3535
+ }
3536
+ }
3537
+ else if (meta !== undefined
3538
+ && (meta.toolName === 'Agent' || meta.toolName === 'Task')
3539
+ && (kind === 'tool_call' || meta.isBackground === true)) {
3540
+ // A NEW dispatch needs the CodeBuddy shape. A turn may dispatch more
3541
+ // than one worker; the hold waits for all of them.
3542
+ const dispatch = {
3543
+ toolCallId,
3544
+ status,
3545
+ completed: completedStatus,
3546
+ detached: meta.isBackground === true,
3547
+ sawWorkerUpdate: false,
3548
+ };
3549
+ this.dispatches.set(toolCallId, dispatch);
3550
+ if (!dispatch.completed) {
3551
+ const held = this.activeBackgroundHold();
3552
+ // A dispatch registered after the drain phase began means new work:
3553
+ // the worker phase (and its absence of a quiet clock) is back.
3554
+ if (held !== undefined)
3555
+ this.enterBackgroundWorkerPhase(held);
3556
+ }
3557
+ }
3558
+ }
3559
+ if (meta?.parentToolCallId !== undefined) {
3560
+ const parent = this.dispatches.get(meta.parentToolCallId);
3561
+ if (parent !== undefined) {
3562
+ parent.detached = true;
3563
+ parent.sawWorkerUpdate = true;
3564
+ }
3565
+ }
3566
+ if (meta?.unsolicitedTurn !== undefined) {
3567
+ const held = this.activeBackgroundHold();
3568
+ if (held !== undefined)
3569
+ this.startBackgroundDrain(held, meta.unsolicitedTurn.requestId);
3570
+ }
3571
+ }
3572
+ /**
3573
+ * A dispatch just reported: once the LAST one has, the drain phase starts.
3574
+ * Until then the turn stays in its worker phase, with no quiet clock.
3575
+ */
3576
+ noteDispatchProgress() {
3577
+ if (!this.allDispatchesCompleted())
3578
+ return;
3579
+ const held = this.activeBackgroundHold();
3580
+ if (held !== undefined)
3581
+ this.startBackgroundDrain(held);
3582
+ }
3583
+ /** Any session update is evidence the drain still works: push the quiet clock out. */
3584
+ noteAmbientActivity() {
3585
+ const wait = this.awaitingAmbient;
3586
+ if (wait === undefined)
3587
+ return;
3588
+ clearTimeout(wait.quietTimer);
3589
+ wait.quietTimer = setTimeout(() => { this.onAmbientQuiet(wait); }, this.ambientTuning.quietMs);
3590
+ wait.quietTimer.unref?.();
3591
+ }
3592
+ /** The drain went quiet: the turn ends with the stop reason the response gave. */
3593
+ onAmbientQuiet(wait) {
3594
+ if (this.awaitingAmbient !== wait)
3595
+ return;
3596
+ this.finishAmbient(wait);
3597
+ }
3598
+ /** The drain outlived the ceiling: say where the rest will land, then close. */
3599
+ onAmbientHard(wait) {
3600
+ if (this.awaitingAmbient !== wait)
3601
+ return;
3602
+ const seg = this.segment;
3603
+ if (seg !== undefined && !seg.finished) {
3604
+ this.emitProse(seg, 'text', `\n[dsh-ntr] agent "${this.session.agentId}" is still running a background task; the rest of its output will appear in the ACP transcript view.\n`);
3605
+ }
3606
+ // The ceiling is evidence the drain is STILL RUNNING (unlike a quiet close),
3607
+ // so the ending keeps the session's drain record; see `closeTurn`.
3608
+ wait.hard = true;
3609
+ // The hard explanation already says the turn was cut short; the deferred
3610
+ // line would only repeat it in different words.
3611
+ this.finishAmbient(wait, { announceDeferral: false });
3612
+ }
3613
+ /**
3614
+ * Close an ambient wait exactly once. A turn that produced no answer TEXT
3615
+ * says so: the drain's output is in the ACP transcript view, and this
3616
+ * bubble must not be empty (the zero-output report, reworded for a turn that
3617
+ * was deferred rather than failed).
3618
+ */
3619
+ finishAmbient(wait, options) {
3620
+ if (this.awaitingAmbient !== wait)
3621
+ return;
3622
+ const line = options?.announceDeferral === false || this.sawVisibleText
3623
+ ? undefined
3624
+ : backgroundDeferralLine(this.session.agentId);
3625
+ this.closeTurn({ kind: 'stop', stopReason: wait.stopReason }, { line, keepAmbient: wait.hard === true, requestId: wait.requestId });
3626
+ }
3627
+ /** Drop the ambient wait's clocks (the turn is being closed by someone else). */
3628
+ clearAmbientWait() {
3629
+ const wait = this.awaitingAmbient;
3630
+ if (wait === undefined)
3631
+ return;
3632
+ this.awaitingAmbient = undefined;
3633
+ clearTimeout(wait.quietTimer);
3634
+ clearTimeout(wait.hardTimer);
3635
+ }
3636
+ /**
3637
+ * Withhold one background-drain update until the prompt response says what
3638
+ * this turn is. The cursor advances past it anyway: it is already in the
3639
+ * transcript, and replaying it into a later segment would emit it twice.
3640
+ * Bounded (see `PENDING_AMBIENT_MAX_*`) — the transcript still keeps every
3641
+ * entry the buffer drops.
3642
+ */
3643
+ bufferAmbientUpdate(seq, update) {
3644
+ const chars = JSON.stringify(update).length;
3645
+ this.pendingAmbientUpdates.push({ seq, update, chars });
3646
+ this.pendingAmbientChars += chars;
3647
+ while (this.pendingAmbientUpdates.length > PENDING_AMBIENT_MAX_ENTRIES
3648
+ || (this.pendingAmbientChars > PENDING_AMBIENT_MAX_CHARS && this.pendingAmbientUpdates.length > 1)) {
3649
+ const dropped = this.pendingAmbientUpdates.shift();
3650
+ if (dropped === undefined)
3651
+ break;
3652
+ this.pendingAmbientChars -= dropped.chars;
3653
+ this.pendingAmbientTruncated = true;
3654
+ }
3655
+ }
3656
+ /** Fold the withheld drain updates into the now-ambient segment, once, in order. */
3657
+ flushPendingAmbient(seg) {
3658
+ if (this.pendingAmbientUpdates.length === 0) {
3659
+ this.pendingAmbientTruncated = false;
3660
+ return;
3661
+ }
3662
+ const truncated = this.pendingAmbientTruncated;
3663
+ this.pendingAmbientTruncated = false;
3664
+ const pending = this.pendingAmbientUpdates.splice(0, this.pendingAmbientUpdates.length);
3665
+ this.pendingAmbientChars = 0;
3666
+ if (truncated && !seg.finished) {
3667
+ // The front of the drain's output was evicted from the bounded buffer:
3668
+ // say so rather than letting the turn quietly start mid-story.
3669
+ this.emitProse(seg, 'text', '\n[dsh-ntr] background output was truncated; the ACP transcript view still has every update.\n');
3670
+ }
3671
+ for (const entry of pending) {
3672
+ if (seg.finished)
3673
+ break;
3674
+ this.foldInto(seg, entry.update, entry.seq);
3675
+ }
3676
+ }
3677
+ /** Drop withheld drain updates: this turn is not the drain, or it is closing. */
3678
+ clearPendingAmbient() {
3679
+ this.pendingAmbientUpdates.length = 0;
3680
+ this.pendingAmbientChars = 0;
3681
+ this.pendingAmbientTruncated = false;
3682
+ }
2596
3683
  /** The agent settled its `session/prompt`. Ends the turn's streaming face. */
2597
3684
  settle(outcome) {
2598
3685
  if (this.settled)
2599
3686
  return;
3687
+ // `settled` is claimed NOW (a second settle, a cancel or a watchdog that
3688
+ // races the tail drain must find the turn already spoken for), but the
3689
+ // CLOSING work waits one macrotask: the SDK dispatches `session/update`
3690
+ // notifications independently of the `session/prompt` RESPONSE, so the
3691
+ // agent's last chunks can still be queued when this lands (see
3692
+ // `settleTranscript`). Only what already reached the transcript is read —
3693
+ // one macrotask, never an open-ended wait.
2600
3694
  this.settled = true;
3695
+ this.clearAmbientWait();
3696
+ this.clearPendingAmbient();
3697
+ // An outside ending (watchdog, cancel) abandons a detached-worker hold: its
3698
+ // run is no longer this turn's, so its id is retired before the close.
3699
+ this.clearBackgroundHold({ retire: true });
3700
+ this.pendingOutcome = outcome;
3701
+ this.closeScheduled = true;
3702
+ setImmediate(() => { this.flushScheduledClose(); });
3703
+ }
3704
+ /**
3705
+ * Force the local end of this turn — the cancel fallback and any watchdog
3706
+ * that reached the controller. Runs the SAME closing work as a normal settle
3707
+ * (`closeTurn`): the ambient clocks stop, the on-air segment closes (so the
3708
+ * driver step parked on its queue ends), exactly one `turn/end` goes out
3709
+ * with THIS turn's id, the session stops running and the prompt slot is
3710
+ * released. Idempotent: a turn that already closed ignores the call.
3711
+ */
3712
+ forceFinalize(outcome) {
3713
+ if (this.stale || this.closedTurn)
3714
+ return;
3715
+ this.clearAmbientWait();
3716
+ // A forced ending abandons a detached-worker hold: retire its run's id so
3717
+ // its late words stay transcript-only for the next turn.
3718
+ this.clearBackgroundHold({ retire: true });
3719
+ // A settle's deferred tail read is moot once this close runs: the turn is
3720
+ // over now, and the pending macrotask must not drain anything into it.
3721
+ this.pendingOutcome = undefined;
3722
+ this.closeScheduled = false;
3723
+ this.closeTurn(outcome);
3724
+ }
3725
+ /**
3726
+ * Run the deferred closing work now, when a driver step is about to open on
3727
+ * an already settled turn: the continuation step must see the closed turn
3728
+ * (and the session NOT left `running` across the macrotask boundary).
3729
+ * Idempotent — the scheduled tick then finds nothing left to do.
3730
+ */
3731
+ flushScheduledClose() {
3732
+ if (!this.closeScheduled)
3733
+ return;
3734
+ this.closeScheduled = false;
3735
+ const outcome = this.pendingOutcome;
3736
+ this.pendingOutcome = undefined;
3737
+ if (outcome === undefined)
3738
+ return;
3739
+ // A DISPOSE that raced this queued close cleared it (see `dispose`), so a
3740
+ // close still scheduled here belongs to a settle that landed AFTER the
3741
+ // stream was retired (a crash rejects its prompt only after the teardown).
3742
+ // That turn still ends through its own queue and real id — the teardown's
3743
+ // releaseAll frame carried an empty id.
3744
+ this.drainTail();
3745
+ this.closeTurn(outcome);
3746
+ }
3747
+ /**
3748
+ * Fold the notifications that reached the transcript but not the segment:
3749
+ * the tail a response raced past. Stops AT a cutting event without consuming
3750
+ * it (same rule as `startSegment`) — its next segment is not this turn's.
3751
+ */
3752
+ drainTail() {
3753
+ // The deferred read belongs to the turn that scheduled it. If the turn was
3754
+ // retired or a NEWER turn already began (turnSeq moved on) in this one
3755
+ // macrotask window, everything past the cursor is the successor's: folding
3756
+ // it here would leak its text into this turn's driver step.
3757
+ if (this.stale)
3758
+ return;
3759
+ const seg = this.segment;
3760
+ if (seg === undefined || seg.finished)
3761
+ return;
3762
+ for (const entry of this.session.transcript.since(this.cursorSeq)) {
3763
+ if (seg.finished)
3764
+ break;
3765
+ const update = entry.update;
3766
+ // The same split the live path makes: a background drain's text in the
3767
+ // tail is not this turn's answer. Record what the registry needs (tool
3768
+ // outputs), move the cursor past it, and do NOT fold it — otherwise the
3769
+ // deferred read smuggles the drain's prose into a normal turn.
3770
+ if (this.isForeignAmbientText(update)) {
3771
+ this.recordWithoutSegment(update);
3772
+ this.cursorSeq = entry.seq;
3773
+ continue;
3774
+ }
3775
+ if (!this.foldInto(seg, update, entry.seq))
3776
+ break;
3777
+ this.cursorSeq = entry.seq;
3778
+ }
3779
+ }
3780
+ /**
3781
+ * End the turn exactly once: burst metrics, ONE `turn/end`, the provisional
3782
+ * lock, the closing text, the segment, the session flags, the prompt slot.
3783
+ *
3784
+ * `ambient.line` marks a controlled ambient ending: the given line closes
3785
+ * the segment when the turn carried no answer text, instead of the generic
3786
+ * zero-output warning (which would blame the agent for a turn the drain
3787
+ * simply had not finished yet). `ambient.keepAmbient` says the ending was the
3788
+ * CEILING, not silence: see the drain-record comment below.
3789
+ */
3790
+ closeTurn(outcome, ambient) {
3791
+ if (this.closedTurn)
3792
+ return;
3793
+ this.closedTurn = true;
3794
+ // A closed turn is a settled turn, whichever path got here first (an
3795
+ // ambient ending does not go through `settle`).
3796
+ this.settled = true;
3797
+ this.clearAmbientWait();
3798
+ this.clearPendingAmbient();
3799
+ // Whoever closed this turn already decided what happens to the held
3800
+ // run's record (`closeBackgroundHold` retired or kept it); only the clocks
3801
+ // are still ours to drop.
3802
+ this.clearBackgroundHold();
2601
3803
  // A late answer to a SUPERSEDED turn still has to close this controller's
2602
3804
  // queue (a continuation step may be parked on it) and its settle gate —
2603
3805
  // but it must not touch the session the NEWER turn now owns, nor release
@@ -2609,6 +3811,41 @@ class TurnStreamController {
2609
3811
  ? { kind: 'error', failure: { message: outcome.message, code: outcome.code ?? 'INTERNAL' } }
2610
3812
  : { kind: 'stop' };
2611
3813
  if (current) {
3814
+ // An ambient ending that came from SILENCE retires the drain record with
3815
+ // its turn: a record that outlives the wait would make the NEXT instant
3816
+ // answer with no conversation request id look steered (see
3817
+ // `awaitAmbient`), holding a normal turn open for a drain this one
3818
+ // already finished waiting on. A HARD ceiling ending is the opposite
3819
+ // evidence — the drain was still ticking when the turn was cut — so the
3820
+ // record SURVIVES (activity clock refreshed; `ambientStaleMs` expires it
3821
+ // lazily) precisely so the next prompt folded into the same drain is
3822
+ // still recognised as steered. `ambientNotices` is never cleared here:
3823
+ // a repeated announcement of the same run must stay silent, and the set
3824
+ // is bounded where entries are added.
3825
+ if (ambient !== undefined) {
3826
+ if (ambient.keepAmbient === true) {
3827
+ if (this.session.ambient !== undefined)
3828
+ this.session.ambient.lastActivityAt = Date.now();
3829
+ }
3830
+ else {
3831
+ // Silence closed this wait, but silence is not proof the run ended: a
3832
+ // queued message or a long tool can make the drain speak again. So
3833
+ // RETIRE its id before dropping the record — a revived run's late
3834
+ // text must stay in the ACP transcript view instead of teaching the
3835
+ // NEXT turn the drain's id and mixing the drain's answer into the
3836
+ // user's (a double answer). The trade-off is deliberate: a next
3837
+ // prompt the agent folds into that same revived drain may not receive
3838
+ // the drain's text on the chat plane (transcript-only); a double
3839
+ // answer is worse.
3840
+ if (ambient.requestId !== undefined)
3841
+ retireAmbientId(this.session, ambient.requestId);
3842
+ this.session.ambient = undefined;
3843
+ }
3844
+ }
3845
+ // The turn's burst aggregate must be visible before the turn is marked
3846
+ // over: the final `turn/metrics` lands first, then `turn/end` flips the
3847
+ // session/pill state. Persisting and broadcasting share one value.
3848
+ this.finalizeTps();
2612
3849
  if (outcome.kind === 'cancelled') {
2613
3850
  this.hub.broadcast({ type: 'turn/end', sessionId: this.session.sessionId, turnId: this.turnId, stopReason: 'cancelled' });
2614
3851
  }
@@ -2620,20 +3857,31 @@ class TurnStreamController {
2620
3857
  }
2621
3858
  // The provisional lock settles BEFORE any synthesized warning below: the
2622
3859
  // warning is our text, not evidence that the first turn delivered anything.
3860
+ //
3861
+ // An AMBIENT ending ends the provisional phase WITHOUT a rollback: a
3862
+ // deferral to a background drain is not a failed first turn, the drain's
3863
+ // text still lands in the ACP transcript view, and rolling the lock back
3864
+ // would close the session under it — dropping exactly the output the
3865
+ // deferred turn promised. The lock therefore becomes permanent.
2623
3866
  if (this.session.provisionalLock === true) {
2624
3867
  this.session.provisionalLock = false;
2625
- if (!this.sawVisibleBlock)
3868
+ if (!this.sawVisibleBlock && ambient === undefined)
2626
3869
  this.rollbackFirstTurn(this.session);
2627
3870
  }
2628
3871
  }
2629
3872
  const seg = this.segment;
2630
3873
  if (seg !== undefined) {
2631
- if (current && outcome.kind === 'stop' && !this.sawVisibleBlock) {
3874
+ if (ambient !== undefined) {
3875
+ // The ambient line is OUR report, so it only appears when the drain
3876
+ // delivered no answer text of its own; reasoning alone does not count.
3877
+ if (ambient.line !== undefined && current)
3878
+ this.emitProse(seg, 'text', ambient.line);
3879
+ }
3880
+ else if (current && outcome.kind === 'stop' && !this.sawVisibleBlock) {
2632
3881
  // The silent-failure report: a normal stop with nothing the chat
2633
3882
  // plane renders. Aborted or failing turns need no extra hint; this
2634
3883
  // is specifically "protocol says success, the bubble is empty".
2635
- 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`;
2636
- this.emitProse(seg, 'text', line);
3884
+ this.emitProse(seg, 'text', this.zeroOutputLine(outcome.stopReason));
2637
3885
  }
2638
3886
  this.finishSegment(seg, reason);
2639
3887
  }
@@ -2648,25 +3896,60 @@ class TurnStreamController {
2648
3896
  }
2649
3897
  this.endTurn();
2650
3898
  // The controller stays indexed only while a continuation step can still
2651
- // come for its cards. A cancelled, failed, or card-less turn has none:
2652
- // retire it now; the next turn's `rememberTurnStream` would anyway.
3899
+ // come for its cards. A cancelled, failed, or card-less turn has none: retire
3900
+ // it now; the next turn's `rememberTurnStream` would anyway. An ambient turn
3901
+ // ALWAYS retires: its suppressed calls never open cards, so keeping the index
3902
+ // would pin the controller (and its session id) to a turn nobody can continue.
2653
3903
  if (!current)
2654
3904
  this.retire();
2655
- else if (outcome.kind !== 'stop' || this.seenCalls.size === 0)
3905
+ else if (ambient !== undefined || outcome.kind !== 'stop' || this.seenCalls.size === 0)
2656
3906
  this.dispose();
2657
3907
  }
2658
- /** Cancelled by the user: finish the on-air segment and release the turn's slots. */
3908
+ /**
3909
+ * Cancelled by the user: finish the on-air segment and release the turn's
3910
+ * slots. An AMBIENT turn has already released the prompt slot (its response
3911
+ * landed) and holds no segment the cancel fallback could close, so the
3912
+ * cancel closes the turn itself with the cancelled stop.
3913
+ */
2659
3914
  abortActiveSegment() {
2660
3915
  if (this.stale)
2661
3916
  return;
3917
+ this.clearPendingAmbient();
3918
+ if (this.closeScheduled) {
3919
+ // `settle` claimed the turn but queued its closing work for the next
3920
+ // macrotask. An abort landing inside that window must not let the
3921
+ // scheduled close broadcast the RESPONSE's stop reason while the segment
3922
+ // already finished `aborted`: drop the queued outcome and close NOW with
3923
+ // the cancelled one, so `turn/end` and the segment's finish agree.
3924
+ this.closeScheduled = false;
3925
+ this.pendingOutcome = undefined;
3926
+ this.closeTurn({ kind: 'cancelled' });
3927
+ return;
3928
+ }
3929
+ if (this.awaitingAmbient !== undefined) {
3930
+ this.settle({ kind: 'cancelled' });
3931
+ return;
3932
+ }
3933
+ if (this.backgroundHold !== undefined || this.pendingBackgroundHold !== undefined) {
3934
+ // A detached-worker hold is a turn whose prompt response already landed
3935
+ // (the slot is free) and whose segment is still open on purpose: the
3936
+ // cancel ends OUR face of it now, once, and the held run's id is retired
3937
+ // so its late text cannot join the next turn.
3938
+ this.clearBackgroundHold({ retire: true });
3939
+ this.settle({ kind: 'cancelled' });
3940
+ return;
3941
+ }
2662
3942
  this.finishActive({ kind: 'aborted', failure: { message: 'aborted', code: 'ABORTED' } });
2663
3943
  // The agent may take its time acknowledging the cancel, but OUR face of
2664
- // the turn ends now: the session stops running and a provisional first
2665
- // lock settles against what was actually shown. The turn SLOT stays
2666
- // claimed, though: the agent-side prompt is still in flight, and a second
3944
+ // the turn ends now: the session stops running, the HOST's prompt slot is
3945
+ // released (so a new prompt may claim it), and a provisional first lock
3946
+ // settles against what was actually shown. The AGENT-side gate (`settled`)
3947
+ // stays claimed: the prompt is still in flight over there, and a second
2667
3948
  // prompt sent into that window would be a concurrent `session/prompt` the
2668
- // protocol forbids — `settle` (or the cancel fallback) releases the slot.
3949
+ // protocol forbids — `claimTurn` waits it out, `settle` (or the cancel
3950
+ // fallback) opens it.
2669
3951
  this.session.running = false;
3952
+ this.session.promptInFlight = false;
2670
3953
  if (this.session.provisionalLock === true) {
2671
3954
  this.session.provisionalLock = false;
2672
3955
  if (!this.sawVisibleBlock)
@@ -2674,10 +3957,30 @@ class TurnStreamController {
2674
3957
  }
2675
3958
  releaseAllToolOutputWaiters();
2676
3959
  }
2677
- /** Stop observing and release the manager's index. Idempotent. */
3960
+ /**
3961
+ * Stop observing and release the manager's index. Idempotent.
3962
+ *
3963
+ * The segment is CLOSED here (aborted) before unsubscribing: a driver step
3964
+ * parked on its queue (`yield* segment.queue`) only ends when the queue
3965
+ * closes, and a controller replaced by the conversation's next turn would
3966
+ * otherwise leave that step hanging forever. An already finished segment is a
3967
+ * no-op, and the session's flags are left alone — a superseded turn must not
3968
+ * mark the newer turn's session idle.
3969
+ */
2678
3970
  dispose() {
2679
3971
  if (this.disposed)
2680
3972
  return;
3973
+ // A settle may have queued its closing work for the next macrotask: this
3974
+ // close owns the turn now, so that tick must not broadcast a second
3975
+ // `turn/end` (with this turn's real id) over the teardown.
3976
+ this.closeScheduled = false;
3977
+ this.pendingOutcome = undefined;
3978
+ this.finishActive({ kind: 'aborted', failure: { message: 'turn stream retired', code: 'RETIRED' } });
3979
+ this.clearAmbientWait();
3980
+ this.clearPendingAmbient();
3981
+ // A successor turn must never fold this hold's drain or worker text: the
3982
+ // abandoned run's id is retired before the subscription goes away.
3983
+ this.clearBackgroundHold({ retire: true });
2681
3984
  this.retire();
2682
3985
  releaseAllToolOutputWaiters();
2683
3986
  }
@@ -2741,12 +4044,24 @@ class TurnStreamController {
2741
4044
  else {
2742
4045
  // A settled turn that never showed anything: the zero-output warning the
2743
4046
  // plane would have gotten from a single-stream turn (constraint 23).
2744
- const line = `\n[dsh-ntr] agent "${this.session.agentId}" ended the turn with no visible output. Some agents fold upstream failures — auth, billing, rate limits — into a normal stop; try resending, and check the agent process logs if it repeats.\n`;
4047
+ const line = this.zeroOutputLine();
2745
4048
  if (seg.index < 0)
2746
4049
  this.emitProse(seg, 'text', line);
2747
4050
  this.finishSegment(seg, { kind: 'stop' });
2748
4051
  }
2749
4052
  }
4053
+ /**
4054
+ * The "protocol says success, the bubble is empty" line. When a recently
4055
+ * retired drain may have taken the prompt without re-announcing itself, say
4056
+ * that instead of blaming the agent: the output exists in the transcript.
4057
+ */
4058
+ zeroOutputLine(stopReason) {
4059
+ if (this.recentRetiredDrain) {
4060
+ return `\n[dsh-ntr] agent "${this.session.agentId}" produced no visible output this turn; it may have been folded into a background run; its output is in the ACP transcript view.\n`;
4061
+ }
4062
+ const reason = stopReason === undefined ? '' : ` (stop reason: ${stopReason})`;
4063
+ return `\n[dsh-ntr] agent "${this.session.agentId}" ended the turn with no visible output${reason}. Some agents fold upstream failures — auth, billing, rate limits — into a normal stop; try resending, and check the agent process logs if it repeats.\n`;
4064
+ }
2750
4065
  onEvent(event) {
2751
4066
  if (this.disposed)
2752
4067
  return;
@@ -2755,9 +4070,50 @@ class TurnStreamController {
2755
4070
  return;
2756
4071
  if (row.type === 'session/update') {
2757
4072
  const typed = event;
4073
+ // Any update — a `usage_update` / phase tick included — says the
4074
+ // background drain is still working, so the ambient quiet clock restarts.
4075
+ this.noteAmbientActivity();
4076
+ // A detached-worker hold's drain phase has the same rule; its worker
4077
+ // phase never does (see `BridgeTuning.backgroundTaskMaxMs`).
4078
+ this.noteBackgroundHoldActivity();
4079
+ const update = typed.update;
4080
+ // The dispatch / worker / announcement bookkeeping runs BEFORE the cursor
4081
+ // guard: those are turn-level facts, not segment content, and an update
4082
+ // withheld by the ambient buffer must still keep them.
4083
+ this.trackBackgroundDispatch(update);
2758
4084
  if (typed.seq <= this.cursorSeq)
2759
4085
  return;
2760
- const update = typed.update;
4086
+ // A background drain's prose is not this turn's answer: keep it out of
4087
+ // the plane (the transcript still has it). Fail-open — nothing is split
4088
+ // until this turn has a request id of its own to compare against.
4089
+ this.noteTurnRequestId(update);
4090
+ if (this.isForeignAmbientText(update)) {
4091
+ this.recordWithoutSegment(update);
4092
+ // Advance past it only when nothing is left unconsumed behind it: the
4093
+ // event that CUT the previous segment is parked for the next one, and
4094
+ // jumping the cursor over it would lose its text for good. The replay
4095
+ // applies the same foreign split, so leaving this one behind is safe.
4096
+ if (typed.seq === this.cursorSeq + 1)
4097
+ this.cursorSeq = typed.seq;
4098
+ return;
4099
+ }
4100
+ // The turn's OWN request id is still unknown and this update belongs to
4101
+ // the background drain: folding it now could make the drain's tick teach
4102
+ // `noteTurnRequestId` the drain's id, mixing the drain's text into the
4103
+ // user's answer. Withhold it until the prompt response decides what this
4104
+ // turn is (see `pendingAmbientUpdates`); the cursor moves on because the
4105
+ // transcript already holds the entry.
4106
+ if (this.turnRequestId === undefined
4107
+ && this.awaitingAmbient === undefined
4108
+ && this.isAmbientOwned(update)) {
4109
+ this.bufferAmbientUpdate(typed.seq, update);
4110
+ this.cursorSeq = typed.seq;
4111
+ return;
4112
+ }
4113
+ // Live-only burst accounting runs BEFORE the segment cursor: prose that
4114
+ // arrives between segments still belongs to this turn's output, and its
4115
+ // gaps are real. Replay never reaches this path.
4116
+ this.trackTps(typed);
2761
4117
  const seg = this.segment;
2762
4118
  if (seg === undefined || seg.finished) {
2763
4119
  // Between segments: tool outputs still reach the shared registry (the
@@ -2770,18 +4126,81 @@ class TurnStreamController {
2770
4126
  }
2771
4127
  // The cursor advances only past events fully consumed into this
2772
4128
  // segment; the event that CUTS the segment belongs to the next one.
2773
- if (this.foldInto(seg, update))
4129
+ if (this.foldInto(seg, update, typed.seq))
2774
4130
  this.cursorSeq = typed.seq;
2775
4131
  }
2776
4132
  else if (row.type === 'permission/request') {
4133
+ this.tps.onBreak();
2777
4134
  const typed = event;
2778
4135
  this.pushProse(this.permissionLine(typed));
2779
4136
  }
2780
4137
  else if (row.type === 'permission/expired') {
4138
+ this.tps.onBreak();
2781
4139
  const typed = event;
2782
4140
  this.pushProse(this.expiredLine(typed));
2783
4141
  }
2784
4142
  }
4143
+ /** Feed one live update into the turn's burst tracker. Never called by replay. */
4144
+ trackTps(typed) {
4145
+ if (typed.seq <= this.tpsSeq)
4146
+ return;
4147
+ this.tpsSeq = typed.seq;
4148
+ const update = typed.update;
4149
+ const kind = String(update.sessionUpdate ?? '');
4150
+ if (kind === 'agent_thought_chunk' || kind === 'agent_message_chunk') {
4151
+ const text = chunkTextOf(update.content);
4152
+ if (text === '')
4153
+ return;
4154
+ this.tps.onProse(text, Date.now());
4155
+ this.pushLiveTps();
4156
+ }
4157
+ else if (kind === 'tool_call' || kind === 'tool_call_update') {
4158
+ this.tps.onBreak();
4159
+ }
4160
+ }
4161
+ /** Throttled live aggregate so the dock's speed moves during a long turn. */
4162
+ pushLiveTps() {
4163
+ const dshSessionId = this.session.dshSessionId;
4164
+ if (dshSessionId === undefined)
4165
+ return;
4166
+ const now = Date.now();
4167
+ if (now - this.lastTpsPushAt < TPS_PUSH_INTERVAL_MS)
4168
+ return;
4169
+ const snap = this.tps.snapshot();
4170
+ if (snap.outputTokens <= 0 || snap.burstMs <= 0)
4171
+ return;
4172
+ this.lastTpsPushAt = now;
4173
+ const base = this.session.burst ?? { outputTokens: 0, burstMs: 0 };
4174
+ this.hub.broadcast({
4175
+ type: 'turn/metrics',
4176
+ sessionId: this.session.sessionId,
4177
+ dshSessionId,
4178
+ turnId: this.turnId,
4179
+ outputTokens: base.outputTokens + snap.outputTokens,
4180
+ burstMs: base.burstMs + snap.burstMs,
4181
+ final: false,
4182
+ });
4183
+ }
4184
+ /** Close the turn's metrics: persist the measurable part, then broadcast it. */
4185
+ finalizeTps() {
4186
+ const dshSessionId = this.session.dshSessionId;
4187
+ if (dshSessionId === undefined)
4188
+ return;
4189
+ const snap = this.tps.snapshot();
4190
+ if (snap.outputTokens > 0 && snap.burstMs > 0) {
4191
+ this.manager.noteSessionBurst(this.session, snap.outputTokens, snap.burstMs);
4192
+ }
4193
+ const aggregate = this.session.burst ?? { outputTokens: 0, burstMs: 0 };
4194
+ this.hub.broadcast({
4195
+ type: 'turn/metrics',
4196
+ sessionId: this.session.sessionId,
4197
+ dshSessionId,
4198
+ turnId: this.turnId,
4199
+ outputTokens: aggregate.outputTokens,
4200
+ burstMs: aggregate.burstMs,
4201
+ final: true,
4202
+ });
4203
+ }
2785
4204
  /**
2786
4205
  * Put one narrative line into the turn's prose stream.
2787
4206
  *
@@ -2818,15 +4237,124 @@ class TurnStreamController {
2818
4237
  return;
2819
4238
  recordAcpToolOutput(callId, updateContentToText(update.content));
2820
4239
  }
4240
+ /**
4241
+ * Whether this update belongs to a background drain — the one the session is
4242
+ * waiting on, or one whose wait already closed (retired) but whose run can
4243
+ * still speak.
4244
+ */
4245
+ isAmbientOwned(update) {
4246
+ const requestId = codebuddyMetaOf(update)?.requestId;
4247
+ if (requestId === undefined)
4248
+ return false;
4249
+ return requestId === this.session.ambient?.requestId
4250
+ || isRetiredAmbientId(this.session, requestId);
4251
+ }
4252
+ /** Learn this turn's own CodeBuddy request id from the first update naming one. */
4253
+ noteTurnRequestId(update) {
4254
+ if (this.turnRequestId !== undefined)
4255
+ return;
4256
+ const meta = codebuddyMetaOf(update);
4257
+ // A detached worker's update names A WORKER, never this turn: adopting it
4258
+ // would let the worker's own turn pass as the session's answer.
4259
+ if (meta?.parentToolCallId !== undefined)
4260
+ return;
4261
+ // While a hold runs, the only ids this turn owns are the drain ids it
4262
+ // accepted (see `BackgroundHold.acceptedIds`) — and those belong to the
4263
+ // background run, never to a second "own turn id" that would outlive the
4264
+ // hold. Nothing else may be adopted.
4265
+ if (this.activeBackgroundHold() !== undefined)
4266
+ return;
4267
+ const requestId = meta?.requestId;
4268
+ // A background drain's update names THE DRAIN, never this turn: adopting it
4269
+ // would make `isForeignAmbientText` pass the drain's own text through. A
4270
+ // RETIRED drain (its wait closed on silence, the run revived) is the same
4271
+ // trap — that id belongs to the background run whichever turn is on air.
4272
+ if (requestId === undefined || requestId === this.session.ambient?.requestId)
4273
+ return;
4274
+ if (isRetiredAmbientId(this.session, requestId))
4275
+ return;
4276
+ this.turnRequestId = requestId;
4277
+ }
4278
+ /** Adopt the request id the prompt RESPONSE declared for this turn. */
4279
+ adoptTurnRequestId(requestId) {
4280
+ // A retired drain id is never this turn's own: adopting it would make the
4281
+ // background run's prose pass `isForeignAmbientText` as the answer. The
4282
+ // response still ends a normal turn, so the withheld drain buffer is
4283
+ // dropped either way (the transcript keeps it).
4284
+ if (!isRetiredAmbientId(this.session, requestId))
4285
+ this.turnRequestId = requestId;
4286
+ // A normal turn: whatever the drain streamed while the id was unknown was
4287
+ // never this turn's answer. Drop it (the transcript keeps it) — it must not
4288
+ // surface when a later segment replays or when the turn flushes anything.
4289
+ this.clearPendingAmbient();
4290
+ }
4291
+ /**
4292
+ * Prose of the session's background drain while this turn runs on its OWN
4293
+ * request id: it belongs to the background run, not to the user's answer.
4294
+ * Fail-open by design — an unknown turn id splits nothing, and a turn that
4295
+ * was itself folded into the drain keeps its prose.
4296
+ */
4297
+ isForeignAmbientText(update) {
4298
+ const meta = codebuddyMetaOf(update);
4299
+ const requestId = meta?.requestId;
4300
+ // A detached worker's own turn is never this session's answer, whichever
4301
+ // turn is on air (the fold-level guard lives in `foldInto` as well, for
4302
+ // the replay paths that never ask this question). This includes a worker
4303
+ // update that also names a drain id: its prose is the WORKER's.
4304
+ if (meta?.parentToolCallId !== undefined)
4305
+ return true;
4306
+ // A detached-worker hold owns the drain phase: any run it accepted (the
4307
+ // response's id, an announcement that named a different one) is this
4308
+ // turn's answer, and every OTHER id is background noise — whatever the
4309
+ // turn's own id says. Parked holds count: the response already owns them.
4310
+ const hold = this.activeBackgroundHold();
4311
+ if (hold !== undefined) {
4312
+ if (requestId === undefined)
4313
+ return false;
4314
+ return !hold.acceptedIds.has(requestId);
4315
+ }
4316
+ // The drain THIS turn is waiting on owns its text unconditionally: the
4317
+ // wait was opened for this id (a revived drain may still carry a retired
4318
+ // mark if the update raced the revival), so the whole steered answer must
4319
+ // reach the user instead of being filtered as background noise.
4320
+ if (requestId !== undefined && this.awaitingAmbient?.requestId === requestId)
4321
+ return false;
4322
+ // A RETIRED drain is foreign even though its record is gone: the quiet
4323
+ // close only means WE stopped waiting, not that the run ended. Its late
4324
+ // text must never join the turn now on air — even before that turn has
4325
+ // learned a request id of its own (the fail-open below does not apply to
4326
+ // an id known to be a background run).
4327
+ if (isRetiredAmbientId(this.session, requestId))
4328
+ return true;
4329
+ if (this.turnRequestId === undefined)
4330
+ return false;
4331
+ if (requestId === undefined || requestId === this.turnRequestId)
4332
+ return false;
4333
+ const ambient = this.session.ambient;
4334
+ return ambient !== undefined && requestId === ambient.requestId;
4335
+ }
2821
4336
  /**
2822
4337
  * The turn's whole fold, shared by live events and segment replay. Visible
2823
4338
  * non-tool events END a card-bearing segment instead of joining it — that is
2824
4339
  * the segmentation rule that puts each card between two assistant messages.
2825
4340
  * Returns whether the event was consumed into this segment: the cutting
2826
4341
  * event is left unconsumed (and thus behind the cursor) for the next one.
4342
+ *
4343
+ * `seq` is the update's transcript position when the caller has one: the
4344
+ * detached-worker hold uses it to count only the answer text that arrived
4345
+ * AFTER the hold began (a parked hold replays the parent's earlier ack).
2827
4346
  */
2828
- foldInto(seg, update) {
4347
+ foldInto(seg, update, seq) {
2829
4348
  const kind = String(update.sessionUpdate ?? '');
4349
+ // A DETACHED WORKER's own turn: its words and tools are the worker's, not
4350
+ // this session's answer. The transcript keeps them (the ACP view is where
4351
+ // the user can read them) and the shared tool-output registry still sees
4352
+ // the call, but nothing reaches this turn's message and no card opens for
4353
+ // the host to execute. This applies to EVERY turn, not just a hold.
4354
+ if (codebuddyMetaOf(update)?.parentToolCallId !== undefined) {
4355
+ this.noteToolRecord(update);
4356
+ return true;
4357
+ }
2830
4358
  if (kind === 'agent_thought_chunk' || kind === 'agent_message_chunk') {
2831
4359
  const text = chunkTextOf(update.content);
2832
4360
  if (text === '')
@@ -2836,11 +4364,36 @@ class TurnStreamController {
2836
4364
  return false;
2837
4365
  }
2838
4366
  seg.outputChars += text.length;
4367
+ // "The turn's own answer", as opposed to a background drain's prose that
4368
+ // happens to stream through the same session (see `isForeignAmbientText`).
4369
+ if (!this.isAmbientOwned(update))
4370
+ this.sawAgentProse = true;
4371
+ if (kind === 'agent_message_chunk') {
4372
+ this.sawVisibleText = true;
4373
+ // A hold's closing line asks whether ITS runs delivered answer text.
4374
+ // The parent's interim acknowledgement arrived before the hold and
4375
+ // already set `sawVisibleText`, so the hold keeps its own counter —
4376
+ // and only text that arrived after the hold began counts (a parked
4377
+ // hold replays that earlier ack from the transcript).
4378
+ const hold = this.activeBackgroundHold();
4379
+ if (hold !== undefined && (seq === undefined || seq > hold.floorSeq)) {
4380
+ hold.answerChars += text.length;
4381
+ }
4382
+ }
2839
4383
  this.emitProse(seg, kind === 'agent_thought_chunk' ? 'reasoning' : 'text', text);
2840
4384
  return true;
2841
4385
  }
2842
4386
  else if (kind === 'tool_call' || kind === 'tool_call_update') {
2843
4387
  this.noteToolRecord(update);
4388
+ if (seg.ambient || this.isAmbientOwned(update)) {
4389
+ // A background drain's tools stay in the transcript: opening a card
4390
+ // would make the host execute a tool and propose a continuation step
4391
+ // for a turn whose answer has not started yet. The ownership check
4392
+ // covers a NORMAL turn too — its own segment is not ambient, but a
4393
+ // drain can stream a tool call on the same session while it runs, and
4394
+ // that call is not this turn's work.
4395
+ return true;
4396
+ }
2844
4397
  const snapshot = acpToolCallFromUpdate(update);
2845
4398
  if (snapshot === undefined)
2846
4399
  return true;
@@ -2854,6 +4407,13 @@ class TurnStreamController {
2854
4407
  }
2855
4408
  if (this.seenCalls.has(snapshot.callId))
2856
4409
  return true; // a restatement of a card already settled
4410
+ // A BARE status-only update for a call this turn never announced is not
4411
+ // a card. The real wire sends the detached dispatch's completion exactly
4412
+ // that way (no `_meta`, no title, no input); a turn that begins after the
4413
+ // dispatch belongs to another turn's card, and opening one here would
4414
+ // make the host execute a phantom tool.
4415
+ if (kind === 'tool_call_update' && snapshot.name === '' && snapshot.argsRaw === '')
4416
+ return true;
2857
4417
  this.seenCalls.add(snapshot.callId);
2858
4418
  this.openToolCallBlock(seg, snapshot);
2859
4419
  return true;
@@ -3094,6 +4654,32 @@ export class BridgeManager {
3094
4654
  turnStreamFor(dshSessionId) {
3095
4655
  return this.turnStreams.get(dshSessionId);
3096
4656
  }
4657
+ /**
4658
+ * The ambient windows a turn's controller reads once at construction (see
4659
+ * {@link BridgeTuning.ambientQuietMs}). Exposed as a plain snapshot so the
4660
+ * controller never pokes at the manager's tuning object, and tests can tune
4661
+ * the cadence through the constructor like every other window.
4662
+ */
4663
+ get ambientTuning() {
4664
+ return {
4665
+ quietMs: this.tuning.ambientQuietMs,
4666
+ maxMs: this.tuning.ambientMaxMs,
4667
+ };
4668
+ }
4669
+ /**
4670
+ * The detached-worker hold's windows, read once at controller construction
4671
+ * for the same reason as {@link ambientTuning}: `taskMaxMs` is the worker
4672
+ * phase's only clock (see {@link BridgeTuning.backgroundTaskMaxMs}) and
4673
+ * `graceMs` the bounded wait a PARKED hold gives the host to open its
4674
+ * continuation step (see {@link BridgeTuning.backgroundParkedGraceMs}). The
4675
+ * drain phase reuses {@link ambientTuning}.
4676
+ */
4677
+ get backgroundTuning() {
4678
+ return {
4679
+ taskMaxMs: this.tuning.backgroundTaskMaxMs,
4680
+ graceMs: this.tuning.backgroundParkedGraceMs,
4681
+ };
4682
+ }
3097
4683
  /** Index a turn's controller, retiring whatever turn it supersedes. */
3098
4684
  rememberTurnStream(dshSessionId, controller) {
3099
4685
  const previous = this.turnStreams.get(dshSessionId);
@@ -3284,6 +4870,12 @@ export class BridgeManager {
3284
4870
  setSessionWorkspaceResolver(resolver) {
3285
4871
  this.sessionWorkspaceResolver = resolver;
3286
4872
  }
4873
+ /**
4874
+ * Set the attachment service or context used to resolve image attachments in user prompts.
4875
+ */
4876
+ setAttachmentResolver(resolver) {
4877
+ this.attachmentResolver = resolver;
4878
+ }
3287
4879
  /**
3288
4880
  * Wire the ACP-mode probe: given a DSH session id, report whether that
3289
4881
  * session runs the ACP agent preset. The host half reads it back from the
@@ -3404,6 +4996,7 @@ export class BridgeManager {
3404
4996
  ...(session.modes === undefined ? {} : { modes: session.modes }),
3405
4997
  ...(session.availableCommands === undefined ? {} : { availableCommands: session.availableCommands }),
3406
4998
  ...(session.usage === undefined ? {} : { usage: session.usage }),
4999
+ ...(session.burst === undefined ? {} : { burst: session.burst }),
3407
5000
  ...(session.released ? { released: true } : {}),
3408
5001
  ...(session.running ? { running: true } : {}),
3409
5002
  ...forkRowInfo(session, this.canForkNow(session.agentId)),
@@ -4316,6 +5909,21 @@ export class BridgeManager {
4316
5909
  for (const dshSessionId of this.chatAgentIntent.keys())
4317
5910
  this.syncConversationRoute(dshSessionId);
4318
5911
  }
5912
+ /**
5913
+ * Fold one settled ACP turn's measurable burst into the session aggregate and
5914
+ * persist it. A turn with no measured inter-chunk gap contributes neither
5915
+ * side: adding its tokens with zero time would inflate the session rate.
5916
+ */
5917
+ noteSessionBurst(session, outputTokens, burstMs) {
5918
+ if (outputTokens <= 0 || burstMs <= 0)
5919
+ return;
5920
+ const base = session.burst ?? { outputTokens: 0, burstMs: 0 };
5921
+ session.burst = {
5922
+ outputTokens: base.outputTokens + outputTokens,
5923
+ burstMs: base.burstMs + burstMs,
5924
+ };
5925
+ this.persistAgents();
5926
+ }
4319
5927
  persistAgents() {
4320
5928
  const filePath = this.getStoragePath();
4321
5929
  if (!filePath)
@@ -4390,7 +5998,7 @@ export class BridgeManager {
4390
5998
  continue;
4391
5999
  if (!this.defsMap.has(agentId))
4392
6000
  continue;
4393
- const { title, modelId, reasoningEffort, modes, usage, hasHistory } = record;
6001
+ const { title, modelId, reasoningEffort, modes, usage, burst, hasHistory } = record;
4394
6002
  const session = {
4395
6003
  sessionId,
4396
6004
  acpSessionId,
@@ -4404,6 +6012,7 @@ export class BridgeManager {
4404
6012
  ...(typeof reasoningEffort === 'string' && reasoningEffort !== '' ? { reasoningEffort } : {}),
4405
6013
  ...(isPersistedModes(modes) ? { modes } : {}),
4406
6014
  ...(isPersistedUsage(usage) ? { usage } : {}),
6015
+ ...(isPersistedBurst(burst) ? { burst } : {}),
4407
6016
  ...(hasHistory === true ? { hasHistory: true } : {}),
4408
6017
  running: false,
4409
6018
  turnSeq: 0,
@@ -4678,6 +6287,11 @@ export class BridgeManager {
4678
6287
  const def = this.defsMap.get(agentId);
4679
6288
  if (def === undefined || def.autoDiscoverModels === false || before === undefined)
4680
6289
  return false;
6290
+ // A different model-slot identity is a corrected reading, not newly added
6291
+ // models. Diffing across it would expose an entire real catalog the first
6292
+ // time a parser fix replaces a mislabelled slot (Qoder CN).
6293
+ if (before.model !== undefined && next.model !== undefined && before.model.configId !== next.model.configId)
6294
+ return false;
4681
6295
  const known = new Set((before.model?.choices ?? []).map(choice => choice.value));
4682
6296
  const exposed = new Set(def.exposedModels ?? []);
4683
6297
  const added = (next.model?.choices ?? [])
@@ -5077,6 +6691,12 @@ export class BridgeManager {
5077
6691
  const wanted = modelChoice !== undefined && modelChoice !== '' ? modelChoice : undefined;
5078
6692
  if (wanted === undefined)
5079
6693
  return undefined;
6694
+ // `category` is a UX hint, not identity: an agent may publish several
6695
+ // `category: 'model'` options (Qoder CN ships `reasoning_effort` that way)
6696
+ // and no explicit `model` id. In that case the selector is a guess, so a
6697
+ // "not offered" verdict would refuse a perfectly valid pick — fail open.
6698
+ if (!hasUnambiguousModelSelector(session.configOptions))
6699
+ return undefined;
5080
6700
  const model = parseCatalogOptions(session.configOptions).model;
5081
6701
  if (model === undefined)
5082
6702
  return undefined;
@@ -5320,7 +6940,7 @@ export class BridgeManager {
5320
6940
  const session = await this.bridge(agentId).createSession(cwd);
5321
6941
  return { sessionId: session.sessionId };
5322
6942
  }
5323
- async prompt(sessionId, text) {
6943
+ async prompt(sessionId, prompt) {
5324
6944
  let located = await this.locateOrResume(sessionId);
5325
6945
  // The watchdog abandoned a previous turn here and the agent may still be
5326
6946
  // holding its prompt slot on the old process: reclaim BEFORE sending, or
@@ -5339,8 +6959,36 @@ export class BridgeManager {
5339
6959
  located = await this.locateOrResume(sessionId);
5340
6960
  }
5341
6961
  const { bridge, session } = located;
5342
- if (session.running)
6962
+ // The agent prompt slot is SHARED with chat turns: `running` alone misses
6963
+ // a chat turn whose cancellation already released it (`running` false,
6964
+ // `promptInFlight` still naming the agent-side turn), and a live chat
6965
+ // controller means a continuation step may resume the SAME agent turn.
6966
+ // A controller that already SETTLED (kept only so its card's continuation
6967
+ // can still stream) has given the agent slot back: `/acp` may use it, and
6968
+ // advancing `turnSeq` retires it — the behaviour the stale-controller
6969
+ // watchdog case pins.
6970
+ const chatController = session.turnController;
6971
+ if (session.running
6972
+ || session.promptInFlight === true
6973
+ || (chatController !== undefined && !chatController.stale && !chatController.hasSettled)) {
5343
6974
  throw new AcpError('turn_in_flight', 'acp: this session already has a prompt in flight');
6975
+ }
6976
+ const blocks = typeof prompt === 'string'
6977
+ ? [{ type: 'text', text: prompt }]
6978
+ : prompt;
6979
+ if (blocks.length === 0) {
6980
+ throw new AcpError('empty_prompt', 'acp: prompt cannot be empty');
6981
+ }
6982
+ const hasImages = blocks.some(b => b.type === 'image');
6983
+ if (hasImages && bridge.initResult?.agentCapabilities?.promptCapabilities?.image !== true) {
6984
+ throw new AcpError('image_unsupported', `acp: agent "${bridge.def.id}" does not support image input`);
6985
+ }
6986
+ // Claim the SAME slot the chat path claims (`claimTurn` + `beginTurn`): an
6987
+ // `/acp` turn in flight must make the next chat prompt wait, and vice
6988
+ // versa. The settler is idempotent and identity-guarded; the turn still
6989
+ // ends through `turn/end` below, so chat and RPC turns share one gate
6990
+ // without sharing a stream controller.
6991
+ const endTurn = bridge.beginTurn(session);
5344
6992
  session.running = true;
5345
6993
  session.turnSeq += 1;
5346
6994
  const turnGen = session.turnSeq;
@@ -5351,9 +6999,12 @@ export class BridgeManager {
5351
6999
  session.reclaimedTurnSeq = undefined;
5352
7000
  this.armStallTimer();
5353
7001
  const turnId = randomUUID();
5354
- this.hub.broadcast({ type: 'turn/start', sessionId, turnId, text });
5355
- void bridge.runPrompt(session, text, turnId)
5356
- .then(stopReason => {
7002
+ const promptText = typeof prompt === 'string'
7003
+ ? prompt
7004
+ : blocks.filter(b => b.type === 'text').map(b => b.text).join('\n');
7005
+ this.hub.broadcast({ type: 'turn/start', sessionId, turnId, text: promptText });
7006
+ void bridge.runPrompt(session, blocks, turnId, turnGen)
7007
+ .then(result => {
5357
7008
  // The reclaim already ended this turn locally; the answer it caused is
5358
7009
  // that same turn reporting back, not a second turn end for the caller.
5359
7010
  if (session.reclaimedTurnSeq === turnGen) {
@@ -5364,8 +7015,11 @@ export class BridgeManager {
5364
7015
  if (session.turnSeq !== turnGen)
5365
7016
  return;
5366
7017
  this.clearOrphanPrompt(session, turnGen);
7018
+ // Release the shared slot BEFORE the terminal frame: a listener that
7019
+ // sends the next prompt on `turn/end` must not read a stale claim.
7020
+ endTurn();
5367
7021
  session.running = false;
5368
- this.hub.broadcast({ type: 'turn/end', sessionId, turnId, stopReason });
7022
+ this.hub.broadcast({ type: 'turn/end', sessionId, turnId, stopReason: result.stopReason });
5369
7023
  })
5370
7024
  .catch((error) => {
5371
7025
  if (session.reclaimedTurnSeq === turnGen) {
@@ -5383,6 +7037,7 @@ export class BridgeManager {
5383
7037
  return;
5384
7038
  }
5385
7039
  this.clearOrphanPrompt(session, turnGen);
7040
+ endTurn();
5386
7041
  session.running = false;
5387
7042
  this.hub.broadcast({
5388
7043
  type: 'turn/end',
@@ -5390,7 +7045,10 @@ export class BridgeManager {
5390
7045
  turnId,
5391
7046
  error: describeError(error),
5392
7047
  });
5393
- });
7048
+ })
7049
+ // Every outcome — normal answer, failure, reclaim early-return, or a
7050
+ // close's rejection — releases the shared prompt slot exactly once.
7051
+ .finally(() => { endTurn(); });
5394
7052
  return { turnId };
5395
7053
  }
5396
7054
  /** The late answer landed (or failed): the turn is no longer an orphan. */
@@ -5698,6 +7356,7 @@ export class BridgeManager {
5698
7356
  }
5699
7357
  sessionWorkspaceResolver;
5700
7358
  acpModeResolver;
7359
+ attachmentResolver;
5701
7360
  /** Cached `chatSessionAcpMode` answers, keyed by DSH chat session id. */
5702
7361
  acpModeCache = new Map();
5703
7362
  /** In-flight cold reads behind `chatSessionAcpModeAnswer`, keyed by session id. */
@@ -5982,6 +7641,44 @@ export class BridgeManager {
5982
7641
  });
5983
7642
  return { ok: true, reset: true };
5984
7643
  }
7644
+ /**
7645
+ * 聊天插话的槽位回收:宿主把插话作为同一会话在飞回合的下一步送达,
7646
+ * 拒绝会让整条 host turn 失败。ACP 不能向运行中的 prompt 注入消息,
7647
+ * 所以按插话语义中止当前回合,等 agent 真正结算后再让调用方重发。
7648
+ *
7649
+ * 只处理 `claimTurn` 的 `busy`(agent 仍持有本会话 prompt);`unsettled`
7650
+ * 说明我们这一侧已经释放过槽位(用户按过停止),保持原样拒绝。
7651
+ *
7652
+ * - 控制器已 settle(响应已到,延后收口还没释放闸门):什么都不取消,只等闸门;
7653
+ * - agent 真在跑:走用户取消同一条管线(session/cancel + 结算权限 + cancel fallback),
7654
+ * 并立即关闭宿主这一侧的 segment,让闸门在 agent 应答/兜底后打开。
7655
+ */
7656
+ async reclaimChatPrompt(session, controller, signal) {
7657
+ if (session.closed || session.released)
7658
+ return 'ok';
7659
+ if (!controller.hasSettled && session.promptInFlight === true) {
7660
+ await this.bridge(session.agentId).cancel(session);
7661
+ controller.abortActiveSegment();
7662
+ }
7663
+ return this.waitForSettleGate(session, signal);
7664
+ }
7665
+ /** 等 agent 侧 prompt slot 真正释放;步骤被取消时立即返回 aborted。 */
7666
+ waitForSettleGate(session, signal) {
7667
+ const pending = session.settled;
7668
+ if (pending === undefined)
7669
+ return Promise.resolve('ok');
7670
+ if (signal?.aborted)
7671
+ return Promise.resolve('aborted');
7672
+ return new Promise(resolve => {
7673
+ const done = (value) => {
7674
+ signal?.removeEventListener('abort', onAbort);
7675
+ resolve(value);
7676
+ };
7677
+ const onAbort = () => { done('aborted'); };
7678
+ signal?.addEventListener('abort', onAbort, { once: true });
7679
+ void pending.then(() => { done('ok'); });
7680
+ });
7681
+ }
5985
7682
  async *streamPrompt(options) {
5986
7683
  // The plugin's own conversation↔agent mapping decides who serves the turn
5987
7684
  // (see `bindChatAgent`): the driver may arrive here with the previous
@@ -6074,8 +7771,39 @@ export class BridgeManager {
6074
7771
  // Forward the person's own words: the host folds its context sections
6075
7772
  // (AGENTS.md digest, skill catalog, runtime snapshot) after the claimed user
6076
7773
  // batch precisely so a consumer can tell them apart by `source` — see
6077
- // `selectForwardPrompt` and AGENTS.md rule 16.
6078
- const selection = selectForwardPrompt(options.messages ?? []);
7774
+ const workspaceRoot = chatWorkspaceRoot(this.sessionWorkspaceResolver, options.sessionId);
7775
+ const defaultReadFile = (p) => {
7776
+ try {
7777
+ if (existsSync(p))
7778
+ return readFileSync(p);
7779
+ if (workspaceRoot !== undefined && !isAbsolutePath(p)) {
7780
+ const joined = join(workspaceRoot, p);
7781
+ if (existsSync(joined))
7782
+ return readFileSync(joined);
7783
+ }
7784
+ }
7785
+ catch { }
7786
+ return undefined;
7787
+ };
7788
+ const resolver = this.attachmentResolver;
7789
+ const attachmentContext = resolver !== undefined && typeof resolver === 'object' && resolver !== null
7790
+ ? Object.assign(Object.create(resolver), {
7791
+ readFile: (p) => {
7792
+ if (typeof resolver.readFile === 'function') {
7793
+ try {
7794
+ const res = resolver.readFile(p);
7795
+ if (res !== undefined)
7796
+ return res;
7797
+ }
7798
+ catch { }
7799
+ }
7800
+ return defaultReadFile(p);
7801
+ },
7802
+ })
7803
+ : {
7804
+ readFile: defaultReadFile,
7805
+ };
7806
+ const selection = selectForwardPrompt(options.messages ?? [], attachmentContext);
6079
7807
  if (selection.kind === 'no-user-input') {
6080
7808
  // An injected-context wake: nothing the user said, nothing to send.
6081
7809
  yield { type: 'finish', reason: { kind: 'stop' } };
@@ -6140,13 +7868,8 @@ export class BridgeManager {
6140
7868
  };
6141
7869
  return;
6142
7870
  }
6143
- // One prompt slot per session, and a turn the user aborted still occupies
6144
- // the AGENT's slot until it acknowledges the cancel (see `claimTurn`).
6145
- const claim = await bridge.claimTurn(session);
6146
- if (!claim.ok) {
6147
- // A first turn that never got the prompt slot delivered nothing either:
6148
- // give the conversation back instead of leaving it locked to an agent
6149
- // that refused the only message it was asked to serve.
7871
+ const hasImages = (selection.images?.length ?? 0) > 0 || (selection.content?.some(b => b.type === 'image') ?? false);
7872
+ if (hasImages && bridge.initResult?.agentCapabilities?.promptCapabilities?.image !== true) {
6150
7873
  if (session.provisionalLock === true)
6151
7874
  this.rollbackFirstTurn(session);
6152
7875
  yield {
@@ -6154,15 +7877,92 @@ export class BridgeManager {
6154
7877
  reason: {
6155
7878
  kind: 'error',
6156
7879
  failure: {
6157
- message: claim.reason === 'busy'
6158
- ? 'acp: this session already has a prompt in flight'
6159
- : 'acp: the agent is still finishing the previous (cancelled) turn; send again in a moment',
6160
- code: 'turn_in_flight',
7880
+ message: `acp: agent "${agentId}" does not support image input`,
7881
+ code: 'image_unsupported',
6161
7882
  },
6162
7883
  },
6163
7884
  };
6164
7885
  return;
6165
7886
  }
7887
+ // One prompt slot per session, and a turn the user aborted still occupies
7888
+ // the AGENT's slot until it acknowledges the cancel (see `claimTurn`).
7889
+ let claim = await bridge.claimTurn(session);
7890
+ if (!claim.ok) {
7891
+ // A STEER ("插话"): the host delivers the user's mid-turn message as the
7892
+ // NEXT step of the SAME host turn, so the conversation's own ACP prompt
7893
+ // is still on air. Refusing would fail the whole host turn. ACP forbids
7894
+ // injecting into a running prompt, so the steer's real semantic — abort
7895
+ // the current turn, then send this one — is spelled cancel + wait +
7896
+ // re-claim here.
7897
+ //
7898
+ // Only `busy` can be a steer: it means the AGENT still holds OUR prompt
7899
+ // (`promptInFlight`). `unsettled` means our side already released the
7900
+ // slot (the person pressed stop, or an earlier steer cancelled): the
7901
+ // agent has not acknowledged yet, and quietly queueing the next message
7902
+ // behind a turn the user stopped is not this path's business — that
7903
+ // stays the honest `turn_in_flight` refusal below.
7904
+ //
7905
+ // Only the conversation's OWN in-flight turn qualifies (its live stream
7906
+ // controller, on this very session). A `/acp` RPC turn has no chat
7907
+ // controller, and another conversation never reaches this session, so
7908
+ // both keep the plain refusal: nothing may be cancelled behind a
7909
+ // caller's back.
7910
+ const chatController = claim.reason === 'busy' && options.sessionId !== undefined
7911
+ ? this.turnStreamFor(options.sessionId)
7912
+ : undefined;
7913
+ if (options.sessionId !== undefined
7914
+ && chatController !== undefined
7915
+ && !chatController.stale
7916
+ && chatController.session === session) {
7917
+ const reclaimed = await this.reclaimChatPrompt(session, chatController, options.signal);
7918
+ if (reclaimed === 'aborted') {
7919
+ // The person pressed stop while the previous turn was winding down:
7920
+ // the steer is theirs to re-send, nothing goes to the agent.
7921
+ yield {
7922
+ type: 'finish',
7923
+ reason: { kind: 'aborted', failure: { message: 'aborted', code: 'ABORTED' } },
7924
+ };
7925
+ return;
7926
+ }
7927
+ // A first turn that produced nothing rolled its provisional lock back
7928
+ // and CLOSED (and forgot) this session: the steer needs a fresh one.
7929
+ if (session.closed || session.released) {
7930
+ try {
7931
+ session = await this.getOrCreateDshSession(options.sessionId, agentId);
7932
+ }
7933
+ catch (err) {
7934
+ yield {
7935
+ type: 'finish',
7936
+ reason: { kind: 'error', failure: { message: describeError(err), code: 'session_init_failed' } },
7937
+ };
7938
+ return;
7939
+ }
7940
+ }
7941
+ // Same bridge: the session may have been re-created, but the
7942
+ // conversation is still served by this agent's process.
7943
+ claim = await bridge.claimTurn(session);
7944
+ }
7945
+ if (!claim.ok) {
7946
+ // Never roll the provisional lock back here: a failed claim means
7947
+ // ANOTHER prompt holds the slot (or the agent has not settled it), and
7948
+ // that turn owns the conversation's lock. Closing the session would
7949
+ // kill a turn that is actively streaming — the refusal already tells
7950
+ // the user to retry.
7951
+ yield {
7952
+ type: 'finish',
7953
+ reason: {
7954
+ kind: 'error',
7955
+ failure: {
7956
+ message: claim.reason === 'busy'
7957
+ ? 'acp: this session already has a prompt in flight'
7958
+ : 'acp: the agent is still finishing the previous (cancelled) turn; send again in a moment',
7959
+ code: 'turn_in_flight',
7960
+ },
7961
+ },
7962
+ };
7963
+ return;
7964
+ }
7965
+ }
6166
7966
  // Same-agent model/effort switches ride the SAME ACP session (context
6167
7967
  // stays continuous); switching to another agent lands in a separate one
6168
7968
  // via the `${dshSessionId}:${agentId}` key in getOrCreateDshSession.
@@ -6280,8 +8080,11 @@ export class BridgeManager {
6280
8080
  // next turn has to respect. Settlement reaches the controller, which
6281
8081
  // releases the slot (`endTurn`) whether the segment on air is the first
6282
8082
  // or a continuation one.
6283
- bridge.runPrompt(session, promptText, turnId)
6284
- .then(stopReason => {
8083
+ const promptBlocks = selection.content && selection.content.length > 0
8084
+ ? selection.content
8085
+ : [{ type: 'text', text: promptText }];
8086
+ bridge.runPrompt(session, promptBlocks, turnId, turnGen)
8087
+ .then(result => {
6285
8088
  // The reclaim already ended this turn locally, and the close it
6286
8089
  // performed rejected this very request: the late answer is that same
6287
8090
  // turn, not a second one to broadcast.
@@ -6289,9 +8092,76 @@ export class BridgeManager {
6289
8092
  session.reclaimedTurnSeq = undefined;
6290
8093
  return;
6291
8094
  }
6292
- controller.settle(stopReason === 'cancelled'
8095
+ const responseMeta = codebuddyMetaOf({ _meta: result.meta });
8096
+ // A DETACHED-WORKER HOLD: this turn's `Agent` call handed real work
8097
+ // to a background worker that has not produced its answer yet, yet
8098
+ // the agent answered the prompt normally (CodeBuddy says "已派…" and
8099
+ // ends its own turn). Closing here loses the only answer the turn
8100
+ // will ever produce — the worker's report streams into this same
8101
+ // session later, as a background drain. Hold the turn open until
8102
+ // every dispatch reports and the drain goes quiet (see
8103
+ // `holdForBackgroundDispatch`); the prompt slot is released there.
8104
+ //
8105
+ // The decision comes BEFORE `adoptTurnRequestId`: adopting the id
8106
+ // would clear the updates the drain streamed while this turn's own id
8107
+ // was unknown, and those are exactly the ones the hold must fold.
8108
+ const holdDispatch = controller.dispatchedDetachedWorker
8109
+ && !session.released
8110
+ && !session.closed
8111
+ && result.stopReason !== 'cancelled';
8112
+ if (!holdDispatch && responseMeta?.conversationRequestId !== undefined) {
8113
+ // The response names the conversation turn it queued: an instant
8114
+ // answer that still carries one is a real (if silent) turn, and its
8115
+ // prose can be told apart from a background drain's.
8116
+ controller.adoptTurnRequestId(responseMeta.conversationRequestId);
8117
+ }
8118
+ // An announced drain that has shown NO sign of life for the staleness
8119
+ // window is over, even though nothing said so (CodeBuddy never sends
8120
+ // `session_end`). Keeping the record would make a later instant answer
8121
+ // with no conversation request id look steered, and hold a normal turn
8122
+ // open against a drain that is never coming back. RETIRE the id
8123
+ // first, so a late update or a response naming it is never adopted as
8124
+ // this session's own turn; then forget the record (the notice de-dupe
8125
+ // set with it) so this response settles normally.
8126
+ if (session.ambient !== undefined
8127
+ && Date.now() - session.ambient.lastActivityAt > this.tuning.ambientStaleMs) {
8128
+ retireAmbientId(session, session.ambient.requestId);
8129
+ session.ambient = undefined;
8130
+ session.ambientNotices?.clear();
8131
+ }
8132
+ if (holdDispatch) {
8133
+ // The response may name one run while an announcement named
8134
+ // another; the hold accepts both (see `BackgroundHold.acceptedIds`).
8135
+ controller.holdForBackgroundDispatch(result.stopReason, responseMeta?.conversationRequestId ?? responseMeta?.requestId);
8136
+ return;
8137
+ }
8138
+ // An instant answer with NO conversation request id, while the agent
8139
+ // announced a background drain this turn has not spoken into: the
8140
+ // prompt was folded into that drain (CodeBuddy's multitask). The
8141
+ // response is a formality — keep the turn open until the drain speaks.
8142
+ if (session.ambient !== undefined
8143
+ && !session.released
8144
+ && !session.closed
8145
+ && result.stopReason !== 'cancelled'
8146
+ && responseMeta?.conversationRequestId === undefined
8147
+ && !controller.hasAgentProse) {
8148
+ controller.awaitAmbient(session.ambient.requestId, result.stopReason);
8149
+ return;
8150
+ }
8151
+ // No ambient record to wait on, but a drain this session stopped
8152
+ // waiting on was retired moments ago: the run may have taken this
8153
+ // prompt WITHOUT re-announcing itself. The turn cannot be held open
8154
+ // (nothing names the drain), but a silent close must say the output
8155
+ // may be in the transcript instead of blaming the agent.
8156
+ if (session.ambient === undefined
8157
+ && result.stopReason !== 'cancelled'
8158
+ && responseMeta?.conversationRequestId === undefined
8159
+ && hasRecentRetiredAmbient(session, Math.max(this.tuning.ambientQuietMs * 2, RETIRED_AMBIENT_RECENT_MIN_MS))) {
8160
+ controller.noteRecentRetiredDrain();
8161
+ }
8162
+ controller.settle(result.stopReason === 'cancelled'
6293
8163
  ? { kind: 'cancelled' }
6294
- : { kind: 'stop', stopReason });
8164
+ : { kind: 'stop', stopReason: result.stopReason });
6295
8165
  })
6296
8166
  .catch((err) => {
6297
8167
  if (session.reclaimedTurnSeq === turnGen) {