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/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"];
@@ -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") return field.get();
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
- writeJson(res, 200, { values: snapshotOf(config) });
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
- writeJson(res, 200, { values: snapshotOf(config) });
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
+ }