dsh-switchman 1.3.1 → 1.5.0
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.de.md +61 -29
- package/README.es.md +61 -29
- package/README.fr.md +60 -28
- package/README.it.md +53 -21
- package/README.ja.md +58 -26
- package/README.ko.md +54 -22
- package/README.md +57 -25
- package/README.pt.md +54 -22
- package/README.ru.md +61 -29
- package/README.zh-TW.md +57 -25
- package/README.zh.md +54 -22
- package/client.js +900 -17
- package/cordis.patch.yml +24 -8
- package/host/config.js +23 -0
- package/host/context-watch.js +267 -68
- package/host/dispatch.js +107 -6
- package/host/handover-state.js +38 -0
- package/host/lang.js +211 -36
- package/host/lib/dsh-home.js +18 -0
- package/host/routes.js +138 -7
- package/host/subagent-model.js +147 -0
- package/host/subagents.js +144 -0
- package/host/teams.js +430 -0
- package/host/ui-locale.js +35 -0
- package/host/vision.js +367 -0
- package/index.js +40 -8
- package/package.json +1 -1
package/host/routes.js
CHANGED
|
@@ -7,13 +7,28 @@
|
|
|
7
7
|
* plugin established). This module owns that route family:
|
|
8
8
|
*
|
|
9
9
|
* GET /api/dsh-switchman/health — liveness probe for the client page.
|
|
10
|
-
* GET /api/dsh-switchman/config — current settings values snapshot
|
|
10
|
+
* GET /api/dsh-switchman/config — current settings values snapshot plus
|
|
11
|
+
* `whitelistSync` (null unless teamsMode
|
|
12
|
+
* && syncWhitelist, else the teams
|
|
13
|
+
* layer's last replace-sync outcome).
|
|
11
14
|
* POST /api/dsh-switchman/config — { expected, values } fenced write
|
|
12
15
|
* through ctx.settings.update; a stale
|
|
13
16
|
* `expected` answers 409 + fresh values.
|
|
17
|
+
* A successful write schedules the teams
|
|
18
|
+
* orchestration (fire-and-forget).
|
|
14
19
|
* GET /api/dsh-switchman/models — model-catalog pass-through from the
|
|
15
20
|
* host sessionController (degrades to
|
|
16
21
|
* llm.listProviders, then []).
|
|
22
|
+
* GET /api/dsh-switchman/authorized — the session's DSH-authorized child
|
|
23
|
+
* models ({enabled, routes}; both null
|
|
24
|
+
* when the host service is unreadable).
|
|
25
|
+
* GET /api/dsh-switchman/vision-state — composer-dock hint; the optional
|
|
26
|
+
* `session` query parameter judges image
|
|
27
|
+
* capability against THAT session's
|
|
28
|
+
* model (unknown/missing → root model).
|
|
29
|
+
* POST /api/dsh-switchman/ui-locale — the client half reports its active
|
|
30
|
+
* UI locale (feeds the ask guidance's
|
|
31
|
+
* question language; no settings write).
|
|
17
32
|
*
|
|
18
33
|
* Every route carries the shared trust fence (loopback + same-origin browser
|
|
19
34
|
* markers; a live remoteWebUiPairing cookie is an extra allow path), mirroring
|
|
@@ -21,17 +36,30 @@
|
|
|
21
36
|
* settings, so unpaired LAN clients must not reach it.
|
|
22
37
|
*/
|
|
23
38
|
|
|
39
|
+
import { authorizedChildRoutes } from "./dispatch.js";
|
|
40
|
+
import { handoverSnapshot } from "./handover-state.js";
|
|
41
|
+
import { pumpTeamsOrchestration, scheduleTeamsOrchestration, SWITCHMAN_SETTINGS_NS, teamsBundleState, teamsSyncSnapshot } from "./teams.js";
|
|
42
|
+
import { reportedUiLocale, setReportedUiLocale } from "./ui-locale.js";
|
|
43
|
+
import { visionStateOf } from "./vision.js";
|
|
44
|
+
|
|
24
45
|
/** Route paths (client.js mirrors these literals). */
|
|
25
46
|
const ROUTES = {
|
|
26
47
|
health: "/api/dsh-switchman/health",
|
|
27
48
|
config: "/api/dsh-switchman/config",
|
|
28
49
|
models: "/api/dsh-switchman/models",
|
|
50
|
+
authorized: "/api/dsh-switchman/authorized",
|
|
51
|
+
handoverState: "/api/dsh-switchman/handover-state",
|
|
52
|
+
visionState: "/api/dsh-switchman/vision-state",
|
|
53
|
+
uiLocale: "/api/dsh-switchman/ui-locale",
|
|
29
54
|
};
|
|
30
55
|
|
|
31
56
|
/** Settings namespace this plugin's writes target: the LOADER ENTRY id from
|
|
32
57
|
* cordis.patch.yml (`- id: dsh-switchman-host`), not the plugin's `name`
|
|
33
|
-
* export — dsh-settings' write() looks entries up by entry.options.id.
|
|
34
|
-
|
|
58
|
+
* export — dsh-settings' write() looks entries up by entry.options.id.
|
|
59
|
+
* Single-sourced as SWITCHMAN_SETTINGS_NS in host/teams.js (whose
|
|
60
|
+
* settings/document-updated listener keys on it); aliased here for the
|
|
61
|
+
* write call sites below. */
|
|
62
|
+
const NS = SWITCHMAN_SETTINGS_NS;
|
|
35
63
|
|
|
36
64
|
/** All settings field names, grouped for coercion (mirrors host/config.js). */
|
|
37
65
|
const STRING_FIELDS = ["langConversation", "langComments", "langDocs"];
|
|
@@ -60,6 +88,9 @@ const BOOLEAN_FIELDS = [
|
|
|
60
88
|
"poolHardManual",
|
|
61
89
|
"poolVisionManual",
|
|
62
90
|
"poolReviewManual",
|
|
91
|
+
// teams 开关(mirrors host/config.js 的 teams* 组)
|
|
92
|
+
"teamsMode",
|
|
93
|
+
"syncWhitelist",
|
|
63
94
|
];
|
|
64
95
|
/** Per-pool effort-route lists (mirrors host/config.js pool*Efforts). */
|
|
65
96
|
const EFFORTS_FIELDS = [
|
|
@@ -71,6 +102,7 @@ const EFFORTS_FIELDS = [
|
|
|
71
102
|
"poolReviewEfforts",
|
|
72
103
|
];
|
|
73
104
|
const ENUM_FIELDS = {
|
|
105
|
+
langScope: ["global", "project"],
|
|
74
106
|
dispatchEnforce: ["off", "advice", "enforce"],
|
|
75
107
|
wmDenyMode: ["cap", "deny"],
|
|
76
108
|
};
|
|
@@ -156,9 +188,17 @@ function isAllowed(ctx, request) {
|
|
|
156
188
|
);
|
|
157
189
|
}
|
|
158
190
|
|
|
159
|
-
/** Read one volatile settings field's raw value (live `.get()` reference
|
|
191
|
+
/** Read one volatile settings field's raw value (live `.get()` reference;
|
|
192
|
+
* undefined when the getter itself throws — same guard as teams.js's
|
|
193
|
+
* readValue, kept on this GET-config hot path). */
|
|
160
194
|
function rawOf(field) {
|
|
161
|
-
if (field !== null && typeof field === "object" && typeof field.get === "function")
|
|
195
|
+
if (field !== null && typeof field === "object" && typeof field.get === "function") {
|
|
196
|
+
try {
|
|
197
|
+
return field.get();
|
|
198
|
+
} catch {
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
162
202
|
return field;
|
|
163
203
|
}
|
|
164
204
|
|
|
@@ -206,6 +246,7 @@ export function snapshotOf(config) {
|
|
|
206
246
|
values[field] = Array.isArray(rawOf(config?.[field]))
|
|
207
247
|
? rawOf(config[field]).map(cleanEffortEntry).filter(Boolean)
|
|
208
248
|
: [];
|
|
249
|
+
values.langScope = ENUM_FIELDS.langScope.includes(rawOf(config?.langScope)) ? rawOf(config.langScope) : "global";
|
|
209
250
|
values.dispatchEnforce = ENUM_FIELDS.dispatchEnforce.includes(rawOf(config?.dispatchEnforce))
|
|
210
251
|
? rawOf(config.dispatchEnforce)
|
|
211
252
|
: "advice";
|
|
@@ -220,6 +261,15 @@ export function snapshotOf(config) {
|
|
|
220
261
|
return values;
|
|
221
262
|
}
|
|
222
263
|
|
|
264
|
+
/** The whitelistSync status for config responses: null unless syncWhitelist
|
|
265
|
+
* is ON (an INDEPENDENT switch — teamsMode is not required), else the teams
|
|
266
|
+
* layer's last replace-sync outcome ({ ok, count, at, error? } — itself
|
|
267
|
+
* null before the first sync). */
|
|
268
|
+
function whitelistSyncOf(config) {
|
|
269
|
+
if (rawOf(config?.syncWhitelist) !== true) return null;
|
|
270
|
+
return teamsSyncSnapshot();
|
|
271
|
+
}
|
|
272
|
+
|
|
223
273
|
/** Coerce and validate a client-submitted values object; null when invalid. */
|
|
224
274
|
function coerceValues(input) {
|
|
225
275
|
if (input === null || typeof input !== "object" || Array.isArray(input)) return null;
|
|
@@ -244,6 +294,8 @@ function coerceValues(input) {
|
|
|
244
294
|
if (efforts.some((entry) => entry === null)) return null;
|
|
245
295
|
values[field] = efforts;
|
|
246
296
|
}
|
|
297
|
+
if (!ENUM_FIELDS.langScope.includes(input.langScope)) return null;
|
|
298
|
+
values.langScope = input.langScope;
|
|
247
299
|
if (!ENUM_FIELDS.dispatchEnforce.includes(input.dispatchEnforce)) return null;
|
|
248
300
|
values.dispatchEnforce = input.dispatchEnforce;
|
|
249
301
|
for (const field of NUMBER_FIELDS) {
|
|
@@ -327,7 +379,11 @@ export function makeRoutes(ctx, config) {
|
|
|
327
379
|
return;
|
|
328
380
|
}
|
|
329
381
|
if (req.method === "GET") {
|
|
330
|
-
|
|
382
|
+
// Clean-context retry pump: HTTP handlers never run inside an
|
|
383
|
+
// HMR transaction, so an orchestration deferred by the nested
|
|
384
|
+
// rejection re-runs here (no-op when nothing is pending).
|
|
385
|
+
pumpTeamsOrchestration(ctx, config);
|
|
386
|
+
writeJson(res, 200, { values: snapshotOf(config), whitelistSync: whitelistSyncOf(config), teamsBundle: teamsBundleState() });
|
|
331
387
|
return;
|
|
332
388
|
}
|
|
333
389
|
if (req.method !== "POST") {
|
|
@@ -363,7 +419,14 @@ export function makeRoutes(ctx, config) {
|
|
|
363
419
|
writeJson(res, 500, { error: error?.message ?? String(error) });
|
|
364
420
|
return;
|
|
365
421
|
}
|
|
366
|
-
|
|
422
|
+
// Teams orchestration hook (fire-and-forget: the teams layer defers
|
|
423
|
+
// the real work to a setImmediate turn, so the bundle enable +
|
|
424
|
+
// optional whitelist replace-sync never block this response; a
|
|
425
|
+
// nested-HMR rejection is retried from the next clean trigger —
|
|
426
|
+
// see host/teams.js). Runs when EITHER switch is on (they are
|
|
427
|
+
// independent); each half gates itself inside the orchestration.
|
|
428
|
+
scheduleTeamsOrchestration(ctx, config);
|
|
429
|
+
writeJson(res, 200, { values: snapshotOf(config), whitelistSync: whitelistSyncOf(config), teamsBundle: teamsBundleState() });
|
|
367
430
|
},
|
|
368
431
|
},
|
|
369
432
|
{
|
|
@@ -379,6 +442,74 @@ export function makeRoutes(ctx, config) {
|
|
|
379
442
|
}
|
|
380
443
|
},
|
|
381
444
|
},
|
|
445
|
+
{
|
|
446
|
+
kind: "exact",
|
|
447
|
+
path: ROUTES.authorized,
|
|
448
|
+
handler: async (req, res) => {
|
|
449
|
+
if (!guard(req, res, "GET")) return;
|
|
450
|
+
try {
|
|
451
|
+
const snapshot = authorizedChildRoutes(ctx);
|
|
452
|
+
writeJson(
|
|
453
|
+
res,
|
|
454
|
+
200,
|
|
455
|
+
snapshot === null
|
|
456
|
+
? { enabled: null, routes: null }
|
|
457
|
+
: { enabled: snapshot.enabled, routes: snapshot.routes },
|
|
458
|
+
);
|
|
459
|
+
} catch (error) {
|
|
460
|
+
ctx.logger.warn(`dsh-switchman: authorized-models read failed: ${error?.message ?? error}`);
|
|
461
|
+
writeJson(res, 500, { error: error?.message ?? String(error) });
|
|
462
|
+
}
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
kind: "exact",
|
|
467
|
+
path: ROUTES.handoverState,
|
|
468
|
+
handler: async (req, res) => {
|
|
469
|
+
if (!guard(req, res, "GET")) return;
|
|
470
|
+
// Read-only live registry: the client badge polls this during
|
|
471
|
+
// the otherwise-silent fork+compact window ({count, handovers}),
|
|
472
|
+
// so the GUI shows an animated "handover in progress" cue.
|
|
473
|
+
const handovers = handoverSnapshot();
|
|
474
|
+
writeJson(res, 200, { count: handovers.length, handovers });
|
|
475
|
+
},
|
|
476
|
+
},
|
|
477
|
+
{
|
|
478
|
+
kind: "exact",
|
|
479
|
+
path: ROUTES.visionState,
|
|
480
|
+
handler: async (req, res) => {
|
|
481
|
+
if (!guard(req, res, "GET")) return;
|
|
482
|
+
pumpTeamsOrchestration(ctx, config); // clean-context retry pump (see GET config)
|
|
483
|
+
try {
|
|
484
|
+
// Optional `session` query parameter (a child session id):
|
|
485
|
+
// judge image capability against THAT session's model
|
|
486
|
+
// instead of the root's (the root verdict misreported for
|
|
487
|
+
// children on a different route). A missing or unknown
|
|
488
|
+
// session falls back to the root verdict.
|
|
489
|
+
const session = new URL(req.url ?? "/", "http://localhost").searchParams.get("session");
|
|
490
|
+
writeJson(res, 200, await visionStateOf(ctx, config, typeof session === "string" && session !== "" ? session : null));
|
|
491
|
+
} catch (error) {
|
|
492
|
+
ctx.logger.warn(`dsh-switchman: vision-state read failed: ${error?.message ?? error}`);
|
|
493
|
+
writeJson(res, 500, { error: error?.message ?? String(error) });
|
|
494
|
+
}
|
|
495
|
+
},
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
kind: "exact",
|
|
499
|
+
path: ROUTES.uiLocale,
|
|
500
|
+
handler: async (req, res) => {
|
|
501
|
+
if (!guard(req, res, "POST")) return;
|
|
502
|
+
let body;
|
|
503
|
+
try {
|
|
504
|
+
body = await readJsonBody(req);
|
|
505
|
+
} catch (error) {
|
|
506
|
+
writeJson(res, 400, { error: error?.message ?? String(error) });
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
setReportedUiLocale(body?.locale);
|
|
510
|
+
writeJson(res, 200, { ok: true, locale: reportedUiLocale() });
|
|
511
|
+
},
|
|
512
|
+
},
|
|
382
513
|
];
|
|
383
514
|
}
|
|
384
515
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/** dsh-switchman subagent model-visibility layer.
|
|
2
|
+
*
|
|
3
|
+
* Parent-side model annotation for delegation results: a `tools/post-execute`
|
|
4
|
+
* waterfall listener (registered exactly like host/lang.js's capture) watches
|
|
5
|
+
* the `subagent` / `subagent_fork` tools and, when the call succeeded, reads
|
|
6
|
+
* the child session's `modelSelection` projection through the `sessionQuery`
|
|
7
|
+
* service (`observeSession` — cold sessions resolve without activating the
|
|
8
|
+
* child) and appends one `model: provider/model@effort` line to the accepted
|
|
9
|
+
* result content. DSH's own UI shows a child's model nowhere, so this line is
|
|
10
|
+
* the only place the parent learns which model actually runs each child.
|
|
11
|
+
*
|
|
12
|
+
* Shapes verified against dsh 0.2.1-alpha.1 sources:
|
|
13
|
+
* - Tool result values: `{kind:"continuable", subagentId}` (background
|
|
14
|
+
* continuable — subagentId IS the durable child session id,
|
|
15
|
+
* subagents.startContinuable's childId), `{kind:"foreground", runId,
|
|
16
|
+
* output}` (SubagentRun.id equals the published child session id for local
|
|
17
|
+
* providers; remote providers mint parent-namespaced ids, which simply fail
|
|
18
|
+
* the cold read and pass through unannotated), and `{kind:"background",
|
|
19
|
+
* jobId}` (a jobs id, not a session id — skipped).
|
|
20
|
+
* - `PostToolDecision` accept: `content` replaces the model-visible content
|
|
21
|
+
* and cannot coexist with a `value` replacement (the runtime throws on
|
|
22
|
+
* both), so only value-less accepts gain the line. `next()` is always
|
|
23
|
+
* awaited first and its decision passed through untouched otherwise —
|
|
24
|
+
* best-effort fail-open: an unreadable child projection means no
|
|
25
|
+
* annotation, never an altered result.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Child session id out of one subagent tool result value; null for shapes
|
|
29
|
+
* without a session id (one-shot background jobs) or malformed values. */
|
|
30
|
+
function childSessionIdOf(value) {
|
|
31
|
+
if (value === null || typeof value !== "object") return null;
|
|
32
|
+
if (value.kind === "continuable" && typeof value.subagentId === "string" && value.subagentId !== "")
|
|
33
|
+
return value.subagentId;
|
|
34
|
+
if (value.kind === "foreground" && typeof value.runId === "string" && value.runId !== "")
|
|
35
|
+
return value.runId;
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Release one observation lease (explicit-resource-management protocol; both
|
|
40
|
+
* the native Symbol.dispose and the TS-downlevel Symbol.for key are tried).
|
|
41
|
+
* Prepared cuts pin an LRU cache entry, so a dropped lease would leak. */
|
|
42
|
+
function disposeObservation(observation) {
|
|
43
|
+
if (observation === null || observation === undefined) return;
|
|
44
|
+
for (const key of [Symbol.dispose, Symbol.for("Symbol.dispose")]) {
|
|
45
|
+
const dispose = key === undefined ? undefined : observation[key];
|
|
46
|
+
if (typeof dispose === "function") {
|
|
47
|
+
try {
|
|
48
|
+
dispose.call(observation);
|
|
49
|
+
} catch {
|
|
50
|
+
/* lease release is best-effort */
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The effective model selection of one session, via the session-query
|
|
59
|
+
* observeSession channel (shared with host/vision.js's per-session verdict).
|
|
60
|
+
* @param {import("@deepseek-ai/cordis").Context} ctx - host plugin context.
|
|
61
|
+
* @param {string} sessionId - session id (live or persisted).
|
|
62
|
+
* @returns {Promise<{provider: string, model: string, reasoningEffort?: string} | null>}
|
|
63
|
+
* null when the session is unknown, unreadable, or unselected yet.
|
|
64
|
+
*/
|
|
65
|
+
async function sessionModelSelectionOf(ctx, sessionId) {
|
|
66
|
+
if (typeof sessionId !== "string" || sessionId === "") return null;
|
|
67
|
+
let query;
|
|
68
|
+
try {
|
|
69
|
+
query = ctx.get?.("sessionQuery", false);
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
if (query === undefined || query === null || typeof query.observeSession !== "function") return null;
|
|
74
|
+
let observation = null;
|
|
75
|
+
try {
|
|
76
|
+
observation = await query.observeSession(sessionId);
|
|
77
|
+
const selection = observation?.projections?.values?.modelSelection;
|
|
78
|
+
const route = selection?.next ?? selection?.lastUsed; // next already falls back to lastUsed
|
|
79
|
+
if (route === null || typeof route !== "object") return null;
|
|
80
|
+
if (typeof route.provider !== "string" || route.provider === "" || typeof route.model !== "string" || route.model === "")
|
|
81
|
+
return null;
|
|
82
|
+
return typeof route.reasoningEffort === "string" && route.reasoningEffort !== ""
|
|
83
|
+
? { provider: route.provider, model: route.model, reasoningEffort: route.reasoningEffort }
|
|
84
|
+
: { provider: route.provider, model: route.model };
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
} finally {
|
|
88
|
+
disposeObservation(observation);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The appended annotation line, or null when the child's model is unknown. */
|
|
93
|
+
function modelLineOf(selection) {
|
|
94
|
+
if (selection === null || typeof selection !== "object") return null;
|
|
95
|
+
const effort =
|
|
96
|
+
typeof selection.reasoningEffort === "string" && selection.reasoningEffort !== ""
|
|
97
|
+
? `@${selection.reasoningEffort}`
|
|
98
|
+
: "";
|
|
99
|
+
return `model: ${selection.provider}/${selection.model}${effort}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Await the downstream decision, then append the annotation line to a
|
|
103
|
+
* value-less accept decision only. A next() rejection propagates (the
|
|
104
|
+
* pipeline's own containment turns it into an error result); a failure in
|
|
105
|
+
* the annotation logic after next() settled degrades to the untouched
|
|
106
|
+
* decision — never a lost or re-run outcome. */
|
|
107
|
+
async function annotate(ctx, exec, result, next, warn) {
|
|
108
|
+
const downstream = await next();
|
|
109
|
+
try {
|
|
110
|
+
if (downstream === null || typeof downstream !== "object" || downstream.kind !== "accept") return downstream;
|
|
111
|
+
// A value-replacing accept cannot also carry content (the runtime
|
|
112
|
+
// throws "cannot replace both value and content").
|
|
113
|
+
if (Object.hasOwn(downstream, "value")) return downstream;
|
|
114
|
+
const sessionId = childSessionIdOf(result?.value);
|
|
115
|
+
if (sessionId === null) return downstream;
|
|
116
|
+
const line = modelLineOf(await sessionModelSelectionOf(ctx, sessionId));
|
|
117
|
+
if (line === null) return downstream; // projection not ready/known — passthrough
|
|
118
|
+
const base = Array.isArray(downstream.content)
|
|
119
|
+
? downstream.content
|
|
120
|
+
: Array.isArray(result?.content)
|
|
121
|
+
? result.content
|
|
122
|
+
: [];
|
|
123
|
+
if (base.length === 0) return downstream; // nothing rendered to append to — never fabricate content
|
|
124
|
+
return { ...downstream, content: [...base, { type: "text", text: line }] };
|
|
125
|
+
} catch (error) {
|
|
126
|
+
// next() already settled — pass its decision through untouched (a
|
|
127
|
+
// failed annotation must never alter or fabricate an outcome).
|
|
128
|
+
warn(`subagent model annotation failed: ${error?.message ?? error}`);
|
|
129
|
+
return downstream;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Mount the subagent model-visibility layer (one post-execute listener).
|
|
135
|
+
* @param {import("@deepseek-ai/cordis").Context} ctx - host plugin context.
|
|
136
|
+
*/
|
|
137
|
+
function applySubagentModel(ctx) {
|
|
138
|
+
const warn = (message) => ctx.logger?.warn?.(`dsh-switchman: ${message}`);
|
|
139
|
+
ctx.on("tools/post-execute", (exec, result, next) => {
|
|
140
|
+
// Fast path: every non-subagent tool and every failed call stays
|
|
141
|
+
// exactly on the current pipeline (promise pass-through).
|
|
142
|
+
if ((exec?.name !== "subagent" && exec?.name !== "subagent_fork") || result?.isError === true) return next();
|
|
143
|
+
return annotate(ctx, exec, result, next, warn);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export { applySubagentModel, sessionModelSelectionOf };
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/** Enumeration of this session's still-running background subagents.
|
|
2
|
+
*
|
|
3
|
+
* Why this exists: a context handover (compaction) preserves dispatched
|
|
4
|
+
* background subagents — they keep running — but the compacted parent
|
|
5
|
+
* loses every handle (job registries do not list them, send_message
|
|
6
|
+
* cannot address them mid-run). The post-handover agent then wrongly
|
|
7
|
+
* concludes the work is lost and double-dispatches. Recording the live
|
|
8
|
+
* children into the handover document closes that gap.
|
|
9
|
+
*
|
|
10
|
+
* Disk format (verified against a packaged host's live records under
|
|
11
|
+
* <DSH_HOME>/storages/session_projcache/sessions/):
|
|
12
|
+
*
|
|
13
|
+
* - Parent record `<sessionId>.json` (sessionId carries the `session-`
|
|
14
|
+
* prefix) -> { record: { rows: { subagentCatalog: { val: { head:
|
|
15
|
+
* { values: [{ childId, childCreatedAt, mode, label }, ...] } } } } } }
|
|
16
|
+
* — the append-only catalog of every subagent dispatched from the
|
|
17
|
+
* session (labels are the dispatch `description`).
|
|
18
|
+
* - Child record `<childId>.json` (bare UUID) — running iff
|
|
19
|
+
* rows.sessionStats.val.openStep !== null; a settled child has
|
|
20
|
+
* openStep === null and rows.turnBoundary.val.lastStepBoundary
|
|
21
|
+
* === { kind: "end" } while a running one shows { kind: "start" }.
|
|
22
|
+
*
|
|
23
|
+
* Every failure mode is fail-open to [] — enumeration is advisory
|
|
24
|
+
* context for the handover document, never a blocker. */
|
|
25
|
+
import { readFileSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { dshHome } from "./lib/dsh-home.js";
|
|
28
|
+
|
|
29
|
+
/** Parse one session-projcache record into its rows map, or null on any
|
|
30
|
+
* structural surprise (missing file, bad JSON, unexpected shape). */
|
|
31
|
+
function readRows(path) {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
34
|
+
const rows = parsed?.record?.rows;
|
|
35
|
+
return rows !== null && typeof rows === "object" ? rows : null;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Directory holding the session-projcache records (advisory text). */
|
|
42
|
+
export function sessionRecordsDir() {
|
|
43
|
+
return join(dshHome(), "storages", "session_projcache", "sessions");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Absolute path of one session's projcache record — advisory text for
|
|
47
|
+
* handover documents, so recorded collection paths honor DSH_HOME the
|
|
48
|
+
* same way the enumeration lookups do. */
|
|
49
|
+
export function sessionRecordPath(id) {
|
|
50
|
+
return join(sessionRecordsDir(), `${id}.json`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Candidate file names for a session record: the observed convention is
|
|
54
|
+
* `session-<uuid>.json` for conversation sessions and `<uuid>.json` for
|
|
55
|
+
* subagent children, but probing both keeps the lookup robust. */
|
|
56
|
+
function recordPaths(directory, id) {
|
|
57
|
+
const names = new Set([`${id}.json`]);
|
|
58
|
+
const bare = id.replace(/^session-/u, "");
|
|
59
|
+
if (bare !== id) names.add(`${bare}.json`);
|
|
60
|
+
else names.add(`session-${id}.json`);
|
|
61
|
+
return [...names].map((name) => join(directory, name));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** This session's still-running background subagents as
|
|
65
|
+
* [{ id, label, mode }], empty when none are detected (or on any error).
|
|
66
|
+
* Reads only; never throws. Disk fallback for enumerateRunningSubagents:
|
|
67
|
+
* the projcache snapshot lags live state slightly (and a crashed child
|
|
68
|
+
* can leave a stale open step), but it survives every service hiccup. */
|
|
69
|
+
export function runningSubagentsOf(sessionId) {
|
|
70
|
+
if (typeof sessionId !== "string" || sessionId === "") return [];
|
|
71
|
+
const directory = sessionRecordsDir();
|
|
72
|
+
const parentRows = recordPaths(directory, sessionId)
|
|
73
|
+
.map((path) => readRows(path))
|
|
74
|
+
.find((rows) => rows !== null);
|
|
75
|
+
const catalog = parentRows?.subagentCatalog?.val;
|
|
76
|
+
const entries = catalog?.head?.values;
|
|
77
|
+
if (!Array.isArray(entries)) return [];
|
|
78
|
+
const running = [];
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
81
|
+
const childId = entry.childId;
|
|
82
|
+
if (typeof childId !== "string" || childId === "") continue;
|
|
83
|
+
const childRows = recordPaths(directory, childId)
|
|
84
|
+
.map((path) => readRows(path))
|
|
85
|
+
.find((rows) => rows !== null);
|
|
86
|
+
if (childRows === null) continue; // never created, or pruned: not live work
|
|
87
|
+
if (childRows.sessionStats?.val?.openStep == null) continue; // settled
|
|
88
|
+
running.push({
|
|
89
|
+
id: childId,
|
|
90
|
+
label: typeof entry.label === "string" ? entry.label : "",
|
|
91
|
+
mode: typeof entry.mode === "string" ? entry.mode : "",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return running;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Sanctioned enumeration (preferred over the disk scan): the live
|
|
98
|
+
* subagent catalog service plus the in-memory agent registry — no flush
|
|
99
|
+
* lag, no stale-after-crash heuristic. `listChildren` accepts the session
|
|
100
|
+
* id; both id forms (with/without the `session-` prefix) are probed
|
|
101
|
+
* because the wire form is not contractual. Falls back to the disk scan
|
|
102
|
+
* whenever the services are absent, disagree with the shapes above, or
|
|
103
|
+
* throw. Never throws. */
|
|
104
|
+
export async function enumerateRunningSubagents(ctx, sessionId) {
|
|
105
|
+
if (typeof sessionId !== "string" || sessionId === "") return [];
|
|
106
|
+
try {
|
|
107
|
+
const subagents = typeof ctx?.get === "function" ? ctx.get("subagents") : null;
|
|
108
|
+
const agents = typeof ctx?.get === "function" ? ctx.get("agents") : null;
|
|
109
|
+
if (
|
|
110
|
+
subagents !== null &&
|
|
111
|
+
typeof subagents.listChildren === "function" &&
|
|
112
|
+
agents !== null &&
|
|
113
|
+
typeof agents.get === "function"
|
|
114
|
+
) {
|
|
115
|
+
const candidates = new Set([sessionId]);
|
|
116
|
+
const bare = sessionId.replace(/^session-/u, "");
|
|
117
|
+
if (bare !== sessionId) candidates.add(bare);
|
|
118
|
+
for (const id of candidates) {
|
|
119
|
+
let children;
|
|
120
|
+
try {
|
|
121
|
+
children = await subagents.listChildren(id);
|
|
122
|
+
} catch {
|
|
123
|
+
continue; // this id form is unusable — probe the next one
|
|
124
|
+
}
|
|
125
|
+
if (!Array.isArray(children)) continue;
|
|
126
|
+
const running = [];
|
|
127
|
+
for (const child of children) {
|
|
128
|
+
if (child === null || typeof child !== "object") continue;
|
|
129
|
+
if (typeof child.id !== "string" || child.id === "") continue;
|
|
130
|
+
if (agents.get(child.id)?.status !== "running") continue;
|
|
131
|
+
running.push({
|
|
132
|
+
id: child.id,
|
|
133
|
+
label: typeof child.label === "string" ? child.label : "",
|
|
134
|
+
mode: typeof child.mode === "string" ? child.mode : "",
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return running; // first id form that answers wins, even when empty
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
/* fall through to the disk scan */
|
|
142
|
+
}
|
|
143
|
+
return runningSubagentsOf(sessionId);
|
|
144
|
+
}
|