dsh-ntr 0.3.3 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/bridge.js +741 -94
- package/lib/client.js.map +1 -1
- package/lib/impl.js +138 -16
- package/lib/index.js +25 -1
- package/lib/types/bridge.d.ts +220 -37
- package/lib/types/impl.d.ts +25 -0
- package/lib/types/protocol.d.ts +3 -2
- package/package.json +1 -1
- package/src/bridge.ts +783 -107
- package/src/impl.ts +147 -17
- 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'
|
|
@@ -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
|
-
*
|
|
461
|
-
* `TurnStreamController.holdForBackgroundDispatch`):
|
|
462
|
-
* or more background workers through `Agent` tool
|
|
463
|
-
* the prompt, and at least one worker has not
|
|
464
|
-
*
|
|
465
|
-
* a detached worker's own updates are
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
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
|
-
|
|
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(() => {
|
|
1087
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
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):
|
|
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
|
-
|
|
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 —
|
|
3529
|
-
* {@link BridgeTuning.backgroundTaskMaxMs}
|
|
3530
|
-
*
|
|
3531
|
-
*
|
|
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
|
|
3684
|
-
*
|
|
3685
|
-
*
|
|
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
|
|
3938
|
-
* {@link BridgeTuning.backgroundTaskMaxMs}
|
|
3939
|
-
*
|
|
3940
|
-
*
|
|
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
|
|
4063
|
-
* 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`).
|
|
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
|
|
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 (
|
|
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)
|
|
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
|
|
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
|
|
5379
|
-
* `
|
|
5380
|
-
*
|
|
5381
|
-
*
|
|
5382
|
-
|
|
5383
|
-
|
|
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
|
-
|
|
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
|
|
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`
|
|
8086
|
-
* created starts on whatever the agent itself defaults to. Without this
|
|
8087
|
-
* user is shown one model and the agent runs another — the defect
|
|
8088
|
-
* as "the displayed model was not used"
|
|
8089
|
-
*
|
|
8090
|
-
*
|
|
8091
|
-
*
|
|
8092
|
-
*
|
|
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
|
-
|
|
9138
|
+
let claim = await bridge.claimTurn(session)
|
|
8518
9139
|
if (!claim.ok) {
|
|
8519
|
-
//
|
|
8520
|
-
//
|
|
8521
|
-
//
|
|
8522
|
-
//
|
|
8523
|
-
//
|
|
8524
|
-
|
|
8525
|
-
|
|
8526
|
-
|
|
8527
|
-
|
|
8528
|
-
|
|
8529
|
-
|
|
8530
|
-
|
|
8531
|
-
|
|
8532
|
-
|
|
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
|