dsh-ntr 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.en.md +3 -1
  2. package/README.md +3 -1
  3. package/cordis.patch.yml +17 -1
  4. package/lib/bridge.js +2436 -82
  5. package/lib/client/acp-ui.js +103 -3
  6. package/lib/client/acp-view.js +24 -4
  7. package/lib/client/activity-pill.js +156 -0
  8. package/lib/client/branch-action.js +149 -0
  9. package/lib/client/composer-mode.js +48 -13
  10. package/lib/client/context-meter.js +86 -0
  11. package/lib/client/index.js +279 -16
  12. package/lib/client/model-seat.js +8 -2
  13. package/lib/client/panel.js +96 -29
  14. package/lib/client/released-banner.js +66 -0
  15. package/lib/client/session-controls.js +38 -13
  16. package/lib/client/slash-isolation.js +2 -2
  17. package/lib/client/store.js +380 -13
  18. package/lib/client.js +1364 -70
  19. package/lib/client.js.map +1 -1
  20. package/lib/impl.js +1229 -8
  21. package/lib/index.js +34 -0
  22. package/lib/protocol.js +19 -0
  23. package/lib/tps.js +32 -0
  24. package/lib/types/bridge.d.ts +850 -18
  25. package/lib/types/client/acp-ui.d.ts +34 -2
  26. package/lib/types/client/acp-view.d.ts +13 -2
  27. package/lib/types/client/activity-pill.d.ts +81 -0
  28. package/lib/types/client/branch-action.d.ts +47 -0
  29. package/lib/types/client/composer-mode.d.ts +28 -0
  30. package/lib/types/client/context-meter.d.ts +51 -0
  31. package/lib/types/client/model-seat.d.ts +21 -0
  32. package/lib/types/client/released-banner.d.ts +34 -0
  33. package/lib/types/client/slash-isolation.d.ts +1 -1
  34. package/lib/types/client/store.d.ts +108 -1
  35. package/lib/types/impl.d.ts +94 -7
  36. package/lib/types/protocol.d.ts +145 -0
  37. package/lib/types/tps.d.ts +17 -0
  38. package/package.json +2 -1
  39. package/src/bridge.ts +2754 -86
  40. package/src/client/acp-ui.tsx +123 -2
  41. package/src/client/acp-view.tsx +30 -5
  42. package/src/client/activity-pill.tsx +201 -0
  43. package/src/client/branch-action.tsx +178 -0
  44. package/src/client/composer-mode.tsx +59 -13
  45. package/src/client/context-meter.tsx +128 -0
  46. package/src/client/index.tsx +298 -15
  47. package/src/client/model-seat.tsx +28 -3
  48. package/src/client/panel.tsx +157 -40
  49. package/src/client/primitives.d.ts +27 -0
  50. package/src/client/released-banner.tsx +97 -0
  51. package/src/client/session-controls.tsx +34 -11
  52. package/src/client/slash-isolation.ts +2 -1
  53. package/src/client/store.ts +412 -14
  54. package/src/impl.ts +1251 -13
  55. package/src/index.ts +33 -0
  56. package/src/protocol.ts +145 -0
  57. package/src/tps.ts +40 -0
@@ -6,10 +6,10 @@
6
6
  * Connection RPC channel (authenticated) into `BridgeManager`, which is the
7
7
  * only object allowed to spawn a configured command.
8
8
  */
9
- import { type InitializeResponse } from '@agentclientprotocol/sdk';
9
+ import { type ContentBlock, type InitializeResponse } from '@agentclientprotocol/sdk';
10
10
  import { EventHub, PermissionLedger, Transcript } from './impl.ts';
11
11
  import type { AcpToolCallSnapshot, PendingPermissionView, RosterPresetRow } from './impl.ts';
12
- import type { AcpAgentCatalog, AcpAgentDef, AcpApprovalDeadlines, AcpConversationChoice, AcpPresetStatus, AcpReleaseCause, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentTestResult, AgentView, ChatSessionView, ChatTranscriptView, DetectedAgentInfo, GenerateOptions, LlmAdapterLike, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, ApprovalDeadlineKey, ApprovalDeadlinePatch, PermissionTimeoutMode, PluginUpdateState, PreparedAdapterCall, SessionView, StreamChunk } from './protocol.ts';
12
+ import type { AcpAgentCatalog, AcpAgentDef, AcpApprovalDeadlines, AcpConversationChoice, AcpPresetStatus, AcpReleaseCause, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentTestResult, AgentView, ChatSessionView, ChatTranscriptView, DetectedAgentInfo, GenerateOptions, LlmAdapterLike, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, AcpIdleRelease, ApprovalDeadlineKey, ApprovalDeadlinePatch, PermissionTimeoutMode, PluginUpdateState, PreparedAdapterCall, SessionView, StreamChunk } from './protocol.ts';
13
13
  import { UpdateService } from './update.ts';
14
14
  /**
15
15
  * Version of this installed package, read from the shipped `package.json` —
@@ -41,10 +41,42 @@ interface LiveSession {
41
41
  availableCommands?: SessionView['availableCommands'];
42
42
  plan?: unknown[];
43
43
  usage?: SessionView['usage'];
44
+ /**
45
+ * The plugin's own measurable burst aggregate over this ACP session's turns
46
+ * (see `src/tps.ts`). Persisted with the chat-session record so the dock's
47
+ * rate survives a host restart; absent until a turn contributes a measurable
48
+ * burst (>= 1 inter-chunk gap).
49
+ */
50
+ burst?: SessionView['burst'];
44
51
  running: boolean;
52
+ /**
53
+ * The HOST's prompt slot for this session: true from the moment a turn
54
+ * starts until the agent answers it (or the host aborts the turn, or the
55
+ * turn is discarded). Together with the {@link settled} gate — which STAYS
56
+ * claimed after an abort until the agent acknowledges the cancel — this is
57
+ * what `claimTurn` arbitrates, NOT `running`.
58
+ *
59
+ * The three are deliberately different:
60
+ * - an ambient wait keeps `running` true (the background drain is still the
61
+ * session's work, so nothing may release the process) while this slot is
62
+ * already FREE (the agent answered; only the drain keeps talking);
63
+ * - an aborted turn drops THIS flag at once (our face of the turn ends) but
64
+ * leaves `settled` in place, so the next prompt still waits out
65
+ * `turnSettleMs` for the agent instead of racing a concurrent prompt.
66
+ */
67
+ promptInFlight?: boolean;
45
68
  closed: boolean;
46
69
  /** The agent process died; the session awaits a `session/load` resume. */
47
70
  released: boolean;
71
+ /**
72
+ * The agent-side session has history while the LOCAL buffer is empty: this
73
+ * record was restored from disk after a host restart (the replay has not
74
+ * landed yet), or a resume cleared the buffer ahead of the replay. The ACP
75
+ * view then says history replays on restore instead of "nothing yet". Set
76
+ * by `doResumeSession` (captured before the reset) and the restore path; the
77
+ * serializer ORs it with the live buffer length.
78
+ */
79
+ hasHistory?: boolean;
48
80
  /**
49
81
  * When the agent last showed ANY sign of life on this session: an update, a
50
82
  * permission ask (arrival or answer), or a turn start. The stall watchdog
@@ -142,8 +174,70 @@ interface LiveSession {
142
174
  * the controller's own `retire` when it still points at it.
143
175
  */
144
176
  turnController?: TurnStreamController;
177
+ /**
178
+ * A background turn the agent started on its OWN initiative (CodeBuddy's
179
+ * multitask drains: a sub-agent completion notice starts a run the client
180
+ * never asked for). Its first `session_info_update` announces the request id
181
+ * every later update of that run carries in `_meta`; a `session/prompt`
182
+ * answered instantly with no conversation request id was folded into this
183
+ * drain and its real answer arrives as the drain's updates.
184
+ *
185
+ * Only lifetime events clear it (close / release / resume / stop): a drain
186
+ * may outlive the turn that was steered into it, and clearing on a normal
187
+ * settle would misclassify the NEXT steered prompt.
188
+ */
189
+ ambient?: AmbientTurn;
190
+ /**
191
+ * Request ids whose `agent/notice` already went out, so a repeated
192
+ * announcement of the same background run is not logged twice.
193
+ */
194
+ ambientNotices?: Set<string>;
195
+ /**
196
+ * Background drains this session already STOPPED waiting on (an ambient wait
197
+ * that closed on silence, or a record that outlived `ambientStaleMs`). The
198
+ * record is gone from {@link ambient}, but the drain itself may still be
199
+ * running: a queued message or a long tool makes it speak again while the
200
+ * NEXT turn is on air. Without this list the late text would teach that turn
201
+ * the drain's id (`noteTurnRequestId` / `adoptTurnRequestId`) and fold the
202
+ * background run's answer into the user's answer (a double answer).
203
+ *
204
+ * An update or re-announcement carrying a listed id REVIVES it
205
+ * (`reviveAmbientId` + a rebuilt {@link ambient}): the run is alive, so its
206
+ * next steered prompt must receive the drain's text. Only while the record
207
+ * stays retired is its text transcript-only. Timestamps let the zero-output
208
+ * report recognise a prompt folded into a recently quieted drain
209
+ * (`hasRecentRetiredAmbient`). Bounded ({@link RETIRED_AMBIENT_MAX}), oldest
210
+ * evicted first; every lifecycle boundary clears the list
211
+ * (`clearAmbientState`).
212
+ */
213
+ retiredAmbients?: RetiredAmbient[];
145
214
  transcript: Transcript;
146
215
  }
216
+ /** One unsolicited (background) turn the agent is running, as announced in `_meta`. */
217
+ interface AmbientTurn {
218
+ /** `codebuddy.ai/requestId` of the drain; every update of the run carries it. */
219
+ requestId: string;
220
+ startedAt: number;
221
+ /**
222
+ * When this drain last showed any sign of life (announcement or an update
223
+ * carrying its request id). {@link BridgeTuning.ambientStaleMs} expires the
224
+ * record from here, so a drain that died silently does not capture a later
225
+ * normal turn as steered.
226
+ */
227
+ lastActivityAt: number;
228
+ /** Whether the drain already streamed prose (text or thought) into the transcript. */
229
+ sawOutput: boolean;
230
+ }
231
+ /** A drain id the session stopped waiting on, and when it was retired. */
232
+ interface RetiredAmbient {
233
+ requestId: string;
234
+ /**
235
+ * When the wait was retired. `hasRecentRetiredAmbient` uses it to tell a
236
+ * prompt folded into a just-quieted drain from one that ended on its own;
237
+ * `adoptTurnRequestId` / `noteTurnRequestId` ignore the id at any age.
238
+ */
239
+ retiredAt: number;
240
+ }
147
241
  /** Reliability tuning, threaded from `AcpPluginConfig` through the manager. */
148
242
  export interface BridgeTuning {
149
243
  startupTimeoutMs?: number;
@@ -238,6 +332,65 @@ export interface BridgeTuning {
238
332
  * probe-only process has no stake in. `0` disables it.
239
333
  */
240
334
  probeIdleTimeoutMs?: number;
335
+ /**
336
+ * How long an ambient wait (see `TurnStreamController.awaitAmbient`) stays
337
+ * open after the LAST session update before the turn closes itself.
338
+ *
339
+ * A drain's end is its silence — the SDK's schema rejects the `session_end`
340
+ * CodeBuddy never sends anyway — but "silence" must be measured in the
341
+ * agent's own cadence, not in a chat-app-paced one. Measured against
342
+ * CodeBuddy 2.179.0: the gaps between drain ticks (the
343
+ * `model_requesting`→`model_streaming` hand-off of a real model call) run
344
+ * 1.0–1.4s, so any window below that closes the turn BEFORE the answer
345
+ * exists. 10s leaves room for two slow hand-offs without holding the turn
346
+ * open for a drain that actually finished.
347
+ *
348
+ * Deliberately NOT keyed on an `agentPhase` idle flag: a drain goes idle
349
+ * between queued messages too, so that signal closes the turn in the middle
350
+ * of its own work.
351
+ */
352
+ ambientQuietMs?: number;
353
+ /**
354
+ * The hard ceiling on ONE ambient wait: a background run that keeps ticking
355
+ * for minutes without finishing must not hold the conversation's turn open
356
+ * forever. At the ceiling the turn says where the rest of the output will
357
+ * land and closes.
358
+ */
359
+ ambientMaxMs?: number;
360
+ /**
361
+ * The hard ceiling on the WORKER phase of a detached-worker hold (see
362
+ * `TurnStreamController.holdForBackgroundDispatch`): the turn dispatched one
363
+ * or more background workers through `Agent` tool calls, the agent answered
364
+ * the prompt, and at least one worker has not reported back yet. The worker
365
+ * phase has NO quiet window on purpose — measured against CodeBuddy 2.179.0,
366
+ * a detached worker's own updates are 13–24s apart while it is perfectly
367
+ * alive — so only EVERY known dispatch reporting (completed / failed) or
368
+ * this ceiling can end it; a drain announcement is recorded but arms no
369
+ * clock before then. Once they have all reported, the drain's own
370
+ * `ambientQuietMs` / `ambientMaxMs` windows take over.
371
+ *
372
+ * Default 600000 ms (10 minutes).
373
+ */
374
+ backgroundTaskMaxMs?: number;
375
+ /**
376
+ * How long a PARKED detached-worker hold may wait for the host to open the
377
+ * driver step that carries the worker's report. The prompt response landed
378
+ * between two steps (the host is about to execute the `Agent` card), and a
379
+ * host that never opens the continuation step must not hang the turn. The
380
+ * host normally opens it at once, so this is a safety bound, not a cadence —
381
+ * deliberately far larger than one model hand-off.
382
+ *
383
+ * Default 30000 ms (30 seconds).
384
+ */
385
+ backgroundParkedGraceMs?: number;
386
+ /**
387
+ * How long an announced background drain may go without ANY update before a
388
+ * later instant prompt stops treating itself as folded into it. A drain that
389
+ * went silent for minutes is over even though nothing told us; keeping the
390
+ * record would misclassify the next normal turn as steered. Checked lazily
391
+ * when a prompt response arrives (see `streamPrompt`), never on a timer.
392
+ */
393
+ ambientStaleMs?: number;
241
394
  }
242
395
  /**
243
396
  * Resolves the workspace root of one chat session (`dshSessionId` = DSH session
@@ -279,6 +432,20 @@ export declare class AgentBridge {
279
432
  * this bridge was built.
280
433
  */
281
434
  private readonly sessionWorkspace?;
435
+ /**
436
+ * Told whenever the SESSION RECORDS change shape (a release parks them, a
437
+ * resume revives them): the manager persists `chatSessions` through it, so
438
+ * a parked conversation stays resumable across a host restart (see
439
+ * `BridgeManager.loadPersistedChatSessions`).
440
+ */
441
+ private readonly onSessionsDirty?;
442
+ /**
443
+ * Told whenever a fresh `initialize` reports the agent's
444
+ * `sessionCapabilities.fork` bit — the LAST KNOWN fork capability the
445
+ * manager persists and serves to the browser (the bridge's own
446
+ * `initResult` dies with the process; the manager's copy survives it).
447
+ */
448
+ private readonly onForkCapability?;
282
449
  state: BridgeState;
283
450
  initResult?: InitializeResponse;
284
451
  lastError?: string;
@@ -309,7 +476,7 @@ export declare class AgentBridge {
309
476
  private readonly staleCancelRetryDelayMs;
310
477
  private readonly staleCancelMaxRetries;
311
478
  private readonly turnSettleMs;
312
- private readonly defaultIdleTimeoutMs;
479
+ private defaultIdleTimeoutMs;
313
480
  private readonly probeIdleTimeoutMs;
314
481
  /** Armed while READY; fires the idle-release check. */
315
482
  private idleTimer?;
@@ -330,6 +497,13 @@ export declare class AgentBridge {
330
497
  * that a caller is waiting on the child it is about to kill.
331
498
  */
332
499
  private pendingCreates;
500
+ /**
501
+ * `session/fork` requests in flight (the adopt path of a branched
502
+ * conversation). Same teardown hazard as {@link pendingCreates}: until the
503
+ * forked session is registered, nothing else would stop an idle release
504
+ * from killing the child out from under the caller.
505
+ */
506
+ private pendingForks;
333
507
  /**
334
508
  * Whether a REAL session has ever been registered on this bridge. Probes
335
509
  * bypass session bookkeeping, so a bridge that has never hosted anything was
@@ -366,7 +540,21 @@ export declare class AgentBridge {
366
540
  * Resolved per turn: the host entry may wire the workspace reader after
367
541
  * this bridge was built.
368
542
  */
369
- sessionWorkspace?: (() => SessionWorkspaceResolver | undefined) | undefined);
543
+ sessionWorkspace?: (() => SessionWorkspaceResolver | undefined) | undefined,
544
+ /**
545
+ * Told whenever the SESSION RECORDS change shape (a release parks them, a
546
+ * resume revives them): the manager persists `chatSessions` through it, so
547
+ * a parked conversation stays resumable across a host restart (see
548
+ * `BridgeManager.loadPersistedChatSessions`).
549
+ */
550
+ onSessionsDirty?: (() => void) | undefined,
551
+ /**
552
+ * Told whenever a fresh `initialize` reports the agent's
553
+ * `sessionCapabilities.fork` bit — the LAST KNOWN fork capability the
554
+ * manager persists and serves to the browser (the bridge's own
555
+ * `initResult` dies with the process; the manager's copy survives it).
556
+ */
557
+ onForkCapability?: ((agentId: string, supported: boolean) => void) | undefined);
370
558
  /**
371
559
  * Adopt a rewritten approval deadline or mode (the settings page's write).
372
560
  * Only the window and the mode live here; every other tuning value is
@@ -381,6 +569,18 @@ export declare class AgentBridge {
381
569
  permissionTimeoutMs?: number;
382
570
  mode?: PermissionTimeoutMode;
383
571
  }): void;
572
+ /**
573
+ * Adopt a rewritten host-default idle window (the settings page's write).
574
+ *
575
+ * Only the DEFAULT moves: an agent that names its own `idleTimeoutMs` keeps
576
+ * it (that promise is about the agent, not about the deployment), and a
577
+ * probe-only child keeps the short probe window.
578
+ *
579
+ * The timer is re-armed so the new window governs from NOW: an already
580
+ * armed timer still holds the old window, so a shortened window would not
581
+ * take effect until the old one elapsed.
582
+ */
583
+ applyIdleRelease(defaultIdleTimeoutMs: number): void;
384
584
  /**
385
585
  * The idle-release window this agent honors for its CHAT process: its own
386
586
  * field, else the host default. This is the number the panel prefills and
@@ -446,6 +646,11 @@ export declare class AgentBridge {
446
646
  * same session — forbidden by the protocol and rejected (or worse, mishandled)
447
647
  * by the agent. Waiting turns a protocol error into a short delay.
448
648
  *
649
+ * The gate is {@link LiveSession.promptInFlight} + the settle promise, NOT
650
+ * {@link LiveSession.running}: an ambient turn keeps `running` true for the
651
+ * whole drain while its prompt slot is already free, so `running` would
652
+ * refuse every message the user sends during a background task.
653
+ *
449
654
  * @param session - the session asking for the slot.
450
655
  * @returns whether the slot was claimed, and why not when it was not.
451
656
  */
@@ -455,9 +660,12 @@ export declare class AgentBridge {
455
660
  ok: false;
456
661
  reason: 'busy' | 'unsettled';
457
662
  }>;
663
+ /** Whether a turn currently holds the agent prompt slot (see `promptInFlight`). */
664
+ private promptSlotHeld;
458
665
  /**
459
- * Mark the agent-side turn as in flight and hand back the settler its stream
460
- * owner must call once the agent is actually done with it.
666
+ * Mark the agent-side turn as in flight — claim the agent prompt slot and
667
+ * open the settle gate — and hand back the settler its stream owner must
668
+ * call once the agent is actually done with it.
461
669
  *
462
670
  * @param session - the session whose turn is starting.
463
671
  * @returns the settler (idempotent; also used by connection loss).
@@ -466,7 +674,8 @@ export declare class AgentBridge {
466
674
  /**
467
675
  * Open a session's settle gate without owning its turn stream: the turn's
468
676
  * stream is gone (a resume, a release) but the next prompt still waits on
469
- * this promise, and nothing is left that could ever resolve it.
677
+ * this promise, and nothing is left that could ever resolve it. The prompt
678
+ * slot itself goes with it: whatever held it is gone too.
470
679
  */
471
680
  private openSettleGate;
472
681
  view(): AgentView;
@@ -559,6 +768,25 @@ export declare class AgentBridge {
559
768
  * only shape that keeps every session of that process consistent.
560
769
  */
561
770
  probeConfigOptions(): Promise<unknown[]>;
771
+ /**
772
+ * Fork one live session through the agent's own `session/fork` (the
773
+ * UNSTABLE fourth session-creation point): the agent copies its internal
774
+ * state into a NEW ACP session that remembers the parent's context — the
775
+ * second half of a branched conversation, after the host's `sessions.fork`
776
+ * has already built the DSH session tree. The child is addressed by the
777
+ * CHILD DSH conversation id, so its first message reaches an agent that
778
+ * actually remembers the parent.
779
+ *
780
+ * The parent must be live on this connection — a released parent has no
781
+ * process to fork from (the browser blocks that click; this is the hard
782
+ * stop behind it). The ACP request carries the SAME `mcpServers` list as
783
+ * `session/new` / `session/load` (one list per process; an empty list would
784
+ * evict the live sessions' MCP scope) and is bounded by the startup timeout
785
+ * like every session-creation step. Counted in {@link pendingForks} while
786
+ * in flight so an idle release cannot kill the child out from under the
787
+ * caller.
788
+ */
789
+ forkSession(parent: LiveSession, dshSessionId: string): Promise<LiveSession>;
562
790
  /**
563
791
  * Fire one prompt turn. Returns after the agent settles the turn; the caller
564
792
  * (manager) keeps the UI informed through broadcast events, so the RPC that
@@ -571,9 +799,25 @@ export declare class AgentBridge {
571
799
  * {@link BridgeTuning.staleCancelRetryDelayMs}, out-waiting the cleanup
572
800
  * window, bounded by {@link BridgeTuning.staleCancelMaxRetries}; a genuinely
573
801
  * requested cancel (`cancelRequestedAtTurn` still names this generation)
574
- * returns as-is.
575
- */
576
- runPrompt(session: LiveSession, text: string, turnId: string): Promise<string>;
802
+ * returns as-is — checked BOTH before the delay and after it, so a second
803
+ * stop click inside the retry window does not resend the prompt the user
804
+ * just stopped.
805
+ *
806
+ * `turnGen` is the {@link LiveSession.turnSeq} this prompt was sent for. A
807
+ * stale-cancel resend is only allowed while that generation still owns the
808
+ * session: once a successor turn began (the cancel fallback finalized this
809
+ * one and the user moved on), resending would race a second `session/prompt`
810
+ * into the successor's turn. The late answer is returned as-is instead.
811
+ *
812
+ * The RESPONSE's `_meta` travels with the stop reason: it is the only place
813
+ * an agent says whether the prompt actually queued a conversation turn (see
814
+ * `CodebuddyMeta.conversationRequestId`), which decides whether an instant
815
+ * answer was the turn ending or a fold into a background drain.
816
+ */
817
+ runPrompt(session: LiveSession, prompt: string | ContentBlock[], turnId: string, turnGen: number): Promise<{
818
+ stopReason: string;
819
+ meta?: Record<string, unknown>;
820
+ }>;
577
821
  cancel(session: LiveSession): Promise<void>;
578
822
  /**
579
823
  * An agent that ignores the `session/cancel` notification must not wedge the
@@ -591,10 +835,19 @@ export declare class AgentBridge {
591
835
  private armCancelFallback;
592
836
  /**
593
837
  * Force the local end of one turn whose agent never acknowledged it: clear
594
- * `running`, open the settle gate, and broadcast the turn end with `turnId:
595
- * ''` (there is no agent response to attribute it to). Shared by the cancel
838
+ * `running`, open the settle gate, and end the turn. Shared by the cancel
596
839
  * fallback and the stall watchdog.
597
840
  *
841
+ * A live controller owns the turn's streaming face — its segment queue, its
842
+ * ambient clocks and its `turn/end` id. Ending the turn behind its back
843
+ * would leave the driver step parked on an open queue, and the controller's
844
+ * own timers would later broadcast a SECOND `turn/end` for a turn already
845
+ * closed. So when the session still points at a current controller, the
846
+ * force goes THROUGH it (`forceFinalize`), which runs the same closing work
847
+ * as a normal settle exactly once. Only a turn with no live controller (an
848
+ * `/acp`-RPC turn, or one already retired) falls back to the local broadcast
849
+ * with `turnId: ''` — there is no stream whose id could be named.
850
+ *
598
851
  * Generation-guarded: a turn that already ended — or that a later turn
599
852
  * superseded — is not this call's to finalize. Returns whether it acted; a
600
853
  * `false` means the caller was late and must leave the session alone.
@@ -617,6 +870,12 @@ export declare class AgentBridge {
617
870
  takeReleased(sessionId: string): LiveSession | undefined;
618
871
  /** Drop a released session record for good (user closed it or the agent was stopped). */
619
872
  dropReleased(sessionId: string): boolean;
873
+ /**
874
+ * Register a session record restored from disk as already parked: a host
875
+ * restart killed every process, so it comes back exactly as `releaseAll`
876
+ * would have left it — resumable via `session/load`, never live.
877
+ */
878
+ parkReleased(session: LiveSession): void;
620
879
  get releasedList(): LiveSession[];
621
880
  hasReleased(sessionId: string): LiveSession | undefined;
622
881
  inflightResume(sessionId: string): Promise<LiveSession> | undefined;
@@ -941,6 +1200,14 @@ interface SegmentState {
941
1200
  activeToolCall: AcpToolCallSnapshot | undefined;
942
1201
  /** Whether this segment opened at least one tool-call block. */
943
1202
  hasCard: boolean;
1203
+ /**
1204
+ * The segment belongs to an ambient wait: the agent folded our prompt into a
1205
+ * background drain it started itself. Tool traffic is recorded (so the
1206
+ * transcript and any parked tool-output waiter get it) but opens NO card —
1207
+ * a card would make the host loop execute a tool for a turn whose answer has
1208
+ * not even started streaming yet.
1209
+ */
1210
+ ambient: boolean;
944
1211
  finished: boolean;
945
1212
  }
946
1213
  /** How one segment's stream ends, in `StreamChunk.finish` terms. */
@@ -990,13 +1257,111 @@ declare class TurnStreamController {
990
1257
  private readonly rollbackFirstTurn;
991
1258
  private segment;
992
1259
  private settled;
1260
+ /** Whether the turn's closing work already ran (see `closeTurn`). */
1261
+ private closedTurn;
993
1262
  private disposed;
994
1263
  private readonly seenCalls;
995
1264
  private readonly announcedKinds;
996
1265
  /** Narrative lines (permission asks) that arrived while no segment was open. */
997
1266
  private readonly pendingProse;
998
1267
  sawVisibleBlock: boolean;
1268
+ /**
1269
+ * Whether THIS turn streamed its own prose (text or reasoning), i.e. an
1270
+ * answer that is not the background drain's. Tool cards do not count — a
1271
+ * drain that only ran tools delivered no words to the conversation.
1272
+ */
1273
+ private sawAgentProse;
1274
+ /**
1275
+ * Whether any `agent_message_chunk` text reached the plane. Reasoning does
1276
+ * not count: a steer whose drain only thought is still a turn with no answer
1277
+ * text, and must say so when it closes.
1278
+ */
1279
+ private sawVisibleText;
1280
+ /**
1281
+ * The CodeBuddy request id this turn has adopted as its own (from the
1282
+ * prompt RESPONSE's `conversationRequestId`, or from the first update that
1283
+ * named one). Used to keep a background drain's prose out of the user's
1284
+ * answer; `undefined` means "unknown" and nothing is split (fail-open).
1285
+ */
1286
+ private turnRequestId?;
1287
+ /**
1288
+ * Updates the session's background drain already streamed while this turn's
1289
+ * OWN request id was still unknown. They are NOT folded on arrival: a drain
1290
+ * tick that lands before the prompt response could otherwise teach the turn
1291
+ * the drain's id (`noteTurnRequestId`) and mix the drain's text into the
1292
+ * user's answer. Withheld here (cursor already advanced — the transcript
1293
+ * keeps a copy) until the response decides what this turn is:
1294
+ * - a normal turn (response carries a conversation id) discards them — they
1295
+ * were never this turn's answer;
1296
+ * - a steered turn flushes them into the segment, in order, once
1297
+ * `awaitAmbient` marks the segment ambient (tool cards suppressed).
1298
+ */
1299
+ private readonly pendingAmbientUpdates;
1300
+ private pendingAmbientChars;
1301
+ /**
1302
+ * Whether the bounded buffer dropped at least one update. The flush then
1303
+ * says so in the conversation instead of silently losing the front of the
1304
+ * drain's output (constraint 29: ACP payloads are never dropped silently).
1305
+ */
1306
+ private pendingAmbientTruncated;
1307
+ /** The ambient wait in progress, if the agent folded this prompt into a drain. */
1308
+ private awaitingAmbient?;
1309
+ /**
1310
+ * The prompt response named no conversation turn while a drain this session
1311
+ * retired RECENTLY may still be running without re-announcing. The turn still
1312
+ * settles at once (a drain record that no update revived is not waited on),
1313
+ * but its zero-output report says the turn may have been folded into that run
1314
+ * instead of blaming the agent and suggesting a resend.
1315
+ */
1316
+ private recentRetiredDrain;
1317
+ /** The outcome of a settle whose closing work is waiting one macrotask (see `settle`). */
1318
+ private pendingOutcome?;
1319
+ private closeScheduled;
999
1320
  private cursorSeq;
1321
+ /**
1322
+ * Burst tracker for this turn, fed from the LIVE hub frames only. Segment
1323
+ * replay must not touch it: every event is already counted once when it
1324
+ * arrived, and replay would double the numerator and the gaps.
1325
+ */
1326
+ private readonly tps;
1327
+ private tpsSeq;
1328
+ private lastTpsPushAt;
1329
+ /**
1330
+ * The ambient quiet/ceiling windows, read once from the manager: they are
1331
+ * deployment tuning, not per-tick facts, and small test values must be stable
1332
+ * for the whole turn (see {@link BridgeTuning.ambientQuietMs} for the
1333
+ * measured CodeBuddy tick cadence these defaults come from).
1334
+ */
1335
+ private readonly ambientTuning;
1336
+ /**
1337
+ * The detached workers this turn dispatched (see {@link BackgroundDispatch}),
1338
+ * by `toolCallId`, recorded from the wire while the turn is still streaming —
1339
+ * typically in the first step, before the prompt response lands. A turn may
1340
+ * dispatch several, and the hold waits for ALL of them.
1341
+ */
1342
+ private readonly dispatches;
1343
+ /** The detached-worker hold in progress, once a segment carries it. */
1344
+ private backgroundHold?;
1345
+ /**
1346
+ * A hold parked because the prompt response landed while NO driver segment
1347
+ * was open (the host is between steps, about to execute the Agent card). It
1348
+ * begins when the next `startSegment` opens — or closes after a bounded
1349
+ * grace so a host that never opens one does not hang the turn.
1350
+ */
1351
+ private pendingBackgroundHold?;
1352
+ /**
1353
+ * The hold's own windows, read once at construction like the ambient pair:
1354
+ * `taskMaxMs` is the worker phase's ceiling, `graceMs` the parked hold's
1355
+ * bounded wait for a driver step (see {@link BridgeTuning.backgroundTaskMaxMs}
1356
+ * and {@link BridgeTuning.backgroundParkedGraceMs}).
1357
+ */
1358
+ private readonly backgroundTuning;
1359
+ /**
1360
+ * When this controller was built. Only a drain announced AFTER this moment
1361
+ * may be adopted by the hold: an `ambient` record that predates the turn is
1362
+ * some earlier run's, and folding its text would mix two answers.
1363
+ */
1364
+ private readonly constructedAt;
1000
1365
  constructor(manager: BridgeManager, hub: EventHub, session: LiveSession, turnId: string,
1001
1366
  /** Releases the agent-side turn slot (the `beginTurn` settler; idempotent). */
1002
1367
  endTurn: () => void, rollbackFirstTurn: (session: LiveSession) => void);
@@ -1021,11 +1386,270 @@ declare class TurnStreamController {
1021
1386
  startSegment(): SegmentState;
1022
1387
  /** Finish the segment on air (if any). Used by cancel paths and teardown. */
1023
1388
  finishActive(reason: SegmentFinishReason): void;
1389
+ /** Whether this turn already streamed prose of its own (not a drain's). */
1390
+ get hasAgentProse(): boolean;
1391
+ /**
1392
+ * Keep the turn open for a background drain the agent folded our prompt into.
1393
+ *
1394
+ * THE SHAPE: the agent answered `session/prompt` at once (CodeBuddy returns
1395
+ * `end_turn` in ~14ms when it merges the message into a drain it is already
1396
+ * running) and the real answer streams afterwards under the DRAIN's request
1397
+ * id. Closing on the response would cut the turn before its text exists —
1398
+ * which is exactly the bug this state exists for. So:
1399
+ *
1400
+ * - the agent-side prompt slot is released (`endTurn`: the response HAS
1401
+ * landed) but the turn is NOT settled: `settled` means "the turn is over",
1402
+ * and it is not — no `turn/end`, no `running = false`, no segment close;
1403
+ * - the segment keeps folding the session's updates, with cards suppressed
1404
+ * (see `SegmentState.ambient`): the host must not execute a background
1405
+ * tool for a turn whose answer has not begun;
1406
+ * - any update on the session resets the quiet clock, and the drain's end is
1407
+ * its silence (a `session_end` is rejected by the SDK's schema and never
1408
+ * reaches us — never wait for one); a hard ceiling closes what never ends.
1409
+ */
1410
+ awaitAmbient(requestId: string, stopReason: string): void;
1411
+ /**
1412
+ * The prompt response named no conversation turn while a drain the session
1413
+ * retired recently may still be running (and did not re-announce): the turn
1414
+ * cannot be held open, but a zero-output report must not blame the agent.
1415
+ * See `hasRecentRetiredAmbient` for the window.
1416
+ */
1417
+ noteRecentRetiredDrain(): void;
1418
+ /**
1419
+ * Whether this turn dispatched a detached worker whose answer is still
1420
+ * outstanding. Read when the prompt response lands: such a turn is NOT over
1421
+ * even though the agent answered it (see {@link holdForBackgroundDispatch}).
1422
+ *
1423
+ * Two ways a dispatch keeps the turn open:
1424
+ * - it has not reported back yet (the worker is still running), or
1425
+ * - it DID report — before the response, even — but it was structurally
1426
+ * detached and emitted worker updates: the drain that carries its report
1427
+ * is still coming.
1428
+ * A synchronous `Agent` / `Task` call (completed, no `isBackground`, no
1429
+ * worker updates) matches neither and closes normally.
1430
+ */
1431
+ get dispatchedDetachedWorker(): boolean;
1432
+ /** The hold this turn is inside — adopted, or parked waiting for a step. */
1433
+ private activeBackgroundHold;
1434
+ /** Whether every KNOWN dispatch has reported (no dispatch at all counts as done). */
1435
+ private allDispatchesCompleted;
1436
+ /**
1437
+ * Hold the turn open for the detached worker(s) its `Agent` tool calls
1438
+ * dispatched — the second controlled ambient-family exception (constraint
1439
+ * 23), not a new idle watchdog.
1440
+ *
1441
+ * THE SHAPE (measured against CodeBuddy 2.179.0, multitask on): the
1442
+ * coordinator opens an `Agent` call that hands work to a background worker,
1443
+ * says "已派…", and the `session/prompt` RESPONSE arrives normally while the
1444
+ * worker keeps running. The worker's own updates (requestId B +
1445
+ * `parentToolCallId`) never touch this session's message; when it finishes,
1446
+ * the agent starts a background DRAIN (requestId A — the response's own
1447
+ * `conversationRequestId`) whose steps carry the report and the final answer.
1448
+ * Nothing tells the client the worker is running: no `session_end` (the SDK's
1449
+ * schema rejects it) and an `agentPhase` idle tick even fires BETWEEN the
1450
+ * worker and the drain. So the response cannot close the turn.
1451
+ *
1452
+ * The hold:
1453
+ * - releases the agent-side prompt slot (`endTurn`: the response landed)
1454
+ * while keeping `running` and the driver step open;
1455
+ * - marks the segment background, so EVERY tool call for the rest of the
1456
+ * hold — the workers', the drain's TaskOutput — stays transcript-only and
1457
+ * the final message carries text with no card in front of it;
1458
+ * - waits in two phases: the worker phase only has the
1459
+ * {@link BridgeTuning.backgroundTaskMaxMs} ceiling (a detached worker goes
1460
+ * silent for tens of seconds while it works) and ends only when ALL known
1461
+ * dispatches reported, after which the drain phase uses the ambient
1462
+ * windows.
1463
+ */
1464
+ holdForBackgroundDispatch(stopReason: string, requestId?: string): void;
1465
+ /** Give a (possibly parked) hold a driver segment: cards off for the rest of it. */
1466
+ private adoptBackgroundHold;
1467
+ /**
1468
+ * The worker phase is over once EVERY known dispatch reported: the drain
1469
+ * that carries their reports is (about to be) running, so the existing
1470
+ * ambient windows take over, measured from HERE.
1471
+ *
1472
+ * An announced run is accepted immediately, but while any dispatch is still
1473
+ * incomplete it only records the id and switches the hold's primary run —
1474
+ * arming the quiet clock there would let a drain that spoke early cut the
1475
+ * turn off before its other worker ever reports.
1476
+ */
1477
+ private startBackgroundDrain;
1478
+ /**
1479
+ * A dispatch that lands while the drain phase already began means the
1480
+ * coordinator handed out NEW work: the worker phase is back, and with it the
1481
+ * absence of a quiet window.
1482
+ */
1483
+ private enterBackgroundWorkerPhase;
1484
+ /** Push the drain phase's quiet clock out: every update is evidence of work. */
1485
+ private armBackgroundQuiet;
1486
+ /**
1487
+ * Any session update restarts the drain phase's quiet clock. Only an
1488
+ * ADOPTED hold has one: a parked hold's wait belongs to its grace timer, and
1489
+ * the worker phase never has a clock at all.
1490
+ */
1491
+ private noteBackgroundHoldActivity;
1492
+ /** The drained run went quiet: the turn ends with the stop reason the response gave. */
1493
+ private onBackgroundQuiet;
1494
+ /** The hold outlived its ceiling (worker or drain): the run is still going. */
1495
+ private onBackgroundHard;
1496
+ /**
1497
+ * No driver step opened inside the grace: the host is not coming back for
1498
+ * this turn's Agent card. Close it the way a quiet ambient ending does — the
1499
+ * deferral line, never the zero-output blame — and keep ONE drain record:
1500
+ * the worker may still report into a run no turn is waiting on.
1501
+ *
1502
+ * A parked hold has NO segment by definition, so the chat plane has nowhere
1503
+ * to put the deferral line: it goes to the settings log (`agent/notice`)
1504
+ * instead of being parked on prose nobody will read.
1505
+ */
1506
+ private onBackgroundGraceExpired;
1507
+ /**
1508
+ * Close a detached-worker hold exactly once. A turn that folded NO answer
1509
+ * text from its held runs says where the rest of the output will land (the
1510
+ * deferral wording, never the silent-failure blame); the parent's interim
1511
+ * acknowledgement does not count as that answer.
1512
+ *
1513
+ * Ownership of the drain ids at close (`releaseBackgroundHoldIds`): a quiet
1514
+ * close retires every accepted id and drops the session record; a hard or
1515
+ * grace close keeps exactly ONE record, because the run is provably still
1516
+ * going and a later update or re-announcement must revive it normally.
1517
+ */
1518
+ private closeBackgroundHold;
1519
+ /** Write a hold's deferral line into the settings log (`agent/notice`). */
1520
+ private broadcastBackgroundDeferral;
1521
+ /** Drop a hold's clocks. The hold object itself stays for its `retired` mark. */
1522
+ private clearBackgroundHoldTimers;
1523
+ /**
1524
+ * Drop every hold clock (teardown / a forced ending). `retire` also gives up
1525
+ * every accepted run id — a SUCCESSOR turn must never fold the abandoned
1526
+ * hold's drain or worker text into its own answer.
1527
+ */
1528
+ private clearBackgroundHold;
1529
+ /**
1530
+ * Settle what happens to the session's drain records when a hold closes.
1531
+ *
1532
+ * `hard: false` (quiet close, cancel, teardown): the run is no longer waited
1533
+ * on, so EVERY accepted id is retired — its late text stays transcript-only
1534
+ * for whatever turn runs next — and the session record is dropped if it
1535
+ * names one of them.
1536
+ *
1537
+ * `hard: true` (ceiling / parked grace): the run is provably still going.
1538
+ * Exactly ONE record survives — the session's current drain if it has one,
1539
+ * else the hold's primary run — with its activity clock refreshed; the other
1540
+ * accepted ids are retired. A hold whose primary id had no session record
1541
+ * CREATES one, so a successor's `noteTurnRequestId` cannot adopt that id.
1542
+ * Every accepted id therefore ends up either retired or owned by
1543
+ * `session.ambient`; never both unowned and unretired.
1544
+ */
1545
+ private releaseBackgroundHoldIds;
1546
+ /**
1547
+ * Fold the withheld drain updates that belong to this hold into its segment.
1548
+ * The rest of the buffer was some OTHER run's text: it is dropped here (the
1549
+ * transcript keeps it), never leaked into this turn's answer.
1550
+ */
1551
+ private flushBackgroundHoldUpdates;
1552
+ /**
1553
+ * Follow the detached-worker signals of one live update.
1554
+ *
1555
+ * Only CodeBuddy sends them (`codebuddyMetaOf` answers `undefined` for every
1556
+ * other agent, so nothing here can fire for them):
1557
+ * - an `Agent` / `Task` `tool_call` records the dispatch — the FIRST send
1558
+ * needs that CodeBuddy shape;
1559
+ * - the SAME call re-sent with `isBackground: true`, or any update naming it
1560
+ * as its `parentToolCallId`, marks the work DETACHED;
1561
+ * - that call reporting completed / failed is read BY ID: the real
1562
+ * completion frame carries NO `_meta` at all, so requiring the CodeBuddy
1563
+ * shape here would miss every real completion;
1564
+ * - any update carrying `parentToolCallId` is the worker's OWN turn — it
1565
+ * proves the worker alive but never belongs to this session's answer;
1566
+ * - an `unsolicitedTurn` announcement is the drain that carries the report.
1567
+ */
1568
+ private trackBackgroundDispatch;
1569
+ /**
1570
+ * A dispatch just reported: once the LAST one has, the drain phase starts.
1571
+ * Until then the turn stays in its worker phase, with no quiet clock.
1572
+ */
1573
+ private noteDispatchProgress;
1574
+ /** Any session update is evidence the drain still works: push the quiet clock out. */
1575
+ private noteAmbientActivity;
1576
+ /** The drain went quiet: the turn ends with the stop reason the response gave. */
1577
+ private onAmbientQuiet;
1578
+ /** The drain outlived the ceiling: say where the rest will land, then close. */
1579
+ private onAmbientHard;
1580
+ /**
1581
+ * Close an ambient wait exactly once. A turn that produced no answer TEXT
1582
+ * says so: the drain's output is in the ACP transcript view, and this
1583
+ * bubble must not be empty (the zero-output report, reworded for a turn that
1584
+ * was deferred rather than failed).
1585
+ */
1586
+ private finishAmbient;
1587
+ /** Drop the ambient wait's clocks (the turn is being closed by someone else). */
1588
+ private clearAmbientWait;
1589
+ /**
1590
+ * Withhold one background-drain update until the prompt response says what
1591
+ * this turn is. The cursor advances past it anyway: it is already in the
1592
+ * transcript, and replaying it into a later segment would emit it twice.
1593
+ * Bounded (see `PENDING_AMBIENT_MAX_*`) — the transcript still keeps every
1594
+ * entry the buffer drops.
1595
+ */
1596
+ private bufferAmbientUpdate;
1597
+ /** Fold the withheld drain updates into the now-ambient segment, once, in order. */
1598
+ private flushPendingAmbient;
1599
+ /** Drop withheld drain updates: this turn is not the drain, or it is closing. */
1600
+ private clearPendingAmbient;
1024
1601
  /** The agent settled its `session/prompt`. Ends the turn's streaming face. */
1025
1602
  settle(outcome: TurnSettle): void;
1026
- /** Cancelled by the user: finish the on-air segment and release the turn's slots. */
1603
+ /**
1604
+ * Force the local end of this turn — the cancel fallback and any watchdog
1605
+ * that reached the controller. Runs the SAME closing work as a normal settle
1606
+ * (`closeTurn`): the ambient clocks stop, the on-air segment closes (so the
1607
+ * driver step parked on its queue ends), exactly one `turn/end` goes out
1608
+ * with THIS turn's id, the session stops running and the prompt slot is
1609
+ * released. Idempotent: a turn that already closed ignores the call.
1610
+ */
1611
+ forceFinalize(outcome: TurnSettle): void;
1612
+ /**
1613
+ * Run the deferred closing work now, when a driver step is about to open on
1614
+ * an already settled turn: the continuation step must see the closed turn
1615
+ * (and the session NOT left `running` across the macrotask boundary).
1616
+ * Idempotent — the scheduled tick then finds nothing left to do.
1617
+ */
1618
+ private flushScheduledClose;
1619
+ /**
1620
+ * Fold the notifications that reached the transcript but not the segment:
1621
+ * the tail a response raced past. Stops AT a cutting event without consuming
1622
+ * it (same rule as `startSegment`) — its next segment is not this turn's.
1623
+ */
1624
+ private drainTail;
1625
+ /**
1626
+ * End the turn exactly once: burst metrics, ONE `turn/end`, the provisional
1627
+ * lock, the closing text, the segment, the session flags, the prompt slot.
1628
+ *
1629
+ * `ambient.line` marks a controlled ambient ending: the given line closes
1630
+ * the segment when the turn carried no answer text, instead of the generic
1631
+ * zero-output warning (which would blame the agent for a turn the drain
1632
+ * simply had not finished yet). `ambient.keepAmbient` says the ending was the
1633
+ * CEILING, not silence: see the drain-record comment below.
1634
+ */
1635
+ private closeTurn;
1636
+ /**
1637
+ * Cancelled by the user: finish the on-air segment and release the turn's
1638
+ * slots. An AMBIENT turn has already released the prompt slot (its response
1639
+ * landed) and holds no segment the cancel fallback could close, so the
1640
+ * cancel closes the turn itself with the cancelled stop.
1641
+ */
1027
1642
  abortActiveSegment(): void;
1028
- /** Stop observing and release the manager's index. Idempotent. */
1643
+ /**
1644
+ * Stop observing and release the manager's index. Idempotent.
1645
+ *
1646
+ * The segment is CLOSED here (aborted) before unsubscribing: a driver step
1647
+ * parked on its queue (`yield* segment.queue`) only ends when the queue
1648
+ * closes, and a controller replaced by the conversation's next turn would
1649
+ * otherwise leave that step hanging forever. An already finished segment is a
1650
+ * no-op, and the session's flags are left alone — a superseded turn must not
1651
+ * mark the newer turn's session idle.
1652
+ */
1029
1653
  dispose(): void;
1030
1654
  /**
1031
1655
  * Stop observing and release the manager's index WITHOUT touching the
@@ -1037,7 +1661,19 @@ declare class TurnStreamController {
1037
1661
  private finishSegment;
1038
1662
  /** A segment opened on an already-settled turn: replay, then close it out. */
1039
1663
  private finishAfterSettle;
1664
+ /**
1665
+ * The "protocol says success, the bubble is empty" line. When a recently
1666
+ * retired drain may have taken the prompt without re-announcing itself, say
1667
+ * that instead of blaming the agent: the output exists in the transcript.
1668
+ */
1669
+ private zeroOutputLine;
1040
1670
  private onEvent;
1671
+ /** Feed one live update into the turn's burst tracker. Never called by replay. */
1672
+ private trackTps;
1673
+ /** Throttled live aggregate so the dock's speed moves during a long turn. */
1674
+ private pushLiveTps;
1675
+ /** Close the turn's metrics: persist the measurable part, then broadcast it. */
1676
+ private finalizeTps;
1041
1677
  /**
1042
1678
  * Put one narrative line into the turn's prose stream.
1043
1679
  *
@@ -1051,12 +1687,33 @@ declare class TurnStreamController {
1051
1687
  private recordWithoutSegment;
1052
1688
  /** Record a tool call's output (idempotent) and wake any waiter. */
1053
1689
  private noteToolRecord;
1690
+ /**
1691
+ * Whether this update belongs to a background drain — the one the session is
1692
+ * waiting on, or one whose wait already closed (retired) but whose run can
1693
+ * still speak.
1694
+ */
1695
+ private isAmbientOwned;
1696
+ /** Learn this turn's own CodeBuddy request id from the first update naming one. */
1697
+ private noteTurnRequestId;
1698
+ /** Adopt the request id the prompt RESPONSE declared for this turn. */
1699
+ adoptTurnRequestId(requestId: string): void;
1700
+ /**
1701
+ * Prose of the session's background drain while this turn runs on its OWN
1702
+ * request id: it belongs to the background run, not to the user's answer.
1703
+ * Fail-open by design — an unknown turn id splits nothing, and a turn that
1704
+ * was itself folded into the drain keeps its prose.
1705
+ */
1706
+ private isForeignAmbientText;
1054
1707
  /**
1055
1708
  * The turn's whole fold, shared by live events and segment replay. Visible
1056
1709
  * non-tool events END a card-bearing segment instead of joining it — that is
1057
1710
  * the segmentation rule that puts each card between two assistant messages.
1058
1711
  * Returns whether the event was consumed into this segment: the cutting
1059
1712
  * event is left unconsumed (and thus behind the cursor) for the next one.
1713
+ *
1714
+ * `seq` is the update's transcript position when the caller has one: the
1715
+ * detached-worker hold uses it to count only the answer text that arrived
1716
+ * AFTER the hold began (a parked hold replays the parent's earlier ack).
1060
1717
  */
1061
1718
  private foldInto;
1062
1719
  private emitProse;
@@ -1131,6 +1788,29 @@ export declare class BridgeManager {
1131
1788
  * write would fight with no way to tell which one is live.
1132
1789
  */
1133
1790
  private deadlineOverride;
1791
+ /**
1792
+ * The idle-release window the settings page picked, in ms, persisted
1793
+ * with the agent registry. `undefined` means the deployment baseline runs.
1794
+ *
1795
+ * Merged OVER {@link baselineIdleTimeoutMs} exactly like
1796
+ * {@link deadlineOverride} over {@link baselineDeadline}: a UI pick is what
1797
+ * runs, and `cordis.patch.yml` becomes the value the reset button returns
1798
+ * to. Without this split the two sources would fight with no way to tell
1799
+ * which one is live.
1800
+ */
1801
+ private idleReleaseOverride?;
1802
+ /**
1803
+ * The window `cordis.patch.yml` names, when it names one. Kept apart
1804
+ * from the override for the same reason the deadline keeps its baseline
1805
+ * apart: the settings card has to be able to say "the file asks for
1806
+ * 10 minutes, the value set here wins".
1807
+ */
1808
+ private configuredIdleTimeoutMs?;
1809
+ /**
1810
+ * The idle-release window before any override — the tuning this manager was
1811
+ * constructed with (the deployment baseline, or the built-in 30 minutes).
1812
+ */
1813
+ private baselineIdleTimeoutMs;
1134
1814
  /**
1135
1815
  * Tuning in force, defaults applied. The approval deadline is rewritten
1136
1816
  * IN PLACE by {@link applyDeadlineTuning}, which is why this is a live object
@@ -1150,6 +1830,28 @@ export declare class BridgeManager {
1150
1830
  private readonly turnStreams;
1151
1831
  /** The segmented turn a continuation step of one chat session must resume. */
1152
1832
  turnStreamFor(dshSessionId: string): TurnStreamController | undefined;
1833
+ /**
1834
+ * The ambient windows a turn's controller reads once at construction (see
1835
+ * {@link BridgeTuning.ambientQuietMs}). Exposed as a plain snapshot so the
1836
+ * controller never pokes at the manager's tuning object, and tests can tune
1837
+ * the cadence through the constructor like every other window.
1838
+ */
1839
+ get ambientTuning(): {
1840
+ quietMs: number;
1841
+ maxMs: number;
1842
+ };
1843
+ /**
1844
+ * The detached-worker hold's windows, read once at controller construction
1845
+ * for the same reason as {@link ambientTuning}: `taskMaxMs` is the worker
1846
+ * phase's only clock (see {@link BridgeTuning.backgroundTaskMaxMs}) and
1847
+ * `graceMs` the bounded wait a PARKED hold gives the host to open its
1848
+ * continuation step (see {@link BridgeTuning.backgroundParkedGraceMs}). The
1849
+ * drain phase reuses {@link ambientTuning}.
1850
+ */
1851
+ get backgroundTuning(): {
1852
+ taskMaxMs: number;
1853
+ graceMs: number;
1854
+ };
1153
1855
  /** Index a turn's controller, retiring whatever turn it supersedes. */
1154
1856
  rememberTurnStream(dshSessionId: string, controller: TurnStreamController): void;
1155
1857
  /** Drop a controller from the index (its own `dispose` calls back into this). */
@@ -1197,6 +1899,10 @@ export declare class BridgeManager {
1197
1899
  * sessions and headless deployments.
1198
1900
  */
1199
1901
  setSessionWorkspaceResolver(resolver?: SessionWorkspaceResolver): void;
1902
+ /**
1903
+ * Set the attachment service or context used to resolve image attachments in user prompts.
1904
+ */
1905
+ setAttachmentResolver(resolver?: unknown): void;
1200
1906
  /**
1201
1907
  * Wire the ACP-mode probe: given a DSH session id, report whether that
1202
1908
  * session runs the ACP agent preset. The host half reads it back from the
@@ -1238,6 +1944,29 @@ export declare class BridgeManager {
1238
1944
  * keeps the two controls from reading as duplicates.
1239
1945
  */
1240
1946
  chatSessions(dshSessionId: string): ChatSessionView[];
1947
+ /**
1948
+ * Adopt the ACP side of a branched conversation: the browser has already
1949
+ * asked the HOST to fork the DSH session tree (`sessions.fork`), and this
1950
+ * runs the agent's own `session/fork` of the parent conversation's live ACP
1951
+ * session so the child actually remembers the parent context — then binds
1952
+ * the child to it (the fourth session-creation point, with every discipline
1953
+ * the other three carry).
1954
+ *
1955
+ * Failure is deliberately all-or-nothing: nothing is written for the child
1956
+ * until the agent has answered, so a failed adopt leaves the child an
1957
+ * ordinary copied-history conversation whose first message starts a fresh
1958
+ * `session/new` (today's behavior) — never a half-bound record.
1959
+ *
1960
+ * @param childDshSessionId - the DSH session the host fork created.
1961
+ * @param parentDshSessionId - the conversation being branched; its ACP
1962
+ * session must be LIVE on this connection (a released parent has no
1963
+ * process to fork from — the browser blocks the click, this is the hard
1964
+ * stop behind it).
1965
+ */
1966
+ adoptForkedSession(childDshSessionId: string, parentDshSessionId: string): Promise<{
1967
+ sessionId: string;
1968
+ acpSessionId: string;
1969
+ }>;
1241
1970
  /**
1242
1971
  * Bind one DSH chat session to an ACP agent.
1243
1972
  *
@@ -1391,11 +2120,26 @@ export declare class BridgeManager {
1391
2120
  */
1392
2121
  chatTranscripts(dshSessionId: string, afterSeq?: number): ChatTranscriptView[];
1393
2122
  /**
1394
- * Switch one chat-bound session's ACP mode. A released session is resumed
1395
- * first (`session/load`), matching the "send to recover" promise — the mode
1396
- * switch is itself a reason to bring the agent back.
2123
+ * Switch one chat-bound session's ACP mode.
2124
+ *
2125
+ * A released session is deliberately NOT resumed for it (user decision,
2126
+ * 2026-10-30 — the composer's axes are "what the next turn should use", and
2127
+ * the process comes back only when the user sends a message or hits the
2128
+ * released banner's resume button). The pick is recorded and
2129
+ * `applyChatChoice` applies it on resume, exactly like `setChatSessionModel`
2130
+ * / `setChatSessionEffort` / `setChatSessionConfig`; only a LIVE session is
2131
+ * told immediately (`session/set_mode`).
1397
2132
  */
1398
2133
  setChatSessionMode(dshSessionId: string, modeId: string, agentId?: string): Promise<void>;
2134
+ /**
2135
+ * Bring one chat conversation's released session back on a fresh process on
2136
+ * the USER's explicit gesture (the "恢复进程" banner) — the same work the
2137
+ * next prompt performs lazily (`getOrCreateDshSession`'s released branch),
2138
+ * minus the create path: a banner button must never materialize a session
2139
+ * the conversation has not had yet. A live target passes through untouched,
2140
+ * so a double click (or a click racing the next message) spawns nothing.
2141
+ */
2142
+ resumeChatSession(dshSessionId: string): Promise<void>;
1399
2143
  /**
1400
2144
  * Wire the harness's preset roster (`ctx.agentPresets`) into `presetStatus`.
1401
2145
  *
@@ -1450,8 +2194,37 @@ export declare class BridgeManager {
1450
2194
  * missing agent does not erase the choice it was made for.
1451
2195
  */
1452
2196
  private loadPersistedChoices;
2197
+ /**
2198
+ * Fold one settled ACP turn's measurable burst into the session aggregate and
2199
+ * persist it. A turn with no measured inter-chunk gap contributes neither
2200
+ * side: adding its tokens with zero time would inflate the session rate.
2201
+ */
2202
+ noteSessionBurst(session: LiveSession, outputTokens: number, burstMs: number): void;
1453
2203
  private persistAgents;
2204
+ /**
2205
+ * Restore chat session records from disk as PARKED sessions.
2206
+ *
2207
+ * Every process died with the host, so nothing comes back live — but the
2208
+ * agent-side sessions did not. Restoring each record as `released` is what
2209
+ * makes the released banner, its resume button and the "next message
2210
+ * resumes it" promise survive a restart (the alternative is the next
2211
+ * message quietly starting a NEW, context-less session — constraint 10's
2212
+ * "released ≠ destroyed" lost at the process boundary). Tolerant, one row
2213
+ * at a time: a single corrupt entry must not cost every other conversation
2214
+ * its context. Records whose agent is no longer configured are dropped —
2215
+ * there is nothing to resume ON, and the next write omits them.
2216
+ */
2217
+ private loadPersistedChatSessions;
1454
2218
  private readonly catalogs;
2219
+ /**
2220
+ * Each agent's LAST KNOWN `sessionCapabilities.fork` bit, persisted with the
2221
+ * model catalog. A live `initialize` result outranks it whenever one exists
2222
+ * (see {@link canForkNow}) — this copy answers when the process is gone
2223
+ * (a released session's branch button must not flash into "supported" just
2224
+ * because `initResult` died with it, nor read `view().agentCapabilities`,
2225
+ * which a release clears).
2226
+ */
2227
+ private readonly forkCaps;
1455
2228
  /**
1456
2229
  * The slash commands each agent declared in its most recent session, cached
1457
2230
  * per agent and persisted alongside the model catalog.
@@ -1520,6 +2293,21 @@ export declare class BridgeManager {
1520
2293
  * restatement would make every open composer re-read `agent/catalog`.
1521
2294
  */
1522
2295
  private absorbCommands;
2296
+ /**
2297
+ * Record a fresh `initialize`'s `sessionCapabilities.fork` verdict (the
2298
+ * bridge reports it right after the handshake). Persisted with the model
2299
+ * catalog; no broadcast — the browser learns it through `session/chat-list`
2300
+ * rows, and its branch button deliberately runs on a last-known basis (the
2301
+ * agent's own next `initialize` is the refresh).
2302
+ */
2303
+ private noteForkCapability;
2304
+ /**
2305
+ * Whether an agent's fork can be attempted RIGHT NOW: a live `initialize`
2306
+ * answer wins (the process is up, its handshake is current); only when the
2307
+ * process is gone does the LAST KNOWN persisted bit answer. `false` covers
2308
+ * "never seen" — an unknown agent simply never gets a fork attempt.
2309
+ */
2310
+ private canForkNow;
1523
2311
  /**
1524
2312
  * Append models this observation newly introduced to the agent's selection.
1525
2313
  *
@@ -1698,7 +2486,7 @@ export declare class BridgeManager {
1698
2486
  startSession(agentId: string, cwd: string | undefined): Promise<{
1699
2487
  sessionId: string;
1700
2488
  }>;
1701
- prompt(sessionId: string, text: string): Promise<{
2489
+ prompt(sessionId: string, prompt: string | ContentBlock[]): Promise<{
1702
2490
  turnId: string;
1703
2491
  }>;
1704
2492
  /** The late answer landed (or failed): the turn is no longer an orphan. */
@@ -1736,6 +2524,18 @@ export declare class BridgeManager {
1736
2524
  /** The agent tag's halo preference (composer display, plugin-private). */
1737
2525
  tagHalo(): AcpTagHalo;
1738
2526
  setTagHalo(halo: AcpTagHalo): Promise<void>;
2527
+ /**
2528
+ * Declare the deployment baseline for the idle-release window — the value
2529
+ * `cordis.patch.yml` names, passed RAW (only when it names one).
2530
+ *
2531
+ * Kept separate from the validated tuning for the same reason the
2532
+ * deadline keeps its baseline apart: "the file says 10 minutes" and
2533
+ * "the file says nothing, so 30 minutes applies" are different facts.
2534
+ * The first is the reset target and shows as the fact being overridden;
2535
+ * the second is a built-in default. Called once by the host entry,
2536
+ * before any bridge exists.
2537
+ */
2538
+ setConfiguredIdleRelease(idleTimeoutMs: number | undefined): void;
1739
2539
  /**
1740
2540
  * Declare the deployment baseline for the approval deadline — the values
1741
2541
  * `cordis.patch.yml` names, passed RAW (only the keys it actually contains).
@@ -1758,6 +2558,37 @@ export declare class BridgeManager {
1758
2558
  * stay silent about a plain default.
1759
2559
  */
1760
2560
  approvalDeadlines(): AcpApprovalDeadlines;
2561
+ /**
2562
+ * The idle-release window in force, plus the facts that explain a value the
2563
+ * user did not pick: the deployment baseline an override is outranking.
2564
+ *
2565
+ * `defaultIdleTimeoutMs` is the window a RESET returns to — the deployment
2566
+ * baseline when the config file names one, else the built-in default. It is
2567
+ * NOT a hardcoded constant: a deployment that names 10 minutes must reset
2568
+ * to 10 minutes, not to the built-in 30.
2569
+ *
2570
+ * The config file's own number is reported only when the file names one, so
2571
+ * the settings card can stay silent about a plain default.
2572
+ */
2573
+ idleRelease(): AcpIdleRelease;
2574
+ /**
2575
+ * Write the idle-release window, or drop the persisted override with
2576
+ * `reset` so the deployment baseline applies again.
2577
+ *
2578
+ * HOT: the rewrite reaches every live bridge, so a user changing the window
2579
+ * never has to kill a running session to see it take effect.
2580
+ */
2581
+ setIdleRelease(idleTimeoutMs: number | undefined, reset?: boolean): Promise<void>;
2582
+ /**
2583
+ * Recompute the idle-release window from override → deployment baseline, and
2584
+ * push the result to every live bridge.
2585
+ */
2586
+ private applyIdleReleaseTuning;
2587
+ /**
2588
+ * The idle-release window that runs, in ms: the settings page's pick, else
2589
+ * the deployment baseline.
2590
+ */
2591
+ private idleTimeoutMs;
1761
2592
  /**
1762
2593
  * Write the approval deadline, or the mode (omitted keys keep their value),
1763
2594
  * or drop the persisted override with `reset` so the config-file baseline —
@@ -1788,6 +2619,7 @@ export declare class BridgeManager {
1788
2619
  private forgetDshSession;
1789
2620
  private sessionWorkspaceResolver?;
1790
2621
  private acpModeResolver?;
2622
+ private attachmentResolver?;
1791
2623
  /** Cached `chatSessionAcpMode` answers, keyed by DSH chat session id. */
1792
2624
  private readonly acpModeCache;
1793
2625
  /** In-flight cold reads behind `chatSessionAcpModeAnswer`, keyed by session id. */