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.
Files changed (48) hide show
  1. package/README.en.md +1 -1
  2. package/README.md +1 -1
  3. package/cordis.patch.yml +17 -1
  4. package/lib/bridge.js +607 -18
  5. package/lib/client/acp-ui.js +103 -3
  6. package/lib/client/acp-view.js +24 -4
  7. package/lib/client/branch-action.js +133 -0
  8. package/lib/client/composer-mode.js +48 -13
  9. package/lib/client/context-meter.js +86 -0
  10. package/lib/client/index.js +238 -15
  11. package/lib/client/panel.js +96 -29
  12. package/lib/client/released-banner.js +66 -0
  13. package/lib/client/session-controls.js +38 -13
  14. package/lib/client/slash-isolation.js +2 -2
  15. package/lib/client/store.js +348 -13
  16. package/lib/client.js +1138 -67
  17. package/lib/client.js.map +1 -1
  18. package/lib/impl.js +54 -1
  19. package/lib/index.js +7 -0
  20. package/lib/protocol.js +19 -0
  21. package/lib/types/bridge.d.ts +229 -6
  22. package/lib/types/client/acp-ui.d.ts +34 -2
  23. package/lib/types/client/acp-view.d.ts +13 -2
  24. package/lib/types/client/branch-action.d.ts +40 -0
  25. package/lib/types/client/composer-mode.d.ts +28 -0
  26. package/lib/types/client/context-meter.d.ts +51 -0
  27. package/lib/types/client/released-banner.d.ts +34 -0
  28. package/lib/types/client/slash-isolation.d.ts +1 -1
  29. package/lib/types/client/store.d.ts +98 -1
  30. package/lib/types/impl.d.ts +41 -1
  31. package/lib/types/protocol.d.ts +123 -0
  32. package/package.json +2 -1
  33. package/src/bridge.ts +638 -20
  34. package/src/client/acp-ui.tsx +123 -2
  35. package/src/client/acp-view.tsx +30 -5
  36. package/src/client/branch-action.tsx +160 -0
  37. package/src/client/composer-mode.tsx +59 -13
  38. package/src/client/context-meter.tsx +128 -0
  39. package/src/client/index.tsx +265 -14
  40. package/src/client/panel.tsx +157 -40
  41. package/src/client/primitives.d.ts +27 -0
  42. package/src/client/released-banner.tsx +97 -0
  43. package/src/client/session-controls.tsx +34 -11
  44. package/src/client/slash-isolation.ts +2 -1
  45. package/src/client/store.ts +375 -14
  46. package/src/impl.ts +84 -2
  47. package/src/index.ts +8 -0
  48. 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: 1_800_000,
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 readonly defaultIdleTimeoutMs: number
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 || session.released) continue
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. A released session is resumed
4055
- * first (`session/load`), matching the "send to recover" promise — the mode
4056
- * switch is itself a reason to bring the agent back.
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
- const session = target.released ? await bridge.resumeSession(target) : target
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
- // Both maps are independent (either may be missing), so delete both before
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
- if (droppedCatalog || droppedCommands) this.persistCatalogs()
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) this.dshSessionMap.delete(key)
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