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/lib/bridge.js +617 -75
- package/lib/client.js.map +1 -1
- package/lib/impl.js +32 -0
- package/lib/index.js +25 -1
- package/lib/types/bridge.d.ts +205 -37
- package/lib/types/impl.d.ts +17 -0
- package/lib/types/protocol.d.ts +3 -2
- package/package.json +1 -1
- package/src/bridge.ts +660 -90
- package/src/impl.ts +45 -0
- package/src/index.ts +26 -3
- package/src/protocol.ts +3 -2
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
|
-
*
|
|
462
|
-
* `TurnStreamController.holdForBackgroundDispatch`):
|
|
463
|
-
* or more background workers through `Agent` tool
|
|
464
|
-
* the prompt, and at least one worker has not
|
|
465
|
-
*
|
|
466
|
-
* a detached worker's own updates are
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
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
|
-
|
|
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(() => {
|
|
1088
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
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):
|
|
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
|
-
|
|
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 —
|
|
3530
|
-
* {@link BridgeTuning.backgroundTaskMaxMs}
|
|
3531
|
-
*
|
|
3532
|
-
*
|
|
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
|
|
3685
|
-
*
|
|
3686
|
-
*
|
|
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
|
|
3939
|
-
* {@link BridgeTuning.backgroundTaskMaxMs}
|
|
3940
|
-
*
|
|
3941
|
-
*
|
|
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
|
|
4064
|
-
* the worker phase
|
|
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
|
|
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 (
|
|
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)
|
|
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
|
|
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
|
|
5380
|
-
* `
|
|
5381
|
-
*
|
|
5382
|
-
*
|
|
5383
|
-
|
|
5384
|
-
|
|
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
|
-
|
|
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
|
|
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`
|
|
8096
|
-
* created starts on whatever the agent itself defaults to. Without this
|
|
8097
|
-
* user is shown one model and the agent runs another — the defect
|
|
8098
|
-
* as "the displayed model was not used"
|
|
8099
|
-
*
|
|
8100
|
-
*
|
|
8101
|
-
*
|
|
8102
|
-
*
|
|
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)) {
|