dsh-ntr 0.3.1 → 0.3.2
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/README.en.md +1 -1
- package/README.md +1 -1
- package/cordis.patch.yml +17 -1
- package/lib/bridge.js +607 -18
- package/lib/client/acp-ui.js +103 -3
- package/lib/client/acp-view.js +24 -4
- package/lib/client/branch-action.js +133 -0
- package/lib/client/composer-mode.js +48 -13
- package/lib/client/context-meter.js +86 -0
- package/lib/client/index.js +238 -15
- package/lib/client/panel.js +96 -29
- package/lib/client/released-banner.js +66 -0
- package/lib/client/session-controls.js +38 -13
- package/lib/client/slash-isolation.js +2 -2
- package/lib/client/store.js +348 -13
- package/lib/client.js +1138 -67
- package/lib/client.js.map +1 -1
- package/lib/impl.js +54 -1
- package/lib/index.js +7 -0
- package/lib/protocol.js +19 -0
- package/lib/types/bridge.d.ts +229 -6
- package/lib/types/client/acp-ui.d.ts +34 -2
- package/lib/types/client/acp-view.d.ts +13 -2
- package/lib/types/client/branch-action.d.ts +40 -0
- package/lib/types/client/composer-mode.d.ts +28 -0
- package/lib/types/client/context-meter.d.ts +51 -0
- package/lib/types/client/released-banner.d.ts +34 -0
- package/lib/types/client/slash-isolation.d.ts +1 -1
- package/lib/types/client/store.d.ts +98 -1
- package/lib/types/impl.d.ts +41 -1
- package/lib/types/protocol.d.ts +123 -0
- package/package.json +2 -1
- package/src/bridge.ts +638 -20
- package/src/client/acp-ui.tsx +123 -2
- package/src/client/acp-view.tsx +30 -5
- package/src/client/branch-action.tsx +160 -0
- package/src/client/composer-mode.tsx +59 -13
- package/src/client/context-meter.tsx +128 -0
- package/src/client/index.tsx +265 -14
- package/src/client/panel.tsx +157 -40
- package/src/client/primitives.d.ts +27 -0
- package/src/client/released-banner.tsx +97 -0
- package/src/client/session-controls.tsx +34 -11
- package/src/client/slash-isolation.ts +2 -1
- package/src/client/store.ts +375 -14
- package/src/impl.ts +84 -2
- package/src/index.ts +8 -0
- package/src/protocol.ts +121 -0
package/src/bridge.ts
CHANGED
|
@@ -69,11 +69,11 @@ import type {
|
|
|
69
69
|
RosterPresetRow,
|
|
70
70
|
} from './impl.ts'
|
|
71
71
|
import type {
|
|
72
|
-
AcpAgentCatalog, AcpAgentDef, AcpApprovalDeadlines, AcpConversationChoice, AcpEvent, AcpMcpServerDef, AcpPresetStatus, AcpReleaseCause, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentTestResult, AgentView, ChatSessionView, ChatTranscriptView, DetectedAgentInfo, GenerateOptions, LlmAdapterLike,
|
|
72
|
+
AcpAgentCatalog, AcpAgentDef, AcpApprovalDeadlines, AcpConversationChoice, AcpEvent, AcpMcpServerDef, AcpPresetStatus, AcpReleaseCause, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentTestResult, AgentView, ChatSessionForkInfo, ChatSessionView, ChatTranscriptView, DetectedAgentInfo, GenerateOptions, LlmAdapterLike,
|
|
73
73
|
LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo, LlmReasoningEffortInfo, LlmResolvedModelInfo,
|
|
74
|
-
ApprovalDeadlineKey, ApprovalDeadlinePatch, PermissionOutcome, PermissionOptionView, PermissionTimeoutMode, PluginUpdateState, PreparedAdapterCall, SessionView, StreamChunk,
|
|
74
|
+
AcpIdleRelease, ApprovalDeadlineKey, ApprovalDeadlinePatch, PermissionOutcome, PermissionOptionView, PermissionTimeoutMode, PluginUpdateState, PreparedAdapterCall, SessionView, StreamChunk,
|
|
75
75
|
} from './protocol.ts'
|
|
76
|
-
import { DEFAULT_TAG_HALO } from './protocol.ts'
|
|
76
|
+
import { DEFAULT_IDLE_TIMEOUT_MS, DEFAULT_TAG_HALO, IDLE_RELEASE_MS } from './protocol.ts'
|
|
77
77
|
import { UpdateService } from './update.ts'
|
|
78
78
|
|
|
79
79
|
/**
|
|
@@ -138,6 +138,15 @@ interface LiveSession {
|
|
|
138
138
|
closed: boolean
|
|
139
139
|
/** The agent process died; the session awaits a `session/load` resume. */
|
|
140
140
|
released: boolean
|
|
141
|
+
/**
|
|
142
|
+
* The agent-side session has history while the LOCAL buffer is empty: this
|
|
143
|
+
* record was restored from disk after a host restart (the replay has not
|
|
144
|
+
* landed yet), or a resume cleared the buffer ahead of the replay. The ACP
|
|
145
|
+
* view then says history replays on restore instead of "nothing yet". Set
|
|
146
|
+
* by `doResumeSession` (captured before the reset) and the restore path; the
|
|
147
|
+
* serializer ORs it with the live buffer length.
|
|
148
|
+
*/
|
|
149
|
+
hasHistory?: boolean
|
|
141
150
|
/**
|
|
142
151
|
* When the agent last showed ANY sign of life on this session: an update, a
|
|
143
152
|
* permission ask (arrival or answer), or a turn start. The stall watchdog
|
|
@@ -415,7 +424,7 @@ const DEFAULT_TUNING: Required<BridgeTuning> = {
|
|
|
415
424
|
turnStallMs: 600_000,
|
|
416
425
|
turnStallToolMs: 1_800_000,
|
|
417
426
|
turnStallTickMs: 30_000,
|
|
418
|
-
idleTimeoutMs:
|
|
427
|
+
idleTimeoutMs: DEFAULT_IDLE_TIMEOUT_MS,
|
|
419
428
|
probeIdleTimeoutMs: 300_000,
|
|
420
429
|
}
|
|
421
430
|
|
|
@@ -453,7 +462,7 @@ export class AgentBridge {
|
|
|
453
462
|
private readonly staleCancelRetryDelayMs: number
|
|
454
463
|
private readonly staleCancelMaxRetries: number
|
|
455
464
|
private readonly turnSettleMs: number
|
|
456
|
-
private
|
|
465
|
+
private defaultIdleTimeoutMs: number
|
|
457
466
|
private readonly probeIdleTimeoutMs: number
|
|
458
467
|
/** Armed while READY; fires the idle-release check. */
|
|
459
468
|
private idleTimer?: ReturnType<typeof setTimeout>
|
|
@@ -474,6 +483,13 @@ export class AgentBridge {
|
|
|
474
483
|
* that a caller is waiting on the child it is about to kill.
|
|
475
484
|
*/
|
|
476
485
|
private pendingCreates = 0
|
|
486
|
+
/**
|
|
487
|
+
* `session/fork` requests in flight (the adopt path of a branched
|
|
488
|
+
* conversation). Same teardown hazard as {@link pendingCreates}: until the
|
|
489
|
+
* forked session is registered, nothing else would stop an idle release
|
|
490
|
+
* from killing the child out from under the caller.
|
|
491
|
+
*/
|
|
492
|
+
private pendingForks = 0
|
|
477
493
|
/**
|
|
478
494
|
* Whether a REAL session has ever been registered on this bridge. Probes
|
|
479
495
|
* bypass session bookkeeping, so a bridge that has never hosted anything was
|
|
@@ -516,6 +532,20 @@ export class AgentBridge {
|
|
|
516
532
|
* this bridge was built.
|
|
517
533
|
*/
|
|
518
534
|
private readonly sessionWorkspace?: () => SessionWorkspaceResolver | undefined,
|
|
535
|
+
/**
|
|
536
|
+
* Told whenever the SESSION RECORDS change shape (a release parks them, a
|
|
537
|
+
* resume revives them): the manager persists `chatSessions` through it, so
|
|
538
|
+
* a parked conversation stays resumable across a host restart (see
|
|
539
|
+
* `BridgeManager.loadPersistedChatSessions`).
|
|
540
|
+
*/
|
|
541
|
+
private readonly onSessionsDirty?: () => void,
|
|
542
|
+
/**
|
|
543
|
+
* Told whenever a fresh `initialize` reports the agent's
|
|
544
|
+
* `sessionCapabilities.fork` bit — the LAST KNOWN fork capability the
|
|
545
|
+
* manager persists and serves to the browser (the bridge's own
|
|
546
|
+
* `initResult` dies with the process; the manager's copy survives it).
|
|
547
|
+
*/
|
|
548
|
+
private readonly onForkCapability?: (agentId: string, supported: boolean) => void,
|
|
519
549
|
) {
|
|
520
550
|
const merged = { ...DEFAULT_TUNING, ...tuning }
|
|
521
551
|
this.startupTimeoutMs = merged.startupTimeoutMs
|
|
@@ -550,6 +580,24 @@ export class AgentBridge {
|
|
|
550
580
|
this.permissionTimeoutMs = normalizeApprovalWindow(this.permissionTimeoutMode, this.permissionTimeoutMs)
|
|
551
581
|
}
|
|
552
582
|
|
|
583
|
+
/**
|
|
584
|
+
* Adopt a rewritten host-default idle window (the settings page's write).
|
|
585
|
+
*
|
|
586
|
+
* Only the DEFAULT moves: an agent that names its own `idleTimeoutMs` keeps
|
|
587
|
+
* it (that promise is about the agent, not about the deployment), and a
|
|
588
|
+
* probe-only child keeps the short probe window.
|
|
589
|
+
*
|
|
590
|
+
* The timer is re-armed so the new window governs from NOW: an already
|
|
591
|
+
* armed timer still holds the old window, so a shortened window would not
|
|
592
|
+
* take effect until the old one elapsed.
|
|
593
|
+
*/
|
|
594
|
+
applyIdleRelease(defaultIdleTimeoutMs: number): void {
|
|
595
|
+
if (this.defaultIdleTimeoutMs === defaultIdleTimeoutMs) return
|
|
596
|
+
this.defaultIdleTimeoutMs = defaultIdleTimeoutMs
|
|
597
|
+
this.clearIdleTimer()
|
|
598
|
+
this.touch()
|
|
599
|
+
}
|
|
600
|
+
|
|
553
601
|
/**
|
|
554
602
|
* The idle-release window this agent honors for its CHAT process: its own
|
|
555
603
|
* field, else the host default. This is the number the panel prefills and
|
|
@@ -744,6 +792,7 @@ export class AgentBridge {
|
|
|
744
792
|
private hasActiveWork(): boolean {
|
|
745
793
|
if (this.pendingProbes > 0
|
|
746
794
|
|| this.pendingCreates > 0
|
|
795
|
+
|| this.pendingForks > 0
|
|
747
796
|
|| this.pendingCommands.size > 0
|
|
748
797
|
|| this.resuming.size > 0
|
|
749
798
|
|| this.permissions.sessionIds().length > 0
|
|
@@ -768,6 +817,7 @@ export class AgentBridge {
|
|
|
768
817
|
hasOtherActiveWork(session: LiveSession): boolean {
|
|
769
818
|
if (this.pendingProbes > 0
|
|
770
819
|
|| this.pendingCreates > 0
|
|
820
|
+
|| this.pendingForks > 0
|
|
771
821
|
|| this.resuming.size > 0
|
|
772
822
|
|| this.starting !== undefined) return true
|
|
773
823
|
for (const sessionId of this.pendingCommands.keys()) {
|
|
@@ -944,6 +994,10 @@ export class AgentBridge {
|
|
|
944
994
|
'initialize',
|
|
945
995
|
)
|
|
946
996
|
this.initResult = init
|
|
997
|
+
// The fork capability is agent knowledge that must outlive the process:
|
|
998
|
+
// report it so the manager can persist a LAST KNOWN copy and serve the
|
|
999
|
+
// browser's branch button after a release (`initResult` itself dies here).
|
|
1000
|
+
this.onForkCapability?.(this.def.id, this.initResult?.agentCapabilities?.sessionCapabilities?.fork != null)
|
|
947
1001
|
}
|
|
948
1002
|
|
|
949
1003
|
/**
|
|
@@ -1094,6 +1148,103 @@ export class AgentBridge {
|
|
|
1094
1148
|
}
|
|
1095
1149
|
}
|
|
1096
1150
|
|
|
1151
|
+
/**
|
|
1152
|
+
* Fork one live session through the agent's own `session/fork` (the
|
|
1153
|
+
* UNSTABLE fourth session-creation point): the agent copies its internal
|
|
1154
|
+
* state into a NEW ACP session that remembers the parent's context — the
|
|
1155
|
+
* second half of a branched conversation, after the host's `sessions.fork`
|
|
1156
|
+
* has already built the DSH session tree. The child is addressed by the
|
|
1157
|
+
* CHILD DSH conversation id, so its first message reaches an agent that
|
|
1158
|
+
* actually remembers the parent.
|
|
1159
|
+
*
|
|
1160
|
+
* The parent must be live on this connection — a released parent has no
|
|
1161
|
+
* process to fork from (the browser blocks that click; this is the hard
|
|
1162
|
+
* stop behind it). The ACP request carries the SAME `mcpServers` list as
|
|
1163
|
+
* `session/new` / `session/load` (one list per process; an empty list would
|
|
1164
|
+
* evict the live sessions' MCP scope) and is bounded by the startup timeout
|
|
1165
|
+
* like every session-creation step. Counted in {@link pendingForks} while
|
|
1166
|
+
* in flight so an idle release cannot kill the child out from under the
|
|
1167
|
+
* caller.
|
|
1168
|
+
*/
|
|
1169
|
+
async forkSession(parent: LiveSession, dshSessionId: string): Promise<LiveSession> {
|
|
1170
|
+
if (this.initResult?.agentCapabilities?.sessionCapabilities?.fork == null) {
|
|
1171
|
+
throw new AcpError('fork_unsupported', `acp: agent "${this.def.id}" does not advertise the session/fork capability`)
|
|
1172
|
+
}
|
|
1173
|
+
const conn = this.conn
|
|
1174
|
+
if (conn === undefined) throw new AcpError('agent_unreachable', 'acp: connection is not open')
|
|
1175
|
+
// The child runs where the parent runs: its cwd is the parent's already
|
|
1176
|
+
// validated workspace, re-trusted as its own extra root (the same minimal
|
|
1177
|
+
// trust `resolveSessionCwd` applied when the parent was created — never
|
|
1178
|
+
// the agent's configured sandbox cwd, which is not where the conversation
|
|
1179
|
+
// lives).
|
|
1180
|
+
const resolved = resolveSessionCwd(this.def, parent.cwd, p => realpathSync(p), [parent.cwd])
|
|
1181
|
+
this.pendingForks += 1
|
|
1182
|
+
try {
|
|
1183
|
+
const result = await this.withStartupTimeout(
|
|
1184
|
+
conn.agent.request(methods.agent.session.fork, {
|
|
1185
|
+
sessionId: parent.acpSessionId,
|
|
1186
|
+
cwd: resolved,
|
|
1187
|
+
mcpServers: toAcpMcpServers(this.def.mcpServers),
|
|
1188
|
+
}),
|
|
1189
|
+
'session/fork',
|
|
1190
|
+
)
|
|
1191
|
+
// A fork answer that names the parent (or any session already routed on
|
|
1192
|
+
// this connection) would interleave the parent's updates, asks and
|
|
1193
|
+
// permission cards into the child — refuse it rather than register it.
|
|
1194
|
+
if (typeof result.sessionId !== 'string' || result.sessionId === '') {
|
|
1195
|
+
throw new AcpError('fork_failed', 'acp: session/fork returned no session id')
|
|
1196
|
+
}
|
|
1197
|
+
if (result.sessionId === parent.acpSessionId || this.acpIndex.has(result.sessionId)) {
|
|
1198
|
+
throw new AcpError('fork_id_conflict', 'acp: session/fork returned an id that already exists on this connection')
|
|
1199
|
+
}
|
|
1200
|
+
const session: LiveSession = {
|
|
1201
|
+
sessionId: randomUUID(),
|
|
1202
|
+
agentId: this.def.id,
|
|
1203
|
+
acpSessionId: result.sessionId,
|
|
1204
|
+
cwd: resolved,
|
|
1205
|
+
running: false,
|
|
1206
|
+
turnSeq: 0,
|
|
1207
|
+
closed: false,
|
|
1208
|
+
released: false,
|
|
1209
|
+
lastAgentActivityAt: Date.now(),
|
|
1210
|
+
activeToolCalls: new Set(),
|
|
1211
|
+
transcript: new Transcript(this.transcriptMax),
|
|
1212
|
+
dshSessionId,
|
|
1213
|
+
...(result.modes !== undefined && result.modes !== null
|
|
1214
|
+
? {
|
|
1215
|
+
modes: {
|
|
1216
|
+
currentModeId: result.modes.currentModeId,
|
|
1217
|
+
availableModes: result.modes.availableModes.map(m => ({
|
|
1218
|
+
id: m.id,
|
|
1219
|
+
name: m.name,
|
|
1220
|
+
...(m.description !== null ? { description: m.description } : {}),
|
|
1221
|
+
})),
|
|
1222
|
+
},
|
|
1223
|
+
}
|
|
1224
|
+
: {}),
|
|
1225
|
+
...(usableConfigOptions(result.configOptions) ? { configOptions: result.configOptions } : {}),
|
|
1226
|
+
}
|
|
1227
|
+
// The fork answer's config options are the AGENT-LEVEL surface too (the
|
|
1228
|
+
// same feed `session/new` provides): absorb them so the composer chips
|
|
1229
|
+
// of the fresh conversation render without a probe.
|
|
1230
|
+
if (this.onCatalog !== undefined && usableConfigOptions(result.configOptions)) {
|
|
1231
|
+
this.onCatalog(this.def.id, result.configOptions)
|
|
1232
|
+
}
|
|
1233
|
+
this.sessions.set(session.sessionId, session)
|
|
1234
|
+
this.acpIndex.set(session.acpSessionId, session)
|
|
1235
|
+
// Same flip as `createSession`: a registered session makes this a chat
|
|
1236
|
+
// process; re-arm the idle clock on the agent's own window.
|
|
1237
|
+
this.clearIdleTimer()
|
|
1238
|
+
this.everHostedSession = true
|
|
1239
|
+
this.hub.broadcast({ type: 'session/created', sessionId: session.sessionId, session: snapshot(session) })
|
|
1240
|
+
this.touch()
|
|
1241
|
+
return session
|
|
1242
|
+
} finally {
|
|
1243
|
+
this.pendingForks -= 1
|
|
1244
|
+
this.touch()
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1097
1248
|
/**
|
|
1098
1249
|
* Fire one prompt turn. Returns after the agent settles the turn; the caller
|
|
1099
1250
|
* (manager) keeps the UI informed through broadcast events, so the RPC that
|
|
@@ -1241,7 +1392,11 @@ export class AgentBridge {
|
|
|
1241
1392
|
if (conn === undefined) throw new AcpError('agent_unreachable', 'acp: connection is not open')
|
|
1242
1393
|
const resolved = resolveSessionCwd(this.def, session.cwd, p => realpathSync(p), workspaceRoot === undefined ? [] : [workspaceRoot])
|
|
1243
1394
|
// Replay arrives as brand-new updates, so the transcript restarts too;
|
|
1244
|
-
// seq keeps meaning "position in the current epoch".
|
|
1395
|
+
// seq keeps meaning "position in the current epoch". What the buffer held
|
|
1396
|
+
// is now the agent's to replay — remember that there IS history before
|
|
1397
|
+
// dropping it, or a failed resume parks an empty buffer back and the
|
|
1398
|
+
// view claims the conversation never happened.
|
|
1399
|
+
session.hasHistory = session.hasHistory === true || session.transcript.since(0).length > 0
|
|
1245
1400
|
session.transcript = new Transcript(this.transcriptMax)
|
|
1246
1401
|
session.cwd = resolved
|
|
1247
1402
|
session.running = false
|
|
@@ -1285,6 +1440,7 @@ export class AgentBridge {
|
|
|
1285
1440
|
this.touch()
|
|
1286
1441
|
// Republish so configOptions/modes observed on load reach the browser.
|
|
1287
1442
|
this.hub.broadcast({ type: 'session/meta', sessionId: session.sessionId, session: snapshot(session) })
|
|
1443
|
+
this.onSessionsDirty?.()
|
|
1288
1444
|
return session
|
|
1289
1445
|
} catch (error) {
|
|
1290
1446
|
this.acpIndex.delete(session.acpSessionId)
|
|
@@ -1292,6 +1448,7 @@ export class AgentBridge {
|
|
|
1292
1448
|
session.released = true
|
|
1293
1449
|
this.released.set(session.sessionId, session)
|
|
1294
1450
|
this.hub.broadcast({ type: 'session/released', sessionId: session.sessionId, session: snapshot(session), reason: 'resume failed', cause: 'resume-failed' })
|
|
1451
|
+
this.onSessionsDirty?.()
|
|
1295
1452
|
throw error
|
|
1296
1453
|
}
|
|
1297
1454
|
}
|
|
@@ -1327,6 +1484,10 @@ export class AgentBridge {
|
|
|
1327
1484
|
this.state = 'stopped'
|
|
1328
1485
|
this.initResult = undefined
|
|
1329
1486
|
this.publishStatus()
|
|
1487
|
+
// The parked records are what keeps them resumable after a host restart —
|
|
1488
|
+
// persist now, while their transcripts (the `hasHistory` evidence) are
|
|
1489
|
+
// still the ones they were parked with.
|
|
1490
|
+
this.onSessionsDirty?.()
|
|
1330
1491
|
}
|
|
1331
1492
|
|
|
1332
1493
|
/** Detach one released session record for a resume attempt. */
|
|
@@ -1342,6 +1503,15 @@ export class AgentBridge {
|
|
|
1342
1503
|
return this.released.delete(sessionId)
|
|
1343
1504
|
}
|
|
1344
1505
|
|
|
1506
|
+
/**
|
|
1507
|
+
* Register a session record restored from disk as already parked: a host
|
|
1508
|
+
* restart killed every process, so it comes back exactly as `releaseAll`
|
|
1509
|
+
* would have left it — resumable via `session/load`, never live.
|
|
1510
|
+
*/
|
|
1511
|
+
parkReleased(session: LiveSession): void {
|
|
1512
|
+
this.released.set(session.sessionId, session)
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1345
1515
|
get releasedList(): LiveSession[] {
|
|
1346
1516
|
return [...this.released.values()]
|
|
1347
1517
|
}
|
|
@@ -1918,10 +2088,78 @@ function snapshot(session: LiveSession): SessionView {
|
|
|
1918
2088
|
running: session.running,
|
|
1919
2089
|
closed: session.closed,
|
|
1920
2090
|
...(session.released ? { released: true } : {}),
|
|
2091
|
+
...(session.hasHistory === true ? { hasHistory: true } : {}),
|
|
1921
2092
|
seq: session.transcript.head,
|
|
1922
2093
|
}
|
|
1923
2094
|
}
|
|
1924
2095
|
|
|
2096
|
+
/**
|
|
2097
|
+
* The `fork` half of one `session/chat-list` row: the host's verdict on
|
|
2098
|
+
* forking this session's ACP agent. The capability half is the agent's LAST
|
|
2099
|
+
* KNOWN bit (see `canForkNow`); the state half is the session's own, released
|
|
2100
|
+
* checked before running (a parked record is never running). The browser
|
|
2101
|
+
* renders its branch button from this verdict and never reads a capability
|
|
2102
|
+
* bit itself.
|
|
2103
|
+
*/
|
|
2104
|
+
function forkRowInfo(session: LiveSession, capability: boolean): { fork: ChatSessionForkInfo } {
|
|
2105
|
+
if (!capability) return { fork: { supported: false, reason: 'unsupported' } }
|
|
2106
|
+
if (session.released) return { fork: { supported: false, reason: 'released' } }
|
|
2107
|
+
if (session.running || session.settled !== undefined) return { fork: { supported: false, reason: 'busy' } }
|
|
2108
|
+
return { fork: { supported: true } }
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
/**
|
|
2112
|
+
* One chat-bound session record, as persisted across host restarts (the
|
|
2113
|
+
* `chatSessions` key of the agent registry). Deliberately identity + display
|
|
2114
|
+
* mirrors ONLY — the transcript is the agent's to replay on `session/load`,
|
|
2115
|
+
* and `configOptions` come back from the same load. `hasHistory` is what lets
|
|
2116
|
+
* an empty restored view say "history replays on restore" instead of
|
|
2117
|
+
* "nothing yet".
|
|
2118
|
+
*/
|
|
2119
|
+
interface PersistedChatSession {
|
|
2120
|
+
sessionId: string
|
|
2121
|
+
acpSessionId: string
|
|
2122
|
+
agentId: string
|
|
2123
|
+
dshSessionId: string
|
|
2124
|
+
cwd: string
|
|
2125
|
+
title?: string
|
|
2126
|
+
modelId?: string
|
|
2127
|
+
reasoningEffort?: string
|
|
2128
|
+
modes?: SessionView['modes']
|
|
2129
|
+
usage?: SessionView['usage']
|
|
2130
|
+
hasHistory?: boolean
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
function persistedChatSession(session: LiveSession): PersistedChatSession {
|
|
2134
|
+
return {
|
|
2135
|
+
sessionId: session.sessionId,
|
|
2136
|
+
acpSessionId: session.acpSessionId,
|
|
2137
|
+
agentId: session.agentId,
|
|
2138
|
+
dshSessionId: session.dshSessionId as string,
|
|
2139
|
+
cwd: session.cwd,
|
|
2140
|
+
...(session.title === undefined ? {} : { title: session.title }),
|
|
2141
|
+
...(session.modelId === undefined ? {} : { modelId: session.modelId }),
|
|
2142
|
+
...(session.reasoningEffort === undefined ? {} : { reasoningEffort: session.reasoningEffort }),
|
|
2143
|
+
...(session.modes === undefined ? {} : { modes: session.modes }),
|
|
2144
|
+
...(session.usage === undefined ? {} : { usage: session.usage }),
|
|
2145
|
+
...(session.hasHistory === true || session.transcript.since(0).length > 0 ? { hasHistory: true } : {}),
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
/** Tolerant shape check for a `modes` value read back from disk. */
|
|
2150
|
+
function isPersistedModes(value: unknown): value is SessionView['modes'] {
|
|
2151
|
+
if (value === null || typeof value !== 'object') return false
|
|
2152
|
+
const modes = value as { currentModeId?: unknown; availableModes?: unknown }
|
|
2153
|
+
return typeof modes.currentModeId === 'string' && Array.isArray(modes.availableModes)
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
/** Tolerant shape check for a `usage` value read back from disk. */
|
|
2157
|
+
function isPersistedUsage(value: unknown): value is SessionView['usage'] {
|
|
2158
|
+
if (value === null || typeof value !== 'object') return false
|
|
2159
|
+
const usage = value as { used?: unknown; size?: unknown }
|
|
2160
|
+
return typeof usage.used === 'number' && typeof usage.size === 'number'
|
|
2161
|
+
}
|
|
2162
|
+
|
|
1925
2163
|
/**
|
|
1926
2164
|
* The DSH chat session's OWN workspace, when one is resolvable: the directory
|
|
1927
2165
|
* the user selected in the DSH sidebar, which the host reports back as the
|
|
@@ -3240,6 +3478,29 @@ export class BridgeManager {
|
|
|
3240
3478
|
* write would fight with no way to tell which one is live.
|
|
3241
3479
|
*/
|
|
3242
3480
|
private deadlineOverride: ApprovalDeadlinePatch = {}
|
|
3481
|
+
/**
|
|
3482
|
+
* The idle-release window the settings page picked, in ms, persisted
|
|
3483
|
+
* with the agent registry. `undefined` means the deployment baseline runs.
|
|
3484
|
+
*
|
|
3485
|
+
* Merged OVER {@link baselineIdleTimeoutMs} exactly like
|
|
3486
|
+
* {@link deadlineOverride} over {@link baselineDeadline}: a UI pick is what
|
|
3487
|
+
* runs, and `cordis.patch.yml` becomes the value the reset button returns
|
|
3488
|
+
* to. Without this split the two sources would fight with no way to tell
|
|
3489
|
+
* which one is live.
|
|
3490
|
+
*/
|
|
3491
|
+
private idleReleaseOverride?: number
|
|
3492
|
+
/**
|
|
3493
|
+
* The window `cordis.patch.yml` names, when it names one. Kept apart
|
|
3494
|
+
* from the override for the same reason the deadline keeps its baseline
|
|
3495
|
+
* apart: the settings card has to be able to say "the file asks for
|
|
3496
|
+
* 10 minutes, the value set here wins".
|
|
3497
|
+
*/
|
|
3498
|
+
private configuredIdleTimeoutMs?: number
|
|
3499
|
+
/**
|
|
3500
|
+
* The idle-release window before any override — the tuning this manager was
|
|
3501
|
+
* constructed with (the deployment baseline, or the built-in 30 minutes).
|
|
3502
|
+
*/
|
|
3503
|
+
private baselineIdleTimeoutMs: number
|
|
3243
3504
|
/**
|
|
3244
3505
|
* Tuning in force, defaults applied. The approval deadline is rewritten
|
|
3245
3506
|
* IN PLACE by {@link applyDeadlineTuning}, which is why this is a live object
|
|
@@ -3401,6 +3662,7 @@ export class BridgeManager {
|
|
|
3401
3662
|
// it. Everything else in `tuning` is fixed for the manager's lifetime, so
|
|
3402
3663
|
// only this needs a snapshot.
|
|
3403
3664
|
this.baselineDeadline = this.tuning.permissionTimeoutMs
|
|
3665
|
+
this.baselineIdleTimeoutMs = this.tuning.idleTimeoutMs
|
|
3404
3666
|
this.baselineMode = this.tuning.permissionTimeoutMode
|
|
3405
3667
|
for (const def of defs) {
|
|
3406
3668
|
this.defsMap.set(def.id, def)
|
|
@@ -3417,6 +3679,10 @@ export class BridgeManager {
|
|
|
3417
3679
|
// A persisted deadline override must reach the tuning the bridges are built
|
|
3418
3680
|
// from, not just the settings card's readout.
|
|
3419
3681
|
this.applyDeadlineTuning()
|
|
3682
|
+
// The same for the idle-release window: a persisted override must
|
|
3683
|
+
// reach the tuning the bridges are built from, not just the
|
|
3684
|
+
// settings card's readout.
|
|
3685
|
+
this.applyIdleReleaseTuning()
|
|
3420
3686
|
// The request-path rewrite falls back to this default for a conversation
|
|
3421
3687
|
// the user never bound explicitly — the same agent the composer chip shows.
|
|
3422
3688
|
routeBindingRegistry().defaultModel = this.defaultAgent()
|
|
@@ -3427,8 +3693,10 @@ export class BridgeManager {
|
|
|
3427
3693
|
onConnectionLost: (bridge: AgentBridge, reason: string) => void,
|
|
3428
3694
|
onCatalog?: (agentId: string, configOptions: unknown, shapeOnly?: boolean) => void,
|
|
3429
3695
|
onCommands?: (agentId: string, commands: NonNullable<SessionView['availableCommands']>) => void,
|
|
3696
|
+
onSessionsDirty?: () => void,
|
|
3697
|
+
onForkCapability?: (agentId: string, supported: boolean) => void,
|
|
3430
3698
|
): AgentBridge {
|
|
3431
|
-
return new AgentBridge(def, this.hub, this.transcriptMax, onConnectionLost, onCatalog, onCommands, this.tuning, () => this.sessionWorkspaceResolver)
|
|
3699
|
+
return new AgentBridge(def, this.hub, this.transcriptMax, onConnectionLost, onCatalog, onCommands, this.tuning, () => this.sessionWorkspaceResolver, onSessionsDirty, onForkCapability)
|
|
3432
3700
|
}
|
|
3433
3701
|
|
|
3434
3702
|
/**
|
|
@@ -3567,12 +3835,87 @@ export class BridgeManager {
|
|
|
3567
3835
|
...(session.reasoningEffort === undefined ? {} : { reasoningEffort: session.reasoningEffort }),
|
|
3568
3836
|
...(session.modes === undefined ? {} : { modes: session.modes }),
|
|
3569
3837
|
...(session.availableCommands === undefined ? {} : { availableCommands: session.availableCommands }),
|
|
3838
|
+
...(session.usage === undefined ? {} : { usage: session.usage }),
|
|
3570
3839
|
...(session.released ? { released: true } : {}),
|
|
3840
|
+
...(session.running ? { running: true } : {}),
|
|
3841
|
+
...forkRowInfo(session, this.canForkNow(session.agentId)),
|
|
3571
3842
|
})
|
|
3572
3843
|
}
|
|
3573
3844
|
return out
|
|
3574
3845
|
}
|
|
3575
3846
|
|
|
3847
|
+
/**
|
|
3848
|
+
* Adopt the ACP side of a branched conversation: the browser has already
|
|
3849
|
+
* asked the HOST to fork the DSH session tree (`sessions.fork`), and this
|
|
3850
|
+
* runs the agent's own `session/fork` of the parent conversation's live ACP
|
|
3851
|
+
* session so the child actually remembers the parent context — then binds
|
|
3852
|
+
* the child to it (the fourth session-creation point, with every discipline
|
|
3853
|
+
* the other three carry).
|
|
3854
|
+
*
|
|
3855
|
+
* Failure is deliberately all-or-nothing: nothing is written for the child
|
|
3856
|
+
* until the agent has answered, so a failed adopt leaves the child an
|
|
3857
|
+
* ordinary copied-history conversation whose first message starts a fresh
|
|
3858
|
+
* `session/new` (today's behavior) — never a half-bound record.
|
|
3859
|
+
*
|
|
3860
|
+
* @param childDshSessionId - the DSH session the host fork created.
|
|
3861
|
+
* @param parentDshSessionId - the conversation being branched; its ACP
|
|
3862
|
+
* session must be LIVE on this connection (a released parent has no
|
|
3863
|
+
* process to fork from — the browser blocks the click, this is the hard
|
|
3864
|
+
* stop behind it).
|
|
3865
|
+
*/
|
|
3866
|
+
async adoptForkedSession(childDshSessionId: string, parentDshSessionId: string): Promise<{ sessionId: string; acpSessionId: string }> {
|
|
3867
|
+
// One conversation, one agent (the lock). The parent's LIVE session is
|
|
3868
|
+
// the fork source; released or in-flight-settling parents are refused —
|
|
3869
|
+
// forking a parked record would silently resume it, and forking mid-turn
|
|
3870
|
+
// would fork an agent state the parent turn is still changing.
|
|
3871
|
+
const parent = [...this.dshSessionMap.values()].find(session =>
|
|
3872
|
+
session.dshSessionId === parentDshSessionId && !session.closed && !session.released)
|
|
3873
|
+
if (parent === undefined) {
|
|
3874
|
+
throw new AcpError('parent_not_live', 'acp: the conversation to branch has no live ACP session; send a message to resume it first')
|
|
3875
|
+
}
|
|
3876
|
+
if (parent.running || parent.settled !== undefined) {
|
|
3877
|
+
throw new AcpError('turn_in_flight', 'acp: the conversation to branch is running a turn; wait for it to finish')
|
|
3878
|
+
}
|
|
3879
|
+
const agentId = parent.agentId
|
|
3880
|
+
const childKey = `${childDshSessionId}:${agentId}`
|
|
3881
|
+
if (this.dshSessionMap.has(childKey)) {
|
|
3882
|
+
throw new AcpError('id_conflict', 'acp: the forked conversation is already bound to an ACP session')
|
|
3883
|
+
}
|
|
3884
|
+
// Capability gate BEFORE any request: an agent that never advertised
|
|
3885
|
+
// `session/fork` must not be asked (it would answer an unknown-method
|
|
3886
|
+
// error at best). Live init wins, last-known falls back — the same
|
|
3887
|
+
// verdict the browser's row already showed.
|
|
3888
|
+
if (!this.canForkNow(agentId)) {
|
|
3889
|
+
throw new AcpError('fork_unsupported', `acp: agent "${agentId}" does not advertise the session/fork capability`)
|
|
3890
|
+
}
|
|
3891
|
+
const session = await this.bridge(agentId).forkSession(parent, childDshSessionId)
|
|
3892
|
+
// The fork answered — the child is real on the agent side. From here the
|
|
3893
|
+
// binding is written through the SAME paths as any chat session: one
|
|
3894
|
+
// choice record (a copy of the parent's, locked — a branched conversation
|
|
3895
|
+
// belongs to its agent from birth), the route pin, and persistence.
|
|
3896
|
+
this.dshSessionMap.set(childKey, session)
|
|
3897
|
+
const parentChoice = this.chatAgentIntent.get(parentDshSessionId)
|
|
3898
|
+
this.patchChatChoice(childDshSessionId, {
|
|
3899
|
+
agentId,
|
|
3900
|
+
locked: true,
|
|
3901
|
+
...(parentChoice?.modelId === undefined ? {} : { modelId: parentChoice.modelId }),
|
|
3902
|
+
...(parentChoice?.effort === undefined ? {} : { effort: parentChoice.effort }),
|
|
3903
|
+
...(parentChoice?.mode === undefined ? {} : { mode: parentChoice.mode }),
|
|
3904
|
+
...(parentChoice?.extraConfigs === undefined ? {} : { extraConfigs: { ...parentChoice.extraConfigs } }),
|
|
3905
|
+
})
|
|
3906
|
+
// The fourth creation point follows the same discipline as `createSession`
|
|
3907
|
+
// and `doResumeSession`: apply the recorded pick to the fresh session, so
|
|
3908
|
+
// mode and extra configs (which only this path applies) cannot drift from
|
|
3909
|
+
// what the composer shows — a forked session starts on the agent-side
|
|
3910
|
+
// state the fork copied, and the record must match what runs.
|
|
3911
|
+
await this.applyChatChoice(session)
|
|
3912
|
+
// Re-publish so the browser's list and the composer's occupancy ring see
|
|
3913
|
+
// the child immediately (with its just-bound choice mirrors).
|
|
3914
|
+
this.hub.broadcast({ type: 'session/meta', sessionId: session.sessionId, session: snapshot(session) })
|
|
3915
|
+
this.persistAgents()
|
|
3916
|
+
return { sessionId: session.sessionId, acpSessionId: session.acpSessionId }
|
|
3917
|
+
}
|
|
3918
|
+
|
|
3576
3919
|
/**
|
|
3577
3920
|
* Bind one DSH chat session to an ACP agent.
|
|
3578
3921
|
*
|
|
@@ -3827,11 +4170,12 @@ export class BridgeManager {
|
|
|
3827
4170
|
this.patchChatChoice(dshSessionId, { agentId, modelId: choice === undefined ? undefined : modelId })
|
|
3828
4171
|
for (const session of this.dshSessionMap.values()) {
|
|
3829
4172
|
if (session.dshSessionId !== dshSessionId || session.agentId !== agentId) continue
|
|
3830
|
-
if (session.released) continue
|
|
3831
|
-
await this.alignSessionConfig(session, choice, session.reasoningEffort)
|
|
3832
4173
|
session.choice = choice
|
|
3833
4174
|
session.modelId = modelId
|
|
4175
|
+
if (session.released) continue
|
|
4176
|
+
await this.alignSessionConfig(session, choice, session.reasoningEffort)
|
|
3834
4177
|
}
|
|
4178
|
+
this.persistAgents()
|
|
3835
4179
|
}
|
|
3836
4180
|
|
|
3837
4181
|
/**
|
|
@@ -3857,12 +4201,15 @@ export class BridgeManager {
|
|
|
3857
4201
|
// session is skipped here and re-aligned by `applyChatChoice` on resume.
|
|
3858
4202
|
this.patchChatChoice(dshSessionId, { ...(agentId === undefined ? {} : { agentId }), effort })
|
|
3859
4203
|
for (const session of this.dshSessionMap.values()) {
|
|
3860
|
-
if (session.dshSessionId !== dshSessionId
|
|
4204
|
+
if (session.dshSessionId !== dshSessionId) continue
|
|
3861
4205
|
if (agentId !== undefined && session.agentId !== agentId) continue
|
|
4206
|
+
session.reasoningEffort = effort
|
|
4207
|
+
if (session.released) continue
|
|
3862
4208
|
const aligned = await this.alignSessionConfig(session, session.choice, effort)
|
|
3863
4209
|
// Same rule as the model axis: a dropped level is no longer "in force".
|
|
3864
4210
|
session.reasoningEffort = aligned.effortStale ? undefined : effort
|
|
3865
4211
|
}
|
|
4212
|
+
this.persistAgents()
|
|
3866
4213
|
}
|
|
3867
4214
|
|
|
3868
4215
|
/**
|
|
@@ -4051,9 +4398,15 @@ export class BridgeManager {
|
|
|
4051
4398
|
}
|
|
4052
4399
|
|
|
4053
4400
|
/**
|
|
4054
|
-
* Switch one chat-bound session's ACP mode.
|
|
4055
|
-
*
|
|
4056
|
-
*
|
|
4401
|
+
* Switch one chat-bound session's ACP mode.
|
|
4402
|
+
*
|
|
4403
|
+
* A released session is deliberately NOT resumed for it (user decision,
|
|
4404
|
+
* 2026-10-30 — the composer's axes are "what the next turn should use", and
|
|
4405
|
+
* the process comes back only when the user sends a message or hits the
|
|
4406
|
+
* released banner's resume button). The pick is recorded and
|
|
4407
|
+
* `applyChatChoice` applies it on resume, exactly like `setChatSessionModel`
|
|
4408
|
+
* / `setChatSessionEffort` / `setChatSessionConfig`; only a LIVE session is
|
|
4409
|
+
* told immediately (`session/set_mode`).
|
|
4057
4410
|
*/
|
|
4058
4411
|
async setChatSessionMode(dshSessionId: string, modeId: string, agentId?: string): Promise<void> {
|
|
4059
4412
|
if (agentId !== undefined) {
|
|
@@ -4083,9 +4436,20 @@ export class BridgeManager {
|
|
|
4083
4436
|
if (target.closed) {
|
|
4084
4437
|
throw new AcpError('unknown_session', `acp: the ACP session bound to chat session ${dshSessionId} is closed`)
|
|
4085
4438
|
}
|
|
4439
|
+
// A PARKED session gets the same record-only treatment (its agent is its
|
|
4440
|
+
// own `agentId`, whatever the caller passed — the selection above already
|
|
4441
|
+
// matched it). Resuming here was the one composer axis that broke ranks
|
|
4442
|
+
// with the others, and it woke a process the user had explicitly parked.
|
|
4443
|
+
if (target.released) {
|
|
4444
|
+
this.patchChatChoice(dshSessionId, { agentId: target.agentId, mode: modeId })
|
|
4445
|
+
if (target.modes !== undefined) {
|
|
4446
|
+
target.modes = { ...target.modes, currentModeId: modeId }
|
|
4447
|
+
}
|
|
4448
|
+
this.persistAgents()
|
|
4449
|
+
return
|
|
4450
|
+
}
|
|
4086
4451
|
const bridge = this.bridge(target.agentId)
|
|
4087
|
-
|
|
4088
|
-
await bridge.setMode(session, modeId)
|
|
4452
|
+
await bridge.setMode(target, modeId)
|
|
4089
4453
|
// Persisted only AFTER the agent accepted it: a mode the agent rejected must
|
|
4090
4454
|
// not come back on the next restore. This is the same contract the model and
|
|
4091
4455
|
// thought level follow (user decision, 2026-10-18 — every control in the
|
|
@@ -4093,6 +4457,42 @@ export class BridgeManager {
|
|
|
4093
4457
|
this.patchChatChoice(dshSessionId, { agentId: target.agentId, mode: modeId })
|
|
4094
4458
|
}
|
|
4095
4459
|
|
|
4460
|
+
/**
|
|
4461
|
+
* Bring one chat conversation's released session back on a fresh process on
|
|
4462
|
+
* the USER's explicit gesture (the "恢复进程" banner) — the same work the
|
|
4463
|
+
* next prompt performs lazily (`getOrCreateDshSession`'s released branch),
|
|
4464
|
+
* minus the create path: a banner button must never materialize a session
|
|
4465
|
+
* the conversation has not had yet. A live target passes through untouched,
|
|
4466
|
+
* so a double click (or a click racing the next message) spawns nothing.
|
|
4467
|
+
*/
|
|
4468
|
+
async resumeChatSession(dshSessionId: string): Promise<void> {
|
|
4469
|
+
const candidates = [...this.dshSessionMap.values()].filter(
|
|
4470
|
+
session => session.dshSessionId === dshSessionId && !session.closed,
|
|
4471
|
+
)
|
|
4472
|
+
// The session the NEXT message would use — the bound agent's — is the one
|
|
4473
|
+
// to bring back; the first candidate is the fallback for a conversation
|
|
4474
|
+
// whose binding has drifted (same selection `setChatSessionMode` makes
|
|
4475
|
+
// without an explicit agentId).
|
|
4476
|
+
const target = candidates.find(session => session.agentId === this.chatAgentFor(dshSessionId)) ?? candidates[0]
|
|
4477
|
+
if (target === undefined) {
|
|
4478
|
+
throw new AcpError('unknown_session', `acp: no ACP session is bound to chat session ${dshSessionId}`)
|
|
4479
|
+
}
|
|
4480
|
+
if (!target.released) return
|
|
4481
|
+
const bridge = this.bridge(target.agentId)
|
|
4482
|
+
const resumed = await bridge.resumeSession(target)
|
|
4483
|
+
// Chat-side rebind + pick re-application, verbatim the lazy path's: the
|
|
4484
|
+
// freshly loaded session comes back at the agent's defaults, so the
|
|
4485
|
+
// conversation's model / thought level must be re-applied or the composer
|
|
4486
|
+
// would show one thing and the process run another (see
|
|
4487
|
+
// `getOrCreateDshSession`).
|
|
4488
|
+
resumed.dshSessionId = dshSessionId
|
|
4489
|
+
this.lockChatAgent(dshSessionId, target.agentId)
|
|
4490
|
+
if (this.chatAgentIntent.has(dshSessionId)) this.syncConversationRoute(dshSessionId)
|
|
4491
|
+
else routeBindingRegistry().conversations.set(dshSessionId, resumed.modelId ?? target.agentId)
|
|
4492
|
+
await this.applyChatChoice(resumed)
|
|
4493
|
+
resumed.provisionalLock = false
|
|
4494
|
+
}
|
|
4495
|
+
|
|
4096
4496
|
/**
|
|
4097
4497
|
* Wire the harness's preset roster (`ctx.agentPresets`) into `presetStatus`.
|
|
4098
4498
|
*
|
|
@@ -4276,7 +4676,21 @@ export class BridgeManager {
|
|
|
4276
4676
|
} catch {
|
|
4277
4677
|
this.deadlineOverride = {}
|
|
4278
4678
|
}
|
|
4679
|
+
// The settings page's idle-release pick. Re-validated on every boot
|
|
4680
|
+
// because the state file is user- and version-editable: an
|
|
4681
|
+
// unreadable value must not poison the window, and a value the
|
|
4682
|
+
// validator accepts must be the value that runs.
|
|
4683
|
+
try {
|
|
4684
|
+
const raw = (parsed as { idleRelease?: unknown }).idleRelease
|
|
4685
|
+
const value = (raw as { idleTimeoutMs?: unknown } | undefined)?.idleTimeoutMs
|
|
4686
|
+
this.idleReleaseOverride = typeof value === 'number' && IDLE_RELEASE_MS.includes(value)
|
|
4687
|
+
? value
|
|
4688
|
+
: undefined
|
|
4689
|
+
} catch {
|
|
4690
|
+
this.idleReleaseOverride = undefined
|
|
4691
|
+
}
|
|
4279
4692
|
this.loadPersistedChoices((parsed as { conversations?: unknown }).conversations)
|
|
4693
|
+
this.loadPersistedChatSessions((parsed as { chatSessions?: unknown }).chatSessions)
|
|
4280
4694
|
}
|
|
4281
4695
|
} catch {
|
|
4282
4696
|
// Storage load failure is non-fatal
|
|
@@ -4323,6 +4737,13 @@ export class BridgeManager {
|
|
|
4323
4737
|
const filePath = this.getStoragePath()
|
|
4324
4738
|
if (!filePath) return
|
|
4325
4739
|
try {
|
|
4740
|
+
// The chat-bound session records themselves: a host restart kills the
|
|
4741
|
+
// processes but not the agent-side sessions, so restoring these as
|
|
4742
|
+
// PARKED keeps a conversation resumable (`session/load`) instead of
|
|
4743
|
+
// its next message silently starting a context-less new one.
|
|
4744
|
+
const chatSessions = [...this.dshSessionMap.values()]
|
|
4745
|
+
.filter(session => session.dshSessionId !== undefined && !session.closed)
|
|
4746
|
+
.map(persistedChatSession)
|
|
4326
4747
|
const payload = {
|
|
4327
4748
|
version: 2,
|
|
4328
4749
|
...(this.defaultAgentId === undefined ? {} : { defaultAgentId: this.defaultAgentId }),
|
|
@@ -4333,12 +4754,18 @@ export class BridgeManager {
|
|
|
4333
4754
|
...(Object.keys(this.deadlineOverride).length === 0
|
|
4334
4755
|
? {}
|
|
4335
4756
|
: { approvalDeadlines: { ...this.deadlineOverride } }),
|
|
4757
|
+
// The settings page's idle-release pick. Absent while none was
|
|
4758
|
+
// ever written, so a fresh deployment's state file stays as it was.
|
|
4759
|
+
...(this.idleReleaseOverride === undefined
|
|
4760
|
+
? {}
|
|
4761
|
+
: { idleRelease: { idleTimeoutMs: this.idleReleaseOverride } }),
|
|
4336
4762
|
// The per-conversation picks: agent + model + thought level + mode +
|
|
4337
4763
|
// extra config values chosen in the composer, including for
|
|
4338
4764
|
// conversations with no live session yet.
|
|
4339
4765
|
...(this.chatAgentIntent.size === 0
|
|
4340
4766
|
? {}
|
|
4341
4767
|
: { conversations: Object.fromEntries(this.chatAgentIntent) }),
|
|
4768
|
+
...(chatSessions.length === 0 ? {} : { chatSessions }),
|
|
4342
4769
|
}
|
|
4343
4770
|
writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8')
|
|
4344
4771
|
} catch {
|
|
@@ -4346,9 +4773,73 @@ export class BridgeManager {
|
|
|
4346
4773
|
}
|
|
4347
4774
|
}
|
|
4348
4775
|
|
|
4776
|
+
/**
|
|
4777
|
+
* Restore chat session records from disk as PARKED sessions.
|
|
4778
|
+
*
|
|
4779
|
+
* Every process died with the host, so nothing comes back live — but the
|
|
4780
|
+
* agent-side sessions did not. Restoring each record as `released` is what
|
|
4781
|
+
* makes the released banner, its resume button and the "next message
|
|
4782
|
+
* resumes it" promise survive a restart (the alternative is the next
|
|
4783
|
+
* message quietly starting a NEW, context-less session — constraint 10's
|
|
4784
|
+
* "released ≠ destroyed" lost at the process boundary). Tolerant, one row
|
|
4785
|
+
* at a time: a single corrupt entry must not cost every other conversation
|
|
4786
|
+
* its context. Records whose agent is no longer configured are dropped —
|
|
4787
|
+
* there is nothing to resume ON, and the next write omits them.
|
|
4788
|
+
*/
|
|
4789
|
+
private loadPersistedChatSessions(raw: unknown): void {
|
|
4790
|
+
if (!Array.isArray(raw)) return
|
|
4791
|
+
for (const row of raw) {
|
|
4792
|
+
if (row === null || typeof row !== 'object') continue
|
|
4793
|
+
const record = row as Record<string, unknown>
|
|
4794
|
+
const { sessionId, acpSessionId, agentId, dshSessionId, cwd } = record
|
|
4795
|
+
if (typeof sessionId !== 'string' || sessionId === '') continue
|
|
4796
|
+
if (typeof acpSessionId !== 'string' || acpSessionId === '') continue
|
|
4797
|
+
if (typeof agentId !== 'string' || agentId === '') continue
|
|
4798
|
+
if (typeof dshSessionId !== 'string' || dshSessionId === '') continue
|
|
4799
|
+
if (typeof cwd !== 'string') continue
|
|
4800
|
+
if (!this.defsMap.has(agentId)) continue
|
|
4801
|
+
const { title, modelId, reasoningEffort, modes, usage, hasHistory } = record as {
|
|
4802
|
+
title?: unknown; modelId?: unknown; reasoningEffort?: unknown; modes?: unknown; usage?: unknown; hasHistory?: unknown
|
|
4803
|
+
}
|
|
4804
|
+
const session: LiveSession = {
|
|
4805
|
+
sessionId,
|
|
4806
|
+
acpSessionId,
|
|
4807
|
+
agentId,
|
|
4808
|
+
dshSessionId,
|
|
4809
|
+
cwd,
|
|
4810
|
+
...(typeof title === 'string' && title !== '' ? { title } : {}),
|
|
4811
|
+
...(typeof modelId === 'string' && modelId !== ''
|
|
4812
|
+
? { modelId, ...(splitAdapterModelId(modelId).choice === undefined ? {} : { choice: splitAdapterModelId(modelId).choice }) }
|
|
4813
|
+
: {}),
|
|
4814
|
+
...(typeof reasoningEffort === 'string' && reasoningEffort !== '' ? { reasoningEffort } : {}),
|
|
4815
|
+
...(isPersistedModes(modes) ? { modes } : {}),
|
|
4816
|
+
...(isPersistedUsage(usage) ? { usage } : {}),
|
|
4817
|
+
...(hasHistory === true ? { hasHistory: true } : {}),
|
|
4818
|
+
running: false,
|
|
4819
|
+
turnSeq: 0,
|
|
4820
|
+
closed: false,
|
|
4821
|
+
released: true,
|
|
4822
|
+
lastAgentActivityAt: Date.now(),
|
|
4823
|
+
activeToolCalls: new Set<string>(),
|
|
4824
|
+
transcript: new Transcript(this.transcriptMax),
|
|
4825
|
+
}
|
|
4826
|
+
this.dshSessionMap.set(`${dshSessionId}:${agentId}`, session)
|
|
4827
|
+
this.bridge(agentId).parkReleased(session)
|
|
4828
|
+
}
|
|
4829
|
+
}
|
|
4830
|
+
|
|
4349
4831
|
// ---- Model catalog ----
|
|
4350
4832
|
|
|
4351
4833
|
private readonly catalogs = new Map<string, { catalog: AcpAgentCatalog; updatedAt: number }>()
|
|
4834
|
+
/**
|
|
4835
|
+
* Each agent's LAST KNOWN `sessionCapabilities.fork` bit, persisted with the
|
|
4836
|
+
* model catalog. A live `initialize` result outranks it whenever one exists
|
|
4837
|
+
* (see {@link canForkNow}) — this copy answers when the process is gone
|
|
4838
|
+
* (a released session's branch button must not flash into "supported" just
|
|
4839
|
+
* because `initResult` died with it, nor read `view().agentCapabilities`,
|
|
4840
|
+
* which a release clears).
|
|
4841
|
+
*/
|
|
4842
|
+
private readonly forkCaps = new Map<string, boolean>()
|
|
4352
4843
|
/**
|
|
4353
4844
|
* The slash commands each agent declared in its most recent session, cached
|
|
4354
4845
|
* per agent and persisted alongside the model catalog.
|
|
@@ -4415,7 +4906,10 @@ export class BridgeManager {
|
|
|
4415
4906
|
const stale = parsed.version !== BridgeManager.CATALOG_SCHEMA
|
|
4416
4907
|
for (const [agentId, raw] of Object.entries(agents)) {
|
|
4417
4908
|
if (typeof raw !== 'object' || raw === null) continue
|
|
4418
|
-
const row = raw as { catalog?: unknown; updatedAt?: unknown; commands?: unknown; commandsUpdatedAt?: unknown }
|
|
4909
|
+
const row = raw as { catalog?: unknown; updatedAt?: unknown; commands?: unknown; commandsUpdatedAt?: unknown; fork?: unknown }
|
|
4910
|
+
// The persisted fork capability: a LAST KNOWN bit, loaded before any
|
|
4911
|
+
// bridge can report a fresh one (a fresh `initialize` overwrites it).
|
|
4912
|
+
if (typeof row.fork === 'boolean') this.forkCaps.set(agentId, row.fork)
|
|
4419
4913
|
// Commands and the model catalog are learned independently (a session
|
|
4420
4914
|
// may declare one before the other), so either half alone is a valid row.
|
|
4421
4915
|
if (Array.isArray(row.commands)) {
|
|
@@ -4445,11 +4939,18 @@ export class BridgeManager {
|
|
|
4445
4939
|
updatedAt?: number
|
|
4446
4940
|
commands?: NonNullable<SessionView['availableCommands']>
|
|
4447
4941
|
commandsUpdatedAt?: number
|
|
4942
|
+
fork?: boolean
|
|
4448
4943
|
}> = {}
|
|
4449
4944
|
for (const [agentId, entry] of this.catalogs) agents[agentId] = { catalog: entry.catalog, updatedAt: entry.updatedAt }
|
|
4450
4945
|
for (const [agentId, entry] of this.commandCache) {
|
|
4451
4946
|
agents[agentId] = { ...(agents[agentId] ?? {}), commands: entry.commands, commandsUpdatedAt: entry.updatedAt }
|
|
4452
4947
|
}
|
|
4948
|
+
// The LAST KNOWN fork capability rides the same file: it outlives the
|
|
4949
|
+
// agent process, which is exactly when the browser needs it (see
|
|
4950
|
+
// `canForkNow`).
|
|
4951
|
+
for (const [agentId, supported] of this.forkCaps) {
|
|
4952
|
+
agents[agentId] = { ...(agents[agentId] ?? {}), fork: supported }
|
|
4953
|
+
}
|
|
4453
4954
|
writeFileSync(filePath, JSON.stringify({ version: BridgeManager.CATALOG_SCHEMA, agents }, null, 2), 'utf-8')
|
|
4454
4955
|
} catch {
|
|
4455
4956
|
// Storage persist failure is non-fatal
|
|
@@ -4541,6 +5042,33 @@ export class BridgeManager {
|
|
|
4541
5042
|
this.hub.broadcast({ type: 'catalog/updated', agentId, updatedAt: entry.updatedAt })
|
|
4542
5043
|
}
|
|
4543
5044
|
|
|
5045
|
+
/**
|
|
5046
|
+
* Record a fresh `initialize`'s `sessionCapabilities.fork` verdict (the
|
|
5047
|
+
* bridge reports it right after the handshake). Persisted with the model
|
|
5048
|
+
* catalog; no broadcast — the browser learns it through `session/chat-list`
|
|
5049
|
+
* rows, and its branch button deliberately runs on a last-known basis (the
|
|
5050
|
+
* agent's own next `initialize` is the refresh).
|
|
5051
|
+
*/
|
|
5052
|
+
private noteForkCapability(agentId: string, supported: boolean): void {
|
|
5053
|
+
if (this.forkCaps.get(agentId) === supported) return
|
|
5054
|
+
this.forkCaps.set(agentId, supported)
|
|
5055
|
+
this.persistCatalogs()
|
|
5056
|
+
}
|
|
5057
|
+
|
|
5058
|
+
/**
|
|
5059
|
+
* Whether an agent's fork can be attempted RIGHT NOW: a live `initialize`
|
|
5060
|
+
* answer wins (the process is up, its handshake is current); only when the
|
|
5061
|
+
* process is gone does the LAST KNOWN persisted bit answer. `false` covers
|
|
5062
|
+
* "never seen" — an unknown agent simply never gets a fork attempt.
|
|
5063
|
+
*/
|
|
5064
|
+
private canForkNow(agentId: string): boolean {
|
|
5065
|
+
const bridge = this.bridges.get(agentId)
|
|
5066
|
+
if (bridge?.initResult !== undefined) {
|
|
5067
|
+
return bridge.initResult.agentCapabilities?.sessionCapabilities?.fork != null
|
|
5068
|
+
}
|
|
5069
|
+
return this.forkCaps.get(agentId) === true
|
|
5070
|
+
}
|
|
5071
|
+
|
|
4544
5072
|
/**
|
|
4545
5073
|
* Append models this observation newly introduced to the agent's selection.
|
|
4546
5074
|
*
|
|
@@ -4966,6 +5494,10 @@ export class BridgeManager {
|
|
|
4966
5494
|
else this.absorbCatalog(observedAgentId, configOptions)
|
|
4967
5495
|
}, (observedAgentId, commands) => {
|
|
4968
5496
|
this.absorbCommands(observedAgentId, commands)
|
|
5497
|
+
}, () => {
|
|
5498
|
+
this.persistAgents()
|
|
5499
|
+
}, (observedAgentId, supported) => {
|
|
5500
|
+
this.noteForkCapability(observedAgentId, supported)
|
|
4969
5501
|
})
|
|
4970
5502
|
this.bridges.set(agentId, bridge)
|
|
4971
5503
|
}
|
|
@@ -5125,11 +5657,15 @@ export class BridgeManager {
|
|
|
5125
5657
|
if (splitAdapterModelId(model).agentId === agentId) routeBindingRegistry().conversations.delete(dshSessionId)
|
|
5126
5658
|
}
|
|
5127
5659
|
if (routeBindingRegistry().defaultModel === agentId) routeBindingRegistry().defaultModel = undefined
|
|
5128
|
-
//
|
|
5129
|
-
// deciding whether the file needs rewriting.
|
|
5660
|
+
// All three maps are independent (any may be missing), so delete all
|
|
5661
|
+
// before deciding whether the file needs rewriting. The fork capability
|
|
5662
|
+
// goes with the agent: a same-id rebuild must not inherit the OLD agent's
|
|
5663
|
+
// "supported" and click into a `fork_unsupported` wall before its own
|
|
5664
|
+
// first `initialize` answers.
|
|
5130
5665
|
const droppedCatalog = this.catalogs.delete(agentId)
|
|
5131
5666
|
const droppedCommands = this.commandCache.delete(agentId)
|
|
5132
|
-
|
|
5667
|
+
const droppedFork = this.forkCaps.delete(agentId)
|
|
5668
|
+
if (droppedCatalog || droppedCommands || droppedFork) this.persistCatalogs()
|
|
5133
5669
|
this.persistAgents()
|
|
5134
5670
|
this.hub.broadcast({
|
|
5135
5671
|
type: 'agent/status',
|
|
@@ -5378,6 +5914,23 @@ export class BridgeManager {
|
|
|
5378
5914
|
this.persistAgents()
|
|
5379
5915
|
}
|
|
5380
5916
|
|
|
5917
|
+
/**
|
|
5918
|
+
* Declare the deployment baseline for the idle-release window — the value
|
|
5919
|
+
* `cordis.patch.yml` names, passed RAW (only when it names one).
|
|
5920
|
+
*
|
|
5921
|
+
* Kept separate from the validated tuning for the same reason the
|
|
5922
|
+
* deadline keeps its baseline apart: "the file says 10 minutes" and
|
|
5923
|
+
* "the file says nothing, so 30 minutes applies" are different facts.
|
|
5924
|
+
* The first is the reset target and shows as the fact being overridden;
|
|
5925
|
+
* the second is a built-in default. Called once by the host entry,
|
|
5926
|
+
* before any bridge exists.
|
|
5927
|
+
*/
|
|
5928
|
+
setConfiguredIdleRelease(idleTimeoutMs: number | undefined): void {
|
|
5929
|
+
this.configuredIdleTimeoutMs = idleTimeoutMs
|
|
5930
|
+
if (idleTimeoutMs !== undefined) this.baselineIdleTimeoutMs = idleTimeoutMs
|
|
5931
|
+
this.applyIdleReleaseTuning()
|
|
5932
|
+
}
|
|
5933
|
+
|
|
5381
5934
|
/**
|
|
5382
5935
|
* Declare the deployment baseline for the approval deadline — the values
|
|
5383
5936
|
* `cordis.patch.yml` names, passed RAW (only the keys it actually contains).
|
|
@@ -5424,6 +5977,58 @@ export class BridgeManager {
|
|
|
5424
5977
|
return deadlines
|
|
5425
5978
|
}
|
|
5426
5979
|
|
|
5980
|
+
/**
|
|
5981
|
+
* The idle-release window in force, plus the facts that explain a value the
|
|
5982
|
+
* user did not pick: the deployment baseline an override is outranking.
|
|
5983
|
+
*
|
|
5984
|
+
* `defaultIdleTimeoutMs` is the window a RESET returns to — the deployment
|
|
5985
|
+
* baseline when the config file names one, else the built-in default. It is
|
|
5986
|
+
* NOT a hardcoded constant: a deployment that names 10 minutes must reset
|
|
5987
|
+
* to 10 minutes, not to the built-in 30.
|
|
5988
|
+
*
|
|
5989
|
+
* The config file's own number is reported only when the file names one, so
|
|
5990
|
+
* the settings card can stay silent about a plain default.
|
|
5991
|
+
*/
|
|
5992
|
+
idleRelease(): AcpIdleRelease {
|
|
5993
|
+
const release: AcpIdleRelease = {
|
|
5994
|
+
idleTimeoutMs: this.idleTimeoutMs(),
|
|
5995
|
+
defaultIdleTimeoutMs: this.baselineIdleTimeoutMs,
|
|
5996
|
+
}
|
|
5997
|
+
if (this.configuredIdleTimeoutMs !== undefined) release.configuredIdleTimeoutMs = this.configuredIdleTimeoutMs
|
|
5998
|
+
return release
|
|
5999
|
+
}
|
|
6000
|
+
|
|
6001
|
+
/**
|
|
6002
|
+
* Write the idle-release window, or drop the persisted override with
|
|
6003
|
+
* `reset` so the deployment baseline applies again.
|
|
6004
|
+
*
|
|
6005
|
+
* HOT: the rewrite reaches every live bridge, so a user changing the window
|
|
6006
|
+
* never has to kill a running session to see it take effect.
|
|
6007
|
+
*/
|
|
6008
|
+
async setIdleRelease(idleTimeoutMs: number | undefined, reset = false): Promise<void> {
|
|
6009
|
+
this.idleReleaseOverride = reset ? undefined : idleTimeoutMs
|
|
6010
|
+
this.applyIdleReleaseTuning()
|
|
6011
|
+
this.persistAgents()
|
|
6012
|
+
}
|
|
6013
|
+
|
|
6014
|
+
/**
|
|
6015
|
+
* Recompute the idle-release window from override → deployment baseline, and
|
|
6016
|
+
* push the result to every live bridge.
|
|
6017
|
+
*/
|
|
6018
|
+
private applyIdleReleaseTuning(): void {
|
|
6019
|
+
const window = this.idleTimeoutMs()
|
|
6020
|
+
this.tuning.idleTimeoutMs = window
|
|
6021
|
+
for (const bridge of this.bridges.values()) bridge.applyIdleRelease(window)
|
|
6022
|
+
}
|
|
6023
|
+
|
|
6024
|
+
/**
|
|
6025
|
+
* The idle-release window that runs, in ms: the settings page's pick, else
|
|
6026
|
+
* the deployment baseline.
|
|
6027
|
+
*/
|
|
6028
|
+
private idleTimeoutMs(): number {
|
|
6029
|
+
return this.idleReleaseOverride ?? this.baselineIdleTimeoutMs
|
|
6030
|
+
}
|
|
6031
|
+
|
|
5427
6032
|
/**
|
|
5428
6033
|
* Write the approval deadline, or the mode (omitted keys keep their value),
|
|
5429
6034
|
* or drop the persisted override with `reset` so the config-file baseline —
|
|
@@ -5486,9 +6091,16 @@ export class BridgeManager {
|
|
|
5486
6091
|
|
|
5487
6092
|
/** Drop every chat-keyed record pointing at one host session (close/stop paths). */
|
|
5488
6093
|
private forgetDshSession(sessionId: string): void {
|
|
6094
|
+
let dropped = false
|
|
5489
6095
|
for (const [key, session] of this.dshSessionMap) {
|
|
5490
|
-
if (session.sessionId === sessionId)
|
|
6096
|
+
if (session.sessionId === sessionId) {
|
|
6097
|
+
this.dshSessionMap.delete(key)
|
|
6098
|
+
dropped = true
|
|
6099
|
+
}
|
|
5491
6100
|
}
|
|
6101
|
+
// The disk record goes with it: a closed session is destroyed, not parked,
|
|
6102
|
+
// and must not come back on the next boot (constraint 10).
|
|
6103
|
+
if (dropped) this.persistAgents()
|
|
5492
6104
|
}
|
|
5493
6105
|
private sessionWorkspaceResolver?: SessionWorkspaceResolver
|
|
5494
6106
|
private acpModeResolver?: AcpModeResolver
|
|
@@ -5638,6 +6250,9 @@ export class BridgeManager {
|
|
|
5638
6250
|
// A RESUMED session had context before this call; a lock written here is
|
|
5639
6251
|
// not provisional even if the conversation record was missing it.
|
|
5640
6252
|
resumed.provisionalLock = false
|
|
6253
|
+
// The load refreshed the record's mirrors (modes / usage / hasHistory);
|
|
6254
|
+
// persist so a later restart restores what this resume saw.
|
|
6255
|
+
this.persistAgents()
|
|
5641
6256
|
return resumed
|
|
5642
6257
|
}
|
|
5643
6258
|
// A chat session runs where its DSH session lives, not in the plugin's
|
|
@@ -5687,6 +6302,9 @@ export class BridgeManager {
|
|
|
5687
6302
|
// conversation back (a failed first attempt must not trap it).
|
|
5688
6303
|
session.provisionalLock = this.lockChatAgent(dshSessionId, agentId)
|
|
5689
6304
|
}
|
|
6305
|
+
// The record is on disk from here: a host restart restores it as parked
|
|
6306
|
+
// instead of the next message starting a context-less new session.
|
|
6307
|
+
this.persistAgents()
|
|
5690
6308
|
return session
|
|
5691
6309
|
}
|
|
5692
6310
|
|