dsh-ntr 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/impl.js CHANGED
@@ -834,6 +834,42 @@ export function childEnv(parent, extra) {
834
834
  return out;
835
835
  }
836
836
  // ---- Model catalog (pure parsing of ACP `configOptions`) ----
837
+ /**
838
+ * ACP categories are free-form UX hints, not identity. Qoder CN tags
839
+ * `reasoning_effort` as `category: 'model'` (the spec means `thought_level`),
840
+ * and nothing stops an agent from reusing a category for several options.
841
+ * Slots are therefore assigned by `configId` first and category second, and a
842
+ * same-category option NEVER replaces another option's slot: whatever loses
843
+ * stays reachable through `extras` instead of silently becoming the model list.
844
+ */
845
+ const EFFORT_OPTION_HINT = /effort|thinking|thought|reason/i;
846
+ function isModelSelector(configId, name) {
847
+ return configId === 'model' || name.trim().toLowerCase() === 'model';
848
+ }
849
+ function catalogSlotFor(category, configId, name) {
850
+ const effortish = EFFORT_OPTION_HINT.test(configId) || EFFORT_OPTION_HINT.test(name);
851
+ if (category === 'model') {
852
+ // The option explicitly named `model` is the selector even when other
853
+ // model-scoped options are present.
854
+ if (isModelSelector(configId, name))
855
+ return { slot: 'model', rank: 3 };
856
+ // A model-scoped effort is mislabelled, not a model: route it to the
857
+ // thought chip when no real `thought_level` option is published.
858
+ if (effortish)
859
+ return { slot: 'thought', rank: 1 };
860
+ // An unknown model-scoped select: a last-resort selector only.
861
+ return { slot: 'model', rank: 1 };
862
+ }
863
+ if (category === 'thought_level')
864
+ return { slot: 'thought', rank: 3 };
865
+ if (category === 'mode')
866
+ return { slot: 'mode', rank: 2 };
867
+ // `model_config` is where the spec puts model-dependent knobs (effort,
868
+ // context size); only the effort-shaped ones have a dedicated control.
869
+ if (category === 'model_config' && effortish)
870
+ return { slot: 'thought', rank: 1 };
871
+ return undefined;
872
+ }
837
873
  /**
838
874
  * Extract select options from a raw ACP `configOptions` payload.
839
875
  *
@@ -848,19 +884,13 @@ export function parseCatalogOptions(configOptions) {
848
884
  const out = {};
849
885
  if (!Array.isArray(configOptions))
850
886
  return out;
851
- const extras = new Map();
887
+ const definitions = [];
888
+ const candidates = [];
852
889
  for (const raw of configOptions) {
853
890
  if (typeof raw !== 'object' || raw === null)
854
891
  continue;
855
892
  const row = raw;
856
893
  const category = typeof row.category === 'string' ? row.category : undefined;
857
- const slot = category === 'model'
858
- ? 'model'
859
- : category === 'thought_level'
860
- ? 'thought'
861
- : category === 'mode'
862
- ? 'mode'
863
- : undefined;
864
894
  const configIdRaw = row.configId ?? row.id;
865
895
  if (typeof configIdRaw !== 'string' && typeof configIdRaw !== 'number')
866
896
  continue;
@@ -869,6 +899,7 @@ export function parseCatalogOptions(configOptions) {
869
899
  continue;
870
900
  const choices = [];
871
901
  collectCatalogChoices(row.options, choices);
902
+ const name = typeof row.name === 'string' && row.name !== '' ? row.name : configId;
872
903
  // A boolean toggle has no `options` at all; its current value is a JSON
873
904
  // boolean. It is encoded as 'true' / 'false' so the catalog stays
874
905
  // string-typed for every existing reader, and carries `kind` so the
@@ -877,7 +908,7 @@ export function parseCatalogOptions(configOptions) {
877
908
  || (typeof row.currentValue === 'boolean' && choices.length === 0);
878
909
  const option = {
879
910
  configId,
880
- name: typeof row.name === 'string' && row.name !== '' ? row.name : configId,
911
+ name,
881
912
  ...(booleanKind ? { kind: 'boolean' } : {}),
882
913
  currentValue: booleanKind
883
914
  ? row.currentValue === true || row.currentValue === 'true'
@@ -886,22 +917,81 @@ export function parseCatalogOptions(configOptions) {
886
917
  : typeof row.currentValue === 'string' && row.currentValue !== '' ? row.currentValue : null,
887
918
  choices,
888
919
  };
920
+ definitions.push(option);
889
921
  // A toggle is never a model / thought / mode SELECT: those controls pick
890
922
  // among choices. It belongs to the advanced base, where it is a checkbox.
891
- if (slot !== undefined && !booleanKind) {
892
- out[slot] = option;
923
+ if (booleanKind)
893
924
  continue;
925
+ const slot = catalogSlotFor(category, configId, name);
926
+ if (slot !== undefined)
927
+ candidates.push({ ...slot, option });
928
+ }
929
+ const winnerIds = new Set();
930
+ for (const slot of ['model', 'thought', 'mode']) {
931
+ let winner;
932
+ for (const candidate of candidates) {
933
+ if (candidate.slot !== slot)
934
+ continue;
935
+ if (winner === undefined || candidate.rank > winner.rank) {
936
+ winner = candidate;
937
+ }
938
+ else if (candidate.rank === winner.rank && candidate.option.configId === winner.option.configId) {
939
+ // The same option redefined later in one answer: the last definition
940
+ // is the current one.
941
+ winner = candidate;
942
+ }
894
943
  }
895
- // Choice-less options have nothing to pick; they would render an empty
896
- // section in the advanced menu, so they stay out of the catalog entirely —
897
- // except a boolean, whose whole point is that it has no choices.
898
- if (choices.length > 0 || booleanKind)
899
- extras.set(configId, option);
944
+ if (winner === undefined)
945
+ continue;
946
+ out[slot] = winner.option;
947
+ winnerIds.add(winner.option.configId);
948
+ }
949
+ // Choice-less options have nothing to pick; they would render an empty
950
+ // section in the advanced menu, so they stay out of the catalog entirely —
951
+ // except a boolean, whose whole point is that it has no choices. Anything
952
+ // that lost a slot race is still a real, settable option: keep it here.
953
+ const extras = new Map();
954
+ for (const option of definitions) {
955
+ if (winnerIds.has(option.configId))
956
+ continue;
957
+ if (option.choices.length > 0 || option.kind === 'boolean')
958
+ extras.set(option.configId, option);
900
959
  }
901
960
  if (extras.size > 0)
902
961
  out.extras = [...extras.values()];
903
962
  return out;
904
963
  }
964
+ /**
965
+ * Whether a payload names ONE model selector without relying on category
966
+ * alone. When an agent publishes several `category: 'model'` options and none
967
+ * is explicitly the `model` id, the category is not trustworthy enough to
968
+ * reject the user's pick — callers must fail open instead of reporting
969
+ * `model_unavailable`.
970
+ */
971
+ export function hasUnambiguousModelSelector(configOptions) {
972
+ if (!Array.isArray(configOptions))
973
+ return true;
974
+ let scoped = 0;
975
+ let explicit = false;
976
+ for (const raw of configOptions) {
977
+ if (typeof raw !== 'object' || raw === null)
978
+ continue;
979
+ const row = raw;
980
+ if (row.category !== 'model')
981
+ continue;
982
+ const configIdRaw = row.configId ?? row.id;
983
+ if (typeof configIdRaw !== 'string' && typeof configIdRaw !== 'number')
984
+ continue;
985
+ const configId = String(configIdRaw);
986
+ if (configId === '')
987
+ continue;
988
+ scoped += 1;
989
+ const name = typeof row.name === 'string' ? row.name : '';
990
+ if (isModelSelector(configId, name))
991
+ explicit = true;
992
+ }
993
+ return explicit || scoped <= 1;
994
+ }
905
995
  /**
906
996
  * Fold one per-session option answer's SHAPE into an agent-level catalog.
907
997
  *
@@ -2486,6 +2576,38 @@ export function isUnderPath(path, root) {
2486
2576
  return path.startsWith('/') && !isWin32Style(path);
2487
2577
  return trimmedPath === trimmedRoot || trimmedPath.startsWith(`${trimmedRoot}/`);
2488
2578
  }
2579
+ /**
2580
+ * Record one ACP-side worker in the host's parent-owned subagent catalog as an
2581
+ * EXTERNAL execution. Display-only: external entries have no child session, no
2582
+ * activity/timing and no continuation, and `list_agents` omits them. The
2583
+ * catalog is append-only, so a replayed/resumed dispatch must never add the
2584
+ * same `childId` twice. Returns whether a new fact was appended.
2585
+ */
2586
+ export function appendExternalSubagentCatalog(session, childId, label, now = Date.now()) {
2587
+ if (childId === '' || session?.append === undefined)
2588
+ return false;
2589
+ const events = session.events;
2590
+ if (Array.isArray(events)) {
2591
+ for (let index = events.length - 1; index >= 0; index -= 1) {
2592
+ const event = events[index];
2593
+ if (event?.type === 'subagent/catalog' && event.data?.childId === childId)
2594
+ return false;
2595
+ }
2596
+ }
2597
+ try {
2598
+ session.append('subagent/catalog', {
2599
+ version: 2,
2600
+ childId,
2601
+ childCreatedAt: now,
2602
+ mode: 'external',
2603
+ label,
2604
+ });
2605
+ return true;
2606
+ }
2607
+ catch {
2608
+ return false;
2609
+ }
2610
+ }
2489
2611
  /**
2490
2612
  * The agent preset a session actually runs.
2491
2613
  *
package/lib/index.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * hide the loader metadata — see harness postmortem 0001).
6
6
  */
7
7
  import { ACP_PRESET_ID, AcpLlmAdapter, BridgeManager, PLUGIN_VERSION } from "./bridge.js";
8
- import { EventHub, dispatchRpc, permissionReplayFrames, sessionAgentPreset, sessionAgentPresetFromLog, validateConfig } from "./impl.js";
8
+ import { appendExternalSubagentCatalog, EventHub, dispatchRpc, permissionReplayFrames, sessionAgentPreset, sessionAgentPresetFromLog, validateConfig } from "./impl.js";
9
9
  import { UpdateService } from "./update.js";
10
10
  export const name = 'dsh-ntr';
11
11
  // Deliberately minimal: every other host service is probed structurally
@@ -200,6 +200,28 @@ export function apply(ctx, config) {
200
200
  return undefined;
201
201
  }
202
202
  };
203
+ /**
204
+ * Optional host integration: record detached ACP workers as EXTERNAL
205
+ * children in the parent chat session's subagent catalog. Display-only — the
206
+ * host renders them as read-only "external task" rows with no child
207
+ * session, activity or continuation — and is skipped silently on hosts
208
+ * without the catalog event.
209
+ */
210
+ const wireSubagentCatalog = () => {
211
+ const sessions = (typeof ctx.get === 'function' ? ctx.get('sessions') : undefined);
212
+ if (sessions === undefined)
213
+ return;
214
+ manager.setSubagentCatalogReporter((dshSessionId, childId, label) => {
215
+ let session;
216
+ try {
217
+ session = sessions.get(dshSessionId);
218
+ }
219
+ catch {
220
+ return;
221
+ }
222
+ appendExternalSubagentCatalog(session, childId, label);
223
+ });
224
+ };
203
225
  wireWorkspace();
204
226
  if (typeof ctx.inject === 'function') {
205
227
  // Late arrival for the workspace collaborators: a deployment missing one
@@ -211,9 +233,11 @@ export function apply(ctx, config) {
211
233
  // The ACP-preset verdict only needs the session store (live) and the
212
234
  // persistence store (cold); the sandbox knobs are NOT its dependencies.
213
235
  wireAcpMode();
236
+ wireSubagentCatalog();
214
237
  if (typeof ctx.inject === 'function') {
215
238
  ctx.inject(['sessions'], () => {
216
239
  wireAcpMode();
240
+ wireSubagentCatalog();
217
241
  });
218
242
  }
219
243
  const wireAttachments = (injectedCtx) => {
@@ -6,6 +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 ChildProcess } from 'node:child_process';
9
10
  import { type ContentBlock, type InitializeResponse } from '@agentclientprotocol/sdk';
10
11
  import { EventHub, PermissionLedger, Transcript } from './impl.ts';
11
12
  import type { AcpToolCallSnapshot, PendingPermissionView, RosterPresetRow } from './impl.ts';
@@ -358,20 +359,33 @@ export interface BridgeTuning {
358
359
  */
359
360
  ambientMaxMs?: number;
360
361
  /**
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
362
+ * How long the WORKER phase of a detached-worker hold may go WITHOUT any
363
+ * evidence from the worker (see `TurnStreamController.holdForBackgroundDispatch`):
364
+ * the turn dispatched one or more background workers through `Agent` tool
365
+ * calls, the agent answered the prompt, and at least one worker has not
366
+ * reported back yet. There is still no quiet clock in the ambient sense —
367
+ * measured against CodeBuddy 2.179.0, a detached worker's own updates are
368
+ * 13–24s apart while it is perfectly alive — so every update naming the
369
+ * dispatch or carrying its `parentToolCallId` PUSHES THIS WINDOW OUT. Only a
370
+ * genuinely silent worker, or every known dispatch reporting (completed /
371
+ * failed), ends the phase; a drain announcement is recorded but arms no
372
+ * quiet clock before then. Once they have all reported, the drain's own
370
373
  * `ambientQuietMs` / `ambientMaxMs` windows take over.
371
374
  *
372
- * Default 600000 ms (10 minutes).
375
+ * Default 600000 ms (10 minutes of silence, not of total runtime).
373
376
  */
374
377
  backgroundTaskMaxMs?: number;
378
+ /**
379
+ * Optional ABSOLUTE ceiling on a detached-worker hold, both phases included.
380
+ * `backgroundTaskMaxMs` keeps an ACTIVE worker alive indefinitely; this is
381
+ * the emergency bound for a zombie that keeps emitting updates forever.
382
+ * `0` disables it — the turn can then stay open as long as the worker keeps
383
+ * proving it is alive, and the person can still steer/cancel the hold by
384
+ * sending a message.
385
+ *
386
+ * Default 0 (no ceiling).
387
+ */
388
+ backgroundTaskCeilingMs?: number;
375
389
  /**
376
390
  * How long a PARKED detached-worker hold may wait for the host to open the
377
391
  * driver step that carries the worker's report. The prompt response landed
@@ -410,6 +424,11 @@ export type SessionWorkspaceResolver = (dshSessionId: string) => {
410
424
  * browser's seat gate, which polls.
411
425
  */
412
426
  export type AcpModeResolver = (dshSessionId: string) => boolean | undefined | Promise<boolean | undefined>;
427
+ /**
428
+ * Report one detached ACP worker to the host's parent-owned subagent catalog.
429
+ * Display-only: the host records an EXTERNAL execution with no child session.
430
+ */
431
+ export type SubagentCatalogReporter = (dshSessionId: string, childId: string, label: string) => void;
413
432
  type BridgeState = 'stopped' | 'starting' | 'ready' | 'error';
414
433
  export declare class AgentBridge {
415
434
  readonly def: AcpAgentDef;
@@ -452,6 +471,24 @@ export declare class AgentBridge {
452
471
  stderrTail: string[];
453
472
  private child?;
454
473
  private conn?;
474
+ /**
475
+ * Teardown generation, bumped ONLY by a deliberate teardown (`stop` /
476
+ * `shutdown` / `forceReset`), so a start that was already in flight can tell
477
+ * that nobody owns its outcome any more: `spawnAndConnect` re-checks this
478
+ * after each await and disposes the child it would otherwise have published
479
+ * as `ready`. Without it, a teardown landing during `ensureAgentExecutable`
480
+ * (a global npm install can take minutes) left a freshly spawned agent
481
+ * running with no owner.
482
+ *
483
+ * `releaseAll` and `killChild` deliberately do NOT bump it: both are also the
484
+ * failure path of a start's own handshake — a child dying during startup goes
485
+ * `exit` → `connectionLost` → `releaseAll('died')` — and that start must
486
+ * still publish `error` with its stderr tail rather than be mistaken for a
487
+ * teardown (see `ensureReady`). It cannot leak a process either way: a crash
488
+ * release only happens once the child exists, and `spawnAndConnect` has no
489
+ * spawn point left after that.
490
+ */
491
+ private teardownEpoch;
455
492
  private starting?;
456
493
  private readonly sessions;
457
494
  private readonly acpIndex;
@@ -733,6 +770,11 @@ export declare class AgentBridge {
733
770
  * it.
734
771
  */
735
772
  ensureReady(spawnCwd?: string): Promise<void>;
773
+ /**
774
+ * @param epoch - `teardownEpoch` captured when this start claimed the bridge.
775
+ * Every await below is a window in which a teardown can land; a stale start
776
+ * owns nothing and must leave no process behind (see `teardownEpoch`).
777
+ */
736
778
  private spawnAndConnect;
737
779
  /**
738
780
  * Bound one agent-startup request (`initialize`, `session/new`,
@@ -864,8 +906,31 @@ export declare class AgentBridge {
864
906
  */
865
907
  resumeSession(session: LiveSession): Promise<LiveSession>;
866
908
  private doResumeSession;
867
- /** Release all live sessions (process died or went idle): keep them resumable, settle permissions, drop the connection. */
909
+ /**
910
+ * Release all live sessions (process died or went idle): keep them resumable,
911
+ * settle permissions, drop the connection.
912
+ *
913
+ * Deliberately does NOT bump `teardownEpoch`: a child's death during startup
914
+ * runs through `connectionLost` into this method, and that start must still
915
+ * publish `error` with its stderr tail — the settings card expands on exactly
916
+ * that pair (see `view` / `teardownEpoch`).
917
+ */
868
918
  releaseAll(reason: string, cause: AcpReleaseCause): void;
919
+ /**
920
+ * Park every live session as RELEASED — resumable through `session/load`,
921
+ * never destroyed — and persist the records.
922
+ *
923
+ * This is the local half shared by `releaseAll` (crash / idle / workspace) and
924
+ * `shutdown` (host exit): those paths differ only in what happens to the child
925
+ * afterwards. A `closed` session is skipped — its record belongs to
926
+ * `closeSession`, and flipping it to released would let a resume resurrect a
927
+ * conversation that is being destroyed (constraint 10).
928
+ *
929
+ * Returns how many sessions were parked, so a shutdown with nothing to park
930
+ * does not rewrite the state file (a second `shutdownAll` must not erase the
931
+ * records the first one persisted).
932
+ */
933
+ private parkSessions;
869
934
  /** Detach one released session record for a resume attempt. */
870
935
  takeReleased(sessionId: string): LiveSession | undefined;
871
936
  /** Drop a released session record for good (user closed it or the agent was stopped). */
@@ -901,7 +966,43 @@ export declare class AgentBridge {
901
966
  * message. This is a stop, not a discard.
902
967
  */
903
968
  forceReset(reason: 'agent-stopped'): void;
969
+ /**
970
+ * Fire-and-forget kill for the NON-teardown paths: idle release, workspace
971
+ * move, crash handling, `forceReset`, and a start's own failure. None of them
972
+ * may block on a dying process, and CRASH ≠ DESTROY (constraint 10): the
973
+ * sessions were already parked by the caller, this only takes the process
974
+ * away. The awaitable variant is `killChildAndWait`, used by host shutdown.
975
+ */
904
976
  private killChild;
977
+ /**
978
+ * Take the current child away and WAIT until it is gone (bounded). Only the
979
+ * host shutdown path uses this — every other teardown must stay
980
+ * fire-and-forget.
981
+ *
982
+ * Always resolves: a teardown that could hang on an agent ignoring SIGTERM
983
+ * would be worse than a leaked process, and the process-exit reaper
984
+ * (`reapLiveChildren`) still holds the pid if even the escalation loses.
985
+ */
986
+ private killChildAndWait;
987
+ /**
988
+ * Host shutdown: park every live session as RELEASED (resumable, exactly as a
989
+ * crash would leave it), then WAIT for the child to die.
990
+ *
991
+ * Deliberately not `stop`: `stop` asks every session's agent to
992
+ * `session/close` and then clears the released records — i.e. it DESTROYS the
993
+ * conversations. A host shutdown is not the user's "stop this agent" gesture:
994
+ * the host is coming back, and the conversations must come back with it
995
+ * (constraint 10, plus the `chatSessions` records that outlive the restart).
996
+ * Skipping the per-session RPC is also what keeps teardown bounded: N sessions
997
+ * would otherwise spend up to N × SESSION_CLOSE_TIMEOUT_MS before the child
998
+ * was even signalled, and the process is about to die anyway — "the agent
999
+ * forgot this session" costs nothing a `session/load` cannot rebuild.
1000
+ *
1001
+ * Always resolves: `budgetMs` bounds the WHOLE kill — parking is synchronous,
1002
+ * the graceful wait and the escalation both come out of the same deadline,
1003
+ * and whatever still lives after it is the exit reaper's.
1004
+ */
1005
+ shutdown(budgetMs?: number): Promise<void>;
905
1006
  /**
906
1007
  * Answer one ACP permission ask.
907
1008
  *
@@ -982,6 +1083,17 @@ export declare function permissionToolName(toolCall: unknown): string;
982
1083
  * capped so a pathological input cannot fill the card or the audit log.
983
1084
  */
984
1085
  export declare function permissionAskReason(toolCall: unknown): string;
1086
+ /**
1087
+ * One line naming a detached ACP worker in the host's subagent catalog.
1088
+ *
1089
+ * The identity is read from what CodeBuddy actually sends, in order: the
1090
+ * `_meta` keys on the dispatch call's result frames (`subagentType` /
1091
+ * `description` / `memberName` — the FIRST `tool_call` omits `rawInput`, so
1092
+ * these are often the only source), then the structured `rawInput`, then the
1093
+ * tool title. The prompt body is only used when nothing shorter exists. Capped
1094
+ * so a pathological input cannot fill the catalog row.
1095
+ */
1096
+ export declare function subagentDispatchLabel(update: Record<string, unknown>): string;
985
1097
  /**
986
1098
  * This plugin's OWN state directory, `~/.dsh/dsh-ntr`.
987
1099
  *
@@ -1077,7 +1189,7 @@ export declare function windowsShimLaunch(command: string, args: readonly string
1077
1189
  * Best effort — `taskkill` is absent from no Windows this runs on, and a
1078
1190
  * failure here only means the OS reclaims the process later.
1079
1191
  */
1080
- export declare function killProcessTree(pid: number): void;
1192
+ export declare function killProcessTree(pid: number): ChildProcess | undefined;
1081
1193
  /**
1082
1194
  * Globally install an agent's npm package. The package name reaches the
1083
1195
  * installer as a spawn argv item; on Windows the installer needs a shell,
@@ -1351,9 +1463,11 @@ declare class TurnStreamController {
1351
1463
  private pendingBackgroundHold?;
1352
1464
  /**
1353
1465
  * 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}).
1466
+ * `taskMaxMs` is the worker phase's silence window, `ceilingMs` the optional
1467
+ * whole-hold absolute bound (0 = none), `graceMs` the parked hold's bounded
1468
+ * wait for a driver step (see {@link BridgeTuning.backgroundTaskMaxMs} /
1469
+ * {@link BridgeTuning.backgroundTaskCeilingMs} /
1470
+ * {@link BridgeTuning.backgroundParkedGraceMs}).
1357
1471
  */
1358
1472
  private readonly backgroundTuning;
1359
1473
  /**
@@ -1455,11 +1569,13 @@ declare class TurnStreamController {
1455
1569
  * - marks the segment background, so EVERY tool call for the rest of the
1456
1570
  * hold — the workers', the drain's TaskOutput — stays transcript-only and
1457
1571
  * 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.
1572
+ * - waits in two phases: the worker phase's clock is a SILENCE window
1573
+ * ({@link BridgeTuning.backgroundTaskMaxMs}) that every dispatch / worker
1574
+ * update pushes out, so a long task that keeps reporting is never cut at a
1575
+ * fixed total runtime. It ends when ALL known dispatches reported, after
1576
+ * which the drain phase uses the ambient windows. An optional absolute
1577
+ * ceiling ({@link BridgeTuning.backgroundTaskCeilingMs}, default none)
1578
+ * bounds a zombie that keeps talking forever.
1463
1579
  */
1464
1580
  holdForBackgroundDispatch(stopReason: string, requestId?: string): void;
1465
1581
  /** Give a (possibly parked) hold a driver segment: cards off for the rest of it. */
@@ -1483,15 +1599,24 @@ declare class TurnStreamController {
1483
1599
  private enterBackgroundWorkerPhase;
1484
1600
  /** Push the drain phase's quiet clock out: every update is evidence of work. */
1485
1601
  private armBackgroundQuiet;
1602
+ /**
1603
+ * Evidence from a live dispatch / worker: the worker phase is SILENCE-based,
1604
+ * so each update pushes its deadline out. A long task that keeps reporting is
1605
+ * never cut at a fixed total runtime; only a genuinely quiet worker is.
1606
+ */
1607
+ private noteBackgroundWorkerActivity;
1608
+ /** Arm the hold's optional absolute ceiling (0 = unlimited). */
1609
+ private armBackgroundCeiling;
1486
1610
  /**
1487
1611
  * 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.
1612
+ * ADOPTED hold has a quiet clock: a parked hold's wait belongs to its grace
1613
+ * timer, and the worker phase is pushed forward only by dispatch / worker
1614
+ * evidence (see `noteBackgroundWorkerActivity`).
1490
1615
  */
1491
1616
  private noteBackgroundHoldActivity;
1492
1617
  /** The drained run went quiet: the turn ends with the stop reason the response gave. */
1493
1618
  private onBackgroundQuiet;
1494
- /** The hold outlived its ceiling (worker or drain): the run is still going. */
1619
+ /** The hold went silent past its window, or outlived the absolute ceiling. */
1495
1620
  private onBackgroundHard;
1496
1621
  /**
1497
1622
  * No driver step opened inside the grace: the host is not coming back for
@@ -1566,6 +1691,22 @@ declare class TurnStreamController {
1566
1691
  * - an `unsolicitedTurn` announcement is the drain that carries the report.
1567
1692
  */
1568
1693
  private trackBackgroundDispatch;
1694
+ /**
1695
+ * Hand one DETACHED dispatch to the host's subagent catalog. One fact per
1696
+ * tool call, and only once the wire's structure proves the work runs
1697
+ * off-turn — a synchronous `Agent` / `Task` call is a tool call, not a
1698
+ * catalog child.
1699
+ */
1700
+ private reportSubagentDispatch;
1701
+ /**
1702
+ * Take a better label from a later frame of the SAME dispatch call.
1703
+ *
1704
+ * CodeBuddy omits `rawInput` (and often the title) on the dispatch call's
1705
+ * first send, then restates the whole task identity in `_meta` on the result
1706
+ * frame. A frame with no identity and no title is a bare status update and
1707
+ * must not downgrade what is already known.
1708
+ */
1709
+ private refreshSubagentDispatchLabel;
1569
1710
  /**
1570
1711
  * A dispatch just reported: once the LAST one has, the drain phase starts.
1571
1712
  * Until then the turn stays in its worker phase, with no quiet clock.
@@ -1843,13 +1984,16 @@ export declare class BridgeManager {
1843
1984
  /**
1844
1985
  * The detached-worker hold's windows, read once at controller construction
1845
1986
  * 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}.
1987
+ * phase's SILENCE window, pushed out by every dispatch / worker update (see
1988
+ * {@link BridgeTuning.backgroundTaskMaxMs}), `ceilingMs` its optional
1989
+ * absolute bound (0 = none) and `graceMs` the bounded wait a PARKED hold
1990
+ * gives the host to open its continuation step (see
1991
+ * {@link BridgeTuning.backgroundParkedGraceMs}). The drain phase reuses
1992
+ * {@link ambientTuning}.
1850
1993
  */
1851
1994
  get backgroundTuning(): {
1852
1995
  taskMaxMs: number;
1996
+ ceilingMs: number;
1853
1997
  graceMs: number;
1854
1998
  };
1855
1999
  /** Index a turn's controller, retiring whatever turn it supersedes. */
@@ -1910,6 +2054,19 @@ export declare class BridgeManager {
1910
2054
  * falls back to its own session-list rows.
1911
2055
  */
1912
2056
  setAcpModeResolver(resolver?: AcpModeResolver): void;
2057
+ /**
2058
+ * Optional host integration: report a detached ACP worker as an EXTERNAL
2059
+ * child in the parent chat session's subagent catalog. Display-only — the
2060
+ * host renders it as a read-only "external task" row with no child session,
2061
+ * activity or continuation. An absent reporter is a no-op.
2062
+ */
2063
+ setSubagentCatalogReporter(reporter?: SubagentCatalogReporter): void;
2064
+ /**
2065
+ * Hand one detached worker to the host catalog. Returns whether a reporter
2066
+ * handled it; a missing chat id or reporter leaves the dispatch unclaimed so
2067
+ * a later wire update can retry.
2068
+ */
2069
+ reportSubagentCatalog(session: LiveSession, childId: string, label: string): boolean;
1913
2070
  /**
1914
2071
  * Whether ONE DSH chat session runs the ACP agent preset, asked by chat
1915
2072
  * session id. The browser's composer surfaces consume this: on hosts whose
@@ -2505,6 +2662,15 @@ export declare class BridgeManager {
2505
2662
  };
2506
2663
  sessions(): unknown[];
2507
2664
  stopAgent(agentId: string): Promise<void>;
2665
+ /**
2666
+ * Host shutdown: park every conversation, then make sure no agent child
2667
+ * outlives the host. Bridges run CONCURRENTLY against one bounded budget, so a
2668
+ * single wedged agent cannot spend every other bridge's slice too.
2669
+ *
2670
+ * `/acp`'s "stop this agent" gesture is `stopAgent` → `stop`, which is a
2671
+ * different promise: it closes the sessions on purpose. See
2672
+ * `AgentBridge.shutdown` for why host exit must not.
2673
+ */
2508
2674
  shutdownAll(): Promise<void>;
2509
2675
  /** Host-side reader of the harness preset roster (`ctx.agentPresets`), for `presetStatus`. */
2510
2676
  private presetRosterReader?;
@@ -2619,6 +2785,7 @@ export declare class BridgeManager {
2619
2785
  private forgetDshSession;
2620
2786
  private sessionWorkspaceResolver?;
2621
2787
  private acpModeResolver?;
2788
+ private subagentCatalogReporter?;
2622
2789
  private attachmentResolver?;
2623
2790
  /** Cached `chatSessionAcpMode` answers, keyed by DSH chat session id. */
2624
2791
  private readonly acpModeCache;
@@ -2626,8 +2793,8 @@ export declare class BridgeManager {
2626
2793
  private readonly acpModeInflight;
2627
2794
  /**
2628
2795
  * The agent values the composer is showing for a conversation that has not
2629
- * picked them — the catalog's current model, thought level and extra config
2630
- * values.
2796
+ * picked them — the catalog's current model, thought level, mode and extra
2797
+ * config values.
2631
2798
  *
2632
2799
  * Captured BEFORE `session/new`: materializing the session absorbs its own
2633
2800
  * defaults into the catalog (`absorbCatalog`), which would overwrite exactly
@@ -2640,14 +2807,15 @@ export declare class BridgeManager {
2640
2807
  * first prompt runs.
2641
2808
  *
2642
2809
  * A conversation with no explicit pick displays the agent catalog's current
2643
- * model / thought level / extras, while the ACP session `session/new` just
2644
- * created starts on whatever the agent itself defaults to. Without this the
2645
- * user is shown one model and the agent runs another — the defect reported
2646
- * as "the displayed model was not used". Values the fresh session does not
2647
- * offer are skipped (its `configOptions` are the authority, and the catalog
2648
- * update that rode `session/new` will correct the display), never turned
2649
- * into a failed first turn. Explicit picks always win: only missing fields
2650
- * are filled.
2810
+ * model / thought level / mode / extras, while the ACP session `session/new`
2811
+ * just created starts on whatever the agent itself defaults to. Without this
2812
+ * the user is shown one model and the agent runs another — the defect
2813
+ * reported as "the displayed model was not used", and the reported first
2814
+ * turn that ignored the mode select's value. Values the fresh session does
2815
+ * not offer are skipped (its `configOptions` are the authority, and the
2816
+ * catalog update that rode `session/new` will correct the display), never
2817
+ * turned into a failed first turn. Explicit picks always win: only missing
2818
+ * fields are filled.
2651
2819
  */
2652
2820
  private seedChatChoiceFromCatalog;
2653
2821
  getOrCreateDshSession(dshSessionId: string | undefined, agentId: string, cwd?: string): Promise<LiveSession>;
@@ -2667,6 +2835,21 @@ export declare class BridgeManager {
2667
2835
  * user is told the context is gone.
2668
2836
  */
2669
2837
  private prepareStalledSession;
2838
+ /**
2839
+ * 聊天插话的槽位回收:宿主把插话作为同一会话在飞回合的下一步送达,
2840
+ * 拒绝会让整条 host turn 失败。ACP 不能向运行中的 prompt 注入消息,
2841
+ * 所以按插话语义中止当前回合,等 agent 真正结算后再让调用方重发。
2842
+ *
2843
+ * 只处理 `claimTurn` 的 `busy`(agent 仍持有本会话 prompt);`unsettled`
2844
+ * 说明我们这一侧已经释放过槽位(用户按过停止),保持原样拒绝。
2845
+ *
2846
+ * - 控制器已 settle(响应已到,延后收口还没释放闸门):什么都不取消,只等闸门;
2847
+ * - agent 真在跑:走用户取消同一条管线(session/cancel + 结算权限 + cancel fallback),
2848
+ * 并立即关闭宿主这一侧的 segment,让闸门在 agent 应答/兜底后打开。
2849
+ */
2850
+ private reclaimChatPrompt;
2851
+ /** 等 agent 侧 prompt slot 真正释放;步骤被取消时立即返回 aborted。 */
2852
+ private waitForSettleGate;
2670
2853
  streamPrompt(options: GenerateOptions): AsyncIterable<StreamChunk>;
2671
2854
  }
2672
2855
  export declare class AcpLlmAdapter implements LlmAdapterLike {