dsh-ntr 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/cordis.patch.yml +17 -1
- package/lib/bridge.js +607 -18
- package/lib/client/acp-ui.js +103 -3
- package/lib/client/acp-view.js +24 -4
- package/lib/client/branch-action.js +133 -0
- package/lib/client/composer-mode.js +48 -13
- package/lib/client/context-meter.js +86 -0
- package/lib/client/index.js +238 -15
- package/lib/client/panel.js +96 -29
- package/lib/client/released-banner.js +66 -0
- package/lib/client/session-controls.js +38 -13
- package/lib/client/slash-isolation.js +2 -2
- package/lib/client/store.js +348 -13
- package/lib/client.js +1138 -67
- package/lib/client.js.map +1 -1
- package/lib/impl.js +54 -1
- package/lib/index.js +7 -0
- package/lib/protocol.js +19 -0
- package/lib/types/bridge.d.ts +229 -6
- package/lib/types/client/acp-ui.d.ts +34 -2
- package/lib/types/client/acp-view.d.ts +13 -2
- package/lib/types/client/branch-action.d.ts +40 -0
- package/lib/types/client/composer-mode.d.ts +28 -0
- package/lib/types/client/context-meter.d.ts +51 -0
- package/lib/types/client/released-banner.d.ts +34 -0
- package/lib/types/client/slash-isolation.d.ts +1 -1
- package/lib/types/client/store.d.ts +98 -1
- package/lib/types/impl.d.ts +41 -1
- package/lib/types/protocol.d.ts +123 -0
- package/package.json +2 -1
- package/src/bridge.ts +638 -20
- package/src/client/acp-ui.tsx +123 -2
- package/src/client/acp-view.tsx +30 -5
- package/src/client/branch-action.tsx +160 -0
- package/src/client/composer-mode.tsx +59 -13
- package/src/client/context-meter.tsx +128 -0
- package/src/client/index.tsx +265 -14
- package/src/client/panel.tsx +157 -40
- package/src/client/primitives.d.ts +27 -0
- package/src/client/released-banner.tsx +97 -0
- package/src/client/session-controls.tsx +34 -11
- package/src/client/slash-isolation.ts +2 -1
- package/src/client/store.ts +375 -14
- package/src/impl.ts +84 -2
- package/src/index.ts +8 -0
- package/src/protocol.ts +121 -0
package/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:
|
|
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
|
|
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.
|
|
3662
|
-
*
|
|
3663
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
/**
|