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/lib/bridge.js CHANGED
@@ -13,7 +13,7 @@ import { Writable, Readable } from 'node:stream';
13
13
  import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
14
14
  import { client, methods, ndJsonStream, PROTOCOL_VERSION, RequestError, } from '@agentclientprotocol/sdk';
15
15
  import { AcpError, EventHub, PermissionLedger, Transcript, acpToolCallDelta, acpToolCallFromUpdate, buildAdapterModelId, childEnv, isAcpToolContinuation, isUnderPath, mergeCatalogShape, parseCatalogOptions, usableConfigOptions, permissionRequestFrame, pickAutoOption, quoteCommandToken, randomUUID, resolveFsPath, resolveSessionCwd, selectForwardPrompt, acpPresetStatusFromRoster, splitAdapterModelId, updateContentToText, describeUnhandledUpdate, BOOKKEEPING_UPDATES, validateAgentDef, validateTagHalo, validateApprovalDeadlines, validatePermissionTimeoutMode, normalizeApprovalWindow, } from "./impl.js";
16
- import { DEFAULT_TAG_HALO } from "./protocol.js";
16
+ import { DEFAULT_IDLE_TIMEOUT_MS, DEFAULT_TAG_HALO, IDLE_RELEASE_MS } from "./protocol.js";
17
17
  /**
18
18
  * Version of this installed package, read from the shipped `package.json` —
19
19
  * one level above both `src/` at test time and `lib/` at runtime. The settings
@@ -104,7 +104,7 @@ const DEFAULT_TUNING = {
104
104
  turnStallMs: 600_000,
105
105
  turnStallToolMs: 1_800_000,
106
106
  turnStallTickMs: 30_000,
107
- idleTimeoutMs: 1_800_000,
107
+ idleTimeoutMs: DEFAULT_IDLE_TIMEOUT_MS,
108
108
  probeIdleTimeoutMs: 300_000,
109
109
  };
110
110
  export class AgentBridge {
@@ -115,6 +115,8 @@ export class AgentBridge {
115
115
  onCatalog;
116
116
  onCommands;
117
117
  sessionWorkspace;
118
+ onSessionsDirty;
119
+ onForkCapability;
118
120
  state = 'stopped';
119
121
  initResult;
120
122
  lastError;
@@ -166,6 +168,13 @@ export class AgentBridge {
166
168
  * that a caller is waiting on the child it is about to kill.
167
169
  */
168
170
  pendingCreates = 0;
171
+ /**
172
+ * `session/fork` requests in flight (the adopt path of a branched
173
+ * conversation). Same teardown hazard as {@link pendingCreates}: until the
174
+ * forked session is registered, nothing else would stop an idle release
175
+ * from killing the child out from under the caller.
176
+ */
177
+ pendingForks = 0;
169
178
  /**
170
179
  * Whether a REAL session has ever been registered on this bridge. Probes
171
180
  * bypass session bookkeeping, so a bridge that has never hosted anything was
@@ -202,7 +211,21 @@ export class AgentBridge {
202
211
  * Resolved per turn: the host entry may wire the workspace reader after
203
212
  * this bridge was built.
204
213
  */
205
- sessionWorkspace) {
214
+ sessionWorkspace,
215
+ /**
216
+ * Told whenever the SESSION RECORDS change shape (a release parks them, a
217
+ * resume revives them): the manager persists `chatSessions` through it, so
218
+ * a parked conversation stays resumable across a host restart (see
219
+ * `BridgeManager.loadPersistedChatSessions`).
220
+ */
221
+ onSessionsDirty,
222
+ /**
223
+ * Told whenever a fresh `initialize` reports the agent's
224
+ * `sessionCapabilities.fork` bit — the LAST KNOWN fork capability the
225
+ * manager persists and serves to the browser (the bridge's own
226
+ * `initResult` dies with the process; the manager's copy survives it).
227
+ */
228
+ onForkCapability) {
206
229
  this.def = def;
207
230
  this.hub = hub;
208
231
  this.transcriptMax = transcriptMax;
@@ -210,6 +233,8 @@ export class AgentBridge {
210
233
  this.onCatalog = onCatalog;
211
234
  this.onCommands = onCommands;
212
235
  this.sessionWorkspace = sessionWorkspace;
236
+ this.onSessionsDirty = onSessionsDirty;
237
+ this.onForkCapability = onForkCapability;
213
238
  const merged = { ...DEFAULT_TUNING, ...tuning };
214
239
  this.startupTimeoutMs = merged.startupTimeoutMs;
215
240
  this.permissionTimeoutMode = merged.permissionTimeoutMode;
@@ -243,6 +268,24 @@ export class AgentBridge {
243
268
  // on arrival.
244
269
  this.permissionTimeoutMs = normalizeApprovalWindow(this.permissionTimeoutMode, this.permissionTimeoutMs);
245
270
  }
271
+ /**
272
+ * Adopt a rewritten host-default idle window (the settings page's write).
273
+ *
274
+ * Only the DEFAULT moves: an agent that names its own `idleTimeoutMs` keeps
275
+ * it (that promise is about the agent, not about the deployment), and a
276
+ * probe-only child keeps the short probe window.
277
+ *
278
+ * The timer is re-armed so the new window governs from NOW: an already
279
+ * armed timer still holds the old window, so a shortened window would not
280
+ * take effect until the old one elapsed.
281
+ */
282
+ applyIdleRelease(defaultIdleTimeoutMs) {
283
+ if (this.defaultIdleTimeoutMs === defaultIdleTimeoutMs)
284
+ return;
285
+ this.defaultIdleTimeoutMs = defaultIdleTimeoutMs;
286
+ this.clearIdleTimer();
287
+ this.touch();
288
+ }
246
289
  /**
247
290
  * The idle-release window this agent honors for its CHAT process: its own
248
291
  * field, else the host default. This is the number the panel prefills and
@@ -437,6 +480,7 @@ export class AgentBridge {
437
480
  hasActiveWork() {
438
481
  if (this.pendingProbes > 0
439
482
  || this.pendingCreates > 0
483
+ || this.pendingForks > 0
440
484
  || this.pendingCommands.size > 0
441
485
  || this.resuming.size > 0
442
486
  || this.permissions.sessionIds().length > 0
@@ -462,6 +506,7 @@ export class AgentBridge {
462
506
  hasOtherActiveWork(session) {
463
507
  if (this.pendingProbes > 0
464
508
  || this.pendingCreates > 0
509
+ || this.pendingForks > 0
465
510
  || this.resuming.size > 0
466
511
  || this.starting !== undefined)
467
512
  return true;
@@ -640,6 +685,10 @@ export class AgentBridge {
640
685
  clientInfo: { name: 'dsh-ntr', title: 'DSH NTR', version: PLUGIN_VERSION },
641
686
  }), 'initialize');
642
687
  this.initResult = init;
688
+ // The fork capability is agent knowledge that must outlive the process:
689
+ // report it so the manager can persist a LAST KNOWN copy and serve the
690
+ // browser's branch button after a release (`initResult` itself dies here).
691
+ this.onForkCapability?.(this.def.id, this.initResult?.agentCapabilities?.sessionCapabilities?.fork != null);
643
692
  }
644
693
  /**
645
694
  * Bound one agent-startup request (`initialize`, `session/new`,
@@ -783,6 +832,101 @@ export class AgentBridge {
783
832
  this.touch();
784
833
  }
785
834
  }
835
+ /**
836
+ * Fork one live session through the agent's own `session/fork` (the
837
+ * UNSTABLE fourth session-creation point): the agent copies its internal
838
+ * state into a NEW ACP session that remembers the parent's context — the
839
+ * second half of a branched conversation, after the host's `sessions.fork`
840
+ * has already built the DSH session tree. The child is addressed by the
841
+ * CHILD DSH conversation id, so its first message reaches an agent that
842
+ * actually remembers the parent.
843
+ *
844
+ * The parent must be live on this connection — a released parent has no
845
+ * process to fork from (the browser blocks that click; this is the hard
846
+ * stop behind it). The ACP request carries the SAME `mcpServers` list as
847
+ * `session/new` / `session/load` (one list per process; an empty list would
848
+ * evict the live sessions' MCP scope) and is bounded by the startup timeout
849
+ * like every session-creation step. Counted in {@link pendingForks} while
850
+ * in flight so an idle release cannot kill the child out from under the
851
+ * caller.
852
+ */
853
+ async forkSession(parent, dshSessionId) {
854
+ if (this.initResult?.agentCapabilities?.sessionCapabilities?.fork == null) {
855
+ throw new AcpError('fork_unsupported', `acp: agent "${this.def.id}" does not advertise the session/fork capability`);
856
+ }
857
+ const conn = this.conn;
858
+ if (conn === undefined)
859
+ throw new AcpError('agent_unreachable', 'acp: connection is not open');
860
+ // The child runs where the parent runs: its cwd is the parent's already
861
+ // validated workspace, re-trusted as its own extra root (the same minimal
862
+ // trust `resolveSessionCwd` applied when the parent was created — never
863
+ // the agent's configured sandbox cwd, which is not where the conversation
864
+ // lives).
865
+ const resolved = resolveSessionCwd(this.def, parent.cwd, p => realpathSync(p), [parent.cwd]);
866
+ this.pendingForks += 1;
867
+ try {
868
+ const result = await this.withStartupTimeout(conn.agent.request(methods.agent.session.fork, {
869
+ sessionId: parent.acpSessionId,
870
+ cwd: resolved,
871
+ mcpServers: toAcpMcpServers(this.def.mcpServers),
872
+ }), 'session/fork');
873
+ // A fork answer that names the parent (or any session already routed on
874
+ // this connection) would interleave the parent's updates, asks and
875
+ // permission cards into the child — refuse it rather than register it.
876
+ if (typeof result.sessionId !== 'string' || result.sessionId === '') {
877
+ throw new AcpError('fork_failed', 'acp: session/fork returned no session id');
878
+ }
879
+ if (result.sessionId === parent.acpSessionId || this.acpIndex.has(result.sessionId)) {
880
+ throw new AcpError('fork_id_conflict', 'acp: session/fork returned an id that already exists on this connection');
881
+ }
882
+ const session = {
883
+ sessionId: randomUUID(),
884
+ agentId: this.def.id,
885
+ acpSessionId: result.sessionId,
886
+ cwd: resolved,
887
+ running: false,
888
+ turnSeq: 0,
889
+ closed: false,
890
+ released: false,
891
+ lastAgentActivityAt: Date.now(),
892
+ activeToolCalls: new Set(),
893
+ transcript: new Transcript(this.transcriptMax),
894
+ dshSessionId,
895
+ ...(result.modes !== undefined && result.modes !== null
896
+ ? {
897
+ modes: {
898
+ currentModeId: result.modes.currentModeId,
899
+ availableModes: result.modes.availableModes.map(m => ({
900
+ id: m.id,
901
+ name: m.name,
902
+ ...(m.description !== null ? { description: m.description } : {}),
903
+ })),
904
+ },
905
+ }
906
+ : {}),
907
+ ...(usableConfigOptions(result.configOptions) ? { configOptions: result.configOptions } : {}),
908
+ };
909
+ // The fork answer's config options are the AGENT-LEVEL surface too (the
910
+ // same feed `session/new` provides): absorb them so the composer chips
911
+ // of the fresh conversation render without a probe.
912
+ if (this.onCatalog !== undefined && usableConfigOptions(result.configOptions)) {
913
+ this.onCatalog(this.def.id, result.configOptions);
914
+ }
915
+ this.sessions.set(session.sessionId, session);
916
+ this.acpIndex.set(session.acpSessionId, session);
917
+ // Same flip as `createSession`: a registered session makes this a chat
918
+ // process; re-arm the idle clock on the agent's own window.
919
+ this.clearIdleTimer();
920
+ this.everHostedSession = true;
921
+ this.hub.broadcast({ type: 'session/created', sessionId: session.sessionId, session: snapshot(session) });
922
+ this.touch();
923
+ return session;
924
+ }
925
+ finally {
926
+ this.pendingForks -= 1;
927
+ this.touch();
928
+ }
929
+ }
786
930
  /**
787
931
  * Fire one prompt turn. Returns after the agent settles the turn; the caller
788
932
  * (manager) keeps the UI informed through broadcast events, so the RPC that
@@ -930,7 +1074,11 @@ export class AgentBridge {
930
1074
  throw new AcpError('agent_unreachable', 'acp: connection is not open');
931
1075
  const resolved = resolveSessionCwd(this.def, session.cwd, p => realpathSync(p), workspaceRoot === undefined ? [] : [workspaceRoot]);
932
1076
  // Replay arrives as brand-new updates, so the transcript restarts too;
933
- // seq keeps meaning "position in the current epoch".
1077
+ // seq keeps meaning "position in the current epoch". What the buffer held
1078
+ // is now the agent's to replay — remember that there IS history before
1079
+ // dropping it, or a failed resume parks an empty buffer back and the
1080
+ // view claims the conversation never happened.
1081
+ session.hasHistory = session.hasHistory === true || session.transcript.since(0).length > 0;
934
1082
  session.transcript = new Transcript(this.transcriptMax);
935
1083
  session.cwd = resolved;
936
1084
  session.running = false;
@@ -972,6 +1120,7 @@ export class AgentBridge {
972
1120
  this.touch();
973
1121
  // Republish so configOptions/modes observed on load reach the browser.
974
1122
  this.hub.broadcast({ type: 'session/meta', sessionId: session.sessionId, session: snapshot(session) });
1123
+ this.onSessionsDirty?.();
975
1124
  return session;
976
1125
  }
977
1126
  catch (error) {
@@ -980,6 +1129,7 @@ export class AgentBridge {
980
1129
  session.released = true;
981
1130
  this.released.set(session.sessionId, session);
982
1131
  this.hub.broadcast({ type: 'session/released', sessionId: session.sessionId, session: snapshot(session), reason: 'resume failed', cause: 'resume-failed' });
1132
+ this.onSessionsDirty?.();
983
1133
  throw error;
984
1134
  }
985
1135
  }
@@ -1015,6 +1165,10 @@ export class AgentBridge {
1015
1165
  this.state = 'stopped';
1016
1166
  this.initResult = undefined;
1017
1167
  this.publishStatus();
1168
+ // The parked records are what keeps them resumable after a host restart —
1169
+ // persist now, while their transcripts (the `hasHistory` evidence) are
1170
+ // still the ones they were parked with.
1171
+ this.onSessionsDirty?.();
1018
1172
  }
1019
1173
  /** Detach one released session record for a resume attempt. */
1020
1174
  takeReleased(sessionId) {
@@ -1028,6 +1182,14 @@ export class AgentBridge {
1028
1182
  dropReleased(sessionId) {
1029
1183
  return this.released.delete(sessionId);
1030
1184
  }
1185
+ /**
1186
+ * Register a session record restored from disk as already parked: a host
1187
+ * restart killed every process, so it comes back exactly as `releaseAll`
1188
+ * would have left it — resumable via `session/load`, never live.
1189
+ */
1190
+ parkReleased(session) {
1191
+ this.released.set(session.sessionId, session);
1192
+ }
1031
1193
  get releasedList() {
1032
1194
  return [...this.released.values()];
1033
1195
  }
@@ -1594,9 +1756,56 @@ function snapshot(session) {
1594
1756
  running: session.running,
1595
1757
  closed: session.closed,
1596
1758
  ...(session.released ? { released: true } : {}),
1759
+ ...(session.hasHistory === true ? { hasHistory: true } : {}),
1597
1760
  seq: session.transcript.head,
1598
1761
  };
1599
1762
  }
1763
+ /**
1764
+ * The `fork` half of one `session/chat-list` row: the host's verdict on
1765
+ * forking this session's ACP agent. The capability half is the agent's LAST
1766
+ * KNOWN bit (see `canForkNow`); the state half is the session's own, released
1767
+ * checked before running (a parked record is never running). The browser
1768
+ * renders its branch button from this verdict and never reads a capability
1769
+ * bit itself.
1770
+ */
1771
+ function forkRowInfo(session, capability) {
1772
+ if (!capability)
1773
+ return { fork: { supported: false, reason: 'unsupported' } };
1774
+ if (session.released)
1775
+ return { fork: { supported: false, reason: 'released' } };
1776
+ if (session.running || session.settled !== undefined)
1777
+ return { fork: { supported: false, reason: 'busy' } };
1778
+ return { fork: { supported: true } };
1779
+ }
1780
+ function persistedChatSession(session) {
1781
+ return {
1782
+ sessionId: session.sessionId,
1783
+ acpSessionId: session.acpSessionId,
1784
+ agentId: session.agentId,
1785
+ dshSessionId: session.dshSessionId,
1786
+ cwd: session.cwd,
1787
+ ...(session.title === undefined ? {} : { title: session.title }),
1788
+ ...(session.modelId === undefined ? {} : { modelId: session.modelId }),
1789
+ ...(session.reasoningEffort === undefined ? {} : { reasoningEffort: session.reasoningEffort }),
1790
+ ...(session.modes === undefined ? {} : { modes: session.modes }),
1791
+ ...(session.usage === undefined ? {} : { usage: session.usage }),
1792
+ ...(session.hasHistory === true || session.transcript.since(0).length > 0 ? { hasHistory: true } : {}),
1793
+ };
1794
+ }
1795
+ /** Tolerant shape check for a `modes` value read back from disk. */
1796
+ function isPersistedModes(value) {
1797
+ if (value === null || typeof value !== 'object')
1798
+ return false;
1799
+ const modes = value;
1800
+ return typeof modes.currentModeId === 'string' && Array.isArray(modes.availableModes);
1801
+ }
1802
+ /** Tolerant shape check for a `usage` value read back from disk. */
1803
+ function isPersistedUsage(value) {
1804
+ if (value === null || typeof value !== 'object')
1805
+ return false;
1806
+ const usage = value;
1807
+ return typeof usage.used === 'number' && typeof usage.size === 'number';
1808
+ }
1600
1809
  /**
1601
1810
  * The DSH chat session's OWN workspace, when one is resolvable: the directory
1602
1811
  * the user selected in the DSH sidebar, which the host reports back as the
@@ -2841,6 +3050,29 @@ export class BridgeManager {
2841
3050
  * write would fight with no way to tell which one is live.
2842
3051
  */
2843
3052
  deadlineOverride = {};
3053
+ /**
3054
+ * The idle-release window the settings page picked, in ms, persisted
3055
+ * with the agent registry. `undefined` means the deployment baseline runs.
3056
+ *
3057
+ * Merged OVER {@link baselineIdleTimeoutMs} exactly like
3058
+ * {@link deadlineOverride} over {@link baselineDeadline}: a UI pick is what
3059
+ * runs, and `cordis.patch.yml` becomes the value the reset button returns
3060
+ * to. Without this split the two sources would fight with no way to tell
3061
+ * which one is live.
3062
+ */
3063
+ idleReleaseOverride;
3064
+ /**
3065
+ * The window `cordis.patch.yml` names, when it names one. Kept apart
3066
+ * from the override for the same reason the deadline keeps its baseline
3067
+ * apart: the settings card has to be able to say "the file asks for
3068
+ * 10 minutes, the value set here wins".
3069
+ */
3070
+ configuredIdleTimeoutMs;
3071
+ /**
3072
+ * The idle-release window before any override — the tuning this manager was
3073
+ * constructed with (the deployment baseline, or the built-in 30 minutes).
3074
+ */
3075
+ baselineIdleTimeoutMs;
2844
3076
  /**
2845
3077
  * Tuning in force, defaults applied. The approval deadline is rewritten
2846
3078
  * IN PLACE by {@link applyDeadlineTuning}, which is why this is a live object
@@ -3002,6 +3234,7 @@ export class BridgeManager {
3002
3234
  // it. Everything else in `tuning` is fixed for the manager's lifetime, so
3003
3235
  // only this needs a snapshot.
3004
3236
  this.baselineDeadline = this.tuning.permissionTimeoutMs;
3237
+ this.baselineIdleTimeoutMs = this.tuning.idleTimeoutMs;
3005
3238
  this.baselineMode = this.tuning.permissionTimeoutMode;
3006
3239
  for (const def of defs) {
3007
3240
  this.defsMap.set(def.id, def);
@@ -3018,12 +3251,16 @@ export class BridgeManager {
3018
3251
  // A persisted deadline override must reach the tuning the bridges are built
3019
3252
  // from, not just the settings card's readout.
3020
3253
  this.applyDeadlineTuning();
3254
+ // The same for the idle-release window: a persisted override must
3255
+ // reach the tuning the bridges are built from, not just the
3256
+ // settings card's readout.
3257
+ this.applyIdleReleaseTuning();
3021
3258
  // The request-path rewrite falls back to this default for a conversation
3022
3259
  // the user never bound explicitly — the same agent the composer chip shows.
3023
3260
  routeBindingRegistry().defaultModel = this.defaultAgent();
3024
3261
  }
3025
- makeBridge(def, onConnectionLost, onCatalog, onCommands) {
3026
- return new AgentBridge(def, this.hub, this.transcriptMax, onConnectionLost, onCatalog, onCommands, this.tuning, () => this.sessionWorkspaceResolver);
3262
+ makeBridge(def, onConnectionLost, onCatalog, onCommands, onSessionsDirty, onForkCapability) {
3263
+ return new AgentBridge(def, this.hub, this.transcriptMax, onConnectionLost, onCatalog, onCommands, this.tuning, () => this.sessionWorkspaceResolver, onSessionsDirty, onForkCapability);
3027
3264
  }
3028
3265
  /**
3029
3266
  * Every outstanding ask, across all agents, with the view the browser was
@@ -3166,11 +3403,84 @@ export class BridgeManager {
3166
3403
  ...(session.reasoningEffort === undefined ? {} : { reasoningEffort: session.reasoningEffort }),
3167
3404
  ...(session.modes === undefined ? {} : { modes: session.modes }),
3168
3405
  ...(session.availableCommands === undefined ? {} : { availableCommands: session.availableCommands }),
3406
+ ...(session.usage === undefined ? {} : { usage: session.usage }),
3169
3407
  ...(session.released ? { released: true } : {}),
3408
+ ...(session.running ? { running: true } : {}),
3409
+ ...forkRowInfo(session, this.canForkNow(session.agentId)),
3170
3410
  });
3171
3411
  }
3172
3412
  return out;
3173
3413
  }
3414
+ /**
3415
+ * Adopt the ACP side of a branched conversation: the browser has already
3416
+ * asked the HOST to fork the DSH session tree (`sessions.fork`), and this
3417
+ * runs the agent's own `session/fork` of the parent conversation's live ACP
3418
+ * session so the child actually remembers the parent context — then binds
3419
+ * the child to it (the fourth session-creation point, with every discipline
3420
+ * the other three carry).
3421
+ *
3422
+ * Failure is deliberately all-or-nothing: nothing is written for the child
3423
+ * until the agent has answered, so a failed adopt leaves the child an
3424
+ * ordinary copied-history conversation whose first message starts a fresh
3425
+ * `session/new` (today's behavior) — never a half-bound record.
3426
+ *
3427
+ * @param childDshSessionId - the DSH session the host fork created.
3428
+ * @param parentDshSessionId - the conversation being branched; its ACP
3429
+ * session must be LIVE on this connection (a released parent has no
3430
+ * process to fork from — the browser blocks the click, this is the hard
3431
+ * stop behind it).
3432
+ */
3433
+ async adoptForkedSession(childDshSessionId, parentDshSessionId) {
3434
+ // One conversation, one agent (the lock). The parent's LIVE session is
3435
+ // the fork source; released or in-flight-settling parents are refused —
3436
+ // forking a parked record would silently resume it, and forking mid-turn
3437
+ // would fork an agent state the parent turn is still changing.
3438
+ const parent = [...this.dshSessionMap.values()].find(session => session.dshSessionId === parentDshSessionId && !session.closed && !session.released);
3439
+ if (parent === undefined) {
3440
+ throw new AcpError('parent_not_live', 'acp: the conversation to branch has no live ACP session; send a message to resume it first');
3441
+ }
3442
+ if (parent.running || parent.settled !== undefined) {
3443
+ throw new AcpError('turn_in_flight', 'acp: the conversation to branch is running a turn; wait for it to finish');
3444
+ }
3445
+ const agentId = parent.agentId;
3446
+ const childKey = `${childDshSessionId}:${agentId}`;
3447
+ if (this.dshSessionMap.has(childKey)) {
3448
+ throw new AcpError('id_conflict', 'acp: the forked conversation is already bound to an ACP session');
3449
+ }
3450
+ // Capability gate BEFORE any request: an agent that never advertised
3451
+ // `session/fork` must not be asked (it would answer an unknown-method
3452
+ // error at best). Live init wins, last-known falls back — the same
3453
+ // verdict the browser's row already showed.
3454
+ if (!this.canForkNow(agentId)) {
3455
+ throw new AcpError('fork_unsupported', `acp: agent "${agentId}" does not advertise the session/fork capability`);
3456
+ }
3457
+ const session = await this.bridge(agentId).forkSession(parent, childDshSessionId);
3458
+ // The fork answered — the child is real on the agent side. From here the
3459
+ // binding is written through the SAME paths as any chat session: one
3460
+ // choice record (a copy of the parent's, locked — a branched conversation
3461
+ // belongs to its agent from birth), the route pin, and persistence.
3462
+ this.dshSessionMap.set(childKey, session);
3463
+ const parentChoice = this.chatAgentIntent.get(parentDshSessionId);
3464
+ this.patchChatChoice(childDshSessionId, {
3465
+ agentId,
3466
+ locked: true,
3467
+ ...(parentChoice?.modelId === undefined ? {} : { modelId: parentChoice.modelId }),
3468
+ ...(parentChoice?.effort === undefined ? {} : { effort: parentChoice.effort }),
3469
+ ...(parentChoice?.mode === undefined ? {} : { mode: parentChoice.mode }),
3470
+ ...(parentChoice?.extraConfigs === undefined ? {} : { extraConfigs: { ...parentChoice.extraConfigs } }),
3471
+ });
3472
+ // The fourth creation point follows the same discipline as `createSession`
3473
+ // and `doResumeSession`: apply the recorded pick to the fresh session, so
3474
+ // mode and extra configs (which only this path applies) cannot drift from
3475
+ // what the composer shows — a forked session starts on the agent-side
3476
+ // state the fork copied, and the record must match what runs.
3477
+ await this.applyChatChoice(session);
3478
+ // Re-publish so the browser's list and the composer's occupancy ring see
3479
+ // the child immediately (with its just-bound choice mirrors).
3480
+ this.hub.broadcast({ type: 'session/meta', sessionId: session.sessionId, session: snapshot(session) });
3481
+ this.persistAgents();
3482
+ return { sessionId: session.sessionId, acpSessionId: session.acpSessionId };
3483
+ }
3174
3484
  /**
3175
3485
  * Bind one DSH chat session to an ACP agent.
3176
3486
  *
@@ -3428,12 +3738,13 @@ export class BridgeManager {
3428
3738
  for (const session of this.dshSessionMap.values()) {
3429
3739
  if (session.dshSessionId !== dshSessionId || session.agentId !== agentId)
3430
3740
  continue;
3741
+ session.choice = choice;
3742
+ session.modelId = modelId;
3431
3743
  if (session.released)
3432
3744
  continue;
3433
3745
  await this.alignSessionConfig(session, choice, session.reasoningEffort);
3434
- session.choice = choice;
3435
- session.modelId = modelId;
3436
3746
  }
3747
+ this.persistAgents();
3437
3748
  }
3438
3749
  /**
3439
3750
  * Switch the reasoning effort for one chat session.
@@ -3458,14 +3769,18 @@ export class BridgeManager {
3458
3769
  // session is skipped here and re-aligned by `applyChatChoice` on resume.
3459
3770
  this.patchChatChoice(dshSessionId, { ...(agentId === undefined ? {} : { agentId }), effort });
3460
3771
  for (const session of this.dshSessionMap.values()) {
3461
- if (session.dshSessionId !== dshSessionId || session.released)
3772
+ if (session.dshSessionId !== dshSessionId)
3462
3773
  continue;
3463
3774
  if (agentId !== undefined && session.agentId !== agentId)
3464
3775
  continue;
3776
+ session.reasoningEffort = effort;
3777
+ if (session.released)
3778
+ continue;
3465
3779
  const aligned = await this.alignSessionConfig(session, session.choice, effort);
3466
3780
  // Same rule as the model axis: a dropped level is no longer "in force".
3467
3781
  session.reasoningEffort = aligned.effortStale ? undefined : effort;
3468
3782
  }
3783
+ this.persistAgents();
3469
3784
  }
3470
3785
  /**
3471
3786
  * Set one of the agent's EXTRA config options for one chat session (the
@@ -3658,9 +3973,15 @@ export class BridgeManager {
3658
3973
  return views;
3659
3974
  }
3660
3975
  /**
3661
- * Switch one chat-bound session's ACP mode. A released session is resumed
3662
- * first (`session/load`), matching the "send to recover" promise — the mode
3663
- * switch is itself a reason to bring the agent back.
3976
+ * Switch one chat-bound session's ACP mode.
3977
+ *
3978
+ * A released session is deliberately NOT resumed for it (user decision,
3979
+ * 2026-10-30 — the composer's axes are "what the next turn should use", and
3980
+ * the process comes back only when the user sends a message or hits the
3981
+ * released banner's resume button). The pick is recorded and
3982
+ * `applyChatChoice` applies it on resume, exactly like `setChatSessionModel`
3983
+ * / `setChatSessionEffort` / `setChatSessionConfig`; only a LIVE session is
3984
+ * told immediately (`session/set_mode`).
3664
3985
  */
3665
3986
  async setChatSessionMode(dshSessionId, modeId, agentId) {
3666
3987
  if (agentId !== undefined) {
@@ -3690,15 +4011,62 @@ export class BridgeManager {
3690
4011
  if (target.closed) {
3691
4012
  throw new AcpError('unknown_session', `acp: the ACP session bound to chat session ${dshSessionId} is closed`);
3692
4013
  }
4014
+ // A PARKED session gets the same record-only treatment (its agent is its
4015
+ // own `agentId`, whatever the caller passed — the selection above already
4016
+ // matched it). Resuming here was the one composer axis that broke ranks
4017
+ // with the others, and it woke a process the user had explicitly parked.
4018
+ if (target.released) {
4019
+ this.patchChatChoice(dshSessionId, { agentId: target.agentId, mode: modeId });
4020
+ if (target.modes !== undefined) {
4021
+ target.modes = { ...target.modes, currentModeId: modeId };
4022
+ }
4023
+ this.persistAgents();
4024
+ return;
4025
+ }
3693
4026
  const bridge = this.bridge(target.agentId);
3694
- const session = target.released ? await bridge.resumeSession(target) : target;
3695
- await bridge.setMode(session, modeId);
4027
+ await bridge.setMode(target, modeId);
3696
4028
  // Persisted only AFTER the agent accepted it: a mode the agent rejected must
3697
4029
  // not come back on the next restore. This is the same contract the model and
3698
4030
  // thought level follow (user decision, 2026-10-18 — every control in the
3699
4031
  // bottom bar remembers its last choice, not just the thought level).
3700
4032
  this.patchChatChoice(dshSessionId, { agentId: target.agentId, mode: modeId });
3701
4033
  }
4034
+ /**
4035
+ * Bring one chat conversation's released session back on a fresh process on
4036
+ * the USER's explicit gesture (the "恢复进程" banner) — the same work the
4037
+ * next prompt performs lazily (`getOrCreateDshSession`'s released branch),
4038
+ * minus the create path: a banner button must never materialize a session
4039
+ * the conversation has not had yet. A live target passes through untouched,
4040
+ * so a double click (or a click racing the next message) spawns nothing.
4041
+ */
4042
+ async resumeChatSession(dshSessionId) {
4043
+ const candidates = [...this.dshSessionMap.values()].filter(session => session.dshSessionId === dshSessionId && !session.closed);
4044
+ // The session the NEXT message would use — the bound agent's — is the one
4045
+ // to bring back; the first candidate is the fallback for a conversation
4046
+ // whose binding has drifted (same selection `setChatSessionMode` makes
4047
+ // without an explicit agentId).
4048
+ const target = candidates.find(session => session.agentId === this.chatAgentFor(dshSessionId)) ?? candidates[0];
4049
+ if (target === undefined) {
4050
+ throw new AcpError('unknown_session', `acp: no ACP session is bound to chat session ${dshSessionId}`);
4051
+ }
4052
+ if (!target.released)
4053
+ return;
4054
+ const bridge = this.bridge(target.agentId);
4055
+ const resumed = await bridge.resumeSession(target);
4056
+ // Chat-side rebind + pick re-application, verbatim the lazy path's: the
4057
+ // freshly loaded session comes back at the agent's defaults, so the
4058
+ // conversation's model / thought level must be re-applied or the composer
4059
+ // would show one thing and the process run another (see
4060
+ // `getOrCreateDshSession`).
4061
+ resumed.dshSessionId = dshSessionId;
4062
+ this.lockChatAgent(dshSessionId, target.agentId);
4063
+ if (this.chatAgentIntent.has(dshSessionId))
4064
+ this.syncConversationRoute(dshSessionId);
4065
+ else
4066
+ routeBindingRegistry().conversations.set(dshSessionId, resumed.modelId ?? target.agentId);
4067
+ await this.applyChatChoice(resumed);
4068
+ resumed.provisionalLock = false;
4069
+ }
3702
4070
  /**
3703
4071
  * Wire the harness's preset roster (`ctx.agentPresets`) into `presetStatus`.
3704
4072
  *
@@ -3889,7 +4257,22 @@ export class BridgeManager {
3889
4257
  catch {
3890
4258
  this.deadlineOverride = {};
3891
4259
  }
4260
+ // The settings page's idle-release pick. Re-validated on every boot
4261
+ // because the state file is user- and version-editable: an
4262
+ // unreadable value must not poison the window, and a value the
4263
+ // validator accepts must be the value that runs.
4264
+ try {
4265
+ const raw = parsed.idleRelease;
4266
+ const value = raw?.idleTimeoutMs;
4267
+ this.idleReleaseOverride = typeof value === 'number' && IDLE_RELEASE_MS.includes(value)
4268
+ ? value
4269
+ : undefined;
4270
+ }
4271
+ catch {
4272
+ this.idleReleaseOverride = undefined;
4273
+ }
3892
4274
  this.loadPersistedChoices(parsed.conversations);
4275
+ this.loadPersistedChatSessions(parsed.chatSessions);
3893
4276
  }
3894
4277
  }
3895
4278
  catch {
@@ -3938,6 +4321,13 @@ export class BridgeManager {
3938
4321
  if (!filePath)
3939
4322
  return;
3940
4323
  try {
4324
+ // The chat-bound session records themselves: a host restart kills the
4325
+ // processes but not the agent-side sessions, so restoring these as
4326
+ // PARKED keeps a conversation resumable (`session/load`) instead of
4327
+ // its next message silently starting a context-less new one.
4328
+ const chatSessions = [...this.dshSessionMap.values()]
4329
+ .filter(session => session.dshSessionId !== undefined && !session.closed)
4330
+ .map(persistedChatSession);
3941
4331
  const payload = {
3942
4332
  version: 2,
3943
4333
  ...(this.defaultAgentId === undefined ? {} : { defaultAgentId: this.defaultAgentId }),
@@ -3948,12 +4338,18 @@ export class BridgeManager {
3948
4338
  ...(Object.keys(this.deadlineOverride).length === 0
3949
4339
  ? {}
3950
4340
  : { approvalDeadlines: { ...this.deadlineOverride } }),
4341
+ // The settings page's idle-release pick. Absent while none was
4342
+ // ever written, so a fresh deployment's state file stays as it was.
4343
+ ...(this.idleReleaseOverride === undefined
4344
+ ? {}
4345
+ : { idleRelease: { idleTimeoutMs: this.idleReleaseOverride } }),
3951
4346
  // The per-conversation picks: agent + model + thought level + mode +
3952
4347
  // extra config values chosen in the composer, including for
3953
4348
  // conversations with no live session yet.
3954
4349
  ...(this.chatAgentIntent.size === 0
3955
4350
  ? {}
3956
4351
  : { conversations: Object.fromEntries(this.chatAgentIntent) }),
4352
+ ...(chatSessions.length === 0 ? {} : { chatSessions }),
3957
4353
  };
3958
4354
  writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8');
3959
4355
  }
@@ -3961,8 +4357,77 @@ export class BridgeManager {
3961
4357
  // Storage persist failure is non-fatal
3962
4358
  }
3963
4359
  }
4360
+ /**
4361
+ * Restore chat session records from disk as PARKED sessions.
4362
+ *
4363
+ * Every process died with the host, so nothing comes back live — but the
4364
+ * agent-side sessions did not. Restoring each record as `released` is what
4365
+ * makes the released banner, its resume button and the "next message
4366
+ * resumes it" promise survive a restart (the alternative is the next
4367
+ * message quietly starting a NEW, context-less session — constraint 10's
4368
+ * "released ≠ destroyed" lost at the process boundary). Tolerant, one row
4369
+ * at a time: a single corrupt entry must not cost every other conversation
4370
+ * its context. Records whose agent is no longer configured are dropped —
4371
+ * there is nothing to resume ON, and the next write omits them.
4372
+ */
4373
+ loadPersistedChatSessions(raw) {
4374
+ if (!Array.isArray(raw))
4375
+ return;
4376
+ for (const row of raw) {
4377
+ if (row === null || typeof row !== 'object')
4378
+ continue;
4379
+ const record = row;
4380
+ const { sessionId, acpSessionId, agentId, dshSessionId, cwd } = record;
4381
+ if (typeof sessionId !== 'string' || sessionId === '')
4382
+ continue;
4383
+ if (typeof acpSessionId !== 'string' || acpSessionId === '')
4384
+ continue;
4385
+ if (typeof agentId !== 'string' || agentId === '')
4386
+ continue;
4387
+ if (typeof dshSessionId !== 'string' || dshSessionId === '')
4388
+ continue;
4389
+ if (typeof cwd !== 'string')
4390
+ continue;
4391
+ if (!this.defsMap.has(agentId))
4392
+ continue;
4393
+ const { title, modelId, reasoningEffort, modes, usage, hasHistory } = record;
4394
+ const session = {
4395
+ sessionId,
4396
+ acpSessionId,
4397
+ agentId,
4398
+ dshSessionId,
4399
+ cwd,
4400
+ ...(typeof title === 'string' && title !== '' ? { title } : {}),
4401
+ ...(typeof modelId === 'string' && modelId !== ''
4402
+ ? { modelId, ...(splitAdapterModelId(modelId).choice === undefined ? {} : { choice: splitAdapterModelId(modelId).choice }) }
4403
+ : {}),
4404
+ ...(typeof reasoningEffort === 'string' && reasoningEffort !== '' ? { reasoningEffort } : {}),
4405
+ ...(isPersistedModes(modes) ? { modes } : {}),
4406
+ ...(isPersistedUsage(usage) ? { usage } : {}),
4407
+ ...(hasHistory === true ? { hasHistory: true } : {}),
4408
+ running: false,
4409
+ turnSeq: 0,
4410
+ closed: false,
4411
+ released: true,
4412
+ lastAgentActivityAt: Date.now(),
4413
+ activeToolCalls: new Set(),
4414
+ transcript: new Transcript(this.transcriptMax),
4415
+ };
4416
+ this.dshSessionMap.set(`${dshSessionId}:${agentId}`, session);
4417
+ this.bridge(agentId).parkReleased(session);
4418
+ }
4419
+ }
3964
4420
  // ---- Model catalog ----
3965
4421
  catalogs = new Map();
4422
+ /**
4423
+ * Each agent's LAST KNOWN `sessionCapabilities.fork` bit, persisted with the
4424
+ * model catalog. A live `initialize` result outranks it whenever one exists
4425
+ * (see {@link canForkNow}) — this copy answers when the process is gone
4426
+ * (a released session's branch button must not flash into "supported" just
4427
+ * because `initResult` died with it, nor read `view().agentCapabilities`,
4428
+ * which a release clears).
4429
+ */
4430
+ forkCaps = new Map();
3966
4431
  /**
3967
4432
  * The slash commands each agent declared in its most recent session, cached
3968
4433
  * per agent and persisted alongside the model catalog.
@@ -4035,6 +4500,10 @@ export class BridgeManager {
4035
4500
  if (typeof raw !== 'object' || raw === null)
4036
4501
  continue;
4037
4502
  const row = raw;
4503
+ // The persisted fork capability: a LAST KNOWN bit, loaded before any
4504
+ // bridge can report a fresh one (a fresh `initialize` overwrites it).
4505
+ if (typeof row.fork === 'boolean')
4506
+ this.forkCaps.set(agentId, row.fork);
4038
4507
  // Commands and the model catalog are learned independently (a session
4039
4508
  // may declare one before the other), so either half alone is a valid row.
4040
4509
  if (Array.isArray(row.commands)) {
@@ -4068,6 +4537,12 @@ export class BridgeManager {
4068
4537
  for (const [agentId, entry] of this.commandCache) {
4069
4538
  agents[agentId] = { ...(agents[agentId] ?? {}), commands: entry.commands, commandsUpdatedAt: entry.updatedAt };
4070
4539
  }
4540
+ // The LAST KNOWN fork capability rides the same file: it outlives the
4541
+ // agent process, which is exactly when the browser needs it (see
4542
+ // `canForkNow`).
4543
+ for (const [agentId, supported] of this.forkCaps) {
4544
+ agents[agentId] = { ...(agents[agentId] ?? {}), fork: supported };
4545
+ }
4071
4546
  writeFileSync(filePath, JSON.stringify({ version: BridgeManager.CATALOG_SCHEMA, agents }, null, 2), 'utf-8');
4072
4547
  }
4073
4548
  catch {
@@ -4162,6 +4637,32 @@ export class BridgeManager {
4162
4637
  // first `/`, when there is still no live session to ask.
4163
4638
  this.hub.broadcast({ type: 'catalog/updated', agentId, updatedAt: entry.updatedAt });
4164
4639
  }
4640
+ /**
4641
+ * Record a fresh `initialize`'s `sessionCapabilities.fork` verdict (the
4642
+ * bridge reports it right after the handshake). Persisted with the model
4643
+ * catalog; no broadcast — the browser learns it through `session/chat-list`
4644
+ * rows, and its branch button deliberately runs on a last-known basis (the
4645
+ * agent's own next `initialize` is the refresh).
4646
+ */
4647
+ noteForkCapability(agentId, supported) {
4648
+ if (this.forkCaps.get(agentId) === supported)
4649
+ return;
4650
+ this.forkCaps.set(agentId, supported);
4651
+ this.persistCatalogs();
4652
+ }
4653
+ /**
4654
+ * Whether an agent's fork can be attempted RIGHT NOW: a live `initialize`
4655
+ * answer wins (the process is up, its handshake is current); only when the
4656
+ * process is gone does the LAST KNOWN persisted bit answer. `false` covers
4657
+ * "never seen" — an unknown agent simply never gets a fork attempt.
4658
+ */
4659
+ canForkNow(agentId) {
4660
+ const bridge = this.bridges.get(agentId);
4661
+ if (bridge?.initResult !== undefined) {
4662
+ return bridge.initResult.agentCapabilities?.sessionCapabilities?.fork != null;
4663
+ }
4664
+ return this.forkCaps.get(agentId) === true;
4665
+ }
4165
4666
  /**
4166
4667
  * Append models this observation newly introduced to the agent's selection.
4167
4668
  *
@@ -4602,6 +5103,10 @@ export class BridgeManager {
4602
5103
  this.absorbCatalog(observedAgentId, configOptions);
4603
5104
  }, (observedAgentId, commands) => {
4604
5105
  this.absorbCommands(observedAgentId, commands);
5106
+ }, () => {
5107
+ this.persistAgents();
5108
+ }, (observedAgentId, supported) => {
5109
+ this.noteForkCapability(observedAgentId, supported);
4605
5110
  });
4606
5111
  this.bridges.set(agentId, bridge);
4607
5112
  }
@@ -4760,11 +5265,15 @@ export class BridgeManager {
4760
5265
  }
4761
5266
  if (routeBindingRegistry().defaultModel === agentId)
4762
5267
  routeBindingRegistry().defaultModel = undefined;
4763
- // Both maps are independent (either may be missing), so delete both before
4764
- // deciding whether the file needs rewriting.
5268
+ // All three maps are independent (any may be missing), so delete all
5269
+ // before deciding whether the file needs rewriting. The fork capability
5270
+ // goes with the agent: a same-id rebuild must not inherit the OLD agent's
5271
+ // "supported" and click into a `fork_unsupported` wall before its own
5272
+ // first `initialize` answers.
4765
5273
  const droppedCatalog = this.catalogs.delete(agentId);
4766
5274
  const droppedCommands = this.commandCache.delete(agentId);
4767
- if (droppedCatalog || droppedCommands)
5275
+ const droppedFork = this.forkCaps.delete(agentId);
5276
+ if (droppedCatalog || droppedCommands || droppedFork)
4768
5277
  this.persistCatalogs();
4769
5278
  this.persistAgents();
4770
5279
  this.hub.broadcast({
@@ -5004,6 +5513,23 @@ export class BridgeManager {
5004
5513
  this.tagHaloSetting = validateTagHalo(halo);
5005
5514
  this.persistAgents();
5006
5515
  }
5516
+ /**
5517
+ * Declare the deployment baseline for the idle-release window — the value
5518
+ * `cordis.patch.yml` names, passed RAW (only when it names one).
5519
+ *
5520
+ * Kept separate from the validated tuning for the same reason the
5521
+ * deadline keeps its baseline apart: "the file says 10 minutes" and
5522
+ * "the file says nothing, so 30 minutes applies" are different facts.
5523
+ * The first is the reset target and shows as the fact being overridden;
5524
+ * the second is a built-in default. Called once by the host entry,
5525
+ * before any bridge exists.
5526
+ */
5527
+ setConfiguredIdleRelease(idleTimeoutMs) {
5528
+ this.configuredIdleTimeoutMs = idleTimeoutMs;
5529
+ if (idleTimeoutMs !== undefined)
5530
+ this.baselineIdleTimeoutMs = idleTimeoutMs;
5531
+ this.applyIdleReleaseTuning();
5532
+ }
5007
5533
  /**
5008
5534
  * Declare the deployment baseline for the approval deadline — the values
5009
5535
  * `cordis.patch.yml` names, passed RAW (only the keys it actually contains).
@@ -5051,6 +5577,56 @@ export class BridgeManager {
5051
5577
  deadlines.configuredMode = this.configuredMode;
5052
5578
  return deadlines;
5053
5579
  }
5580
+ /**
5581
+ * The idle-release window in force, plus the facts that explain a value the
5582
+ * user did not pick: the deployment baseline an override is outranking.
5583
+ *
5584
+ * `defaultIdleTimeoutMs` is the window a RESET returns to — the deployment
5585
+ * baseline when the config file names one, else the built-in default. It is
5586
+ * NOT a hardcoded constant: a deployment that names 10 minutes must reset
5587
+ * to 10 minutes, not to the built-in 30.
5588
+ *
5589
+ * The config file's own number is reported only when the file names one, so
5590
+ * the settings card can stay silent about a plain default.
5591
+ */
5592
+ idleRelease() {
5593
+ const release = {
5594
+ idleTimeoutMs: this.idleTimeoutMs(),
5595
+ defaultIdleTimeoutMs: this.baselineIdleTimeoutMs,
5596
+ };
5597
+ if (this.configuredIdleTimeoutMs !== undefined)
5598
+ release.configuredIdleTimeoutMs = this.configuredIdleTimeoutMs;
5599
+ return release;
5600
+ }
5601
+ /**
5602
+ * Write the idle-release window, or drop the persisted override with
5603
+ * `reset` so the deployment baseline applies again.
5604
+ *
5605
+ * HOT: the rewrite reaches every live bridge, so a user changing the window
5606
+ * never has to kill a running session to see it take effect.
5607
+ */
5608
+ async setIdleRelease(idleTimeoutMs, reset = false) {
5609
+ this.idleReleaseOverride = reset ? undefined : idleTimeoutMs;
5610
+ this.applyIdleReleaseTuning();
5611
+ this.persistAgents();
5612
+ }
5613
+ /**
5614
+ * Recompute the idle-release window from override → deployment baseline, and
5615
+ * push the result to every live bridge.
5616
+ */
5617
+ applyIdleReleaseTuning() {
5618
+ const window = this.idleTimeoutMs();
5619
+ this.tuning.idleTimeoutMs = window;
5620
+ for (const bridge of this.bridges.values())
5621
+ bridge.applyIdleRelease(window);
5622
+ }
5623
+ /**
5624
+ * The idle-release window that runs, in ms: the settings page's pick, else
5625
+ * the deployment baseline.
5626
+ */
5627
+ idleTimeoutMs() {
5628
+ return this.idleReleaseOverride ?? this.baselineIdleTimeoutMs;
5629
+ }
5054
5630
  /**
5055
5631
  * Write the approval deadline, or the mode (omitted keys keep their value),
5056
5632
  * or drop the persisted override with `reset` so the config-file baseline —
@@ -5108,10 +5684,17 @@ export class BridgeManager {
5108
5684
  chatAgentIntent = new Map();
5109
5685
  /** Drop every chat-keyed record pointing at one host session (close/stop paths). */
5110
5686
  forgetDshSession(sessionId) {
5687
+ let dropped = false;
5111
5688
  for (const [key, session] of this.dshSessionMap) {
5112
- if (session.sessionId === sessionId)
5689
+ if (session.sessionId === sessionId) {
5113
5690
  this.dshSessionMap.delete(key);
5691
+ dropped = true;
5692
+ }
5114
5693
  }
5694
+ // The disk record goes with it: a closed session is destroyed, not parked,
5695
+ // and must not come back on the next boot (constraint 10).
5696
+ if (dropped)
5697
+ this.persistAgents();
5115
5698
  }
5116
5699
  sessionWorkspaceResolver;
5117
5700
  acpModeResolver;
@@ -5265,6 +5848,9 @@ export class BridgeManager {
5265
5848
  // A RESUMED session had context before this call; a lock written here is
5266
5849
  // not provisional even if the conversation record was missing it.
5267
5850
  resumed.provisionalLock = false;
5851
+ // The load refreshed the record's mirrors (modes / usage / hasHistory);
5852
+ // persist so a later restart restores what this resume saw.
5853
+ this.persistAgents();
5268
5854
  return resumed;
5269
5855
  }
5270
5856
  // A chat session runs where its DSH session lives, not in the plugin's
@@ -5314,6 +5900,9 @@ export class BridgeManager {
5314
5900
  // conversation back (a failed first attempt must not trap it).
5315
5901
  session.provisionalLock = this.lockChatAgent(dshSessionId, agentId);
5316
5902
  }
5903
+ // The record is on disk from here: a host restart restores it as parked
5904
+ // instead of the next message starting a context-less new session.
5905
+ this.persistAgents();
5317
5906
  return session;
5318
5907
  }
5319
5908
  /**