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/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
- const NS = "dsh-switchman-host";
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
- // 以下六个 mirrors host/config.js 的 pool*Manual 开关
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") return field.get();
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
- writeJson(res, 200, { values: snapshotOf(config) });
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
- writeJson(res, 200, { values: snapshotOf(config) });
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
+ }