dsh-switchman 1.3.1 → 1.6.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 +56 -34
- package/README.es.md +53 -31
- package/README.fr.md +52 -30
- package/README.it.md +47 -25
- package/README.ja.md +54 -32
- package/README.ko.md +55 -33
- package/README.md +53 -31
- package/README.pt.md +47 -25
- package/README.ru.md +52 -30
- package/README.zh-TW.md +52 -30
- package/README.zh.md +46 -24
- package/client.js +2344 -40
- package/cordis.patch.yml +24 -8
- package/host/config.js +31 -0
- package/host/context-watch.js +424 -84
- 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 +143 -8
- package/host/subagent-model.js +147 -0
- package/host/subagents.js +160 -0
- package/host/teams.js +583 -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/skills/db-query/package.json +1 -1
- package/skills/db-query/scripts/lib/cli.js +23 -23
- package/skills/db-query/scripts/lib/config.js +78 -61
- package/skills/db-query/scripts/lib/mysql-readonly.js +167 -158
- package/skills/db-query/scripts/lib/output.js +56 -55
- package/skills/db-query/scripts/lib/redis-readonly.js +116 -111
- package/skills/db-query/scripts/mysql-query.js +46 -42
- package/skills/db-query/scripts/redis-query.js +32 -29
- package/skills/db-query/scripts/setup.sh +16 -16
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"];
|
|
@@ -53,13 +81,16 @@ const NUMBER_FIELDS = [
|
|
|
53
81
|
const BOOLEAN_FIELDS = [
|
|
54
82
|
"wmAutoHandover",
|
|
55
83
|
"wmSubagentCap",
|
|
56
|
-
//
|
|
84
|
+
// The following six mirror the pool*Manual switches in host/config.js
|
|
57
85
|
"poolEconomyManual",
|
|
58
86
|
"poolMechanicalManual",
|
|
59
87
|
"poolMainManual",
|
|
60
88
|
"poolHardManual",
|
|
61
89
|
"poolVisionManual",
|
|
62
90
|
"poolReviewManual",
|
|
91
|
+
// Teams switches (mirrors the teams* group in host/config.js)
|
|
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,8 @@ const EFFORTS_FIELDS = [
|
|
|
71
102
|
"poolReviewEfforts",
|
|
72
103
|
];
|
|
73
104
|
const ENUM_FIELDS = {
|
|
105
|
+
uiLocale: ["auto", "en", "zh", "zh-TW", "ja", "ko", "de", "es", "fr", "it", "pt", "ru"],
|
|
106
|
+
langScope: ["global", "project"],
|
|
74
107
|
dispatchEnforce: ["off", "advice", "enforce"],
|
|
75
108
|
wmDenyMode: ["cap", "deny"],
|
|
76
109
|
};
|
|
@@ -156,9 +189,17 @@ function isAllowed(ctx, request) {
|
|
|
156
189
|
);
|
|
157
190
|
}
|
|
158
191
|
|
|
159
|
-
/** Read one volatile settings field's raw value (live `.get()` reference
|
|
192
|
+
/** Read one volatile settings field's raw value (live `.get()` reference;
|
|
193
|
+
* undefined when the getter itself throws — same guard as teams.js's
|
|
194
|
+
* readValue, kept on this GET-config hot path). */
|
|
160
195
|
function rawOf(field) {
|
|
161
|
-
if (field !== null && typeof field === "object" && typeof field.get === "function")
|
|
196
|
+
if (field !== null && typeof field === "object" && typeof field.get === "function") {
|
|
197
|
+
try {
|
|
198
|
+
return field.get();
|
|
199
|
+
} catch {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
162
203
|
return field;
|
|
163
204
|
}
|
|
164
205
|
|
|
@@ -206,6 +247,8 @@ export function snapshotOf(config) {
|
|
|
206
247
|
values[field] = Array.isArray(rawOf(config?.[field]))
|
|
207
248
|
? rawOf(config[field]).map(cleanEffortEntry).filter(Boolean)
|
|
208
249
|
: [];
|
|
250
|
+
values.uiLocale = ENUM_FIELDS.uiLocale.includes(rawOf(config?.uiLocale)) ? rawOf(config.uiLocale) : "auto";
|
|
251
|
+
values.langScope = ENUM_FIELDS.langScope.includes(rawOf(config?.langScope)) ? rawOf(config.langScope) : "global";
|
|
209
252
|
values.dispatchEnforce = ENUM_FIELDS.dispatchEnforce.includes(rawOf(config?.dispatchEnforce))
|
|
210
253
|
? rawOf(config.dispatchEnforce)
|
|
211
254
|
: "advice";
|
|
@@ -220,6 +263,15 @@ export function snapshotOf(config) {
|
|
|
220
263
|
return values;
|
|
221
264
|
}
|
|
222
265
|
|
|
266
|
+
/** The whitelistSync status for config responses: null unless syncWhitelist
|
|
267
|
+
* is ON (an INDEPENDENT switch — teamsMode is not required), else the teams
|
|
268
|
+
* layer's last replace-sync outcome ({ ok, count, at, error? } — itself
|
|
269
|
+
* null before the first sync). */
|
|
270
|
+
function whitelistSyncOf(config) {
|
|
271
|
+
if (rawOf(config?.syncWhitelist) !== true) return null;
|
|
272
|
+
return teamsSyncSnapshot();
|
|
273
|
+
}
|
|
274
|
+
|
|
223
275
|
/** Coerce and validate a client-submitted values object; null when invalid. */
|
|
224
276
|
function coerceValues(input) {
|
|
225
277
|
if (input === null || typeof input !== "object" || Array.isArray(input)) return null;
|
|
@@ -244,6 +296,10 @@ function coerceValues(input) {
|
|
|
244
296
|
if (efforts.some((entry) => entry === null)) return null;
|
|
245
297
|
values[field] = efforts;
|
|
246
298
|
}
|
|
299
|
+
if (!ENUM_FIELDS.uiLocale.includes(input.uiLocale)) return null;
|
|
300
|
+
values.uiLocale = input.uiLocale;
|
|
301
|
+
if (!ENUM_FIELDS.langScope.includes(input.langScope)) return null;
|
|
302
|
+
values.langScope = input.langScope;
|
|
247
303
|
if (!ENUM_FIELDS.dispatchEnforce.includes(input.dispatchEnforce)) return null;
|
|
248
304
|
values.dispatchEnforce = input.dispatchEnforce;
|
|
249
305
|
for (const field of NUMBER_FIELDS) {
|
|
@@ -327,7 +383,11 @@ export function makeRoutes(ctx, config) {
|
|
|
327
383
|
return;
|
|
328
384
|
}
|
|
329
385
|
if (req.method === "GET") {
|
|
330
|
-
|
|
386
|
+
// Clean-context retry pump: HTTP handlers never run inside an
|
|
387
|
+
// HMR transaction, so an orchestration deferred by the nested
|
|
388
|
+
// rejection re-runs here (no-op when nothing is pending).
|
|
389
|
+
pumpTeamsOrchestration(ctx, config);
|
|
390
|
+
writeJson(res, 200, { values: snapshotOf(config), whitelistSync: whitelistSyncOf(config), teamsBundle: teamsBundleState() });
|
|
331
391
|
return;
|
|
332
392
|
}
|
|
333
393
|
if (req.method !== "POST") {
|
|
@@ -363,7 +423,14 @@ export function makeRoutes(ctx, config) {
|
|
|
363
423
|
writeJson(res, 500, { error: error?.message ?? String(error) });
|
|
364
424
|
return;
|
|
365
425
|
}
|
|
366
|
-
|
|
426
|
+
// Teams orchestration hook (fire-and-forget: the teams layer defers
|
|
427
|
+
// the real work to a setImmediate turn, so the bundle enable +
|
|
428
|
+
// optional whitelist replace-sync never block this response; a
|
|
429
|
+
// nested-HMR rejection is retried from the next clean trigger —
|
|
430
|
+
// see host/teams.js). Runs when EITHER switch is on (they are
|
|
431
|
+
// independent); each half gates itself inside the orchestration.
|
|
432
|
+
scheduleTeamsOrchestration(ctx, config);
|
|
433
|
+
writeJson(res, 200, { values: snapshotOf(config), whitelistSync: whitelistSyncOf(config), teamsBundle: teamsBundleState() });
|
|
367
434
|
},
|
|
368
435
|
},
|
|
369
436
|
{
|
|
@@ -379,6 +446,74 @@ export function makeRoutes(ctx, config) {
|
|
|
379
446
|
}
|
|
380
447
|
},
|
|
381
448
|
},
|
|
449
|
+
{
|
|
450
|
+
kind: "exact",
|
|
451
|
+
path: ROUTES.authorized,
|
|
452
|
+
handler: async (req, res) => {
|
|
453
|
+
if (!guard(req, res, "GET")) return;
|
|
454
|
+
try {
|
|
455
|
+
const snapshot = authorizedChildRoutes(ctx);
|
|
456
|
+
writeJson(
|
|
457
|
+
res,
|
|
458
|
+
200,
|
|
459
|
+
snapshot === null
|
|
460
|
+
? { enabled: null, routes: null }
|
|
461
|
+
: { enabled: snapshot.enabled, routes: snapshot.routes },
|
|
462
|
+
);
|
|
463
|
+
} catch (error) {
|
|
464
|
+
ctx.logger.warn(`dsh-switchman: authorized-models read failed: ${error?.message ?? error}`);
|
|
465
|
+
writeJson(res, 500, { error: error?.message ?? String(error) });
|
|
466
|
+
}
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
kind: "exact",
|
|
471
|
+
path: ROUTES.handoverState,
|
|
472
|
+
handler: async (req, res) => {
|
|
473
|
+
if (!guard(req, res, "GET")) return;
|
|
474
|
+
// Read-only live registry: the client badge polls this during
|
|
475
|
+
// the otherwise-silent fork+compact window ({count, handovers}),
|
|
476
|
+
// so the GUI shows an animated "handover in progress" cue.
|
|
477
|
+
const handovers = handoverSnapshot();
|
|
478
|
+
writeJson(res, 200, { count: handovers.length, handovers });
|
|
479
|
+
},
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
kind: "exact",
|
|
483
|
+
path: ROUTES.visionState,
|
|
484
|
+
handler: async (req, res) => {
|
|
485
|
+
if (!guard(req, res, "GET")) return;
|
|
486
|
+
pumpTeamsOrchestration(ctx, config); // clean-context retry pump (see GET config)
|
|
487
|
+
try {
|
|
488
|
+
// Optional `session` query parameter (a child session id):
|
|
489
|
+
// judge image capability against THAT session's model
|
|
490
|
+
// instead of the root's (the root verdict misreported for
|
|
491
|
+
// children on a different route). A missing or unknown
|
|
492
|
+
// session falls back to the root verdict.
|
|
493
|
+
const session = new URL(req.url ?? "/", "http://localhost").searchParams.get("session");
|
|
494
|
+
writeJson(res, 200, await visionStateOf(ctx, config, typeof session === "string" && session !== "" ? session : null));
|
|
495
|
+
} catch (error) {
|
|
496
|
+
ctx.logger.warn(`dsh-switchman: vision-state read failed: ${error?.message ?? error}`);
|
|
497
|
+
writeJson(res, 500, { error: error?.message ?? String(error) });
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
{
|
|
502
|
+
kind: "exact",
|
|
503
|
+
path: ROUTES.uiLocale,
|
|
504
|
+
handler: async (req, res) => {
|
|
505
|
+
if (!guard(req, res, "POST")) return;
|
|
506
|
+
let body;
|
|
507
|
+
try {
|
|
508
|
+
body = await readJsonBody(req);
|
|
509
|
+
} catch (error) {
|
|
510
|
+
writeJson(res, 400, { error: error?.message ?? String(error) });
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
setReportedUiLocale(body?.locale);
|
|
514
|
+
writeJson(res, 200, { ok: true, locale: reportedUiLocale() });
|
|
515
|
+
},
|
|
516
|
+
},
|
|
382
517
|
];
|
|
383
518
|
}
|
|
384
519
|
|
|
@@ -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,160 @@
|
|
|
1
|
+
/** Enumeration of this session's dispatched background subagents.
|
|
2
|
+
*
|
|
3
|
+
* Why this exists: a context handover (compaction) preserves dispatched
|
|
4
|
+
* background subagents — they keep running. The parent's job registries
|
|
5
|
+
* never list continuable children, and while the Agent-Teams bundle is
|
|
6
|
+
* active send_message resolves teammate names only, so subagent-child
|
|
7
|
+
* ids cannot be addressed mid-run. An agent that mistakes "not
|
|
8
|
+
* addressable" for "dead" then double-dispatches. Recording the live
|
|
9
|
+
* children — plus how to read their state — into the handover document
|
|
10
|
+
* closes that gap.
|
|
11
|
+
*
|
|
12
|
+
* Redesign after the 2026-10-05 double-dispatch incident: dispatches
|
|
13
|
+
* issued in the turn that triggered the handover MATERIALIZE only when
|
|
14
|
+
* the session machinery resumes — the catalog entry and the child's
|
|
15
|
+
* session record are stamped minutes after the dispatching tool call,
|
|
16
|
+
* which was after the handover's own snapshot in the old flow. Neither
|
|
17
|
+
* the sanctioned registry nor the disk catalog could have listed them
|
|
18
|
+
* at snapshot time; the fix is timing (context-watch.js re-enumerates
|
|
19
|
+
* after compaction settles, immediately before waking the session) plus
|
|
20
|
+
* semantics: enumeration now answers "what dispatched work exists",
|
|
21
|
+
* never filters on a single "running" flag. A dispatched child whose
|
|
22
|
+
* record exists but has neither an open step nor a definite end
|
|
23
|
+
* boundary is PENDING work (queued / provisioning / interrupted) —
|
|
24
|
+
* exactly the state the incident's children were missed in.
|
|
25
|
+
*
|
|
26
|
+
* Disk format (verified against a packaged host's live records under
|
|
27
|
+
* <DSH_HOME>/storages/session_projcache/sessions/):
|
|
28
|
+
*
|
|
29
|
+
* - Parent record `<sessionId>.json` (sessionId carries the `session-`
|
|
30
|
+
* prefix) -> { record: { rows: { subagentCatalog: { val: { head:
|
|
31
|
+
* { values: [{ childId, childCreatedAt, mode, label }, ...] } } } } } }
|
|
32
|
+
* — the append-only catalog of every subagent dispatched from the
|
|
33
|
+
* session (labels are the dispatch `description`); entries appear
|
|
34
|
+
* when the child MATERIALIZES, not when the tool call is made.
|
|
35
|
+
* - Child record `<childId>.json` (bare UUID) — the same rows map:
|
|
36
|
+
* running iff record.rows.sessionStats.val.openStep is neither null
|
|
37
|
+
* nor missing;
|
|
38
|
+
* settled (delivered/aborted, nothing pending) iff
|
|
39
|
+
* record.rows.turnBoundary.val.lastStepBoundary.kind === "end";
|
|
40
|
+
* anything else with an existing record is pending.
|
|
41
|
+
*
|
|
42
|
+
* Every failure mode is fail-open to [] — enumeration is advisory
|
|
43
|
+
* context for the handover document, never a blocker. */
|
|
44
|
+
|
|
45
|
+
/** Child states, in rough order of "how much work is outstanding".
|
|
46
|
+
* - "running": an open step exists right now.
|
|
47
|
+
* - "pending": dispatched (record exists) but no definite end — covers
|
|
48
|
+
* queued/provisioning children and ones interrupted mid-flight.
|
|
49
|
+
* - "settled": last step boundary is a definite end; the child
|
|
50
|
+
* delivered (or aborted) and holds no pending work.
|
|
51
|
+
* - "gone": no record — never materialized or pruned; not live work. */
|
|
52
|
+
import { readFileSync } from "node:fs";
|
|
53
|
+
import { join } from "node:path";
|
|
54
|
+
import { dshHome } from "./lib/dsh-home.js";
|
|
55
|
+
|
|
56
|
+
/** Parse one session-projcache record into its rows map, or null on any
|
|
57
|
+
* structural surprise (missing file, bad JSON, unexpected shape). */
|
|
58
|
+
function readRows(path) {
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
61
|
+
const rows = parsed?.record?.rows;
|
|
62
|
+
return rows !== null && typeof rows === "object" ? rows : null;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Directory holding the session-projcache records (advisory text). */
|
|
69
|
+
export function sessionRecordsDir() {
|
|
70
|
+
return join(dshHome(), "storages", "session_projcache", "sessions");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Absolute path of one session's projcache record — advisory text for
|
|
74
|
+
* handover documents, so recorded collection paths honor DSH_HOME the
|
|
75
|
+
* same way the enumeration lookups do. */
|
|
76
|
+
export function sessionRecordPath(id) {
|
|
77
|
+
return join(sessionRecordsDir(), `${id}.json`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Candidate file names for a session record: the observed convention is
|
|
81
|
+
* `session-<uuid>.json` for conversation sessions and `<uuid>.json` for
|
|
82
|
+
* subagent children, but probing both keeps the lookup robust. */
|
|
83
|
+
function recordPaths(directory, id) {
|
|
84
|
+
const names = new Set([`${id}.json`]);
|
|
85
|
+
const bare = id.replace(/^session-/u, "");
|
|
86
|
+
if (bare !== id) names.add(`${bare}.json`);
|
|
87
|
+
else names.add(`session-${id}.json`);
|
|
88
|
+
return [...names].map((name) => join(directory, name));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** First parseable rows map for a session id, or null. */
|
|
92
|
+
function rowsOf(directory, id) {
|
|
93
|
+
return recordPaths(directory, id).map((path) => readRows(path)).find((rows) => rows !== null) ?? null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Disk state of one child from its rows map (see the state table in the
|
|
97
|
+
* header). A null record means "gone". */
|
|
98
|
+
function childDiskState(childRows) {
|
|
99
|
+
if (childRows === null) return "gone";
|
|
100
|
+
if (childRows.sessionStats?.val?.openStep != null) return "running";
|
|
101
|
+
if (childRows.turnBoundary?.val?.lastStepBoundary?.kind === "end") return "settled";
|
|
102
|
+
return "pending";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Every cataloged child of a session with its disk state:
|
|
106
|
+
* [{ id, label, mode, childCreatedAt, state }]. The catalog is the
|
|
107
|
+
* authoritative "was ever dispatched" list; states come from each
|
|
108
|
+
* child's own record. Empty on any error — never throws. */
|
|
109
|
+
export function catalogChildrenOf(sessionId) {
|
|
110
|
+
if (typeof sessionId !== "string" || sessionId === "") return [];
|
|
111
|
+
const directory = sessionRecordsDir();
|
|
112
|
+
const parentRows = rowsOf(directory, sessionId);
|
|
113
|
+
const entries = parentRows?.subagentCatalog?.val?.head?.values;
|
|
114
|
+
if (!Array.isArray(entries)) return [];
|
|
115
|
+
const children = [];
|
|
116
|
+
for (const entry of entries) {
|
|
117
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
118
|
+
const childId = entry.childId;
|
|
119
|
+
if (typeof childId !== "string" || childId === "") continue;
|
|
120
|
+
const state = childDiskState(rowsOf(directory, childId));
|
|
121
|
+
children.push({
|
|
122
|
+
id: childId,
|
|
123
|
+
label: typeof entry.label === "string" ? entry.label : "",
|
|
124
|
+
mode: typeof entry.mode === "string" ? entry.mode : "",
|
|
125
|
+
childCreatedAt: typeof entry.childCreatedAt === "number" ? entry.childCreatedAt : null,
|
|
126
|
+
state,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return children;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Live (unresolved) dispatched children of a session as
|
|
133
|
+
* [{ id, label, mode, childCreatedAt, state }|{…, state: "running"}] —
|
|
134
|
+
* running OR pending, disk truth first. The sanctioned agents registry,
|
|
135
|
+
* when reachable, only ever UPGRADES a pending child to "running" (its
|
|
136
|
+
* live view has no flush lag); it can never demote disk truth, because
|
|
137
|
+
* a status vocabulary mismatch is precisely what missed the incident's
|
|
138
|
+
* queued children. Never throws. */
|
|
139
|
+
export async function enumerateLiveSubagents(ctx, sessionId) {
|
|
140
|
+
if (typeof sessionId !== "string" || sessionId === "") return [];
|
|
141
|
+
let live;
|
|
142
|
+
try {
|
|
143
|
+
live = catalogChildrenOf(sessionId).filter((child) => child.state === "running" || child.state === "pending");
|
|
144
|
+
} catch {
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
if (live.length === 0) return [];
|
|
148
|
+
try {
|
|
149
|
+
const agents = typeof ctx?.get === "function" ? ctx.get("agents") : null;
|
|
150
|
+
if (agents !== null && typeof agents.get === "function") {
|
|
151
|
+
for (const child of live) {
|
|
152
|
+
if (child.state !== "pending") continue;
|
|
153
|
+
if (agents.get(child.id)?.status === "running") child.state = "running";
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
/* registry overlay is best-effort */
|
|
158
|
+
}
|
|
159
|
+
return live;
|
|
160
|
+
}
|