dsh-ntr 0.3.2 → 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.
@@ -6,7 +6,7 @@
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
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';
@@ -41,7 +41,30 @@ 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;
@@ -151,8 +174,70 @@ interface LiveSession {
151
174
  * the controller's own `retire` when it still points at it.
152
175
  */
153
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[];
154
214
  transcript: Transcript;
155
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
+ }
156
241
  /** Reliability tuning, threaded from `AcpPluginConfig` through the manager. */
157
242
  export interface BridgeTuning {
158
243
  startupTimeoutMs?: number;
@@ -247,6 +332,65 @@ export interface BridgeTuning {
247
332
  * probe-only process has no stake in. `0` disables it.
248
333
  */
249
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;
250
394
  }
251
395
  /**
252
396
  * Resolves the workspace root of one chat session (`dshSessionId` = DSH session
@@ -502,6 +646,11 @@ export declare class AgentBridge {
502
646
  * same session — forbidden by the protocol and rejected (or worse, mishandled)
503
647
  * by the agent. Waiting turns a protocol error into a short delay.
504
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
+ *
505
654
  * @param session - the session asking for the slot.
506
655
  * @returns whether the slot was claimed, and why not when it was not.
507
656
  */
@@ -511,9 +660,12 @@ export declare class AgentBridge {
511
660
  ok: false;
512
661
  reason: 'busy' | 'unsettled';
513
662
  }>;
663
+ /** Whether a turn currently holds the agent prompt slot (see `promptInFlight`). */
664
+ private promptSlotHeld;
514
665
  /**
515
- * Mark the agent-side turn as in flight and hand back the settler its stream
516
- * 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.
517
669
  *
518
670
  * @param session - the session whose turn is starting.
519
671
  * @returns the settler (idempotent; also used by connection loss).
@@ -522,7 +674,8 @@ export declare class AgentBridge {
522
674
  /**
523
675
  * Open a session's settle gate without owning its turn stream: the turn's
524
676
  * stream is gone (a resume, a release) but the next prompt still waits on
525
- * 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.
526
679
  */
527
680
  private openSettleGate;
528
681
  view(): AgentView;
@@ -646,9 +799,25 @@ export declare class AgentBridge {
646
799
  * {@link BridgeTuning.staleCancelRetryDelayMs}, out-waiting the cleanup
647
800
  * window, bounded by {@link BridgeTuning.staleCancelMaxRetries}; a genuinely
648
801
  * requested cancel (`cancelRequestedAtTurn` still names this generation)
649
- * returns as-is.
650
- */
651
- 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
+ }>;
652
821
  cancel(session: LiveSession): Promise<void>;
653
822
  /**
654
823
  * An agent that ignores the `session/cancel` notification must not wedge the
@@ -666,10 +835,19 @@ export declare class AgentBridge {
666
835
  private armCancelFallback;
667
836
  /**
668
837
  * Force the local end of one turn whose agent never acknowledged it: clear
669
- * `running`, open the settle gate, and broadcast the turn end with `turnId:
670
- * ''` (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
671
839
  * fallback and the stall watchdog.
672
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
+ *
673
851
  * Generation-guarded: a turn that already ended — or that a later turn
674
852
  * superseded — is not this call's to finalize. Returns whether it acted; a
675
853
  * `false` means the caller was late and must leave the session alone.
@@ -1022,6 +1200,14 @@ interface SegmentState {
1022
1200
  activeToolCall: AcpToolCallSnapshot | undefined;
1023
1201
  /** Whether this segment opened at least one tool-call block. */
1024
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;
1025
1211
  finished: boolean;
1026
1212
  }
1027
1213
  /** How one segment's stream ends, in `StreamChunk.finish` terms. */
@@ -1071,13 +1257,111 @@ declare class TurnStreamController {
1071
1257
  private readonly rollbackFirstTurn;
1072
1258
  private segment;
1073
1259
  private settled;
1260
+ /** Whether the turn's closing work already ran (see `closeTurn`). */
1261
+ private closedTurn;
1074
1262
  private disposed;
1075
1263
  private readonly seenCalls;
1076
1264
  private readonly announcedKinds;
1077
1265
  /** Narrative lines (permission asks) that arrived while no segment was open. */
1078
1266
  private readonly pendingProse;
1079
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;
1080
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;
1081
1365
  constructor(manager: BridgeManager, hub: EventHub, session: LiveSession, turnId: string,
1082
1366
  /** Releases the agent-side turn slot (the `beginTurn` settler; idempotent). */
1083
1367
  endTurn: () => void, rollbackFirstTurn: (session: LiveSession) => void);
@@ -1102,11 +1386,270 @@ declare class TurnStreamController {
1102
1386
  startSegment(): SegmentState;
1103
1387
  /** Finish the segment on air (if any). Used by cancel paths and teardown. */
1104
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;
1105
1601
  /** The agent settled its `session/prompt`. Ends the turn's streaming face. */
1106
1602
  settle(outcome: TurnSettle): void;
1107
- /** 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
+ */
1108
1642
  abortActiveSegment(): void;
1109
- /** 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
+ */
1110
1653
  dispose(): void;
1111
1654
  /**
1112
1655
  * Stop observing and release the manager's index WITHOUT touching the
@@ -1118,7 +1661,19 @@ declare class TurnStreamController {
1118
1661
  private finishSegment;
1119
1662
  /** A segment opened on an already-settled turn: replay, then close it out. */
1120
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;
1121
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;
1122
1677
  /**
1123
1678
  * Put one narrative line into the turn's prose stream.
1124
1679
  *
@@ -1132,12 +1687,33 @@ declare class TurnStreamController {
1132
1687
  private recordWithoutSegment;
1133
1688
  /** Record a tool call's output (idempotent) and wake any waiter. */
1134
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;
1135
1707
  /**
1136
1708
  * The turn's whole fold, shared by live events and segment replay. Visible
1137
1709
  * non-tool events END a card-bearing segment instead of joining it — that is
1138
1710
  * the segmentation rule that puts each card between two assistant messages.
1139
1711
  * Returns whether the event was consumed into this segment: the cutting
1140
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).
1141
1717
  */
1142
1718
  private foldInto;
1143
1719
  private emitProse;
@@ -1254,6 +1830,28 @@ export declare class BridgeManager {
1254
1830
  private readonly turnStreams;
1255
1831
  /** The segmented turn a continuation step of one chat session must resume. */
1256
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
+ };
1257
1855
  /** Index a turn's controller, retiring whatever turn it supersedes. */
1258
1856
  rememberTurnStream(dshSessionId: string, controller: TurnStreamController): void;
1259
1857
  /** Drop a controller from the index (its own `dispose` calls back into this). */
@@ -1301,6 +1899,10 @@ export declare class BridgeManager {
1301
1899
  * sessions and headless deployments.
1302
1900
  */
1303
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;
1304
1906
  /**
1305
1907
  * Wire the ACP-mode probe: given a DSH session id, report whether that
1306
1908
  * session runs the ACP agent preset. The host half reads it back from the
@@ -1592,6 +2194,12 @@ export declare class BridgeManager {
1592
2194
  * missing agent does not erase the choice it was made for.
1593
2195
  */
1594
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;
1595
2203
  private persistAgents;
1596
2204
  /**
1597
2205
  * Restore chat session records from disk as PARKED sessions.
@@ -1878,7 +2486,7 @@ export declare class BridgeManager {
1878
2486
  startSession(agentId: string, cwd: string | undefined): Promise<{
1879
2487
  sessionId: string;
1880
2488
  }>;
1881
- prompt(sessionId: string, text: string): Promise<{
2489
+ prompt(sessionId: string, prompt: string | ContentBlock[]): Promise<{
1882
2490
  turnId: string;
1883
2491
  }>;
1884
2492
  /** The late answer landed (or failed): the turn is no longer an orphan. */
@@ -2011,6 +2619,7 @@ export declare class BridgeManager {
2011
2619
  private forgetDshSession;
2012
2620
  private sessionWorkspaceResolver?;
2013
2621
  private acpModeResolver?;
2622
+ private attachmentResolver?;
2014
2623
  /** Cached `chatSessionAcpMode` answers, keyed by DSH chat session id. */
2015
2624
  private readonly acpModeCache;
2016
2625
  /** In-flight cold reads behind `chatSessionAcpModeAnswer`, keyed by session id. */