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/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'
@@ -40,6 +40,7 @@ import {
40
40
  acpToolCallFromUpdate,
41
41
  buildAdapterModelId,
42
42
  childEnv,
43
+ hasUnambiguousModelSelector,
43
44
  isAcpToolContinuation,
44
45
  isUnderPath,
45
46
  mergeCatalogShape,
@@ -107,10 +108,47 @@ const FS_MAX_WRITE_BYTES = 2 * 1024 * 1024
107
108
  */
108
109
  const SESSION_CLOSE_TIMEOUT_MS = 5_000
109
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
+
110
146
  /** What the composer showed for an agent before a conversation picked it. */
111
147
  interface DisplayedChatDefaults {
112
148
  modelId?: string
113
149
  effort?: string
150
+ /** The agent's last-known mode (the composer's select without a pick). */
151
+ mode?: string
114
152
  extraConfigs?: Record<string, string | boolean>
115
153
  }
116
154
 
@@ -457,20 +495,33 @@ export interface BridgeTuning {
457
495
  */
458
496
  ambientMaxMs?: number
459
497
  /**
460
- * The hard ceiling on the WORKER phase of a detached-worker hold (see
461
- * `TurnStreamController.holdForBackgroundDispatch`): the turn dispatched one
462
- * or more background workers through `Agent` tool calls, the agent answered
463
- * the prompt, and at least one worker has not reported back yet. The worker
464
- * phase has NO quiet window on purpose — measured against CodeBuddy 2.179.0,
465
- * a detached worker's own updates are 13–24s apart while it is perfectly
466
- * alive — so only EVERY known dispatch reporting (completed / failed) or
467
- * this ceiling can end it; a drain announcement is recorded but arms no
468
- * 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
469
509
  * `ambientQuietMs` / `ambientMaxMs` windows take over.
470
510
  *
471
- * Default 600000 ms (10 minutes).
511
+ * Default 600000 ms (10 minutes of silence, not of total runtime).
472
512
  */
473
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
474
525
  /**
475
526
  * How long a PARKED detached-worker hold may wait for the host to open the
476
527
  * driver step that carries the worker's report. The prompt response landed
@@ -510,6 +561,12 @@ export type SessionWorkspaceResolver = (dshSessionId: string) => { workspaceRoot
510
561
  */
511
562
  export type AcpModeResolver = (dshSessionId: string) => boolean | undefined | Promise<boolean | undefined>
512
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
+
513
570
  /**
514
571
  * The plugin's normalized MCP server shape → the ACP wire shape.
515
572
  *
@@ -578,6 +635,7 @@ const DEFAULT_TUNING: Required<BridgeTuning> = {
578
635
  ambientQuietMs: 10_000,
579
636
  ambientMaxMs: 120_000,
580
637
  backgroundTaskMaxMs: 600_000,
638
+ backgroundTaskCeilingMs: 0,
581
639
  backgroundParkedGraceMs: 30_000,
582
640
  ambientStaleMs: 300_000,
583
641
  }
@@ -592,6 +650,24 @@ export class AgentBridge {
592
650
 
593
651
  private child?: ChildProcess
594
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
595
671
  private starting?: Promise<void>
596
672
  private readonly sessions = new Map<string, LiveSession>()
597
673
  private readonly acpIndex = new Map<string, LiveSession>()
@@ -1064,10 +1140,19 @@ export class AgentBridge {
1064
1140
  }
1065
1141
  if (this.state === 'ready' && this.conn !== undefined) return Promise.resolve()
1066
1142
  if (this.starting !== undefined) return this.starting
1143
+ const epoch = this.teardownEpoch
1067
1144
  this.state = 'starting'
1068
1145
  this.publishStatus()
1069
- this.starting = this.spawnAndConnect()
1146
+ const started = this.spawnAndConnect(epoch)
1070
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
1071
1156
  this.state = 'ready'
1072
1157
  this.lastError = undefined
1073
1158
  this.publishStatus()
@@ -1076,6 +1161,16 @@ export class AgentBridge {
1076
1161
  this.touch()
1077
1162
  })
1078
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
+ }
1079
1174
  this.state = 'error'
1080
1175
  this.lastError = error instanceof Error ? error.message : String(error)
1081
1176
  this.clearIdleTimer()
@@ -1083,16 +1178,30 @@ export class AgentBridge {
1083
1178
  this.publishStatus()
1084
1179
  throw new AcpError('agent_unreachable', `acp: agent "${this.def.id}" failed to start: ${this.lastError}`)
1085
1180
  })
1086
- .finally(() => { this.starting = undefined })
1087
- 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
1088
1188
  }
1089
1189
 
1090
- 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> {
1091
1196
  this.stderrTail = []
1092
1197
  // Resolve (and, with npmPackage, auto-install) the executable before the
1093
1198
  // spawn; the resolved absolute path also pins the lookup to the host PATH
1094
1199
  // plus the well-known extra dirs, not the child env the agent may override.
1095
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
1096
1205
  const cwd = safeSpawnCwd(this.def, this.wantedSpawnCwd)
1097
1206
  this.spawnCwd = cwd
1098
1207
  const launch = windowsShimLaunch(command, this.def.args ?? [])
@@ -1108,6 +1217,7 @@ export class AgentBridge {
1108
1217
  ...(launch.verbatim ? { windowsVerbatimArguments: true } : {}),
1109
1218
  })
1110
1219
  this.child = child
1220
+ trackAgentChild(child)
1111
1221
  child.on('error', (error) => {
1112
1222
  // Identity guard: a stale event from a PREVIOUS child must not tear down
1113
1223
  // a connection that replaced it (an `exit` can land after the respawn
@@ -1167,6 +1277,25 @@ export class AgentBridge {
1167
1277
  }),
1168
1278
  'initialize',
1169
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
+ }
1170
1299
  this.initResult = init
1171
1300
  // The fork capability is agent knowledge that must outlive the process:
1172
1301
  // report it so the manager can persist a LAST KNOWN copy and serve the
@@ -1613,13 +1742,17 @@ export class AgentBridge {
1613
1742
  // workspace from its own cwd would come back in the plugin's sandbox.
1614
1743
  const workspaceRoot = chatWorkspaceRoot(this.sessionWorkspace?.(), session.dshSessionId)
1615
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')
1616
1751
  // The capability comes from the FRESH initialize: after a crash the
1617
1752
  // bridge restarts the process, so check the (new) initResult here.
1618
1753
  if (this.initResult?.agentCapabilities?.loadSession !== true) {
1619
1754
  throw new AcpError('resume_unsupported', `acp: agent "${this.def.id}" does not advertise the loadSession capability; start a new session`)
1620
1755
  }
1621
- const conn = this.conn
1622
- if (conn === undefined) throw new AcpError('agent_unreachable', 'acp: connection is not open')
1623
1756
  const resolved = resolveSessionCwd(this.def, session.cwd, p => realpathSync(p), workspaceRoot === undefined ? [] : [workspaceRoot])
1624
1757
  // Replay arrives as brand-new updates, so the transcript restarts too;
1625
1758
  // seq keeps meaning "position in the current epoch". What the buffer held
@@ -1685,9 +1818,42 @@ export class AgentBridge {
1685
1818
  }
1686
1819
  }
1687
1820
 
1688
- /** 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
+ */
1689
1830
  releaseAll(reason: string, cause: AcpReleaseCause): void {
1690
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
1691
1857
  for (const session of [...this.sessions.values()]) {
1692
1858
  // A session already being closed is `closeSession`'s to delete. Marking
1693
1859
  // it released would park it as resumable, and `doResumeSession` flips
@@ -1709,19 +1875,15 @@ export class AgentBridge {
1709
1875
  this.settlePermissions(session)
1710
1876
  this.released.set(session.sessionId, session)
1711
1877
  this.hub.broadcast({ type: 'session/released', sessionId: session.sessionId, session: snapshot(session), reason, cause })
1878
+ parked += 1
1712
1879
  }
1713
1880
  this.sessions.clear()
1714
1881
  this.acpIndex.clear()
1715
- this.conn?.close()
1716
- this.conn = undefined
1717
- this.killChild()
1718
- this.state = 'stopped'
1719
- this.initResult = undefined
1720
- this.publishStatus()
1721
1882
  // The parked records are what keeps them resumable after a host restart —
1722
1883
  // persist now, while their transcripts (the `hasHistory` evidence) are
1723
1884
  // still the ones they were parked with.
1724
- this.onSessionsDirty?.()
1885
+ if (parked > 0) this.onSessionsDirty?.()
1886
+ return parked
1725
1887
  }
1726
1888
 
1727
1889
  /** Detach one released session record for a resume attempt. */
@@ -1863,6 +2025,13 @@ export class AgentBridge {
1863
2025
 
1864
2026
  /** Kill the child and drop every local session bound to this connection. */
1865
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
1866
2035
  this.clearIdleTimer()
1867
2036
  for (const session of [...this.sessions.values()]) {
1868
2037
  await this.closeSession(session, reason)
@@ -1889,6 +2058,8 @@ export class AgentBridge {
1889
2058
  * message. This is a stop, not a discard.
1890
2059
  */
1891
2060
  forceReset(reason: 'agent-stopped'): void {
2061
+ this.teardownEpoch += 1
2062
+ this.starting = undefined
1892
2063
  this.clearIdleTimer()
1893
2064
  for (const session of [...this.sessions.values()]) {
1894
2065
  session.closed = true
@@ -1908,6 +2079,13 @@ export class AgentBridge {
1908
2079
  this.publishStatus()
1909
2080
  }
1910
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
+ */
1911
2089
  private killChild(): void {
1912
2090
  const child = this.child
1913
2091
  this.child = undefined
@@ -1915,31 +2093,59 @@ export class AgentBridge {
1915
2093
  // `spawnAndConnect`). Leaving it behind would make `ensureReady` believe a
1916
2094
  // process is still running in that directory.
1917
2095
  this.spawnCwd = undefined
1918
- if (child === undefined || child.pid === undefined) return
1919
- try {
1920
- child.stdin?.end()
1921
- } catch { /* already gone */ }
1922
- const pid = child.pid
1923
- if (process.platform === 'win32') {
1924
- // Parity with the POSIX branch below, which kills the whole process
1925
- // GROUP: on Windows "the group" is the process tree, and it has to be
1926
- // killed while the tree is intact — killing the direct child first (a
1927
- // `cmd.exe` shim wrapper, see `windowsShimLaunch`) re-parents the agent
1928
- // out of `taskkill /T`'s reach. `stdin.end()` above is the graceful half.
1929
- killProcessTree(pid)
1930
- return
1931
- }
1932
- try {
1933
- process.kill(-pid, 'SIGTERM')
1934
- } catch {
1935
- child.kill('SIGTERM')
1936
- }
1937
- setTimeout(() => {
1938
- if (child.exitCode !== null || child.signalCode !== null) return
1939
- try {
1940
- process.kill(-pid, 'SIGKILL')
1941
- } catch { /* already gone */ }
1942
- }, 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()))
1943
2149
  }
1944
2150
 
1945
2151
  /**
@@ -2343,6 +2549,15 @@ interface CodebuddyMeta {
2343
2549
  isBackground?: boolean
2344
2550
  /** `codebuddy.ai/isSubagent` — the same re-send declares the worker a sub-agent. */
2345
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
2346
2561
  }
2347
2562
 
2348
2563
  /** Read the CodeBuddy `_meta` keys off an update or a `{ _meta }` wrapper. */
@@ -2367,6 +2582,14 @@ function codebuddyMetaOf(source: unknown): CodebuddyMeta | undefined {
2367
2582
  // and guessing from it would make another agent's unrelated key look detached.
2368
2583
  if (meta['codebuddy.ai/isBackground'] === true) out.isBackground = true
2369
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
2370
2593
  const unsolicited = meta['codebuddy.ai/unsolicitedTurn']
2371
2594
  if (typeof unsolicited === 'object' && unsolicited !== null) {
2372
2595
  const run = unsolicited as Record<string, unknown>
@@ -2540,6 +2763,71 @@ export function permissionAskReason(toolCall: unknown): string {
2540
2763
  return line.length > APPROVAL_REASON_MAX ? `${line.slice(0, APPROVAL_REASON_MAX - 1)}…` : line
2541
2764
  }
2542
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
+
2543
2831
  function snapshot(session: LiveSession): SessionView {
2544
2832
  return {
2545
2833
  sessionId: session.sessionId,
@@ -3074,11 +3362,135 @@ export function windowsShimLaunch(
3074
3362
  * Best effort — `taskkill` is absent from no Windows this runs on, and a
3075
3363
  * failure here only means the OS reclaims the process later.
3076
3364
  */
3077
- export function killProcessTree(pid: number): void {
3365
+ export function killProcessTree(pid: number): ChildProcess | undefined {
3078
3366
  try {
3079
3367
  const killer = spawn('taskkill', ['/pid', String(pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true })
3080
3368
  killer.on('error', () => { /* nothing else to try; the OS reaps it eventually */ })
3081
- } 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
+ })
3082
3494
  }
3083
3495
 
3084
3496
  /**
@@ -3518,6 +3930,12 @@ interface BackgroundDispatch {
3518
3930
  detached: boolean
3519
3931
  /** Whether any update came FROM the worker (`codebuddy.ai/parentToolCallId`). */
3520
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
3521
3939
  }
3522
3940
 
3523
3941
  /**
@@ -3525,10 +3943,12 @@ interface BackgroundDispatch {
3525
3943
  *
3526
3944
  * Two phases, because their liveness signals differ:
3527
3945
  * - `worker`: at least one worker runs off-turn and may be silent for tens of
3528
- * seconds between model calls — NO quiet timer, only
3529
- * {@link BridgeTuning.backgroundTaskMaxMs}. It ends when EVERY known dispatch
3530
- * reported (completed / failed); a drain announcement is recorded meanwhile
3531
- * 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.
3532
3952
  * - `drain`: every dispatch reported and the background turn that carries
3533
3953
  * their report is (or is about to start) — the existing ambient windows
3534
3954
  * apply (`ambientQuietMs` quiet, reset by every update; `ambientMaxMs`
@@ -3563,7 +3983,8 @@ interface BackgroundHold {
3563
3983
  floorSeq: number
3564
3984
  quietTimer?: ReturnType<typeof setTimeout>
3565
3985
  hardTimer?: ReturnType<typeof setTimeout>
3566
- /**
3986
+ /** Absolute whole-hold ceiling; absent when the tuning disables it (0). */
3987
+ ceilingTimer?: ReturnType<typeof setTimeout> /**
3567
3988
  * The bounded wait for a driver step to speak into. Only while the hold is
3568
3989
  * PARKED (`pendingBackgroundHold`): the response landed between steps, and
3569
3990
  * the host is expected to open the continuation step for the Agent card.
@@ -3680,11 +4101,13 @@ class TurnStreamController {
3680
4101
  private pendingBackgroundHold?: BackgroundHold
3681
4102
  /**
3682
4103
  * The hold's own windows, read once at construction like the ambient pair:
3683
- * `taskMaxMs` is the worker phase's ceiling, `graceMs` the parked hold's
3684
- * bounded wait for a driver step (see {@link BridgeTuning.backgroundTaskMaxMs}
3685
- * 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}).
3686
4109
  */
3687
- private readonly backgroundTuning: { taskMaxMs: number; graceMs: number }
4110
+ private readonly backgroundTuning: { taskMaxMs: number; ceilingMs: number; graceMs: number }
3688
4111
  /**
3689
4112
  * When this controller was built. Only a drain announced AFTER this moment
3690
4113
  * may be adopted by the hold: an `ambient` record that predates the turn is
@@ -3934,11 +4357,13 @@ class TurnStreamController {
3934
4357
  * - marks the segment background, so EVERY tool call for the rest of the
3935
4358
  * hold — the workers', the drain's TaskOutput — stays transcript-only and
3936
4359
  * the final message carries text with no card in front of it;
3937
- * - waits in two phases: the worker phase only has the
3938
- * {@link BridgeTuning.backgroundTaskMaxMs} ceiling (a detached worker goes
3939
- * silent for tens of seconds while it works) and ends only when ALL known
3940
- * dispatches reported, after which the drain phase uses the ambient
3941
- * 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.
3942
4367
  */
3943
4368
  holdForBackgroundDispatch(stopReason: string, requestId?: string): void {
3944
4369
  if (this.settled || this.disposed || this.closedTurn || this.stale) return
@@ -3966,6 +4391,7 @@ class TurnStreamController {
3966
4391
  this.endTurn()
3967
4392
  hold.hardTimer = setTimeout(() => { this.onBackgroundHard(hold) }, this.backgroundTuning.taskMaxMs)
3968
4393
  hold.hardTimer.unref?.()
4394
+ this.armBackgroundCeiling(hold)
3969
4395
  const seg = this.segment
3970
4396
  if (seg !== undefined && !seg.finished) {
3971
4397
  this.adoptBackgroundHold(seg, hold)
@@ -4057,10 +4483,31 @@ class TurnStreamController {
4057
4483
  hold.quietTimer.unref?.()
4058
4484
  }
4059
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
+
4060
4506
  /**
4061
4507
  * Any session update restarts the drain phase's quiet clock. Only an
4062
- * ADOPTED hold has one: a parked hold's wait belongs to its grace timer, and
4063
- * 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`).
4064
4511
  */
4065
4512
  private noteBackgroundHoldActivity(): void {
4066
4513
  const hold = this.backgroundHold
@@ -4074,7 +4521,7 @@ class TurnStreamController {
4074
4521
  this.closeBackgroundHold(hold, { hard: false })
4075
4522
  }
4076
4523
 
4077
- /** 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. */
4078
4525
  private onBackgroundHard(hold: BackgroundHold): void {
4079
4526
  if (hold !== this.backgroundHold && hold !== this.pendingBackgroundHold) return
4080
4527
  this.closeBackgroundHold(hold, { hard: true })
@@ -4142,9 +4589,11 @@ class TurnStreamController {
4142
4589
  private clearBackgroundHoldTimers(hold: BackgroundHold): void {
4143
4590
  if (hold.quietTimer !== undefined) clearTimeout(hold.quietTimer)
4144
4591
  if (hold.hardTimer !== undefined) clearTimeout(hold.hardTimer)
4592
+ if (hold.ceilingTimer !== undefined) clearTimeout(hold.ceilingTimer)
4145
4593
  if (hold.graceTimer !== undefined) clearTimeout(hold.graceTimer)
4146
4594
  hold.quietTimer = undefined
4147
4595
  hold.hardTimer = undefined
4596
+ hold.ceilingTimer = undefined
4148
4597
  hold.graceTimer = undefined
4149
4598
  }
4150
4599
 
@@ -4281,8 +4730,13 @@ class TurnStreamController {
4281
4730
  const known = this.dispatches.get(toolCallId)
4282
4731
  if (known !== undefined) {
4283
4732
  // A known dispatch: the id is the whole evidence (see the doc above).
4733
+ this.noteBackgroundWorkerActivity()
4284
4734
  if (status !== '') known.status = status
4285
- 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
+ }
4286
4740
  if (completedStatus && !known.completed) {
4287
4741
  known.completed = true
4288
4742
  this.noteDispatchProgress()
@@ -4298,8 +4752,13 @@ class TurnStreamController {
4298
4752
  completed: completedStatus,
4299
4753
  detached: meta.isBackground === true,
4300
4754
  sawWorkerUpdate: false,
4755
+ catalogLabel: subagentDispatchLabel(update),
4756
+ catalogLabelIdentified: subagentDispatchDeclaresIdentity(update),
4757
+ catalogReported: false,
4301
4758
  }
4302
4759
  this.dispatches.set(toolCallId, dispatch)
4760
+ this.noteBackgroundWorkerActivity()
4761
+ if (dispatch.detached) this.reportSubagentDispatch(dispatch)
4303
4762
  if (!dispatch.completed) {
4304
4763
  const held = this.activeBackgroundHold()
4305
4764
  // A dispatch registered after the drain phase began means new work:
@@ -4311,22 +4770,64 @@ class TurnStreamController {
4311
4770
  if (meta?.parentToolCallId !== undefined) {
4312
4771
  const parent = this.dispatches.get(meta.parentToolCallId)
4313
4772
  if (parent !== undefined) {
4773
+ this.noteBackgroundWorkerActivity()
4314
4774
  parent.detached = true
4315
4775
  parent.sawWorkerUpdate = true
4776
+ this.reportSubagentDispatch(parent)
4316
4777
  }
4317
4778
  }
4318
4779
  if (meta?.unsolicitedTurn !== undefined) {
4319
4780
  const held = this.activeBackgroundHold()
4320
- 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
4321
4802
  }
4322
4803
  }
4323
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
+
4324
4822
  /**
4325
4823
  * A dispatch just reported: once the LAST one has, the drain phase starts.
4326
4824
  * Until then the turn stays in its worker phase, with no quiet clock.
4327
4825
  */
4328
4826
  private noteDispatchProgress(): void {
4329
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)
4330
4831
  const held = this.activeBackgroundHold()
4331
4832
  if (held !== undefined) this.startBackgroundDrain(held)
4332
4833
  }
@@ -4809,7 +5310,8 @@ class TurnStreamController {
4809
5310
  // background drain is still working, so the ambient quiet clock restarts.
4810
5311
  this.noteAmbientActivity()
4811
5312
  // A detached-worker hold's drain phase has the same rule; its worker
4812
- // phase never does (see `BridgeTuning.backgroundTaskMaxMs`).
5313
+ // phase is pushed out only by dispatch / worker evidence (see
5314
+ // `BridgeTuning.backgroundTaskMaxMs`).
4813
5315
  this.noteBackgroundHoldActivity()
4814
5316
  const update = typed.update as Record<string, unknown>
4815
5317
  // The dispatch / worker / announcement bookkeeping runs BEFORE the cursor
@@ -5375,14 +5877,17 @@ export class BridgeManager {
5375
5877
  /**
5376
5878
  * The detached-worker hold's windows, read once at controller construction
5377
5879
  * for the same reason as {@link ambientTuning}: `taskMaxMs` is the worker
5378
- * phase's only clock (see {@link BridgeTuning.backgroundTaskMaxMs}) and
5379
- * `graceMs` the bounded wait a PARKED hold gives the host to open its
5380
- * continuation step (see {@link BridgeTuning.backgroundParkedGraceMs}). The
5381
- * drain phase reuses {@link ambientTuning}.
5382
- */
5383
- 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 } {
5384
5888
  return {
5385
5889
  taskMaxMs: this.tuning.backgroundTaskMaxMs,
5890
+ ceilingMs: this.tuning.backgroundTaskCeilingMs,
5386
5891
  graceMs: this.tuning.backgroundParkedGraceMs,
5387
5892
  }
5388
5893
  }
@@ -5602,6 +6107,33 @@ export class BridgeManager {
5602
6107
  this.acpModeResolver = resolver
5603
6108
  }
5604
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
+
5605
6137
  /**
5606
6138
  * Whether ONE DSH chat session runs the ACP agent preset, asked by chat
5607
6139
  * session id. The browser's composer surfaces consume this: on hosts whose
@@ -6970,6 +7502,10 @@ export class BridgeManager {
6970
7502
  private autoExposeNewModels(agentId: string, next: AcpAgentCatalog, before: AcpAgentCatalog | undefined): boolean {
6971
7503
  const def = this.defsMap.get(agentId)
6972
7504
  if (def === undefined || def.autoDiscoverModels === false || before === undefined) return false
7505
+ // A different model-slot identity is a corrected reading, not newly added
7506
+ // models. Diffing across it would expose an entire real catalog the first
7507
+ // time a parser fix replaces a mislabelled slot (Qoder CN).
7508
+ if (before.model !== undefined && next.model !== undefined && before.model.configId !== next.model.configId) return false
6973
7509
  const known = new Set((before.model?.choices ?? []).map(choice => choice.value))
6974
7510
  const exposed = new Set(def.exposedModels ?? [])
6975
7511
  const added = (next.model?.choices ?? [])
@@ -7360,6 +7896,11 @@ export class BridgeManager {
7360
7896
  private staleModelChoice(session: LiveSession, modelChoice: string | undefined): string | undefined {
7361
7897
  const wanted = modelChoice !== undefined && modelChoice !== '' ? modelChoice : undefined
7362
7898
  if (wanted === undefined) return undefined
7899
+ // `category` is a UX hint, not identity: an agent may publish several
7900
+ // `category: 'model'` options (Qoder CN ships `reasoning_effort` that way)
7901
+ // and no explicit `model` id. In that case the selector is a guess, so a
7902
+ // "not offered" verdict would refuse a perfectly valid pick — fail open.
7903
+ if (!hasUnambiguousModelSelector(session.configOptions)) return undefined
7363
7904
  const model = parseCatalogOptions(session.configOptions).model
7364
7905
  if (model === undefined) return undefined
7365
7906
  if (model.currentValue === wanted) return undefined
@@ -7790,14 +8331,31 @@ export class BridgeManager {
7790
8331
  await this.bridges.get(agentId)?.stop('agent-stopped')
7791
8332
  }
7792
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
+ */
7793
8343
  async shutdownAll(): Promise<void> {
7794
8344
  if (this.stallTimer !== undefined) {
7795
8345
  clearInterval(this.stallTimer)
7796
8346
  this.stallTimer = undefined
7797
8347
  }
7798
- this.dshSessionMap.clear()
7799
8348
  this.disposeTurnStreamsOf(() => true)
7800
- 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)))
7801
8359
  }
7802
8360
 
7803
8361
  /** Host-side reader of the harness preset roster (`ctx.agentPresets`), for `presetStatus`. */
@@ -8034,6 +8592,7 @@ export class BridgeManager {
8034
8592
  }
8035
8593
  private sessionWorkspaceResolver?: SessionWorkspaceResolver
8036
8594
  private acpModeResolver?: AcpModeResolver
8595
+ private subagentCatalogReporter?: SubagentCatalogReporter
8037
8596
  private attachmentResolver?: unknown
8038
8597
  /** Cached `chatSessionAcpMode` answers, keyed by DSH chat session id. */
8039
8598
  private readonly acpModeCache = new Map<string, boolean>()
@@ -8042,8 +8601,8 @@ export class BridgeManager {
8042
8601
 
8043
8602
  /**
8044
8603
  * The agent values the composer is showing for a conversation that has not
8045
- * picked them — the catalog's current model, thought level and extra config
8046
- * values.
8604
+ * picked them — the catalog's current model, thought level, mode and extra
8605
+ * config values.
8047
8606
  *
8048
8607
  * Captured BEFORE `session/new`: materializing the session absorbs its own
8049
8608
  * defaults into the catalog (`absorbCatalog`), which would overwrite exactly
@@ -8062,6 +8621,14 @@ export class BridgeManager {
8062
8621
  // `defaultEffort` only when the current value is one of the agent's levels.
8063
8622
  const effort = this.reasoningMeta(agentId)?.defaultEffort
8064
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
8065
8632
  const extraConfigs: Record<string, string | boolean> = {}
8066
8633
  for (const option of catalog.extras ?? []) {
8067
8634
  const value = option.currentValue
@@ -8082,14 +8649,15 @@ export class BridgeManager {
8082
8649
  * first prompt runs.
8083
8650
  *
8084
8651
  * A conversation with no explicit pick displays the agent catalog's current
8085
- * model / thought level / extras, while the ACP session `session/new` just
8086
- * created starts on whatever the agent itself defaults to. Without this the
8087
- * user is shown one model and the agent runs another — the defect reported
8088
- * as "the displayed model was not used". Values the fresh session does not
8089
- * offer are skipped (its `configOptions` are the authority, and the catalog
8090
- * update that rode `session/new` will correct the display), never turned
8091
- * into a failed first turn. Explicit picks always win: only missing fields
8092
- * 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.
8093
8661
  */
8094
8662
  private seedChatChoiceFromCatalog(
8095
8663
  dshSessionId: string,
@@ -8118,6 +8686,18 @@ export class BridgeManager {
8118
8686
  changed = true
8119
8687
  }
8120
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
+ }
8121
8701
  if (displayed.extraConfigs !== undefined) {
8122
8702
  const extraConfigs = { ...(current?.agentId === agentId ? current.extraConfigs : undefined) }
8123
8703
  for (const [configId, value] of Object.entries(displayed.extraConfigs)) {
@@ -8309,6 +8889,47 @@ export class BridgeManager {
8309
8889
  return { ok: true, reset: true }
8310
8890
  }
8311
8891
 
8892
+ /**
8893
+ * 聊天插话的槽位回收:宿主把插话作为同一会话在飞回合的下一步送达,
8894
+ * 拒绝会让整条 host turn 失败。ACP 不能向运行中的 prompt 注入消息,
8895
+ * 所以按插话语义中止当前回合,等 agent 真正结算后再让调用方重发。
8896
+ *
8897
+ * 只处理 `claimTurn` 的 `busy`(agent 仍持有本会话 prompt);`unsettled`
8898
+ * 说明我们这一侧已经释放过槽位(用户按过停止),保持原样拒绝。
8899
+ *
8900
+ * - 控制器已 settle(响应已到,延后收口还没释放闸门):什么都不取消,只等闸门;
8901
+ * - agent 真在跑:走用户取消同一条管线(session/cancel + 结算权限 + cancel fallback),
8902
+ * 并立即关闭宿主这一侧的 segment,让闸门在 agent 应答/兜底后打开。
8903
+ */
8904
+ private async reclaimChatPrompt(
8905
+ session: LiveSession,
8906
+ controller: TurnStreamController,
8907
+ signal?: AbortSignal,
8908
+ ): Promise<'ok' | 'aborted'> {
8909
+ if (session.closed || session.released) return 'ok'
8910
+ if (!controller.hasSettled && session.promptInFlight === true) {
8911
+ await this.bridge(session.agentId).cancel(session)
8912
+ controller.abortActiveSegment()
8913
+ }
8914
+ return this.waitForSettleGate(session, signal)
8915
+ }
8916
+
8917
+ /** 等 agent 侧 prompt slot 真正释放;步骤被取消时立即返回 aborted。 */
8918
+ private waitForSettleGate(session: LiveSession, signal?: AbortSignal): Promise<'ok' | 'aborted'> {
8919
+ const pending = session.settled
8920
+ if (pending === undefined) return Promise.resolve('ok')
8921
+ if (signal?.aborted) return Promise.resolve('aborted')
8922
+ return new Promise(resolve => {
8923
+ const done = (value: 'ok' | 'aborted'): void => {
8924
+ signal?.removeEventListener('abort', onAbort)
8925
+ resolve(value)
8926
+ }
8927
+ const onAbort = (): void => { done('aborted') }
8928
+ signal?.addEventListener('abort', onAbort, { once: true })
8929
+ void pending.then(() => { done('ok') })
8930
+ })
8931
+ }
8932
+
8312
8933
  async *streamPrompt(options: GenerateOptions): AsyncIterable<StreamChunk> {
8313
8934
  // The plugin's own conversation↔agent mapping decides who serves the turn
8314
8935
  // (see `bindChatAgent`): the driver may arrive here with the previous
@@ -8514,26 +9135,81 @@ export class BridgeManager {
8514
9135
 
8515
9136
  // One prompt slot per session, and a turn the user aborted still occupies
8516
9137
  // the AGENT's slot until it acknowledges the cancel (see `claimTurn`).
8517
- const claim = await bridge.claimTurn(session)
9138
+ let claim = await bridge.claimTurn(session)
8518
9139
  if (!claim.ok) {
8519
- // Never roll the provisional lock back here: a failed claim means ANOTHER
8520
- // prompt holds the slot (or the agent has not settled it), and that turn
8521
- // owns the conversation's lock. Closing the session would kill a turn
8522
- // that is actively streaming — the refusal already tells the user to
8523
- // retry.
8524
- yield {
8525
- type: 'finish',
8526
- reason: {
8527
- kind: 'error',
8528
- failure: {
8529
- message: claim.reason === 'busy'
8530
- ? 'acp: this session already has a prompt in flight'
8531
- : 'acp: the agent is still finishing the previous (cancelled) turn; send again in a moment',
8532
- code: 'turn_in_flight',
9140
+ // A STEER ("插话"): the host delivers the user's mid-turn message as the
9141
+ // NEXT step of the SAME host turn, so the conversation's own ACP prompt
9142
+ // is still on air. Refusing would fail the whole host turn. ACP forbids
9143
+ // injecting into a running prompt, so the steer's real semantic — abort
9144
+ // the current turn, then send this one — is spelled cancel + wait +
9145
+ // re-claim here.
9146
+ //
9147
+ // Only `busy` can be a steer: it means the AGENT still holds OUR prompt
9148
+ // (`promptInFlight`). `unsettled` means our side already released the
9149
+ // slot (the person pressed stop, or an earlier steer cancelled): the
9150
+ // agent has not acknowledged yet, and quietly queueing the next message
9151
+ // behind a turn the user stopped is not this path's business — that
9152
+ // stays the honest `turn_in_flight` refusal below.
9153
+ //
9154
+ // Only the conversation's OWN in-flight turn qualifies (its live stream
9155
+ // controller, on this very session). A `/acp` RPC turn has no chat
9156
+ // controller, and another conversation never reaches this session, so
9157
+ // both keep the plain refusal: nothing may be cancelled behind a
9158
+ // caller's back.
9159
+ const chatController = claim.reason === 'busy' && options.sessionId !== undefined
9160
+ ? this.turnStreamFor(options.sessionId)
9161
+ : undefined
9162
+ if (options.sessionId !== undefined
9163
+ && chatController !== undefined
9164
+ && !chatController.stale
9165
+ && chatController.session === session) {
9166
+ const reclaimed = await this.reclaimChatPrompt(session, chatController, options.signal)
9167
+ if (reclaimed === 'aborted') {
9168
+ // The person pressed stop while the previous turn was winding down:
9169
+ // the steer is theirs to re-send, nothing goes to the agent.
9170
+ yield {
9171
+ type: 'finish',
9172
+ reason: { kind: 'aborted', failure: { message: 'aborted', code: 'ABORTED' } },
9173
+ }
9174
+ return
9175
+ }
9176
+ // A first turn that produced nothing rolled its provisional lock back
9177
+ // and CLOSED (and forgot) this session: the steer needs a fresh one.
9178
+ if (session.closed || session.released) {
9179
+ try {
9180
+ session = await this.getOrCreateDshSession(options.sessionId, agentId)
9181
+ } catch (err) {
9182
+ yield {
9183
+ type: 'finish',
9184
+ reason: { kind: 'error', failure: { message: describeError(err), code: 'session_init_failed' } },
9185
+ }
9186
+ return
9187
+ }
9188
+ }
9189
+ // Same bridge: the session may have been re-created, but the
9190
+ // conversation is still served by this agent's process.
9191
+ claim = await bridge.claimTurn(session)
9192
+ }
9193
+ if (!claim.ok) {
9194
+ // Never roll the provisional lock back here: a failed claim means
9195
+ // ANOTHER prompt holds the slot (or the agent has not settled it), and
9196
+ // that turn owns the conversation's lock. Closing the session would
9197
+ // kill a turn that is actively streaming — the refusal already tells
9198
+ // the user to retry.
9199
+ yield {
9200
+ type: 'finish',
9201
+ reason: {
9202
+ kind: 'error',
9203
+ failure: {
9204
+ message: claim.reason === 'busy'
9205
+ ? 'acp: this session already has a prompt in flight'
9206
+ : 'acp: the agent is still finishing the previous (cancelled) turn; send again in a moment',
9207
+ code: 'turn_in_flight',
9208
+ },
8533
9209
  },
8534
- },
9210
+ }
9211
+ return
8535
9212
  }
8536
- return
8537
9213
  }
8538
9214
 
8539
9215
  // Same-agent model/effort switches ride the SAME ACP session (context