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/bridge.js CHANGED
@@ -6,13 +6,13 @@
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 { spawn } from 'node:child_process';
9
+ import { spawn, spawnSync } from 'node:child_process';
10
10
  import { homedir } from 'node:os';
11
11
  import { delimiter, dirname, join } from 'node:path';
12
12
  import { Writable, Readable } from 'node:stream';
13
13
  import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
14
14
  import { client, methods, ndJsonStream, PROTOCOL_VERSION, RequestError, } from '@agentclientprotocol/sdk';
15
- import { AcpError, EventHub, PermissionLedger, Transcript, acpToolCallDelta, acpToolCallFromUpdate, buildAdapterModelId, childEnv, isAcpToolContinuation, isUnderPath, mergeCatalogShape, parseCatalogOptions, usableConfigOptions, permissionRequestFrame, pickAutoOption, quoteCommandToken, randomUUID, resolveFsPath, resolveSessionCwd, selectForwardPrompt, isAbsolutePath, acpPresetStatusFromRoster, splitAdapterModelId, updateContentToText, describeUnhandledUpdate, BOOKKEEPING_UPDATES, validateAgentDef, validateTagHalo, validateApprovalDeadlines, validatePermissionTimeoutMode, normalizeApprovalWindow, } from "./impl.js";
15
+ import { AcpError, EventHub, PermissionLedger, Transcript, acpToolCallDelta, acpToolCallFromUpdate, buildAdapterModelId, childEnv, hasUnambiguousModelSelector, isAcpToolContinuation, isUnderPath, mergeCatalogShape, parseCatalogOptions, usableConfigOptions, permissionRequestFrame, pickAutoOption, quoteCommandToken, randomUUID, resolveFsPath, resolveSessionCwd, selectForwardPrompt, isAbsolutePath, acpPresetStatusFromRoster, splitAdapterModelId, updateContentToText, describeUnhandledUpdate, BOOKKEEPING_UPDATES, validateAgentDef, validateTagHalo, validateApprovalDeadlines, validatePermissionTimeoutMode, normalizeApprovalWindow, } from "./impl.js";
16
16
  import { DEFAULT_IDLE_TIMEOUT_MS, DEFAULT_TAG_HALO, IDLE_RELEASE_MS } from "./protocol.js";
17
17
  import { OUTPUT_CHARS_PER_TOKEN, TurnTpsTracker } from "./tps.js";
18
18
  /**
@@ -42,6 +42,37 @@ const FS_MAX_WRITE_BYTES = 2 * 1024 * 1024;
42
42
  * stop — and with it the stall reclaim that calls it — forever.
43
43
  */
44
44
  const SESSION_CLOSE_TIMEOUT_MS = 5_000;
45
+ /**
46
+ * The second — and last — slice after the escalation kill (`SIGKILL` on POSIX,
47
+ * a second `taskkill /T /F` on Windows). Deliberately short: past this point
48
+ * the child is the OS's problem (plus the exit reaper, see `reapLiveChildren`).
49
+ *
50
+ * `disposeChild` RESERVES this inside the budget it is handed, so every caller's
51
+ * number means the WHOLE disposal — graceful wait plus escalation wait — and
52
+ * never just the first one.
53
+ */
54
+ const KILL_ESCALATION_GRACE_MS = 500;
55
+ /**
56
+ * Fire-and-forget kill: the whole disposal budget for the non-teardown paths
57
+ * (see `KILL_ESCALATION_GRACE_MS` for how it splits). Same 3s the old inline
58
+ * SIGKILL timer used before escalating.
59
+ */
60
+ const KILL_ESCALATION_DELAY_MS = 3_000;
61
+ /**
62
+ * Budget for disposing a child whose start was invalidated by a teardown (see
63
+ * `AgentBridge.spawnAndConnect`). Short: that child was spawned moments ago and
64
+ * never served anything, so there is nothing to drain.
65
+ */
66
+ const STALE_START_KILL_BUDGET_MS = 2_000;
67
+ /**
68
+ * The WHOLE host-shutdown budget for killing children, shared by every bridge
69
+ * (bridges run concurrently, so this is the worst case for all of them, not
70
+ * per bridge). One number, one deadline: `AgentBridge.shutdown` hands what is
71
+ * LEFT of it to `killChildAndWait`, and `disposeChild` reserves the escalation
72
+ * grace inside that — so the graceful wait gets at most 4s − 500ms and the
73
+ * whole disposal, escalation included, never exceeds 4s.
74
+ */
75
+ const SHUTDOWN_BUDGET_MS = 4_000;
45
76
  /**
46
77
  * The plugin's normalized MCP server shape → the ACP wire shape.
47
78
  *
@@ -110,6 +141,7 @@ const DEFAULT_TUNING = {
110
141
  ambientQuietMs: 10_000,
111
142
  ambientMaxMs: 120_000,
112
143
  backgroundTaskMaxMs: 600_000,
144
+ backgroundTaskCeilingMs: 0,
113
145
  backgroundParkedGraceMs: 30_000,
114
146
  ambientStaleMs: 300_000,
115
147
  };
@@ -129,6 +161,24 @@ export class AgentBridge {
129
161
  stderrTail = [];
130
162
  child;
131
163
  conn;
164
+ /**
165
+ * Teardown generation, bumped ONLY by a deliberate teardown (`stop` /
166
+ * `shutdown` / `forceReset`), so a start that was already in flight can tell
167
+ * that nobody owns its outcome any more: `spawnAndConnect` re-checks this
168
+ * after each await and disposes the child it would otherwise have published
169
+ * as `ready`. Without it, a teardown landing during `ensureAgentExecutable`
170
+ * (a global npm install can take minutes) left a freshly spawned agent
171
+ * running with no owner.
172
+ *
173
+ * `releaseAll` and `killChild` deliberately do NOT bump it: both are also the
174
+ * failure path of a start's own handshake — a child dying during startup goes
175
+ * `exit` → `connectionLost` → `releaseAll('died')` — and that start must
176
+ * still publish `error` with its stderr tail rather than be mistaken for a
177
+ * teardown (see `ensureReady`). It cannot leak a process either way: a crash
178
+ * release only happens once the child exists, and `spawnAndConnect` has no
179
+ * spawn point left after that.
180
+ */
181
+ teardownEpoch = 0;
132
182
  starting;
133
183
  sessions = new Map();
134
184
  acpIndex = new Map();
@@ -609,10 +659,20 @@ export class AgentBridge {
609
659
  return Promise.resolve();
610
660
  if (this.starting !== undefined)
611
661
  return this.starting;
662
+ const epoch = this.teardownEpoch;
612
663
  this.state = 'starting';
613
664
  this.publishStatus();
614
- this.starting = this.spawnAndConnect()
665
+ const started = this.spawnAndConnect(epoch)
615
666
  .then(() => {
667
+ // A teardown landed while the handshake was in flight: it owns the
668
+ // bridge now, and `spawnAndConnect` has already disposed whatever it
669
+ // spawned. Publishing `ready` here would paint a live status back over
670
+ // the stopped bridge. `state` catches the one teardown that does NOT
671
+ // bump the epoch — a crash release (`releaseAll`): without it, a
672
+ // handshake that resolved just as the child died would paint `ready`
673
+ // over the `stopped` that the crash left behind.
674
+ if (this.teardownEpoch !== epoch || this.state !== 'starting')
675
+ return;
616
676
  this.state = 'ready';
617
677
  this.lastError = undefined;
618
678
  this.publishStatus();
@@ -621,6 +681,16 @@ export class AgentBridge {
621
681
  this.touch();
622
682
  })
623
683
  .catch((error) => {
684
+ // Same guard: a start nobody owns any more must not publish its
685
+ // outcome. A start's OWN failure — `withStartupTimeout`, or the child
686
+ // dying mid-handshake (which runs through `releaseAll`) — still reaches
687
+ // the lines below and publishes `error` with its stderr tail; neither
688
+ // of those paths bumps the epoch.
689
+ if (this.teardownEpoch !== epoch) {
690
+ // Publish nothing, but keep the caller's error contract: every
691
+ // interrupted start has always surfaced as `agent_unreachable`.
692
+ throw new AcpError('agent_unreachable', `acp: agent "${this.def.id}" failed to start: ${error instanceof Error ? error.message : String(error)}`);
693
+ }
624
694
  this.state = 'error';
625
695
  this.lastError = error instanceof Error ? error.message : String(error);
626
696
  this.clearIdleTimer();
@@ -628,15 +698,31 @@ export class AgentBridge {
628
698
  this.publishStatus();
629
699
  throw new AcpError('agent_unreachable', `acp: agent "${this.def.id}" failed to start: ${this.lastError}`);
630
700
  })
631
- .finally(() => { this.starting = undefined; });
632
- return this.starting;
701
+ .finally(() => {
702
+ // Only clear a slot that is still OURS: a teardown may have cleared it
703
+ // (and a fresh `ensureReady` may already hold it).
704
+ if (this.starting === started)
705
+ this.starting = undefined;
706
+ });
707
+ this.starting = started;
708
+ return started;
633
709
  }
634
- async spawnAndConnect() {
710
+ /**
711
+ * @param epoch - `teardownEpoch` captured when this start claimed the bridge.
712
+ * Every await below is a window in which a teardown can land; a stale start
713
+ * owns nothing and must leave no process behind (see `teardownEpoch`).
714
+ */
715
+ async spawnAndConnect(epoch) {
635
716
  this.stderrTail = [];
636
717
  // Resolve (and, with npmPackage, auto-install) the executable before the
637
718
  // spawn; the resolved absolute path also pins the lookup to the host PATH
638
719
  // plus the well-known extra dirs, not the child env the agent may override.
639
720
  const { command } = await ensureAgentExecutable(this.def, undefined, line => this.noteStderr(line));
721
+ // A teardown landed while the executable was being resolved — a global
722
+ // install can hold this await for minutes. Nothing has been spawned yet, so
723
+ // there is nothing to reap; just refuse to create the process.
724
+ if (this.teardownEpoch !== epoch)
725
+ return;
640
726
  const cwd = safeSpawnCwd(this.def, this.wantedSpawnCwd);
641
727
  this.spawnCwd = cwd;
642
728
  const launch = windowsShimLaunch(command, this.def.args ?? []);
@@ -652,6 +738,7 @@ export class AgentBridge {
652
738
  ...(launch.verbatim ? { windowsVerbatimArguments: true } : {}),
653
739
  });
654
740
  this.child = child;
741
+ trackAgentChild(child);
655
742
  child.on('error', (error) => {
656
743
  // Identity guard: a stale event from a PREVIOUS child must not tear down
657
744
  // a connection that replaced it (an `exit` can land after the respawn
@@ -709,6 +796,25 @@ export class AgentBridge {
709
796
  },
710
797
  clientInfo: { name: 'dsh-ntr', title: 'DSH NTR', version: PLUGIN_VERSION },
711
798
  }), 'initialize');
799
+ // The second — and widest — window: `initialize` waits up to the startup
800
+ // budget, and a shutdown / stop during it already dropped the connection and
801
+ // killed the child we spawned. That child is OURS even though the bridge no
802
+ // longer points at it, so dispose it with the awaitable kill instead of
803
+ // leaving a process nobody can reach.
804
+ if (this.teardownEpoch !== epoch) {
805
+ // Only when it is still the CURRENT connection: a newer one belongs to a
806
+ // fresh start and must not be closed from here.
807
+ if (this.conn === conn) {
808
+ this.conn = undefined;
809
+ conn.close();
810
+ }
811
+ if (this.child === child) {
812
+ this.child = undefined;
813
+ this.spawnCwd = undefined;
814
+ }
815
+ await disposeChild(child, STALE_START_KILL_BUDGET_MS);
816
+ return;
817
+ }
712
818
  this.initResult = init;
713
819
  // The fork capability is agent knowledge that must outlive the process:
714
820
  // report it so the manager can persist a LAST KNOWN copy and serve the
@@ -1144,14 +1250,18 @@ export class AgentBridge {
1144
1250
  // workspace from its own cwd would come back in the plugin's sandbox.
1145
1251
  const workspaceRoot = chatWorkspaceRoot(this.sessionWorkspace?.(), session.dshSessionId);
1146
1252
  await this.ensureReady(workspaceRoot);
1253
+ // The CONNECTION first: a teardown that landed during the start leaves no
1254
+ // `initResult` either, and reporting that as `resume_unsupported` would
1255
+ // blame the agent for a capability it may well have. "The bridge is not
1256
+ // up" is the honest answer.
1257
+ const conn = this.conn;
1258
+ if (conn === undefined)
1259
+ throw new AcpError('agent_unreachable', 'acp: connection is not open');
1147
1260
  // The capability comes from the FRESH initialize: after a crash the
1148
1261
  // bridge restarts the process, so check the (new) initResult here.
1149
1262
  if (this.initResult?.agentCapabilities?.loadSession !== true) {
1150
1263
  throw new AcpError('resume_unsupported', `acp: agent "${this.def.id}" does not advertise the loadSession capability; start a new session`);
1151
1264
  }
1152
- const conn = this.conn;
1153
- if (conn === undefined)
1154
- throw new AcpError('agent_unreachable', 'acp: connection is not open');
1155
1265
  const resolved = resolveSessionCwd(this.def, session.cwd, p => realpathSync(p), workspaceRoot === undefined ? [] : [workspaceRoot]);
1156
1266
  // Replay arrives as brand-new updates, so the transcript restarts too;
1157
1267
  // seq keeps meaning "position in the current epoch". What the buffer held
@@ -1215,9 +1325,41 @@ export class AgentBridge {
1215
1325
  throw error;
1216
1326
  }
1217
1327
  }
1218
- /** Release all live sessions (process died or went idle): keep them resumable, settle permissions, drop the connection. */
1328
+ /**
1329
+ * Release all live sessions (process died or went idle): keep them resumable,
1330
+ * settle permissions, drop the connection.
1331
+ *
1332
+ * Deliberately does NOT bump `teardownEpoch`: a child's death during startup
1333
+ * runs through `connectionLost` into this method, and that start must still
1334
+ * publish `error` with its stderr tail — the settings card expands on exactly
1335
+ * that pair (see `view` / `teardownEpoch`).
1336
+ */
1219
1337
  releaseAll(reason, cause) {
1220
1338
  this.clearIdleTimer();
1339
+ this.parkSessions(reason, cause);
1340
+ this.conn?.close();
1341
+ this.conn = undefined;
1342
+ this.killChild();
1343
+ this.state = 'stopped';
1344
+ this.initResult = undefined;
1345
+ this.publishStatus();
1346
+ }
1347
+ /**
1348
+ * Park every live session as RELEASED — resumable through `session/load`,
1349
+ * never destroyed — and persist the records.
1350
+ *
1351
+ * This is the local half shared by `releaseAll` (crash / idle / workspace) and
1352
+ * `shutdown` (host exit): those paths differ only in what happens to the child
1353
+ * afterwards. A `closed` session is skipped — its record belongs to
1354
+ * `closeSession`, and flipping it to released would let a resume resurrect a
1355
+ * conversation that is being destroyed (constraint 10).
1356
+ *
1357
+ * Returns how many sessions were parked, so a shutdown with nothing to park
1358
+ * does not rewrite the state file (a second `shutdownAll` must not erase the
1359
+ * records the first one persisted).
1360
+ */
1361
+ parkSessions(reason, cause) {
1362
+ let parked = 0;
1221
1363
  for (const session of [...this.sessions.values()]) {
1222
1364
  // A session already being closed is `closeSession`'s to delete. Marking
1223
1365
  // it released would park it as resumable, and `doResumeSession` flips
@@ -1240,19 +1382,16 @@ export class AgentBridge {
1240
1382
  this.settlePermissions(session);
1241
1383
  this.released.set(session.sessionId, session);
1242
1384
  this.hub.broadcast({ type: 'session/released', sessionId: session.sessionId, session: snapshot(session), reason, cause });
1385
+ parked += 1;
1243
1386
  }
1244
1387
  this.sessions.clear();
1245
1388
  this.acpIndex.clear();
1246
- this.conn?.close();
1247
- this.conn = undefined;
1248
- this.killChild();
1249
- this.state = 'stopped';
1250
- this.initResult = undefined;
1251
- this.publishStatus();
1252
1389
  // The parked records are what keeps them resumable after a host restart —
1253
1390
  // persist now, while their transcripts (the `hasHistory` evidence) are
1254
1391
  // still the ones they were parked with.
1255
- this.onSessionsDirty?.();
1392
+ if (parked > 0)
1393
+ this.onSessionsDirty?.();
1394
+ return parked;
1256
1395
  }
1257
1396
  /** Detach one released session record for a resume attempt. */
1258
1397
  takeReleased(sessionId) {
@@ -1390,6 +1529,13 @@ export class AgentBridge {
1390
1529
  }
1391
1530
  /** Kill the child and drop every local session bound to this connection. */
1392
1531
  async stop(reason = 'agent-stopped') {
1532
+ // Deliberate teardown: an in-flight start owns nothing any more, and must
1533
+ // not be able to publish `ready` over the stopped state that follows.
1534
+ this.teardownEpoch += 1;
1535
+ // Drop the memoized start: a later `ensureReady` must not adopt a promise
1536
+ // whose result was already discarded. That promise's own `finally`
1537
+ // re-checks identity, so it cannot clear a NEWER start's slot.
1538
+ this.starting = undefined;
1393
1539
  this.clearIdleTimer();
1394
1540
  for (const session of [...this.sessions.values()]) {
1395
1541
  await this.closeSession(session, reason);
@@ -1415,6 +1561,8 @@ export class AgentBridge {
1415
1561
  * message. This is a stop, not a discard.
1416
1562
  */
1417
1563
  forceReset(reason) {
1564
+ this.teardownEpoch += 1;
1565
+ this.starting = undefined;
1418
1566
  this.clearIdleTimer();
1419
1567
  for (const session of [...this.sessions.values()]) {
1420
1568
  session.closed = true;
@@ -1433,6 +1581,13 @@ export class AgentBridge {
1433
1581
  this.initResult = undefined;
1434
1582
  this.publishStatus();
1435
1583
  }
1584
+ /**
1585
+ * Fire-and-forget kill for the NON-teardown paths: idle release, workspace
1586
+ * move, crash handling, `forceReset`, and a start's own failure. None of them
1587
+ * may block on a dying process, and CRASH ≠ DESTROY (constraint 10): the
1588
+ * sessions were already parked by the caller, this only takes the process
1589
+ * away. The awaitable variant is `killChildAndWait`, used by host shutdown.
1590
+ */
1436
1591
  killChild() {
1437
1592
  const child = this.child;
1438
1593
  this.child = undefined;
@@ -1440,36 +1595,59 @@ export class AgentBridge {
1440
1595
  // `spawnAndConnect`). Leaving it behind would make `ensureReady` believe a
1441
1596
  // process is still running in that directory.
1442
1597
  this.spawnCwd = undefined;
1443
- if (child === undefined || child.pid === undefined)
1598
+ if (child === undefined)
1444
1599
  return;
1445
- try {
1446
- child.stdin?.end();
1447
- }
1448
- catch { /* already gone */ }
1449
- const pid = child.pid;
1450
- if (process.platform === 'win32') {
1451
- // Parity with the POSIX branch below, which kills the whole process
1452
- // GROUP: on Windows "the group" is the process tree, and it has to be
1453
- // killed while the tree is intact — killing the direct child first (a
1454
- // `cmd.exe` shim wrapper, see `windowsShimLaunch`) re-parents the agent
1455
- // out of `taskkill /T`'s reach. `stdin.end()` above is the graceful half.
1456
- killProcessTree(pid);
1600
+ void disposeChild(child, KILL_ESCALATION_DELAY_MS);
1601
+ }
1602
+ /**
1603
+ * Take the current child away and WAIT until it is gone (bounded). Only the
1604
+ * host shutdown path uses this — every other teardown must stay
1605
+ * fire-and-forget.
1606
+ *
1607
+ * Always resolves: a teardown that could hang on an agent ignoring SIGTERM
1608
+ * would be worse than a leaked process, and the process-exit reaper
1609
+ * (`reapLiveChildren`) still holds the pid if even the escalation loses.
1610
+ */
1611
+ async killChildAndWait(budgetMs) {
1612
+ const child = this.child;
1613
+ this.child = undefined;
1614
+ this.spawnCwd = undefined;
1615
+ if (child === undefined)
1457
1616
  return;
1458
- }
1459
- try {
1460
- process.kill(-pid, 'SIGTERM');
1461
- }
1462
- catch {
1463
- child.kill('SIGTERM');
1464
- }
1465
- setTimeout(() => {
1466
- if (child.exitCode !== null || child.signalCode !== null)
1467
- return;
1468
- try {
1469
- process.kill(-pid, 'SIGKILL');
1470
- }
1471
- catch { /* already gone */ }
1472
- }, 3000).unref();
1617
+ await disposeChild(child, budgetMs);
1618
+ }
1619
+ /**
1620
+ * Host shutdown: park every live session as RELEASED (resumable, exactly as a
1621
+ * crash would leave it), then WAIT for the child to die.
1622
+ *
1623
+ * Deliberately not `stop`: `stop` asks every session's agent to
1624
+ * `session/close` and then clears the released records — i.e. it DESTROYS the
1625
+ * conversations. A host shutdown is not the user's "stop this agent" gesture:
1626
+ * the host is coming back, and the conversations must come back with it
1627
+ * (constraint 10, plus the `chatSessions` records that outlive the restart).
1628
+ * Skipping the per-session RPC is also what keeps teardown bounded: N sessions
1629
+ * would otherwise spend up to N × SESSION_CLOSE_TIMEOUT_MS before the child
1630
+ * was even signalled, and the process is about to die anyway — "the agent
1631
+ * forgot this session" costs nothing a `session/load` cannot rebuild.
1632
+ *
1633
+ * Always resolves: `budgetMs` bounds the WHOLE kill — parking is synchronous,
1634
+ * the graceful wait and the escalation both come out of the same deadline,
1635
+ * and whatever still lives after it is the exit reaper's.
1636
+ */
1637
+ async shutdown(budgetMs = SHUTDOWN_BUDGET_MS) {
1638
+ const deadline = Date.now() + Math.max(0, budgetMs);
1639
+ // A deliberate teardown: an in-flight start must not publish `ready` (or
1640
+ // leave its process running) over the stopped state this leaves behind.
1641
+ this.teardownEpoch += 1;
1642
+ this.starting = undefined;
1643
+ this.clearIdleTimer();
1644
+ this.parkSessions('the host is shutting down', 'shutdown');
1645
+ this.conn?.close();
1646
+ this.conn = undefined;
1647
+ this.state = 'stopped';
1648
+ this.initResult = undefined;
1649
+ this.publishStatus();
1650
+ await this.killChildAndWait(Math.max(0, deadline - Date.now()));
1473
1651
  }
1474
1652
  /**
1475
1653
  * Answer one ACP permission ask.
@@ -1837,6 +2015,16 @@ function codebuddyMetaOf(source) {
1837
2015
  out.isBackground = true;
1838
2016
  if (meta['codebuddy.ai/isSubagent'] === true)
1839
2017
  out.isSubagent = true;
2018
+ const subagentType = meta['codebuddy.ai/subagentType'];
2019
+ if (typeof subagentType === 'string' && subagentType !== '')
2020
+ out.subagentType = subagentType;
2021
+ const subagentDescription = meta['codebuddy.ai/description'];
2022
+ if (typeof subagentDescription === 'string' && subagentDescription !== '') {
2023
+ out.subagentDescription = subagentDescription;
2024
+ }
2025
+ const subagentName = meta['codebuddy.ai/memberName'];
2026
+ if (typeof subagentName === 'string' && subagentName !== '')
2027
+ out.subagentName = subagentName;
1840
2028
  const unsolicited = meta['codebuddy.ai/unsolicitedTurn'];
1841
2029
  if (typeof unsolicited === 'object' && unsolicited !== null) {
1842
2030
  const run = unsolicited;
@@ -2010,6 +2198,71 @@ export function permissionAskReason(toolCall) {
2010
2198
  const line = detail === undefined || detail === title ? title : `${title} · ${detail}`;
2011
2199
  return line.length > APPROVAL_REASON_MAX ? `${line.slice(0, APPROVAL_REASON_MAX - 1)}…` : line;
2012
2200
  }
2201
+ /** Longest label the host's subagent catalog row carries for an ACP worker. */
2202
+ const SUBAGENT_CATALOG_LABEL_MAX = 160;
2203
+ /** The structured input object an ACP tool call declared, if any. */
2204
+ function rawInputObjectOf(update) {
2205
+ const raw = update.rawInput ?? update.arguments;
2206
+ if (raw !== null && typeof raw === 'object' && !Array.isArray(raw))
2207
+ return raw;
2208
+ if (typeof raw === 'string' && raw.length <= 16_384) {
2209
+ try {
2210
+ const parsed = JSON.parse(raw);
2211
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed))
2212
+ return parsed;
2213
+ }
2214
+ catch {
2215
+ return undefined;
2216
+ }
2217
+ }
2218
+ return undefined;
2219
+ }
2220
+ /**
2221
+ * One line naming a detached ACP worker in the host's subagent catalog.
2222
+ *
2223
+ * The identity is read from what CodeBuddy actually sends, in order: the
2224
+ * `_meta` keys on the dispatch call's result frames (`subagentType` /
2225
+ * `description` / `memberName` — the FIRST `tool_call` omits `rawInput`, so
2226
+ * these are often the only source), then the structured `rawInput`, then the
2227
+ * tool title. The prompt body is only used when nothing shorter exists. Capped
2228
+ * so a pathological input cannot fill the catalog row.
2229
+ */
2230
+ export function subagentDispatchLabel(update) {
2231
+ const meta = codebuddyMetaOf(update);
2232
+ const input = rawInputObjectOf(update) ?? {};
2233
+ const type = meta?.subagentType
2234
+ ?? nonEmptyStringOf(input.subagent_type)
2235
+ ?? nonEmptyStringOf(input.subagentType)
2236
+ ?? permissionToolName(update);
2237
+ const detail = meta?.subagentName
2238
+ ?? nonEmptyStringOf(input.name)
2239
+ ?? meta?.subagentDescription
2240
+ ?? nonEmptyStringOf(input.description)
2241
+ ?? nonEmptyStringOf(input.prompt);
2242
+ const line = detail === undefined || detail === type ? type : `${type} · ${detail}`;
2243
+ return line.length > SUBAGENT_CATALOG_LABEL_MAX ? `${line.slice(0, SUBAGENT_CATALOG_LABEL_MAX - 1)}…` : line;
2244
+ }
2245
+ /**
2246
+ * Whether the update declared the worker's IDENTITY (role / task / name)
2247
+ * rather than just restating the `Agent` tool. A bare dispatch names the tool
2248
+ * as "Agent"; reporting that as the catalog label says nothing, so the report
2249
+ * waits for one of these facts (or for the dispatch to complete).
2250
+ */
2251
+ function subagentDispatchDeclaresIdentity(update) {
2252
+ const meta = codebuddyMetaOf(update);
2253
+ if (meta?.subagentType !== undefined
2254
+ || meta?.subagentDescription !== undefined
2255
+ || meta?.subagentName !== undefined)
2256
+ return true;
2257
+ const input = rawInputObjectOf(update);
2258
+ if (input === undefined)
2259
+ return false;
2260
+ return ['subagent_type', 'subagentType', 'name', 'description', 'prompt']
2261
+ .some(key => nonEmptyStringOf(input[key]) !== undefined);
2262
+ }
2263
+ function nonEmptyStringOf(value) {
2264
+ return typeof value === 'string' && value !== '' ? value : undefined;
2265
+ }
2013
2266
  function snapshot(session) {
2014
2267
  return {
2015
2268
  sessionId: session.sessionId,
@@ -2490,8 +2743,141 @@ export function killProcessTree(pid) {
2490
2743
  try {
2491
2744
  const killer = spawn('taskkill', ['/pid', String(pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true });
2492
2745
  killer.on('error', () => { });
2746
+ // Handed back so a caller that wants to wait can; the existing
2747
+ // fire-and-forget callers simply ignore it.
2748
+ return killer;
2749
+ }
2750
+ catch {
2751
+ return undefined;
2752
+ }
2753
+ }
2754
+ /** Every agent child this host started and has not seen exit yet, by pid. */
2755
+ const liveChildPids = new Set();
2756
+ /** Whether the process-exit reaper is already installed (installed once). */
2757
+ let exitReaperArmed = false;
2758
+ /** Remember a freshly spawned agent child for the last-resort reaper. */
2759
+ function trackAgentChild(child) {
2760
+ const pid = child.pid;
2761
+ if (pid === undefined)
2762
+ return;
2763
+ liveChildPids.add(pid);
2764
+ // Both events: `exit` is the process fact, `close` is it plus its stdio being
2765
+ // closed. Whichever comes first forgets the pid.
2766
+ const forget = () => { liveChildPids.delete(pid); };
2767
+ child.once('exit', forget);
2768
+ child.once('close', forget);
2769
+ if (exitReaperArmed)
2770
+ return;
2771
+ exitReaperArmed = true;
2772
+ process.once('exit', reapLiveChildren);
2773
+ }
2774
+ /**
2775
+ * Last resort for a host that leaves without a graceful teardown: an unhandled
2776
+ * crash that still runs `exit`, an explicit `process.exit()`, or a disposer the
2777
+ * host never awaited. Only SYNC calls are legal here — the `exit` event cannot
2778
+ * wait for a promise — hence `spawnSync` and a bare `process.kill`.
2779
+ *
2780
+ * Deliberately NOT a `SIGINT` / `SIGTERM` handler: registering one flips the
2781
+ * host's own default disposition (killing the default "exit on Ctrl-C", or
2782
+ * double-handling the host's shutdown and hanging it). A host that handles the
2783
+ * signal itself still reaches `exit`; one that is SIGKILLed can run nothing at
2784
+ * all (see AGENTS/protocol.md §7 for that limit).
2785
+ */
2786
+ function reapLiveChildren() {
2787
+ for (const pid of liveChildPids) {
2788
+ try {
2789
+ if (process.platform === 'win32') {
2790
+ spawnSync('taskkill', ['/pid', String(pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true });
2791
+ }
2792
+ else {
2793
+ // The child was spawned `detached`, so it leads its own process group:
2794
+ // -pid reaches everything it started (same reasoning as the graceful
2795
+ // POSIX kill).
2796
+ process.kill(-pid, 'SIGKILL');
2797
+ }
2798
+ }
2799
+ catch { /* already gone, or no permission left to try */ }
2493
2800
  }
2494
- catch { /* spawn itself refused */ }
2801
+ liveChildPids.clear();
2802
+ }
2803
+ /**
2804
+ * End the child's stdin, dispatch the platform kill, and wait until the process
2805
+ * is gone — bounded, and never rejecting.
2806
+ *
2807
+ * The escalation is deliberately platform-shaped: POSIX gets a process-GROUP
2808
+ * `SIGKILL` (an agent that ignores SIGTERM is exactly what a second signal is
2809
+ * for), Windows a second `taskkill /T /F` (the first may never have run at all:
2810
+ * a failed spawn of `taskkill` is swallowed by `killProcessTree`).
2811
+ */
2812
+ async function disposeChild(child, budgetMs) {
2813
+ // ONE deadline for the whole disposal: the escalation wait comes out of the
2814
+ // same budget, so `budgetMs` can never turn into "grace period + grace
2815
+ // period" (see `KILL_ESCALATION_GRACE_MS`).
2816
+ const deadline = Date.now() + Math.max(0, budgetMs);
2817
+ try {
2818
+ child.stdin?.end();
2819
+ }
2820
+ catch { /* already gone */ }
2821
+ const pid = child.pid;
2822
+ if (pid === undefined)
2823
+ return;
2824
+ // A child that already exited may have had its pid reused: signalling it
2825
+ // would reach an unrelated process (group). Nothing left to kill.
2826
+ if (child.exitCode !== null || child.signalCode !== null)
2827
+ return;
2828
+ const windows = process.platform === 'win32';
2829
+ if (windows) {
2830
+ // Parity with the POSIX branch, which kills the whole process GROUP: on
2831
+ // Windows "the group" is the process tree, and it has to be killed while
2832
+ // the tree is intact — killing the direct child first (a `cmd.exe` shim
2833
+ // wrapper, see `windowsShimLaunch`) re-parents the agent out of
2834
+ // `taskkill /T`'s reach. `stdin.end()` above is the graceful half.
2835
+ killProcessTree(pid);
2836
+ }
2837
+ else {
2838
+ try {
2839
+ process.kill(-pid, 'SIGTERM');
2840
+ }
2841
+ catch {
2842
+ child.kill('SIGTERM');
2843
+ }
2844
+ }
2845
+ // Everything except the escalation slice: the second wait is only there to
2846
+ // confirm a SIGKILL / taskkill landed, and it must fit inside the same budget.
2847
+ if (await waitForChildExit(child, Math.max(0, budgetMs - KILL_ESCALATION_GRACE_MS)))
2848
+ return;
2849
+ if (windows) {
2850
+ killProcessTree(pid);
2851
+ }
2852
+ else {
2853
+ try {
2854
+ process.kill(-pid, 'SIGKILL');
2855
+ }
2856
+ catch { /* already gone */ }
2857
+ }
2858
+ // Whatever is left of the deadline, never a fresh grace period.
2859
+ await waitForChildExit(child, Math.max(0, deadline - Date.now()));
2860
+ }
2861
+ /** Resolve true once the child is gone, false when the budget runs out first. */
2862
+ function waitForChildExit(child, budgetMs) {
2863
+ if (child.exitCode !== null || child.signalCode !== null)
2864
+ return Promise.resolve(true);
2865
+ return new Promise((resolve) => {
2866
+ let timer;
2867
+ const settle = (gone) => {
2868
+ if (timer !== undefined)
2869
+ clearTimeout(timer);
2870
+ child.off('exit', onGone);
2871
+ child.off('close', onGone);
2872
+ resolve(gone);
2873
+ };
2874
+ const onGone = () => { settle(true); };
2875
+ timer = setTimeout(() => { settle(false); }, budgetMs);
2876
+ // The host may exit while this waits; an armed timer must not hold it open.
2877
+ timer.unref?.();
2878
+ child.once('exit', onGone);
2879
+ child.once('close', onGone);
2880
+ });
2495
2881
  }
2496
2882
  /**
2497
2883
  * Globally install an agent's npm package. The package name reaches the
@@ -2910,9 +3296,11 @@ class TurnStreamController {
2910
3296
  pendingBackgroundHold;
2911
3297
  /**
2912
3298
  * The hold's own windows, read once at construction like the ambient pair:
2913
- * `taskMaxMs` is the worker phase's ceiling, `graceMs` the parked hold's
2914
- * bounded wait for a driver step (see {@link BridgeTuning.backgroundTaskMaxMs}
2915
- * and {@link BridgeTuning.backgroundParkedGraceMs}).
3299
+ * `taskMaxMs` is the worker phase's silence window, `ceilingMs` the optional
3300
+ * whole-hold absolute bound (0 = none), `graceMs` the parked hold's bounded
3301
+ * wait for a driver step (see {@link BridgeTuning.backgroundTaskMaxMs} /
3302
+ * {@link BridgeTuning.backgroundTaskCeilingMs} /
3303
+ * {@link BridgeTuning.backgroundParkedGraceMs}).
2916
3304
  */
2917
3305
  backgroundTuning;
2918
3306
  /**
@@ -3157,11 +3545,13 @@ class TurnStreamController {
3157
3545
  * - marks the segment background, so EVERY tool call for the rest of the
3158
3546
  * hold — the workers', the drain's TaskOutput — stays transcript-only and
3159
3547
  * the final message carries text with no card in front of it;
3160
- * - waits in two phases: the worker phase only has the
3161
- * {@link BridgeTuning.backgroundTaskMaxMs} ceiling (a detached worker goes
3162
- * silent for tens of seconds while it works) and ends only when ALL known
3163
- * dispatches reported, after which the drain phase uses the ambient
3164
- * windows.
3548
+ * - waits in two phases: the worker phase's clock is a SILENCE window
3549
+ * ({@link BridgeTuning.backgroundTaskMaxMs}) that every dispatch / worker
3550
+ * update pushes out, so a long task that keeps reporting is never cut at a
3551
+ * fixed total runtime. It ends when ALL known dispatches reported, after
3552
+ * which the drain phase uses the ambient windows. An optional absolute
3553
+ * ceiling ({@link BridgeTuning.backgroundTaskCeilingMs}, default none)
3554
+ * bounds a zombie that keeps talking forever.
3165
3555
  */
3166
3556
  holdForBackgroundDispatch(stopReason, requestId) {
3167
3557
  if (this.settled || this.disposed || this.closedTurn || this.stale)
@@ -3193,6 +3583,7 @@ class TurnStreamController {
3193
3583
  this.endTurn();
3194
3584
  hold.hardTimer = setTimeout(() => { this.onBackgroundHard(hold); }, this.backgroundTuning.taskMaxMs);
3195
3585
  hold.hardTimer.unref?.();
3586
+ this.armBackgroundCeiling(hold);
3196
3587
  const seg = this.segment;
3197
3588
  if (seg !== undefined && !seg.finished) {
3198
3589
  this.adoptBackgroundHold(seg, hold);
@@ -3291,10 +3682,32 @@ class TurnStreamController {
3291
3682
  hold.quietTimer = setTimeout(() => { this.onBackgroundQuiet(hold); }, this.ambientTuning.quietMs);
3292
3683
  hold.quietTimer.unref?.();
3293
3684
  }
3685
+ /**
3686
+ * Evidence from a live dispatch / worker: the worker phase is SILENCE-based,
3687
+ * so each update pushes its deadline out. A long task that keeps reporting is
3688
+ * never cut at a fixed total runtime; only a genuinely quiet worker is.
3689
+ */
3690
+ noteBackgroundWorkerActivity() {
3691
+ const hold = this.activeBackgroundHold();
3692
+ if (hold === undefined || hold.phase !== 'worker' || hold.retired)
3693
+ return;
3694
+ if (hold.hardTimer !== undefined)
3695
+ clearTimeout(hold.hardTimer);
3696
+ hold.hardTimer = setTimeout(() => { this.onBackgroundHard(hold); }, this.backgroundTuning.taskMaxMs);
3697
+ hold.hardTimer.unref?.();
3698
+ }
3699
+ /** Arm the hold's optional absolute ceiling (0 = unlimited). */
3700
+ armBackgroundCeiling(hold) {
3701
+ if (this.backgroundTuning.ceilingMs <= 0)
3702
+ return;
3703
+ hold.ceilingTimer = setTimeout(() => { this.onBackgroundHard(hold); }, this.backgroundTuning.ceilingMs);
3704
+ hold.ceilingTimer.unref?.();
3705
+ }
3294
3706
  /**
3295
3707
  * Any session update restarts the drain phase's quiet clock. Only an
3296
- * ADOPTED hold has one: a parked hold's wait belongs to its grace timer, and
3297
- * the worker phase never has a clock at all.
3708
+ * ADOPTED hold has a quiet clock: a parked hold's wait belongs to its grace
3709
+ * timer, and the worker phase is pushed forward only by dispatch / worker
3710
+ * evidence (see `noteBackgroundWorkerActivity`).
3298
3711
  */
3299
3712
  noteBackgroundHoldActivity() {
3300
3713
  const hold = this.backgroundHold;
@@ -3308,7 +3721,7 @@ class TurnStreamController {
3308
3721
  return;
3309
3722
  this.closeBackgroundHold(hold, { hard: false });
3310
3723
  }
3311
- /** The hold outlived its ceiling (worker or drain): the run is still going. */
3724
+ /** The hold went silent past its window, or outlived the absolute ceiling. */
3312
3725
  onBackgroundHard(hold) {
3313
3726
  if (hold !== this.backgroundHold && hold !== this.pendingBackgroundHold)
3314
3727
  return;
@@ -3378,10 +3791,13 @@ class TurnStreamController {
3378
3791
  clearTimeout(hold.quietTimer);
3379
3792
  if (hold.hardTimer !== undefined)
3380
3793
  clearTimeout(hold.hardTimer);
3794
+ if (hold.ceilingTimer !== undefined)
3795
+ clearTimeout(hold.ceilingTimer);
3381
3796
  if (hold.graceTimer !== undefined)
3382
3797
  clearTimeout(hold.graceTimer);
3383
3798
  hold.quietTimer = undefined;
3384
3799
  hold.hardTimer = undefined;
3800
+ hold.ceilingTimer = undefined;
3385
3801
  hold.graceTimer = undefined;
3386
3802
  }
3387
3803
  /**
@@ -3525,10 +3941,15 @@ class TurnStreamController {
3525
3941
  const known = this.dispatches.get(toolCallId);
3526
3942
  if (known !== undefined) {
3527
3943
  // A known dispatch: the id is the whole evidence (see the doc above).
3944
+ this.noteBackgroundWorkerActivity();
3528
3945
  if (status !== '')
3529
3946
  known.status = status;
3530
- if (meta?.isBackground === true)
3947
+ if (!known.catalogReported)
3948
+ this.refreshSubagentDispatchLabel(known, update);
3949
+ if (meta?.isBackground === true) {
3531
3950
  known.detached = true;
3951
+ this.reportSubagentDispatch(known);
3952
+ }
3532
3953
  if (completedStatus && !known.completed) {
3533
3954
  known.completed = true;
3534
3955
  this.noteDispatchProgress();
@@ -3545,8 +3966,14 @@ class TurnStreamController {
3545
3966
  completed: completedStatus,
3546
3967
  detached: meta.isBackground === true,
3547
3968
  sawWorkerUpdate: false,
3969
+ catalogLabel: subagentDispatchLabel(update),
3970
+ catalogLabelIdentified: subagentDispatchDeclaresIdentity(update),
3971
+ catalogReported: false,
3548
3972
  };
3549
3973
  this.dispatches.set(toolCallId, dispatch);
3974
+ this.noteBackgroundWorkerActivity();
3975
+ if (dispatch.detached)
3976
+ this.reportSubagentDispatch(dispatch);
3550
3977
  if (!dispatch.completed) {
3551
3978
  const held = this.activeBackgroundHold();
3552
3979
  // A dispatch registered after the drain phase began means new work:
@@ -3559,16 +3986,56 @@ class TurnStreamController {
3559
3986
  if (meta?.parentToolCallId !== undefined) {
3560
3987
  const parent = this.dispatches.get(meta.parentToolCallId);
3561
3988
  if (parent !== undefined) {
3989
+ this.noteBackgroundWorkerActivity();
3562
3990
  parent.detached = true;
3563
3991
  parent.sawWorkerUpdate = true;
3992
+ this.reportSubagentDispatch(parent);
3564
3993
  }
3565
3994
  }
3566
3995
  if (meta?.unsolicitedTurn !== undefined) {
3567
3996
  const held = this.activeBackgroundHold();
3568
- if (held !== undefined)
3997
+ if (held !== undefined) {
3998
+ this.noteBackgroundWorkerActivity();
3569
3999
  this.startBackgroundDrain(held, meta.unsolicitedTurn.requestId);
4000
+ }
4001
+ }
4002
+ }
4003
+ /**
4004
+ * Hand one DETACHED dispatch to the host's subagent catalog. One fact per
4005
+ * tool call, and only once the wire's structure proves the work runs
4006
+ * off-turn — a synchronous `Agent` / `Task` call is a tool call, not a
4007
+ * catalog child.
4008
+ */
4009
+ reportSubagentDispatch(dispatch) {
4010
+ if (dispatch.catalogReported)
4011
+ return;
4012
+ // A bare "Agent" row says nothing; wait for the identity frame. A
4013
+ // completed dispatch reports with whatever it has — a row must not be
4014
+ // lost because the agent never declared more than the tool name.
4015
+ if (!dispatch.catalogLabelIdentified && !dispatch.completed)
4016
+ return;
4017
+ if (this.manager.reportSubagentCatalog(this.session, `acp:${dispatch.toolCallId}`, dispatch.catalogLabel)) {
4018
+ dispatch.catalogReported = true;
3570
4019
  }
3571
4020
  }
4021
+ /**
4022
+ * Take a better label from a later frame of the SAME dispatch call.
4023
+ *
4024
+ * CodeBuddy omits `rawInput` (and often the title) on the dispatch call's
4025
+ * first send, then restates the whole task identity in `_meta` on the result
4026
+ * frame. A frame with no identity and no title is a bare status update and
4027
+ * must not downgrade what is already known.
4028
+ */
4029
+ refreshSubagentDispatchLabel(dispatch, update) {
4030
+ const identified = subagentDispatchDeclaresIdentity(update);
4031
+ if (!identified && dispatch.catalogLabelIdentified)
4032
+ return;
4033
+ const title = update.title;
4034
+ if (!identified && !(typeof title === 'string' && title !== ''))
4035
+ return;
4036
+ dispatch.catalogLabel = subagentDispatchLabel(update);
4037
+ dispatch.catalogLabelIdentified = identified;
4038
+ }
3572
4039
  /**
3573
4040
  * A dispatch just reported: once the LAST one has, the drain phase starts.
3574
4041
  * Until then the turn stays in its worker phase, with no quiet clock.
@@ -3576,6 +4043,10 @@ class TurnStreamController {
3576
4043
  noteDispatchProgress() {
3577
4044
  if (!this.allDispatchesCompleted())
3578
4045
  return;
4046
+ // Last chance for a row still waiting on identity: completion is a fact
4047
+ // even when the agent never said more than the tool name.
4048
+ for (const dispatch of this.dispatches.values())
4049
+ this.reportSubagentDispatch(dispatch);
3579
4050
  const held = this.activeBackgroundHold();
3580
4051
  if (held !== undefined)
3581
4052
  this.startBackgroundDrain(held);
@@ -4074,7 +4545,8 @@ class TurnStreamController {
4074
4545
  // background drain is still working, so the ambient quiet clock restarts.
4075
4546
  this.noteAmbientActivity();
4076
4547
  // A detached-worker hold's drain phase has the same rule; its worker
4077
- // phase never does (see `BridgeTuning.backgroundTaskMaxMs`).
4548
+ // phase is pushed out only by dispatch / worker evidence (see
4549
+ // `BridgeTuning.backgroundTaskMaxMs`).
4078
4550
  this.noteBackgroundHoldActivity();
4079
4551
  const update = typed.update;
4080
4552
  // The dispatch / worker / announcement bookkeeping runs BEFORE the cursor
@@ -4669,14 +5141,17 @@ export class BridgeManager {
4669
5141
  /**
4670
5142
  * The detached-worker hold's windows, read once at controller construction
4671
5143
  * for the same reason as {@link ambientTuning}: `taskMaxMs` is the worker
4672
- * phase's only clock (see {@link BridgeTuning.backgroundTaskMaxMs}) and
4673
- * `graceMs` the bounded wait a PARKED hold gives the host to open its
4674
- * continuation step (see {@link BridgeTuning.backgroundParkedGraceMs}). The
4675
- * drain phase reuses {@link ambientTuning}.
5144
+ * phase's SILENCE window, pushed out by every dispatch / worker update (see
5145
+ * {@link BridgeTuning.backgroundTaskMaxMs}), `ceilingMs` its optional
5146
+ * absolute bound (0 = none) and `graceMs` the bounded wait a PARKED hold
5147
+ * gives the host to open its continuation step (see
5148
+ * {@link BridgeTuning.backgroundParkedGraceMs}). The drain phase reuses
5149
+ * {@link ambientTuning}.
4676
5150
  */
4677
5151
  get backgroundTuning() {
4678
5152
  return {
4679
5153
  taskMaxMs: this.tuning.backgroundTaskMaxMs,
5154
+ ceilingMs: this.tuning.backgroundTaskCeilingMs,
4680
5155
  graceMs: this.tuning.backgroundParkedGraceMs,
4681
5156
  };
4682
5157
  }
@@ -4885,6 +5360,33 @@ export class BridgeManager {
4885
5360
  setAcpModeResolver(resolver) {
4886
5361
  this.acpModeResolver = resolver;
4887
5362
  }
5363
+ /**
5364
+ * Optional host integration: report a detached ACP worker as an EXTERNAL
5365
+ * child in the parent chat session's subagent catalog. Display-only — the
5366
+ * host renders it as a read-only "external task" row with no child session,
5367
+ * activity or continuation. An absent reporter is a no-op.
5368
+ */
5369
+ setSubagentCatalogReporter(reporter) {
5370
+ this.subagentCatalogReporter = reporter;
5371
+ }
5372
+ /**
5373
+ * Hand one detached worker to the host catalog. Returns whether a reporter
5374
+ * handled it; a missing chat id or reporter leaves the dispatch unclaimed so
5375
+ * a later wire update can retry.
5376
+ */
5377
+ reportSubagentCatalog(session, childId, label) {
5378
+ const dshSessionId = session.dshSessionId;
5379
+ const reporter = this.subagentCatalogReporter;
5380
+ if (dshSessionId === undefined || reporter === undefined)
5381
+ return false;
5382
+ try {
5383
+ reporter(dshSessionId, childId, label);
5384
+ return true;
5385
+ }
5386
+ catch {
5387
+ return false;
5388
+ }
5389
+ }
4888
5390
  /**
4889
5391
  * Whether ONE DSH chat session runs the ACP agent preset, asked by chat
4890
5392
  * session id. The browser's composer surfaces consume this: on hosts whose
@@ -6287,6 +6789,11 @@ export class BridgeManager {
6287
6789
  const def = this.defsMap.get(agentId);
6288
6790
  if (def === undefined || def.autoDiscoverModels === false || before === undefined)
6289
6791
  return false;
6792
+ // A different model-slot identity is a corrected reading, not newly added
6793
+ // models. Diffing across it would expose an entire real catalog the first
6794
+ // time a parser fix replaces a mislabelled slot (Qoder CN).
6795
+ if (before.model !== undefined && next.model !== undefined && before.model.configId !== next.model.configId)
6796
+ return false;
6290
6797
  const known = new Set((before.model?.choices ?? []).map(choice => choice.value));
6291
6798
  const exposed = new Set(def.exposedModels ?? []);
6292
6799
  const added = (next.model?.choices ?? [])
@@ -6686,6 +7193,12 @@ export class BridgeManager {
6686
7193
  const wanted = modelChoice !== undefined && modelChoice !== '' ? modelChoice : undefined;
6687
7194
  if (wanted === undefined)
6688
7195
  return undefined;
7196
+ // `category` is a UX hint, not identity: an agent may publish several
7197
+ // `category: 'model'` options (Qoder CN ships `reasoning_effort` that way)
7198
+ // and no explicit `model` id. In that case the selector is a guess, so a
7199
+ // "not offered" verdict would refuse a perfectly valid pick — fail open.
7200
+ if (!hasUnambiguousModelSelector(session.configOptions))
7201
+ return undefined;
6689
7202
  const model = parseCatalogOptions(session.configOptions).model;
6690
7203
  if (model === undefined)
6691
7204
  return undefined;
@@ -7113,14 +7626,31 @@ export class BridgeManager {
7113
7626
  this.disposeTurnStreamsOf(controller => controller.session.agentId === agentId);
7114
7627
  await this.bridges.get(agentId)?.stop('agent-stopped');
7115
7628
  }
7629
+ /**
7630
+ * Host shutdown: park every conversation, then make sure no agent child
7631
+ * outlives the host. Bridges run CONCURRENTLY against one bounded budget, so a
7632
+ * single wedged agent cannot spend every other bridge's slice too.
7633
+ *
7634
+ * `/acp`'s "stop this agent" gesture is `stopAgent` → `stop`, which is a
7635
+ * different promise: it closes the sessions on purpose. See
7636
+ * `AgentBridge.shutdown` for why host exit must not.
7637
+ */
7116
7638
  async shutdownAll() {
7117
7639
  if (this.stallTimer !== undefined) {
7118
7640
  clearInterval(this.stallTimer);
7119
7641
  this.stallTimer = undefined;
7120
7642
  }
7121
- this.dshSessionMap.clear();
7122
7643
  this.disposeTurnStreamsOf(() => true);
7123
- await Promise.all([...this.bridges.values()].map(b => b.stop('agent-stopped')));
7644
+ // NOT cleared before the shutdown: `persistAgents` (called from
7645
+ // `parkSessions`) writes the parked records out of `dshSessionMap`, and
7646
+ // that write is what keeps the conversations resumable across the restart.
7647
+ //
7648
+ // `allSettled`, not `all`: one bridge's failure must neither skip the
7649
+ // others nor reject a disposer that never asked for an answer. And NOT
7650
+ // cleared afterwards either: a late `persistAgents` would then write
7651
+ // `chatSessions: []` and erase exactly what the shutdown just saved. These
7652
+ // records are terminal — nothing reads them again after disposal.
7653
+ await Promise.allSettled([...this.bridges.values()].map(b => b.shutdown(SHUTDOWN_BUDGET_MS)));
7124
7654
  }
7125
7655
  /** Host-side reader of the harness preset roster (`ctx.agentPresets`), for `presetStatus`. */
7126
7656
  presetRosterReader;
@@ -7345,6 +7875,7 @@ export class BridgeManager {
7345
7875
  }
7346
7876
  sessionWorkspaceResolver;
7347
7877
  acpModeResolver;
7878
+ subagentCatalogReporter;
7348
7879
  attachmentResolver;
7349
7880
  /** Cached `chatSessionAcpMode` answers, keyed by DSH chat session id. */
7350
7881
  acpModeCache = new Map();
@@ -7352,8 +7883,8 @@ export class BridgeManager {
7352
7883
  acpModeInflight = new Map();
7353
7884
  /**
7354
7885
  * The agent values the composer is showing for a conversation that has not
7355
- * picked them — the catalog's current model, thought level and extra config
7356
- * values.
7886
+ * picked them — the catalog's current model, thought level, mode and extra
7887
+ * config values.
7357
7888
  *
7358
7889
  * Captured BEFORE `session/new`: materializing the session absorbs its own
7359
7890
  * defaults into the catalog (`absorbCatalog`), which would overwrite exactly
@@ -7374,6 +7905,15 @@ export class BridgeManager {
7374
7905
  const effort = this.reasoningMeta(agentId)?.defaultEffort;
7375
7906
  if (effort !== undefined)
7376
7907
  out.effort = effort;
7908
+ // The mode select's pre-first-message value is the catalog's last-known
7909
+ // mode (the agent's own answer from another session), and `session/new`
7910
+ // starts on whatever the agent itself defaults to. Without capturing it,
7911
+ // a conversation whose select reads "Bypass Permissions" would run its
7912
+ // first turn on "Always Ask" — the same display/run split the model and
7913
+ // thought level are seeded for.
7914
+ const mode = catalog.mode?.currentValue;
7915
+ if (mode !== null && mode !== undefined && mode !== '')
7916
+ out.mode = mode;
7377
7917
  const extraConfigs = {};
7378
7918
  for (const option of catalog.extras ?? []) {
7379
7919
  const value = option.currentValue;
@@ -7396,14 +7936,15 @@ export class BridgeManager {
7396
7936
  * first prompt runs.
7397
7937
  *
7398
7938
  * A conversation with no explicit pick displays the agent catalog's current
7399
- * model / thought level / extras, while the ACP session `session/new` just
7400
- * created starts on whatever the agent itself defaults to. Without this the
7401
- * user is shown one model and the agent runs another — the defect reported
7402
- * as "the displayed model was not used". Values the fresh session does not
7403
- * offer are skipped (its `configOptions` are the authority, and the catalog
7404
- * update that rode `session/new` will correct the display), never turned
7405
- * into a failed first turn. Explicit picks always win: only missing fields
7406
- * are filled.
7939
+ * model / thought level / mode / extras, while the ACP session `session/new`
7940
+ * just created starts on whatever the agent itself defaults to. Without this
7941
+ * the user is shown one model and the agent runs another — the defect
7942
+ * reported as "the displayed model was not used", and the reported first
7943
+ * turn that ignored the mode select's value. Values the fresh session does
7944
+ * not offer are skipped (its `configOptions` are the authority, and the
7945
+ * catalog update that rode `session/new` will correct the display), never
7946
+ * turned into a failed first turn. Explicit picks always win: only missing
7947
+ * fields are filled.
7407
7948
  */
7408
7949
  seedChatChoiceFromCatalog(dshSessionId, agentId, session, displayed) {
7409
7950
  if (displayed === undefined)
@@ -7429,6 +7970,18 @@ export class BridgeManager {
7429
7970
  changed = true;
7430
7971
  }
7431
7972
  }
7973
+ // The mode is the third axis of the same contract. The select's value
7974
+ // before the first message is the catalog's last-known mode; `session/new`
7975
+ // answers with the agent's own default. Seed the displayed one when the
7976
+ // fresh session still offers it — an agent that publishes no `modes`
7977
+ // field is applied best-effort, exactly like `applyChatChoice`.
7978
+ const displayedMode = displayed.mode;
7979
+ const offeredModes = session.modes?.availableModes ?? [];
7980
+ if (current?.mode === undefined && displayedMode !== undefined
7981
+ && (offeredModes.length === 0 || offeredModes.some(entry => entry.id === displayedMode))) {
7982
+ patch.mode = displayedMode;
7983
+ changed = true;
7984
+ }
7432
7985
  if (displayed.extraConfigs !== undefined) {
7433
7986
  const extraConfigs = { ...(current?.agentId === agentId ? current.extraConfigs : undefined) };
7434
7987
  for (const [configId, value] of Object.entries(displayed.extraConfigs)) {
@@ -7630,6 +8183,44 @@ export class BridgeManager {
7630
8183
  });
7631
8184
  return { ok: true, reset: true };
7632
8185
  }
8186
+ /**
8187
+ * 聊天插话的槽位回收:宿主把插话作为同一会话在飞回合的下一步送达,
8188
+ * 拒绝会让整条 host turn 失败。ACP 不能向运行中的 prompt 注入消息,
8189
+ * 所以按插话语义中止当前回合,等 agent 真正结算后再让调用方重发。
8190
+ *
8191
+ * 只处理 `claimTurn` 的 `busy`(agent 仍持有本会话 prompt);`unsettled`
8192
+ * 说明我们这一侧已经释放过槽位(用户按过停止),保持原样拒绝。
8193
+ *
8194
+ * - 控制器已 settle(响应已到,延后收口还没释放闸门):什么都不取消,只等闸门;
8195
+ * - agent 真在跑:走用户取消同一条管线(session/cancel + 结算权限 + cancel fallback),
8196
+ * 并立即关闭宿主这一侧的 segment,让闸门在 agent 应答/兜底后打开。
8197
+ */
8198
+ async reclaimChatPrompt(session, controller, signal) {
8199
+ if (session.closed || session.released)
8200
+ return 'ok';
8201
+ if (!controller.hasSettled && session.promptInFlight === true) {
8202
+ await this.bridge(session.agentId).cancel(session);
8203
+ controller.abortActiveSegment();
8204
+ }
8205
+ return this.waitForSettleGate(session, signal);
8206
+ }
8207
+ /** 等 agent 侧 prompt slot 真正释放;步骤被取消时立即返回 aborted。 */
8208
+ waitForSettleGate(session, signal) {
8209
+ const pending = session.settled;
8210
+ if (pending === undefined)
8211
+ return Promise.resolve('ok');
8212
+ if (signal?.aborted)
8213
+ return Promise.resolve('aborted');
8214
+ return new Promise(resolve => {
8215
+ const done = (value) => {
8216
+ signal?.removeEventListener('abort', onAbort);
8217
+ resolve(value);
8218
+ };
8219
+ const onAbort = () => { done('aborted'); };
8220
+ signal?.addEventListener('abort', onAbort, { once: true });
8221
+ void pending.then(() => { done('ok'); });
8222
+ });
8223
+ }
7633
8224
  async *streamPrompt(options) {
7634
8225
  // The plugin's own conversation↔agent mapping decides who serves the turn
7635
8226
  // (see `bindChatAgent`): the driver may arrive here with the previous
@@ -7837,26 +8428,82 @@ export class BridgeManager {
7837
8428
  }
7838
8429
  // One prompt slot per session, and a turn the user aborted still occupies
7839
8430
  // the AGENT's slot until it acknowledges the cancel (see `claimTurn`).
7840
- const claim = await bridge.claimTurn(session);
8431
+ let claim = await bridge.claimTurn(session);
7841
8432
  if (!claim.ok) {
7842
- // Never roll the provisional lock back here: a failed claim means ANOTHER
7843
- // prompt holds the slot (or the agent has not settled it), and that turn
7844
- // owns the conversation's lock. Closing the session would kill a turn
7845
- // that is actively streaming — the refusal already tells the user to
7846
- // retry.
7847
- yield {
7848
- type: 'finish',
7849
- reason: {
7850
- kind: 'error',
7851
- failure: {
7852
- message: claim.reason === 'busy'
7853
- ? 'acp: this session already has a prompt in flight'
7854
- : 'acp: the agent is still finishing the previous (cancelled) turn; send again in a moment',
7855
- code: 'turn_in_flight',
8433
+ // A STEER ("插话"): the host delivers the user's mid-turn message as the
8434
+ // NEXT step of the SAME host turn, so the conversation's own ACP prompt
8435
+ // is still on air. Refusing would fail the whole host turn. ACP forbids
8436
+ // injecting into a running prompt, so the steer's real semantic — abort
8437
+ // the current turn, then send this one — is spelled cancel + wait +
8438
+ // re-claim here.
8439
+ //
8440
+ // Only `busy` can be a steer: it means the AGENT still holds OUR prompt
8441
+ // (`promptInFlight`). `unsettled` means our side already released the
8442
+ // slot (the person pressed stop, or an earlier steer cancelled): the
8443
+ // agent has not acknowledged yet, and quietly queueing the next message
8444
+ // behind a turn the user stopped is not this path's business — that
8445
+ // stays the honest `turn_in_flight` refusal below.
8446
+ //
8447
+ // Only the conversation's OWN in-flight turn qualifies (its live stream
8448
+ // controller, on this very session). A `/acp` RPC turn has no chat
8449
+ // controller, and another conversation never reaches this session, so
8450
+ // both keep the plain refusal: nothing may be cancelled behind a
8451
+ // caller's back.
8452
+ const chatController = claim.reason === 'busy' && options.sessionId !== undefined
8453
+ ? this.turnStreamFor(options.sessionId)
8454
+ : undefined;
8455
+ if (options.sessionId !== undefined
8456
+ && chatController !== undefined
8457
+ && !chatController.stale
8458
+ && chatController.session === session) {
8459
+ const reclaimed = await this.reclaimChatPrompt(session, chatController, options.signal);
8460
+ if (reclaimed === 'aborted') {
8461
+ // The person pressed stop while the previous turn was winding down:
8462
+ // the steer is theirs to re-send, nothing goes to the agent.
8463
+ yield {
8464
+ type: 'finish',
8465
+ reason: { kind: 'aborted', failure: { message: 'aborted', code: 'ABORTED' } },
8466
+ };
8467
+ return;
8468
+ }
8469
+ // A first turn that produced nothing rolled its provisional lock back
8470
+ // and CLOSED (and forgot) this session: the steer needs a fresh one.
8471
+ if (session.closed || session.released) {
8472
+ try {
8473
+ session = await this.getOrCreateDshSession(options.sessionId, agentId);
8474
+ }
8475
+ catch (err) {
8476
+ yield {
8477
+ type: 'finish',
8478
+ reason: { kind: 'error', failure: { message: describeError(err), code: 'session_init_failed' } },
8479
+ };
8480
+ return;
8481
+ }
8482
+ }
8483
+ // Same bridge: the session may have been re-created, but the
8484
+ // conversation is still served by this agent's process.
8485
+ claim = await bridge.claimTurn(session);
8486
+ }
8487
+ if (!claim.ok) {
8488
+ // Never roll the provisional lock back here: a failed claim means
8489
+ // ANOTHER prompt holds the slot (or the agent has not settled it), and
8490
+ // that turn owns the conversation's lock. Closing the session would
8491
+ // kill a turn that is actively streaming — the refusal already tells
8492
+ // the user to retry.
8493
+ yield {
8494
+ type: 'finish',
8495
+ reason: {
8496
+ kind: 'error',
8497
+ failure: {
8498
+ message: claim.reason === 'busy'
8499
+ ? 'acp: this session already has a prompt in flight'
8500
+ : 'acp: the agent is still finishing the previous (cancelled) turn; send again in a moment',
8501
+ code: 'turn_in_flight',
8502
+ },
7856
8503
  },
7857
- },
7858
- };
7859
- return;
8504
+ };
8505
+ return;
8506
+ }
7860
8507
  }
7861
8508
  // Same-agent model/effort switches ride the SAME ACP session (context
7862
8509
  // stays continuous); switching to another agent lands in a separate one