dsh-ntr 0.3.4 → 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/src/bridge.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * only object allowed to spawn a configured command.
8
8
  */
9
9
 
10
- import { spawn, type ChildProcess } from 'node:child_process'
10
+ import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
11
11
  import { homedir } from 'node:os'
12
12
  import { delimiter, dirname, join } from 'node:path'
13
13
  import { fileURLToPath } from 'node:url'
@@ -108,10 +108,47 @@ const FS_MAX_WRITE_BYTES = 2 * 1024 * 1024
108
108
  */
109
109
  const SESSION_CLOSE_TIMEOUT_MS = 5_000
110
110
 
111
+ /**
112
+ * The second — and last — slice after the escalation kill (`SIGKILL` on POSIX,
113
+ * a second `taskkill /T /F` on Windows). Deliberately short: past this point
114
+ * the child is the OS's problem (plus the exit reaper, see `reapLiveChildren`).
115
+ *
116
+ * `disposeChild` RESERVES this inside the budget it is handed, so every caller's
117
+ * number means the WHOLE disposal — graceful wait plus escalation wait — and
118
+ * never just the first one.
119
+ */
120
+ const KILL_ESCALATION_GRACE_MS = 500
121
+
122
+ /**
123
+ * Fire-and-forget kill: the whole disposal budget for the non-teardown paths
124
+ * (see `KILL_ESCALATION_GRACE_MS` for how it splits). Same 3s the old inline
125
+ * SIGKILL timer used before escalating.
126
+ */
127
+ const KILL_ESCALATION_DELAY_MS = 3_000
128
+
129
+ /**
130
+ * Budget for disposing a child whose start was invalidated by a teardown (see
131
+ * `AgentBridge.spawnAndConnect`). Short: that child was spawned moments ago and
132
+ * never served anything, so there is nothing to drain.
133
+ */
134
+ const STALE_START_KILL_BUDGET_MS = 2_000
135
+
136
+ /**
137
+ * The WHOLE host-shutdown budget for killing children, shared by every bridge
138
+ * (bridges run concurrently, so this is the worst case for all of them, not
139
+ * per bridge). One number, one deadline: `AgentBridge.shutdown` hands what is
140
+ * LEFT of it to `killChildAndWait`, and `disposeChild` reserves the escalation
141
+ * grace inside that — so the graceful wait gets at most 4s − 500ms and the
142
+ * whole disposal, escalation included, never exceeds 4s.
143
+ */
144
+ const SHUTDOWN_BUDGET_MS = 4_000
145
+
111
146
  /** What the composer showed for an agent before a conversation picked it. */
112
147
  interface DisplayedChatDefaults {
113
148
  modelId?: string
114
149
  effort?: string
150
+ /** The agent's last-known mode (the composer's select without a pick). */
151
+ mode?: string
115
152
  extraConfigs?: Record<string, string | boolean>
116
153
  }
117
154
 
@@ -458,20 +495,33 @@ export interface BridgeTuning {
458
495
  */
459
496
  ambientMaxMs?: number
460
497
  /**
461
- * The hard ceiling on the WORKER phase of a detached-worker hold (see
462
- * `TurnStreamController.holdForBackgroundDispatch`): the turn dispatched one
463
- * or more background workers through `Agent` tool calls, the agent answered
464
- * the prompt, and at least one worker has not reported back yet. The worker
465
- * phase has NO quiet window on purpose — measured against CodeBuddy 2.179.0,
466
- * a detached worker's own updates are 13–24s apart while it is perfectly
467
- * alive — so only EVERY known dispatch reporting (completed / failed) or
468
- * this ceiling can end it; a drain announcement is recorded but arms no
469
- * clock before then. Once they have all reported, the drain's own
498
+ * How long the WORKER phase of a detached-worker hold may go WITHOUT any
499
+ * evidence from the worker (see `TurnStreamController.holdForBackgroundDispatch`):
500
+ * the turn dispatched one or more background workers through `Agent` tool
501
+ * calls, the agent answered the prompt, and at least one worker has not
502
+ * reported back yet. There is still no quiet clock in the ambient sense —
503
+ * measured against CodeBuddy 2.179.0, a detached worker's own updates are
504
+ * 13–24s apart while it is perfectly alive — so every update naming the
505
+ * dispatch or carrying its `parentToolCallId` PUSHES THIS WINDOW OUT. Only a
506
+ * genuinely silent worker, or every known dispatch reporting (completed /
507
+ * failed), ends the phase; a drain announcement is recorded but arms no
508
+ * quiet clock before then. Once they have all reported, the drain's own
470
509
  * `ambientQuietMs` / `ambientMaxMs` windows take over.
471
510
  *
472
- * Default 600000 ms (10 minutes).
511
+ * Default 600000 ms (10 minutes of silence, not of total runtime).
473
512
  */
474
513
  backgroundTaskMaxMs?: number
514
+ /**
515
+ * Optional ABSOLUTE ceiling on a detached-worker hold, both phases included.
516
+ * `backgroundTaskMaxMs` keeps an ACTIVE worker alive indefinitely; this is
517
+ * the emergency bound for a zombie that keeps emitting updates forever.
518
+ * `0` disables it — the turn can then stay open as long as the worker keeps
519
+ * proving it is alive, and the person can still steer/cancel the hold by
520
+ * sending a message.
521
+ *
522
+ * Default 0 (no ceiling).
523
+ */
524
+ backgroundTaskCeilingMs?: number
475
525
  /**
476
526
  * How long a PARKED detached-worker hold may wait for the host to open the
477
527
  * driver step that carries the worker's report. The prompt response landed
@@ -511,6 +561,12 @@ export type SessionWorkspaceResolver = (dshSessionId: string) => { workspaceRoot
511
561
  */
512
562
  export type AcpModeResolver = (dshSessionId: string) => boolean | undefined | Promise<boolean | undefined>
513
563
 
564
+ /**
565
+ * Report one detached ACP worker to the host's parent-owned subagent catalog.
566
+ * Display-only: the host records an EXTERNAL execution with no child session.
567
+ */
568
+ export type SubagentCatalogReporter = (dshSessionId: string, childId: string, label: string) => void
569
+
514
570
  /**
515
571
  * The plugin's normalized MCP server shape → the ACP wire shape.
516
572
  *
@@ -579,6 +635,7 @@ const DEFAULT_TUNING: Required<BridgeTuning> = {
579
635
  ambientQuietMs: 10_000,
580
636
  ambientMaxMs: 120_000,
581
637
  backgroundTaskMaxMs: 600_000,
638
+ backgroundTaskCeilingMs: 0,
582
639
  backgroundParkedGraceMs: 30_000,
583
640
  ambientStaleMs: 300_000,
584
641
  }
@@ -593,6 +650,24 @@ export class AgentBridge {
593
650
 
594
651
  private child?: ChildProcess
595
652
  private conn?: ClientConnection
653
+ /**
654
+ * Teardown generation, bumped ONLY by a deliberate teardown (`stop` /
655
+ * `shutdown` / `forceReset`), so a start that was already in flight can tell
656
+ * that nobody owns its outcome any more: `spawnAndConnect` re-checks this
657
+ * after each await and disposes the child it would otherwise have published
658
+ * as `ready`. Without it, a teardown landing during `ensureAgentExecutable`
659
+ * (a global npm install can take minutes) left a freshly spawned agent
660
+ * running with no owner.
661
+ *
662
+ * `releaseAll` and `killChild` deliberately do NOT bump it: both are also the
663
+ * failure path of a start's own handshake — a child dying during startup goes
664
+ * `exit` → `connectionLost` → `releaseAll('died')` — and that start must
665
+ * still publish `error` with its stderr tail rather than be mistaken for a
666
+ * teardown (see `ensureReady`). It cannot leak a process either way: a crash
667
+ * release only happens once the child exists, and `spawnAndConnect` has no
668
+ * spawn point left after that.
669
+ */
670
+ private teardownEpoch = 0
596
671
  private starting?: Promise<void>
597
672
  private readonly sessions = new Map<string, LiveSession>()
598
673
  private readonly acpIndex = new Map<string, LiveSession>()
@@ -1065,10 +1140,19 @@ export class AgentBridge {
1065
1140
  }
1066
1141
  if (this.state === 'ready' && this.conn !== undefined) return Promise.resolve()
1067
1142
  if (this.starting !== undefined) return this.starting
1143
+ const epoch = this.teardownEpoch
1068
1144
  this.state = 'starting'
1069
1145
  this.publishStatus()
1070
- this.starting = this.spawnAndConnect()
1146
+ const started = this.spawnAndConnect(epoch)
1071
1147
  .then(() => {
1148
+ // A teardown landed while the handshake was in flight: it owns the
1149
+ // bridge now, and `spawnAndConnect` has already disposed whatever it
1150
+ // spawned. Publishing `ready` here would paint a live status back over
1151
+ // the stopped bridge. `state` catches the one teardown that does NOT
1152
+ // bump the epoch — a crash release (`releaseAll`): without it, a
1153
+ // handshake that resolved just as the child died would paint `ready`
1154
+ // over the `stopped` that the crash left behind.
1155
+ if (this.teardownEpoch !== epoch || this.state !== 'starting') return
1072
1156
  this.state = 'ready'
1073
1157
  this.lastError = undefined
1074
1158
  this.publishStatus()
@@ -1077,6 +1161,16 @@ export class AgentBridge {
1077
1161
  this.touch()
1078
1162
  })
1079
1163
  .catch((error: unknown) => {
1164
+ // Same guard: a start nobody owns any more must not publish its
1165
+ // outcome. A start's OWN failure — `withStartupTimeout`, or the child
1166
+ // dying mid-handshake (which runs through `releaseAll`) — still reaches
1167
+ // the lines below and publishes `error` with its stderr tail; neither
1168
+ // of those paths bumps the epoch.
1169
+ if (this.teardownEpoch !== epoch) {
1170
+ // Publish nothing, but keep the caller's error contract: every
1171
+ // interrupted start has always surfaced as `agent_unreachable`.
1172
+ throw new AcpError('agent_unreachable', `acp: agent "${this.def.id}" failed to start: ${error instanceof Error ? error.message : String(error)}`)
1173
+ }
1080
1174
  this.state = 'error'
1081
1175
  this.lastError = error instanceof Error ? error.message : String(error)
1082
1176
  this.clearIdleTimer()
@@ -1084,16 +1178,30 @@ export class AgentBridge {
1084
1178
  this.publishStatus()
1085
1179
  throw new AcpError('agent_unreachable', `acp: agent "${this.def.id}" failed to start: ${this.lastError}`)
1086
1180
  })
1087
- .finally(() => { this.starting = undefined })
1088
- return this.starting
1181
+ .finally(() => {
1182
+ // Only clear a slot that is still OURS: a teardown may have cleared it
1183
+ // (and a fresh `ensureReady` may already hold it).
1184
+ if (this.starting === started) this.starting = undefined
1185
+ })
1186
+ this.starting = started
1187
+ return started
1089
1188
  }
1090
1189
 
1091
- private async spawnAndConnect(): Promise<void> {
1190
+ /**
1191
+ * @param epoch - `teardownEpoch` captured when this start claimed the bridge.
1192
+ * Every await below is a window in which a teardown can land; a stale start
1193
+ * owns nothing and must leave no process behind (see `teardownEpoch`).
1194
+ */
1195
+ private async spawnAndConnect(epoch: number): Promise<void> {
1092
1196
  this.stderrTail = []
1093
1197
  // Resolve (and, with npmPackage, auto-install) the executable before the
1094
1198
  // spawn; the resolved absolute path also pins the lookup to the host PATH
1095
1199
  // plus the well-known extra dirs, not the child env the agent may override.
1096
1200
  const { command } = await ensureAgentExecutable(this.def, undefined, line => this.noteStderr(line))
1201
+ // A teardown landed while the executable was being resolved — a global
1202
+ // install can hold this await for minutes. Nothing has been spawned yet, so
1203
+ // there is nothing to reap; just refuse to create the process.
1204
+ if (this.teardownEpoch !== epoch) return
1097
1205
  const cwd = safeSpawnCwd(this.def, this.wantedSpawnCwd)
1098
1206
  this.spawnCwd = cwd
1099
1207
  const launch = windowsShimLaunch(command, this.def.args ?? [])
@@ -1109,6 +1217,7 @@ export class AgentBridge {
1109
1217
  ...(launch.verbatim ? { windowsVerbatimArguments: true } : {}),
1110
1218
  })
1111
1219
  this.child = child
1220
+ trackAgentChild(child)
1112
1221
  child.on('error', (error) => {
1113
1222
  // Identity guard: a stale event from a PREVIOUS child must not tear down
1114
1223
  // a connection that replaced it (an `exit` can land after the respawn
@@ -1168,6 +1277,25 @@ export class AgentBridge {
1168
1277
  }),
1169
1278
  'initialize',
1170
1279
  )
1280
+ // The second — and widest — window: `initialize` waits up to the startup
1281
+ // budget, and a shutdown / stop during it already dropped the connection and
1282
+ // killed the child we spawned. That child is OURS even though the bridge no
1283
+ // longer points at it, so dispose it with the awaitable kill instead of
1284
+ // leaving a process nobody can reach.
1285
+ if (this.teardownEpoch !== epoch) {
1286
+ // Only when it is still the CURRENT connection: a newer one belongs to a
1287
+ // fresh start and must not be closed from here.
1288
+ if (this.conn === conn) {
1289
+ this.conn = undefined
1290
+ conn.close()
1291
+ }
1292
+ if (this.child === child) {
1293
+ this.child = undefined
1294
+ this.spawnCwd = undefined
1295
+ }
1296
+ await disposeChild(child, STALE_START_KILL_BUDGET_MS)
1297
+ return
1298
+ }
1171
1299
  this.initResult = init
1172
1300
  // The fork capability is agent knowledge that must outlive the process:
1173
1301
  // report it so the manager can persist a LAST KNOWN copy and serve the
@@ -1614,13 +1742,17 @@ export class AgentBridge {
1614
1742
  // workspace from its own cwd would come back in the plugin's sandbox.
1615
1743
  const workspaceRoot = chatWorkspaceRoot(this.sessionWorkspace?.(), session.dshSessionId)
1616
1744
  await this.ensureReady(workspaceRoot)
1745
+ // The CONNECTION first: a teardown that landed during the start leaves no
1746
+ // `initResult` either, and reporting that as `resume_unsupported` would
1747
+ // blame the agent for a capability it may well have. "The bridge is not
1748
+ // up" is the honest answer.
1749
+ const conn = this.conn
1750
+ if (conn === undefined) throw new AcpError('agent_unreachable', 'acp: connection is not open')
1617
1751
  // The capability comes from the FRESH initialize: after a crash the
1618
1752
  // bridge restarts the process, so check the (new) initResult here.
1619
1753
  if (this.initResult?.agentCapabilities?.loadSession !== true) {
1620
1754
  throw new AcpError('resume_unsupported', `acp: agent "${this.def.id}" does not advertise the loadSession capability; start a new session`)
1621
1755
  }
1622
- const conn = this.conn
1623
- if (conn === undefined) throw new AcpError('agent_unreachable', 'acp: connection is not open')
1624
1756
  const resolved = resolveSessionCwd(this.def, session.cwd, p => realpathSync(p), workspaceRoot === undefined ? [] : [workspaceRoot])
1625
1757
  // Replay arrives as brand-new updates, so the transcript restarts too;
1626
1758
  // seq keeps meaning "position in the current epoch". What the buffer held
@@ -1686,9 +1818,42 @@ export class AgentBridge {
1686
1818
  }
1687
1819
  }
1688
1820
 
1689
- /** Release all live sessions (process died or went idle): keep them resumable, settle permissions, drop the connection. */
1821
+ /**
1822
+ * Release all live sessions (process died or went idle): keep them resumable,
1823
+ * settle permissions, drop the connection.
1824
+ *
1825
+ * Deliberately does NOT bump `teardownEpoch`: a child's death during startup
1826
+ * runs through `connectionLost` into this method, and that start must still
1827
+ * publish `error` with its stderr tail — the settings card expands on exactly
1828
+ * that pair (see `view` / `teardownEpoch`).
1829
+ */
1690
1830
  releaseAll(reason: string, cause: AcpReleaseCause): void {
1691
1831
  this.clearIdleTimer()
1832
+ this.parkSessions(reason, cause)
1833
+ this.conn?.close()
1834
+ this.conn = undefined
1835
+ this.killChild()
1836
+ this.state = 'stopped'
1837
+ this.initResult = undefined
1838
+ this.publishStatus()
1839
+ }
1840
+
1841
+ /**
1842
+ * Park every live session as RELEASED — resumable through `session/load`,
1843
+ * never destroyed — and persist the records.
1844
+ *
1845
+ * This is the local half shared by `releaseAll` (crash / idle / workspace) and
1846
+ * `shutdown` (host exit): those paths differ only in what happens to the child
1847
+ * afterwards. A `closed` session is skipped — its record belongs to
1848
+ * `closeSession`, and flipping it to released would let a resume resurrect a
1849
+ * conversation that is being destroyed (constraint 10).
1850
+ *
1851
+ * Returns how many sessions were parked, so a shutdown with nothing to park
1852
+ * does not rewrite the state file (a second `shutdownAll` must not erase the
1853
+ * records the first one persisted).
1854
+ */
1855
+ private parkSessions(reason: string, cause: AcpReleaseCause): number {
1856
+ let parked = 0
1692
1857
  for (const session of [...this.sessions.values()]) {
1693
1858
  // A session already being closed is `closeSession`'s to delete. Marking
1694
1859
  // it released would park it as resumable, and `doResumeSession` flips
@@ -1710,19 +1875,15 @@ export class AgentBridge {
1710
1875
  this.settlePermissions(session)
1711
1876
  this.released.set(session.sessionId, session)
1712
1877
  this.hub.broadcast({ type: 'session/released', sessionId: session.sessionId, session: snapshot(session), reason, cause })
1878
+ parked += 1
1713
1879
  }
1714
1880
  this.sessions.clear()
1715
1881
  this.acpIndex.clear()
1716
- this.conn?.close()
1717
- this.conn = undefined
1718
- this.killChild()
1719
- this.state = 'stopped'
1720
- this.initResult = undefined
1721
- this.publishStatus()
1722
1882
  // The parked records are what keeps them resumable after a host restart —
1723
1883
  // persist now, while their transcripts (the `hasHistory` evidence) are
1724
1884
  // still the ones they were parked with.
1725
- this.onSessionsDirty?.()
1885
+ if (parked > 0) this.onSessionsDirty?.()
1886
+ return parked
1726
1887
  }
1727
1888
 
1728
1889
  /** Detach one released session record for a resume attempt. */
@@ -1864,6 +2025,13 @@ export class AgentBridge {
1864
2025
 
1865
2026
  /** Kill the child and drop every local session bound to this connection. */
1866
2027
  async stop(reason: 'closed' | 'agent-stopped' | 'agent-died' = 'agent-stopped'): Promise<void> {
2028
+ // Deliberate teardown: an in-flight start owns nothing any more, and must
2029
+ // not be able to publish `ready` over the stopped state that follows.
2030
+ this.teardownEpoch += 1
2031
+ // Drop the memoized start: a later `ensureReady` must not adopt a promise
2032
+ // whose result was already discarded. That promise's own `finally`
2033
+ // re-checks identity, so it cannot clear a NEWER start's slot.
2034
+ this.starting = undefined
1867
2035
  this.clearIdleTimer()
1868
2036
  for (const session of [...this.sessions.values()]) {
1869
2037
  await this.closeSession(session, reason)
@@ -1890,6 +2058,8 @@ export class AgentBridge {
1890
2058
  * message. This is a stop, not a discard.
1891
2059
  */
1892
2060
  forceReset(reason: 'agent-stopped'): void {
2061
+ this.teardownEpoch += 1
2062
+ this.starting = undefined
1893
2063
  this.clearIdleTimer()
1894
2064
  for (const session of [...this.sessions.values()]) {
1895
2065
  session.closed = true
@@ -1909,6 +2079,13 @@ export class AgentBridge {
1909
2079
  this.publishStatus()
1910
2080
  }
1911
2081
 
2082
+ /**
2083
+ * Fire-and-forget kill for the NON-teardown paths: idle release, workspace
2084
+ * move, crash handling, `forceReset`, and a start's own failure. None of them
2085
+ * may block on a dying process, and CRASH ≠ DESTROY (constraint 10): the
2086
+ * sessions were already parked by the caller, this only takes the process
2087
+ * away. The awaitable variant is `killChildAndWait`, used by host shutdown.
2088
+ */
1912
2089
  private killChild(): void {
1913
2090
  const child = this.child
1914
2091
  this.child = undefined
@@ -1916,31 +2093,59 @@ export class AgentBridge {
1916
2093
  // `spawnAndConnect`). Leaving it behind would make `ensureReady` believe a
1917
2094
  // process is still running in that directory.
1918
2095
  this.spawnCwd = undefined
1919
- if (child === undefined || child.pid === undefined) return
1920
- try {
1921
- child.stdin?.end()
1922
- } catch { /* already gone */ }
1923
- const pid = child.pid
1924
- if (process.platform === 'win32') {
1925
- // Parity with the POSIX branch below, which kills the whole process
1926
- // GROUP: on Windows "the group" is the process tree, and it has to be
1927
- // killed while the tree is intact — killing the direct child first (a
1928
- // `cmd.exe` shim wrapper, see `windowsShimLaunch`) re-parents the agent
1929
- // out of `taskkill /T`'s reach. `stdin.end()` above is the graceful half.
1930
- killProcessTree(pid)
1931
- return
1932
- }
1933
- try {
1934
- process.kill(-pid, 'SIGTERM')
1935
- } catch {
1936
- child.kill('SIGTERM')
1937
- }
1938
- setTimeout(() => {
1939
- if (child.exitCode !== null || child.signalCode !== null) return
1940
- try {
1941
- process.kill(-pid, 'SIGKILL')
1942
- } catch { /* already gone */ }
1943
- }, 3000).unref()
2096
+ if (child === undefined) return
2097
+ void disposeChild(child, KILL_ESCALATION_DELAY_MS)
2098
+ }
2099
+
2100
+ /**
2101
+ * Take the current child away and WAIT until it is gone (bounded). Only the
2102
+ * host shutdown path uses this — every other teardown must stay
2103
+ * fire-and-forget.
2104
+ *
2105
+ * Always resolves: a teardown that could hang on an agent ignoring SIGTERM
2106
+ * would be worse than a leaked process, and the process-exit reaper
2107
+ * (`reapLiveChildren`) still holds the pid if even the escalation loses.
2108
+ */
2109
+ private async killChildAndWait(budgetMs: number): Promise<void> {
2110
+ const child = this.child
2111
+ this.child = undefined
2112
+ this.spawnCwd = undefined
2113
+ if (child === undefined) return
2114
+ await disposeChild(child, budgetMs)
2115
+ }
2116
+
2117
+ /**
2118
+ * Host shutdown: park every live session as RELEASED (resumable, exactly as a
2119
+ * crash would leave it), then WAIT for the child to die.
2120
+ *
2121
+ * Deliberately not `stop`: `stop` asks every session's agent to
2122
+ * `session/close` and then clears the released records — i.e. it DESTROYS the
2123
+ * conversations. A host shutdown is not the user's "stop this agent" gesture:
2124
+ * the host is coming back, and the conversations must come back with it
2125
+ * (constraint 10, plus the `chatSessions` records that outlive the restart).
2126
+ * Skipping the per-session RPC is also what keeps teardown bounded: N sessions
2127
+ * would otherwise spend up to N × SESSION_CLOSE_TIMEOUT_MS before the child
2128
+ * was even signalled, and the process is about to die anyway — "the agent
2129
+ * forgot this session" costs nothing a `session/load` cannot rebuild.
2130
+ *
2131
+ * Always resolves: `budgetMs` bounds the WHOLE kill — parking is synchronous,
2132
+ * the graceful wait and the escalation both come out of the same deadline,
2133
+ * and whatever still lives after it is the exit reaper's.
2134
+ */
2135
+ async shutdown(budgetMs: number = SHUTDOWN_BUDGET_MS): Promise<void> {
2136
+ const deadline = Date.now() + Math.max(0, budgetMs)
2137
+ // A deliberate teardown: an in-flight start must not publish `ready` (or
2138
+ // leave its process running) over the stopped state this leaves behind.
2139
+ this.teardownEpoch += 1
2140
+ this.starting = undefined
2141
+ this.clearIdleTimer()
2142
+ this.parkSessions('the host is shutting down', 'shutdown')
2143
+ this.conn?.close()
2144
+ this.conn = undefined
2145
+ this.state = 'stopped'
2146
+ this.initResult = undefined
2147
+ this.publishStatus()
2148
+ await this.killChildAndWait(Math.max(0, deadline - Date.now()))
1944
2149
  }
1945
2150
 
1946
2151
  /**
@@ -2344,6 +2549,15 @@ interface CodebuddyMeta {
2344
2549
  isBackground?: boolean
2345
2550
  /** `codebuddy.ai/isSubagent` — the same re-send declares the worker a sub-agent. */
2346
2551
  isSubagent?: boolean
2552
+ /**
2553
+ * `codebuddy.ai/subagentType` / `codebuddy.ai/description` /
2554
+ * `codebuddy.ai/memberName` — the worker's identity. CodeBuddy omits the
2555
+ * dispatch call's `rawInput` on its first send (repl display), so these meta
2556
+ * keys are the ONLY place the assigned role and task line can be read.
2557
+ */
2558
+ subagentType?: string
2559
+ subagentDescription?: string
2560
+ subagentName?: string
2347
2561
  }
2348
2562
 
2349
2563
  /** Read the CodeBuddy `_meta` keys off an update or a `{ _meta }` wrapper. */
@@ -2368,6 +2582,14 @@ function codebuddyMetaOf(source: unknown): CodebuddyMeta | undefined {
2368
2582
  // and guessing from it would make another agent's unrelated key look detached.
2369
2583
  if (meta['codebuddy.ai/isBackground'] === true) out.isBackground = true
2370
2584
  if (meta['codebuddy.ai/isSubagent'] === true) out.isSubagent = true
2585
+ const subagentType = meta['codebuddy.ai/subagentType']
2586
+ if (typeof subagentType === 'string' && subagentType !== '') out.subagentType = subagentType
2587
+ const subagentDescription = meta['codebuddy.ai/description']
2588
+ if (typeof subagentDescription === 'string' && subagentDescription !== '') {
2589
+ out.subagentDescription = subagentDescription
2590
+ }
2591
+ const subagentName = meta['codebuddy.ai/memberName']
2592
+ if (typeof subagentName === 'string' && subagentName !== '') out.subagentName = subagentName
2371
2593
  const unsolicited = meta['codebuddy.ai/unsolicitedTurn']
2372
2594
  if (typeof unsolicited === 'object' && unsolicited !== null) {
2373
2595
  const run = unsolicited as Record<string, unknown>
@@ -2541,6 +2763,71 @@ export function permissionAskReason(toolCall: unknown): string {
2541
2763
  return line.length > APPROVAL_REASON_MAX ? `${line.slice(0, APPROVAL_REASON_MAX - 1)}…` : line
2542
2764
  }
2543
2765
 
2766
+ /** Longest label the host's subagent catalog row carries for an ACP worker. */
2767
+ const SUBAGENT_CATALOG_LABEL_MAX = 160
2768
+
2769
+ /** The structured input object an ACP tool call declared, if any. */
2770
+ function rawInputObjectOf(update: Record<string, unknown>): Record<string, unknown> | undefined {
2771
+ const raw = update.rawInput ?? update.arguments
2772
+ if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) return raw as Record<string, unknown>
2773
+ if (typeof raw === 'string' && raw.length <= 16_384) {
2774
+ try {
2775
+ const parsed: unknown = JSON.parse(raw)
2776
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed as Record<string, unknown>
2777
+ } catch {
2778
+ return undefined
2779
+ }
2780
+ }
2781
+ return undefined
2782
+ }
2783
+
2784
+ /**
2785
+ * One line naming a detached ACP worker in the host's subagent catalog.
2786
+ *
2787
+ * The identity is read from what CodeBuddy actually sends, in order: the
2788
+ * `_meta` keys on the dispatch call's result frames (`subagentType` /
2789
+ * `description` / `memberName` — the FIRST `tool_call` omits `rawInput`, so
2790
+ * these are often the only source), then the structured `rawInput`, then the
2791
+ * tool title. The prompt body is only used when nothing shorter exists. Capped
2792
+ * so a pathological input cannot fill the catalog row.
2793
+ */
2794
+ export function subagentDispatchLabel(update: Record<string, unknown>): string {
2795
+ const meta = codebuddyMetaOf(update)
2796
+ const input = rawInputObjectOf(update) ?? {}
2797
+ const type = meta?.subagentType
2798
+ ?? nonEmptyStringOf(input.subagent_type)
2799
+ ?? nonEmptyStringOf(input.subagentType)
2800
+ ?? permissionToolName(update)
2801
+ const detail = meta?.subagentName
2802
+ ?? nonEmptyStringOf(input.name)
2803
+ ?? meta?.subagentDescription
2804
+ ?? nonEmptyStringOf(input.description)
2805
+ ?? nonEmptyStringOf(input.prompt)
2806
+ const line = detail === undefined || detail === type ? type : `${type} · ${detail}`
2807
+ return line.length > SUBAGENT_CATALOG_LABEL_MAX ? `${line.slice(0, SUBAGENT_CATALOG_LABEL_MAX - 1)}…` : line
2808
+ }
2809
+
2810
+ /**
2811
+ * Whether the update declared the worker's IDENTITY (role / task / name)
2812
+ * rather than just restating the `Agent` tool. A bare dispatch names the tool
2813
+ * as "Agent"; reporting that as the catalog label says nothing, so the report
2814
+ * waits for one of these facts (or for the dispatch to complete).
2815
+ */
2816
+ function subagentDispatchDeclaresIdentity(update: Record<string, unknown>): boolean {
2817
+ const meta = codebuddyMetaOf(update)
2818
+ if (meta?.subagentType !== undefined
2819
+ || meta?.subagentDescription !== undefined
2820
+ || meta?.subagentName !== undefined) return true
2821
+ const input = rawInputObjectOf(update)
2822
+ if (input === undefined) return false
2823
+ return ['subagent_type', 'subagentType', 'name', 'description', 'prompt']
2824
+ .some(key => nonEmptyStringOf(input[key]) !== undefined)
2825
+ }
2826
+
2827
+ function nonEmptyStringOf(value: unknown): string | undefined {
2828
+ return typeof value === 'string' && value !== '' ? value : undefined
2829
+ }
2830
+
2544
2831
  function snapshot(session: LiveSession): SessionView {
2545
2832
  return {
2546
2833
  sessionId: session.sessionId,
@@ -3075,11 +3362,135 @@ export function windowsShimLaunch(
3075
3362
  * Best effort — `taskkill` is absent from no Windows this runs on, and a
3076
3363
  * failure here only means the OS reclaims the process later.
3077
3364
  */
3078
- export function killProcessTree(pid: number): void {
3365
+ export function killProcessTree(pid: number): ChildProcess | undefined {
3079
3366
  try {
3080
3367
  const killer = spawn('taskkill', ['/pid', String(pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true })
3081
3368
  killer.on('error', () => { /* nothing else to try; the OS reaps it eventually */ })
3082
- } catch { /* spawn itself refused */ }
3369
+ // Handed back so a caller that wants to wait can; the existing
3370
+ // fire-and-forget callers simply ignore it.
3371
+ return killer
3372
+ } catch {
3373
+ return undefined
3374
+ }
3375
+ }
3376
+
3377
+ /** Every agent child this host started and has not seen exit yet, by pid. */
3378
+ const liveChildPids = new Set<number>()
3379
+ /** Whether the process-exit reaper is already installed (installed once). */
3380
+ let exitReaperArmed = false
3381
+
3382
+ /** Remember a freshly spawned agent child for the last-resort reaper. */
3383
+ function trackAgentChild(child: ChildProcess): void {
3384
+ const pid = child.pid
3385
+ if (pid === undefined) return
3386
+ liveChildPids.add(pid)
3387
+ // Both events: `exit` is the process fact, `close` is it plus its stdio being
3388
+ // closed. Whichever comes first forgets the pid.
3389
+ const forget = (): void => { liveChildPids.delete(pid) }
3390
+ child.once('exit', forget)
3391
+ child.once('close', forget)
3392
+ if (exitReaperArmed) return
3393
+ exitReaperArmed = true
3394
+ process.once('exit', reapLiveChildren)
3395
+ }
3396
+
3397
+ /**
3398
+ * Last resort for a host that leaves without a graceful teardown: an unhandled
3399
+ * crash that still runs `exit`, an explicit `process.exit()`, or a disposer the
3400
+ * host never awaited. Only SYNC calls are legal here — the `exit` event cannot
3401
+ * wait for a promise — hence `spawnSync` and a bare `process.kill`.
3402
+ *
3403
+ * Deliberately NOT a `SIGINT` / `SIGTERM` handler: registering one flips the
3404
+ * host's own default disposition (killing the default "exit on Ctrl-C", or
3405
+ * double-handling the host's shutdown and hanging it). A host that handles the
3406
+ * signal itself still reaches `exit`; one that is SIGKILLed can run nothing at
3407
+ * all (see AGENTS/protocol.md §7 for that limit).
3408
+ */
3409
+ function reapLiveChildren(): void {
3410
+ for (const pid of liveChildPids) {
3411
+ try {
3412
+ if (process.platform === 'win32') {
3413
+ spawnSync('taskkill', ['/pid', String(pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true })
3414
+ } else {
3415
+ // The child was spawned `detached`, so it leads its own process group:
3416
+ // -pid reaches everything it started (same reasoning as the graceful
3417
+ // POSIX kill).
3418
+ process.kill(-pid, 'SIGKILL')
3419
+ }
3420
+ } catch { /* already gone, or no permission left to try */ }
3421
+ }
3422
+ liveChildPids.clear()
3423
+ }
3424
+
3425
+ /**
3426
+ * End the child's stdin, dispatch the platform kill, and wait until the process
3427
+ * is gone — bounded, and never rejecting.
3428
+ *
3429
+ * The escalation is deliberately platform-shaped: POSIX gets a process-GROUP
3430
+ * `SIGKILL` (an agent that ignores SIGTERM is exactly what a second signal is
3431
+ * for), Windows a second `taskkill /T /F` (the first may never have run at all:
3432
+ * a failed spawn of `taskkill` is swallowed by `killProcessTree`).
3433
+ */
3434
+ async function disposeChild(child: ChildProcess, budgetMs: number): Promise<void> {
3435
+ // ONE deadline for the whole disposal: the escalation wait comes out of the
3436
+ // same budget, so `budgetMs` can never turn into "grace period + grace
3437
+ // period" (see `KILL_ESCALATION_GRACE_MS`).
3438
+ const deadline = Date.now() + Math.max(0, budgetMs)
3439
+ try {
3440
+ child.stdin?.end()
3441
+ } catch { /* already gone */ }
3442
+ const pid = child.pid
3443
+ if (pid === undefined) return
3444
+ // A child that already exited may have had its pid reused: signalling it
3445
+ // would reach an unrelated process (group). Nothing left to kill.
3446
+ if (child.exitCode !== null || child.signalCode !== null) return
3447
+ const windows = process.platform === 'win32'
3448
+ if (windows) {
3449
+ // Parity with the POSIX branch, which kills the whole process GROUP: on
3450
+ // Windows "the group" is the process tree, and it has to be killed while
3451
+ // the tree is intact — killing the direct child first (a `cmd.exe` shim
3452
+ // wrapper, see `windowsShimLaunch`) re-parents the agent out of
3453
+ // `taskkill /T`'s reach. `stdin.end()` above is the graceful half.
3454
+ killProcessTree(pid)
3455
+ } else {
3456
+ try {
3457
+ process.kill(-pid, 'SIGTERM')
3458
+ } catch {
3459
+ child.kill('SIGTERM')
3460
+ }
3461
+ }
3462
+ // Everything except the escalation slice: the second wait is only there to
3463
+ // confirm a SIGKILL / taskkill landed, and it must fit inside the same budget.
3464
+ if (await waitForChildExit(child, Math.max(0, budgetMs - KILL_ESCALATION_GRACE_MS))) return
3465
+ if (windows) {
3466
+ killProcessTree(pid)
3467
+ } else {
3468
+ try {
3469
+ process.kill(-pid, 'SIGKILL')
3470
+ } catch { /* already gone */ }
3471
+ }
3472
+ // Whatever is left of the deadline, never a fresh grace period.
3473
+ await waitForChildExit(child, Math.max(0, deadline - Date.now()))
3474
+ }
3475
+
3476
+ /** Resolve true once the child is gone, false when the budget runs out first. */
3477
+ function waitForChildExit(child: ChildProcess, budgetMs: number): Promise<boolean> {
3478
+ if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true)
3479
+ return new Promise<boolean>((resolve) => {
3480
+ let timer: ReturnType<typeof setTimeout> | undefined
3481
+ const settle = (gone: boolean): void => {
3482
+ if (timer !== undefined) clearTimeout(timer)
3483
+ child.off('exit', onGone)
3484
+ child.off('close', onGone)
3485
+ resolve(gone)
3486
+ }
3487
+ const onGone = (): void => { settle(true) }
3488
+ timer = setTimeout(() => { settle(false) }, budgetMs)
3489
+ // The host may exit while this waits; an armed timer must not hold it open.
3490
+ timer.unref?.()
3491
+ child.once('exit', onGone)
3492
+ child.once('close', onGone)
3493
+ })
3083
3494
  }
3084
3495
 
3085
3496
  /**
@@ -3519,6 +3930,12 @@ interface BackgroundDispatch {
3519
3930
  detached: boolean
3520
3931
  /** Whether any update came FROM the worker (`codebuddy.ai/parentToolCallId`). */
3521
3932
  sawWorkerUpdate: boolean
3933
+ /** One-line label for the host's subagent catalog, refreshed until reported. */
3934
+ catalogLabel: string
3935
+ /** Whether `catalogLabel` carries the worker's identity, not just the tool name. */
3936
+ catalogLabelIdentified: boolean
3937
+ /** Whether the host catalog already accepted this dispatch (one fact per call). */
3938
+ catalogReported: boolean
3522
3939
  }
3523
3940
 
3524
3941
  /**
@@ -3526,10 +3943,12 @@ interface BackgroundDispatch {
3526
3943
  *
3527
3944
  * Two phases, because their liveness signals differ:
3528
3945
  * - `worker`: at least one worker runs off-turn and may be silent for tens of
3529
- * seconds between model calls — NO quiet timer, only
3530
- * {@link BridgeTuning.backgroundTaskMaxMs}. It ends when EVERY known dispatch
3531
- * reported (completed / failed); a drain announcement is recorded meanwhile
3532
- * but arms no clock.
3946
+ * seconds between model calls — its clock is a SILENCE window
3947
+ * ({@link BridgeTuning.backgroundTaskMaxMs}) pushed out by every dispatch /
3948
+ * worker update, plus an optional whole-hold ceiling
3949
+ * ({@link BridgeTuning.backgroundTaskCeilingMs}). It ends when EVERY known
3950
+ * dispatch reported (completed / failed); a drain announcement is recorded
3951
+ * meanwhile but arms no clock.
3533
3952
  * - `drain`: every dispatch reported and the background turn that carries
3534
3953
  * their report is (or is about to start) — the existing ambient windows
3535
3954
  * apply (`ambientQuietMs` quiet, reset by every update; `ambientMaxMs`
@@ -3564,7 +3983,8 @@ interface BackgroundHold {
3564
3983
  floorSeq: number
3565
3984
  quietTimer?: ReturnType<typeof setTimeout>
3566
3985
  hardTimer?: ReturnType<typeof setTimeout>
3567
- /**
3986
+ /** Absolute whole-hold ceiling; absent when the tuning disables it (0). */
3987
+ ceilingTimer?: ReturnType<typeof setTimeout> /**
3568
3988
  * The bounded wait for a driver step to speak into. Only while the hold is
3569
3989
  * PARKED (`pendingBackgroundHold`): the response landed between steps, and
3570
3990
  * the host is expected to open the continuation step for the Agent card.
@@ -3681,11 +4101,13 @@ class TurnStreamController {
3681
4101
  private pendingBackgroundHold?: BackgroundHold
3682
4102
  /**
3683
4103
  * The hold's own windows, read once at construction like the ambient pair:
3684
- * `taskMaxMs` is the worker phase's ceiling, `graceMs` the parked hold's
3685
- * bounded wait for a driver step (see {@link BridgeTuning.backgroundTaskMaxMs}
3686
- * and {@link BridgeTuning.backgroundParkedGraceMs}).
4104
+ * `taskMaxMs` is the worker phase's silence window, `ceilingMs` the optional
4105
+ * whole-hold absolute bound (0 = none), `graceMs` the parked hold's bounded
4106
+ * wait for a driver step (see {@link BridgeTuning.backgroundTaskMaxMs} /
4107
+ * {@link BridgeTuning.backgroundTaskCeilingMs} /
4108
+ * {@link BridgeTuning.backgroundParkedGraceMs}).
3687
4109
  */
3688
- private readonly backgroundTuning: { taskMaxMs: number; graceMs: number }
4110
+ private readonly backgroundTuning: { taskMaxMs: number; ceilingMs: number; graceMs: number }
3689
4111
  /**
3690
4112
  * When this controller was built. Only a drain announced AFTER this moment
3691
4113
  * may be adopted by the hold: an `ambient` record that predates the turn is
@@ -3935,11 +4357,13 @@ class TurnStreamController {
3935
4357
  * - marks the segment background, so EVERY tool call for the rest of the
3936
4358
  * hold — the workers', the drain's TaskOutput — stays transcript-only and
3937
4359
  * the final message carries text with no card in front of it;
3938
- * - waits in two phases: the worker phase only has the
3939
- * {@link BridgeTuning.backgroundTaskMaxMs} ceiling (a detached worker goes
3940
- * silent for tens of seconds while it works) and ends only when ALL known
3941
- * dispatches reported, after which the drain phase uses the ambient
3942
- * windows.
4360
+ * - waits in two phases: the worker phase's clock is a SILENCE window
4361
+ * ({@link BridgeTuning.backgroundTaskMaxMs}) that every dispatch / worker
4362
+ * update pushes out, so a long task that keeps reporting is never cut at a
4363
+ * fixed total runtime. It ends when ALL known dispatches reported, after
4364
+ * which the drain phase uses the ambient windows. An optional absolute
4365
+ * ceiling ({@link BridgeTuning.backgroundTaskCeilingMs}, default none)
4366
+ * bounds a zombie that keeps talking forever.
3943
4367
  */
3944
4368
  holdForBackgroundDispatch(stopReason: string, requestId?: string): void {
3945
4369
  if (this.settled || this.disposed || this.closedTurn || this.stale) return
@@ -3967,6 +4391,7 @@ class TurnStreamController {
3967
4391
  this.endTurn()
3968
4392
  hold.hardTimer = setTimeout(() => { this.onBackgroundHard(hold) }, this.backgroundTuning.taskMaxMs)
3969
4393
  hold.hardTimer.unref?.()
4394
+ this.armBackgroundCeiling(hold)
3970
4395
  const seg = this.segment
3971
4396
  if (seg !== undefined && !seg.finished) {
3972
4397
  this.adoptBackgroundHold(seg, hold)
@@ -4058,10 +4483,31 @@ class TurnStreamController {
4058
4483
  hold.quietTimer.unref?.()
4059
4484
  }
4060
4485
 
4486
+ /**
4487
+ * Evidence from a live dispatch / worker: the worker phase is SILENCE-based,
4488
+ * so each update pushes its deadline out. A long task that keeps reporting is
4489
+ * never cut at a fixed total runtime; only a genuinely quiet worker is.
4490
+ */
4491
+ private noteBackgroundWorkerActivity(): void {
4492
+ const hold = this.activeBackgroundHold()
4493
+ if (hold === undefined || hold.phase !== 'worker' || hold.retired) return
4494
+ if (hold.hardTimer !== undefined) clearTimeout(hold.hardTimer)
4495
+ hold.hardTimer = setTimeout(() => { this.onBackgroundHard(hold) }, this.backgroundTuning.taskMaxMs)
4496
+ hold.hardTimer.unref?.()
4497
+ }
4498
+
4499
+ /** Arm the hold's optional absolute ceiling (0 = unlimited). */
4500
+ private armBackgroundCeiling(hold: BackgroundHold): void {
4501
+ if (this.backgroundTuning.ceilingMs <= 0) return
4502
+ hold.ceilingTimer = setTimeout(() => { this.onBackgroundHard(hold) }, this.backgroundTuning.ceilingMs)
4503
+ hold.ceilingTimer.unref?.()
4504
+ }
4505
+
4061
4506
  /**
4062
4507
  * Any session update restarts the drain phase's quiet clock. Only an
4063
- * ADOPTED hold has one: a parked hold's wait belongs to its grace timer, and
4064
- * the worker phase never has a clock at all.
4508
+ * ADOPTED hold has a quiet clock: a parked hold's wait belongs to its grace
4509
+ * timer, and the worker phase is pushed forward only by dispatch / worker
4510
+ * evidence (see `noteBackgroundWorkerActivity`).
4065
4511
  */
4066
4512
  private noteBackgroundHoldActivity(): void {
4067
4513
  const hold = this.backgroundHold
@@ -4075,7 +4521,7 @@ class TurnStreamController {
4075
4521
  this.closeBackgroundHold(hold, { hard: false })
4076
4522
  }
4077
4523
 
4078
- /** The hold outlived its ceiling (worker or drain): the run is still going. */
4524
+ /** The hold went silent past its window, or outlived the absolute ceiling. */
4079
4525
  private onBackgroundHard(hold: BackgroundHold): void {
4080
4526
  if (hold !== this.backgroundHold && hold !== this.pendingBackgroundHold) return
4081
4527
  this.closeBackgroundHold(hold, { hard: true })
@@ -4143,9 +4589,11 @@ class TurnStreamController {
4143
4589
  private clearBackgroundHoldTimers(hold: BackgroundHold): void {
4144
4590
  if (hold.quietTimer !== undefined) clearTimeout(hold.quietTimer)
4145
4591
  if (hold.hardTimer !== undefined) clearTimeout(hold.hardTimer)
4592
+ if (hold.ceilingTimer !== undefined) clearTimeout(hold.ceilingTimer)
4146
4593
  if (hold.graceTimer !== undefined) clearTimeout(hold.graceTimer)
4147
4594
  hold.quietTimer = undefined
4148
4595
  hold.hardTimer = undefined
4596
+ hold.ceilingTimer = undefined
4149
4597
  hold.graceTimer = undefined
4150
4598
  }
4151
4599
 
@@ -4282,8 +4730,13 @@ class TurnStreamController {
4282
4730
  const known = this.dispatches.get(toolCallId)
4283
4731
  if (known !== undefined) {
4284
4732
  // A known dispatch: the id is the whole evidence (see the doc above).
4733
+ this.noteBackgroundWorkerActivity()
4285
4734
  if (status !== '') known.status = status
4286
- if (meta?.isBackground === true) known.detached = true
4735
+ if (!known.catalogReported) this.refreshSubagentDispatchLabel(known, update)
4736
+ if (meta?.isBackground === true) {
4737
+ known.detached = true
4738
+ this.reportSubagentDispatch(known)
4739
+ }
4287
4740
  if (completedStatus && !known.completed) {
4288
4741
  known.completed = true
4289
4742
  this.noteDispatchProgress()
@@ -4299,8 +4752,13 @@ class TurnStreamController {
4299
4752
  completed: completedStatus,
4300
4753
  detached: meta.isBackground === true,
4301
4754
  sawWorkerUpdate: false,
4755
+ catalogLabel: subagentDispatchLabel(update),
4756
+ catalogLabelIdentified: subagentDispatchDeclaresIdentity(update),
4757
+ catalogReported: false,
4302
4758
  }
4303
4759
  this.dispatches.set(toolCallId, dispatch)
4760
+ this.noteBackgroundWorkerActivity()
4761
+ if (dispatch.detached) this.reportSubagentDispatch(dispatch)
4304
4762
  if (!dispatch.completed) {
4305
4763
  const held = this.activeBackgroundHold()
4306
4764
  // A dispatch registered after the drain phase began means new work:
@@ -4312,22 +4770,64 @@ class TurnStreamController {
4312
4770
  if (meta?.parentToolCallId !== undefined) {
4313
4771
  const parent = this.dispatches.get(meta.parentToolCallId)
4314
4772
  if (parent !== undefined) {
4773
+ this.noteBackgroundWorkerActivity()
4315
4774
  parent.detached = true
4316
4775
  parent.sawWorkerUpdate = true
4776
+ this.reportSubagentDispatch(parent)
4317
4777
  }
4318
4778
  }
4319
4779
  if (meta?.unsolicitedTurn !== undefined) {
4320
4780
  const held = this.activeBackgroundHold()
4321
- if (held !== undefined) this.startBackgroundDrain(held, meta.unsolicitedTurn.requestId)
4781
+ if (held !== undefined) {
4782
+ this.noteBackgroundWorkerActivity()
4783
+ this.startBackgroundDrain(held, meta.unsolicitedTurn.requestId)
4784
+ }
4785
+ }
4786
+ }
4787
+
4788
+ /**
4789
+ * Hand one DETACHED dispatch to the host's subagent catalog. One fact per
4790
+ * tool call, and only once the wire's structure proves the work runs
4791
+ * off-turn — a synchronous `Agent` / `Task` call is a tool call, not a
4792
+ * catalog child.
4793
+ */
4794
+ private reportSubagentDispatch(dispatch: BackgroundDispatch): void {
4795
+ if (dispatch.catalogReported) return
4796
+ // A bare "Agent" row says nothing; wait for the identity frame. A
4797
+ // completed dispatch reports with whatever it has — a row must not be
4798
+ // lost because the agent never declared more than the tool name.
4799
+ if (!dispatch.catalogLabelIdentified && !dispatch.completed) return
4800
+ if (this.manager.reportSubagentCatalog(this.session, `acp:${dispatch.toolCallId}`, dispatch.catalogLabel)) {
4801
+ dispatch.catalogReported = true
4322
4802
  }
4323
4803
  }
4324
4804
 
4805
+ /**
4806
+ * Take a better label from a later frame of the SAME dispatch call.
4807
+ *
4808
+ * CodeBuddy omits `rawInput` (and often the title) on the dispatch call's
4809
+ * first send, then restates the whole task identity in `_meta` on the result
4810
+ * frame. A frame with no identity and no title is a bare status update and
4811
+ * must not downgrade what is already known.
4812
+ */
4813
+ private refreshSubagentDispatchLabel(dispatch: BackgroundDispatch, update: Record<string, unknown>): void {
4814
+ const identified = subagentDispatchDeclaresIdentity(update)
4815
+ if (!identified && dispatch.catalogLabelIdentified) return
4816
+ const title = update.title
4817
+ if (!identified && !(typeof title === 'string' && title !== '')) return
4818
+ dispatch.catalogLabel = subagentDispatchLabel(update)
4819
+ dispatch.catalogLabelIdentified = identified
4820
+ }
4821
+
4325
4822
  /**
4326
4823
  * A dispatch just reported: once the LAST one has, the drain phase starts.
4327
4824
  * Until then the turn stays in its worker phase, with no quiet clock.
4328
4825
  */
4329
4826
  private noteDispatchProgress(): void {
4330
4827
  if (!this.allDispatchesCompleted()) return
4828
+ // Last chance for a row still waiting on identity: completion is a fact
4829
+ // even when the agent never said more than the tool name.
4830
+ for (const dispatch of this.dispatches.values()) this.reportSubagentDispatch(dispatch)
4331
4831
  const held = this.activeBackgroundHold()
4332
4832
  if (held !== undefined) this.startBackgroundDrain(held)
4333
4833
  }
@@ -4810,7 +5310,8 @@ class TurnStreamController {
4810
5310
  // background drain is still working, so the ambient quiet clock restarts.
4811
5311
  this.noteAmbientActivity()
4812
5312
  // A detached-worker hold's drain phase has the same rule; its worker
4813
- // phase never does (see `BridgeTuning.backgroundTaskMaxMs`).
5313
+ // phase is pushed out only by dispatch / worker evidence (see
5314
+ // `BridgeTuning.backgroundTaskMaxMs`).
4814
5315
  this.noteBackgroundHoldActivity()
4815
5316
  const update = typed.update as Record<string, unknown>
4816
5317
  // The dispatch / worker / announcement bookkeeping runs BEFORE the cursor
@@ -5376,14 +5877,17 @@ export class BridgeManager {
5376
5877
  /**
5377
5878
  * The detached-worker hold's windows, read once at controller construction
5378
5879
  * for the same reason as {@link ambientTuning}: `taskMaxMs` is the worker
5379
- * phase's only clock (see {@link BridgeTuning.backgroundTaskMaxMs}) and
5380
- * `graceMs` the bounded wait a PARKED hold gives the host to open its
5381
- * continuation step (see {@link BridgeTuning.backgroundParkedGraceMs}). The
5382
- * drain phase reuses {@link ambientTuning}.
5383
- */
5384
- get backgroundTuning(): { taskMaxMs: number; graceMs: number } {
5880
+ * phase's SILENCE window, pushed out by every dispatch / worker update (see
5881
+ * {@link BridgeTuning.backgroundTaskMaxMs}), `ceilingMs` its optional
5882
+ * absolute bound (0 = none) and `graceMs` the bounded wait a PARKED hold
5883
+ * gives the host to open its continuation step (see
5884
+ * {@link BridgeTuning.backgroundParkedGraceMs}). The drain phase reuses
5885
+ * {@link ambientTuning}.
5886
+ */
5887
+ get backgroundTuning(): { taskMaxMs: number; ceilingMs: number; graceMs: number } {
5385
5888
  return {
5386
5889
  taskMaxMs: this.tuning.backgroundTaskMaxMs,
5890
+ ceilingMs: this.tuning.backgroundTaskCeilingMs,
5387
5891
  graceMs: this.tuning.backgroundParkedGraceMs,
5388
5892
  }
5389
5893
  }
@@ -5603,6 +6107,33 @@ export class BridgeManager {
5603
6107
  this.acpModeResolver = resolver
5604
6108
  }
5605
6109
 
6110
+ /**
6111
+ * Optional host integration: report a detached ACP worker as an EXTERNAL
6112
+ * child in the parent chat session's subagent catalog. Display-only — the
6113
+ * host renders it as a read-only "external task" row with no child session,
6114
+ * activity or continuation. An absent reporter is a no-op.
6115
+ */
6116
+ setSubagentCatalogReporter(reporter?: SubagentCatalogReporter): void {
6117
+ this.subagentCatalogReporter = reporter
6118
+ }
6119
+
6120
+ /**
6121
+ * Hand one detached worker to the host catalog. Returns whether a reporter
6122
+ * handled it; a missing chat id or reporter leaves the dispatch unclaimed so
6123
+ * a later wire update can retry.
6124
+ */
6125
+ reportSubagentCatalog(session: LiveSession, childId: string, label: string): boolean {
6126
+ const dshSessionId = session.dshSessionId
6127
+ const reporter = this.subagentCatalogReporter
6128
+ if (dshSessionId === undefined || reporter === undefined) return false
6129
+ try {
6130
+ reporter(dshSessionId, childId, label)
6131
+ return true
6132
+ } catch {
6133
+ return false
6134
+ }
6135
+ }
6136
+
5606
6137
  /**
5607
6138
  * Whether ONE DSH chat session runs the ACP agent preset, asked by chat
5608
6139
  * session id. The browser's composer surfaces consume this: on hosts whose
@@ -7800,14 +8331,31 @@ export class BridgeManager {
7800
8331
  await this.bridges.get(agentId)?.stop('agent-stopped')
7801
8332
  }
7802
8333
 
8334
+ /**
8335
+ * Host shutdown: park every conversation, then make sure no agent child
8336
+ * outlives the host. Bridges run CONCURRENTLY against one bounded budget, so a
8337
+ * single wedged agent cannot spend every other bridge's slice too.
8338
+ *
8339
+ * `/acp`'s "stop this agent" gesture is `stopAgent` → `stop`, which is a
8340
+ * different promise: it closes the sessions on purpose. See
8341
+ * `AgentBridge.shutdown` for why host exit must not.
8342
+ */
7803
8343
  async shutdownAll(): Promise<void> {
7804
8344
  if (this.stallTimer !== undefined) {
7805
8345
  clearInterval(this.stallTimer)
7806
8346
  this.stallTimer = undefined
7807
8347
  }
7808
- this.dshSessionMap.clear()
7809
8348
  this.disposeTurnStreamsOf(() => true)
7810
- await Promise.all([...this.bridges.values()].map(b => b.stop('agent-stopped')))
8349
+ // NOT cleared before the shutdown: `persistAgents` (called from
8350
+ // `parkSessions`) writes the parked records out of `dshSessionMap`, and
8351
+ // that write is what keeps the conversations resumable across the restart.
8352
+ //
8353
+ // `allSettled`, not `all`: one bridge's failure must neither skip the
8354
+ // others nor reject a disposer that never asked for an answer. And NOT
8355
+ // cleared afterwards either: a late `persistAgents` would then write
8356
+ // `chatSessions: []` and erase exactly what the shutdown just saved. These
8357
+ // records are terminal — nothing reads them again after disposal.
8358
+ await Promise.allSettled([...this.bridges.values()].map(b => b.shutdown(SHUTDOWN_BUDGET_MS)))
7811
8359
  }
7812
8360
 
7813
8361
  /** Host-side reader of the harness preset roster (`ctx.agentPresets`), for `presetStatus`. */
@@ -8044,6 +8592,7 @@ export class BridgeManager {
8044
8592
  }
8045
8593
  private sessionWorkspaceResolver?: SessionWorkspaceResolver
8046
8594
  private acpModeResolver?: AcpModeResolver
8595
+ private subagentCatalogReporter?: SubagentCatalogReporter
8047
8596
  private attachmentResolver?: unknown
8048
8597
  /** Cached `chatSessionAcpMode` answers, keyed by DSH chat session id. */
8049
8598
  private readonly acpModeCache = new Map<string, boolean>()
@@ -8052,8 +8601,8 @@ export class BridgeManager {
8052
8601
 
8053
8602
  /**
8054
8603
  * The agent values the composer is showing for a conversation that has not
8055
- * picked them — the catalog's current model, thought level and extra config
8056
- * values.
8604
+ * picked them — the catalog's current model, thought level, mode and extra
8605
+ * config values.
8057
8606
  *
8058
8607
  * Captured BEFORE `session/new`: materializing the session absorbs its own
8059
8608
  * defaults into the catalog (`absorbCatalog`), which would overwrite exactly
@@ -8072,6 +8621,14 @@ export class BridgeManager {
8072
8621
  // `defaultEffort` only when the current value is one of the agent's levels.
8073
8622
  const effort = this.reasoningMeta(agentId)?.defaultEffort
8074
8623
  if (effort !== undefined) out.effort = effort
8624
+ // The mode select's pre-first-message value is the catalog's last-known
8625
+ // mode (the agent's own answer from another session), and `session/new`
8626
+ // starts on whatever the agent itself defaults to. Without capturing it,
8627
+ // a conversation whose select reads "Bypass Permissions" would run its
8628
+ // first turn on "Always Ask" — the same display/run split the model and
8629
+ // thought level are seeded for.
8630
+ const mode = catalog.mode?.currentValue
8631
+ if (mode !== null && mode !== undefined && mode !== '') out.mode = mode
8075
8632
  const extraConfigs: Record<string, string | boolean> = {}
8076
8633
  for (const option of catalog.extras ?? []) {
8077
8634
  const value = option.currentValue
@@ -8092,14 +8649,15 @@ export class BridgeManager {
8092
8649
  * first prompt runs.
8093
8650
  *
8094
8651
  * A conversation with no explicit pick displays the agent catalog's current
8095
- * model / thought level / extras, while the ACP session `session/new` just
8096
- * created starts on whatever the agent itself defaults to. Without this the
8097
- * user is shown one model and the agent runs another — the defect reported
8098
- * as "the displayed model was not used". Values the fresh session does not
8099
- * offer are skipped (its `configOptions` are the authority, and the catalog
8100
- * update that rode `session/new` will correct the display), never turned
8101
- * into a failed first turn. Explicit picks always win: only missing fields
8102
- * are filled.
8652
+ * model / thought level / mode / extras, while the ACP session `session/new`
8653
+ * just created starts on whatever the agent itself defaults to. Without this
8654
+ * the user is shown one model and the agent runs another — the defect
8655
+ * reported as "the displayed model was not used", and the reported first
8656
+ * turn that ignored the mode select's value. Values the fresh session does
8657
+ * not offer are skipped (its `configOptions` are the authority, and the
8658
+ * catalog update that rode `session/new` will correct the display), never
8659
+ * turned into a failed first turn. Explicit picks always win: only missing
8660
+ * fields are filled.
8103
8661
  */
8104
8662
  private seedChatChoiceFromCatalog(
8105
8663
  dshSessionId: string,
@@ -8128,6 +8686,18 @@ export class BridgeManager {
8128
8686
  changed = true
8129
8687
  }
8130
8688
  }
8689
+ // The mode is the third axis of the same contract. The select's value
8690
+ // before the first message is the catalog's last-known mode; `session/new`
8691
+ // answers with the agent's own default. Seed the displayed one when the
8692
+ // fresh session still offers it — an agent that publishes no `modes`
8693
+ // field is applied best-effort, exactly like `applyChatChoice`.
8694
+ const displayedMode = displayed.mode
8695
+ const offeredModes = session.modes?.availableModes ?? []
8696
+ if (current?.mode === undefined && displayedMode !== undefined
8697
+ && (offeredModes.length === 0 || offeredModes.some(entry => entry.id === displayedMode))) {
8698
+ patch.mode = displayedMode
8699
+ changed = true
8700
+ }
8131
8701
  if (displayed.extraConfigs !== undefined) {
8132
8702
  const extraConfigs = { ...(current?.agentId === agentId ? current.extraConfigs : undefined) }
8133
8703
  for (const [configId, value] of Object.entries(displayed.extraConfigs)) {