dsh-switchman 1.5.0 → 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/config.js CHANGED
@@ -46,6 +46,14 @@ export const EffortEntrySchema = z.object({
46
46
 
47
47
  /** All dsh-switchman settings. Field names are the settings mutation paths. */
48
48
  export const Config = z.object({
49
+ // --- Phase 0: UI locale ------------------------------------------------
50
+ /** Display language for this plugin's own UI (badge, panel, settings
51
+ * page): "auto" follows the DSH app language; any other value forces
52
+ * the plugin UI into that locale (extra locales resolve
53
+ * client-side from the inline EXTRA_UI_DICTS table, falling back
54
+ * to English). */
55
+ uiLocale: z.union(["auto", "en", "zh", "zh-TW", "ja", "ko", "de", "es", "fr", "it", "pt", "ru"]).default("auto").volatile(),
56
+
49
57
  // --- Phase 1: project language preference -----------------------------
50
58
  /** Where the three language slots live: "global" = these profile-level
51
59
  * fields below; "project" = each project's .switchman/lang.json (per
@@ -21,12 +21,17 @@
21
21
  * Every handover trigger — the force-tier automation (fire-and-forget,
22
22
  * session-level in-flight flag plus a 10-minute cooldown) and manual
23
23
  * /ctx-handover — shares one four-step flow: fork a dormant backup
24
- * session (subagents' fork provider, economy pool model), write the
25
- * handover document skeleton, compact via the borrowed per-agent
26
- * /compact (retried on a growing backoff until the agent goes idle,
27
- * because the automation fires mid-turn), then wake this session
28
- * (agent.followup + sessions.flush, the schedule service's delivery
29
- * channel) to fill in the document and continue the unfinished task.
24
+ * session (subagents' fork provider, economy pool model); then, on
25
+ * every compaction attempt, re-enumerate the live dispatched
26
+ * subagents and refresh the handover document as the very last
27
+ * action before compacting via the borrowed per-agent /compact
28
+ * (retried on a growing backoff until the agent goes idle, because
29
+ * the automation fires mid-turn); after compaction settles, diff the
30
+ * enumeration once more and append anything that materialized during
31
+ * the window; finally wake this session (agent.followup +
32
+ * sessions.flush, the schedule service's delivery channel) to fill in
33
+ * the document and continue the unfinished task, with two post-wake
34
+ * rechecks announcing late-materializing dispatches.
30
35
  * The live handover phase feeds the force-tier banner so progress is
31
36
  * visible instead of silent. /ctx-pause and /ctx-resume toggle
32
37
  * process-lifetime pause state (a restart resumes enforcement — the
@@ -38,10 +43,10 @@
38
43
  */
39
44
 
40
45
  import { randomUUID } from "node:crypto";
41
- import { mkdirSync, writeFileSync } from "node:fs";
42
- import { join } from "node:path";
46
+ import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
47
+ import { dirname, join } from "node:path";
43
48
  import { clearHandover, handoverOf, recordHandover } from "./handover-state.js";
44
- import { enumerateRunningSubagents, sessionRecordPath, sessionRecordsDir } from "./subagents.js";
49
+ import { enumerateLiveSubagents, sessionRecordPath, sessionRecordsDir } from "./subagents.js";
45
50
 
46
51
  /** Tools whose result text is accounted against the per-turn read budget. */
47
52
  const ACCOUNT_TOOLS = new Set(["read", "glob", "grep", "bash"]);
@@ -388,46 +393,67 @@ function applyContextWatch(ctx, config) {
388
393
  }
389
394
  };
390
395
 
391
- /** Manual handover step 2 — write the handover document skeleton into
392
- * the session's working directory. The mechanical facts land now (before
393
- * compaction); the agent's continuation turn fills in the task-state
394
- * summary using the compaction summary it can still see. Null when the
395
- * session exposes no usable cwd or the write fails. Any still-running
396
- * background subagents are recorded too: compaction orphans them from
397
- * every plugin-visible registry, so without this section the compacted
398
- * agent cannot know its dispatched work is alive (and double-dispatches).
399
- * */
400
- const writeHandoverSkeleton = (agent, sessionId, backupId, used, running) => {
396
+ /** One handover-document path per handover run (stamp frozen at run
397
+ * start so refreshes overwrite the same file). Null when the session
398
+ * exposes no usable cwd. */
399
+ const handoverDocPath = (agent, sessionId) => {
401
400
  const cwd = agent?.session?.header?.cwd;
402
401
  if (typeof cwd !== "string" || cwd === "") return null;
403
402
  const stamp = new Date().toISOString().replace(/[:.]/gu, "-");
404
- const directory = join(cwd, ".dsh-switchman", "handover");
405
403
  const short = sessionId.replace(/^session-/u, "").slice(0, 8) || sessionId;
406
- const path = join(directory, `${stamp}-${short}.md`);
407
- const runningSection =
408
- Array.isArray(running) && running.length > 0
404
+ return join(cwd, ".dsh-switchman", "handover", `${stamp}-${short}.md`);
405
+ };
406
+
407
+ /** Write (or refresh) the handover document as the LAST action before
408
+ * a compaction attempt — enumerate, write, compact, nothing mutating
409
+ * in between. The old flow wrote the skeleton once, up front, and
410
+ * then sat through the idle-retry loop while the triggering turn
411
+ * kept executing: dispatches issued in that window MATERIALIZED into
412
+ * the catalog only around the post-compaction resume, invisible to
413
+ * both the old snapshot and the compacted agent, which then
414
+ * re-dispatched live work (incident 2026-10-05). Refreshing on every
415
+ * attempt keeps the document truthful at the moment compaction
416
+ * actually lands while still capturing state strictly BEFORE
417
+ * compaction destroys it. `live` entries are { id, label, mode,
418
+ * state } from enumerateLiveSubagents. Returns the path on success,
419
+ * null on failure (fail-open: never blocks compaction). */
420
+ const writeHandoverDoc = (path, sessionId, backupId, used, live) => {
421
+ if (path === null) return null;
422
+ const liveSection =
423
+ Array.isArray(live) && live.length > 0
409
424
  ? [
410
- `## Running background subagents (captured before compaction — still alive)`,
425
+ `## Live background subagents (captured immediately before the compaction attempt — still alive)`,
411
426
  ``,
412
- ...running.map(
427
+ ...live.map(
413
428
  (child) =>
414
- `- \`${child.id}\`${child.label ? ` — ${child.label}` : ""}${child.mode ? ` (${child.mode})` : ""} — STILL RUNNING. Collect its report when it settles (in-session completion notice, or read its session record at ${sessionRecordPath(child.id)}) before re-dispatching anything similar — do not duplicate live work.`,
429
+ `- \`${child.id}\`${child.label ? ` — ${child.label}` : ""}${child.mode ? ` (${child.mode})` : ""} — ${child.state === "running" ? "RUNNING" : "PENDING (dispatched, no open step yet)"}. Collect its report when it settles (in-session completion notice, or read its session record at ${sessionRecordPath(child.id)}) before re-dispatching anything similar — do not duplicate live work.`,
415
430
  ),
431
+ `How to address and re-check them:`,
432
+ ``,
433
+ `- These are subagent children, not teammates. While the Agent-Teams bundle is active, send_message({ target }) resolves teammate names only — it will always answer "active teammate ... not found" for these ids. That error says nothing about the child's health: do not conclude it is dead, and do not re-dispatch while it is alive.`,
434
+ `- Re-check liveness against the child's record JSON (exact paths — note the leading record.): running iff record.rows.sessionStats.val.openStep !== null; settled (nothing pending) iff record.rows.turnBoundary.val.lastStepBoundary.kind === "end"; anything else with an existing record is pending. One-liner: python3 -c 'import json;d=json.load(open("<DSH_HOME>/storages/session_projcache/sessions/<childId>.json"))["record"]["rows"];print("running" if (d.get("sessionStats") or {}).get("val",{}).get("openStep") else ("settled" if (((d.get("turnBoundary") or {}).get("val") or {}).get("lastStepBoundary") or {}).get("kind")=="end" else "pending"))'`,
435
+ `- The catalog record.rows.subagentCatalog.val.head.values of THIS session lists every child ever dispatched from it (ids, labels, childCreatedAt timestamps). Entries younger than the handover time are dispatches the pre-compaction captures could not see — treat them as your own live work, never as candidates for re-dispatch.`,
436
+ `- Report tail: <DSH_HOME>/sessions/<workspace-slug>/<childId>/session.v4.jsonl.zstd (zstd-compressed JSONL; the workspace slug is the dash-encoded session cwd). The last assistant/message records hold interim and final reports.`,
416
437
  ``,
417
438
  ]
418
- : [];
439
+ : [
440
+ `## Live background subagents`,
441
+ ``,
442
+ `- None live at the last capture. If the compaction summary mentions dispatched subagents anyway, they materialized after it: re-check the session catalog (record.rows.subagentCatalog) before re-dispatching anything.`,
443
+ ``,
444
+ ];
419
445
  try {
420
- mkdirSync(directory, { recursive: true });
446
+ mkdirSync(dirname(path), { recursive: true });
421
447
  writeFileSync(
422
448
  path,
423
449
  [
424
450
  `# SWITCHMAN handover — ${sessionId}`,
425
451
  ``,
426
- `- handed over at: ${new Date().toISOString()}`,
452
+ `- handed over at: ${new Date().toISOString()} (document refreshed before every compaction attempt)`,
427
453
  `- context used before compaction: ~${used ?? "unknown"} tokens`,
428
454
  `- backup fork session: ${backupId ?? "(fork failed — the full history remains in this session's append-only log)"}`,
429
455
  ``,
430
- ...runningSection,
456
+ ...liveSection,
431
457
  `## Task state (filled in by the agent right after the handover)`,
432
458
  ``,
433
459
  `- goal / objective in flight:`,
@@ -446,37 +472,106 @@ function applyContextWatch(ctx, config) {
446
472
  }
447
473
  };
448
474
 
449
- /** Manual handover step 4 — wake the freshly compacted session: one
475
+ /** One-shot delayed re-checks after the continuation is armed. A
476
+ * dispatch from the pre-handover turn can materialize in the very
477
+ * instants around the post-compaction resume — after the final
478
+ * enumeration but before the woken agent looks. Two cheap probes
479
+ * (20 s / 75 s) compare the catalog against everything already
480
+ * announced; anything new is appended to the handover document AND
481
+ * pushed to the session as a followup so the agent learns its
482
+ * deferred dispatch exists before it can re-dispatch. Best-effort:
483
+ * process-lifetime timers, fail-open, never throws. */
484
+ const scheduleMaterializationRecheck = (agent, sessionId, backupId, docFile, known) => {
485
+ const knownIds = new Set([backupId ?? "", ...(known ?? []).map((child) => child.id)]);
486
+ const check = async (tag) => {
487
+ try {
488
+ const fresh = (await enumerateLiveSubagents(ctx, sessionId)).filter((child) => !knownIds.has(child.id));
489
+ if (fresh.length === 0) return;
490
+ for (const child of fresh) knownIds.add(child.id);
491
+ const lines = fresh.map(
492
+ (child) =>
493
+ `${child.id}${child.label ? ` (${child.label})` : ""} [${child.state}] → record ${sessionRecordPath(child.id)}`,
494
+ );
495
+ if (docFile !== null) {
496
+ try {
497
+ appendFileSync(
498
+ docFile,
499
+ [
500
+ ``,
501
+ `## Post-wake refresh (${new Date().toISOString()})`,
502
+ ``,
503
+ `- ${fresh.length} dispatched subagent(s) materialized AFTER the wake-time enumeration:`,
504
+ ...lines.map((line) => `- ${line}`),
505
+ `- These are pre-handover dispatches of this same session, now alive. Collect their results; do NOT re-dispatch.`,
506
+ ``,
507
+ ].join("\n"),
508
+ "utf8",
509
+ );
510
+ } catch {
511
+ /* document append is best-effort */
512
+ }
513
+ }
514
+ agent.followup({
515
+ id: randomUUID(),
516
+ role: "user",
517
+ content: [
518
+ {
519
+ type: "text",
520
+ text: `[SWITCHMAN:HANDOVER-REFRESH] ${fresh.length} dispatched subagent(s) materialized after the handover snapshot — dispatched before the wake or by the just-woken agent itself — either way live work of this session the handover document could not have listed: ${lines.join("; ")}. They are ALIVE. Collect their results (each record path is listed; a "running" child will send a completion notice, a "pending" one has not started its first turn yet); do NOT re-dispatch this work.`,
521
+ },
522
+ ],
523
+ source: { kind: "user" },
524
+ });
525
+ await ctx.sessions?.flush?.(agent.session);
526
+ } catch (error) {
527
+ warn(`handover materialization recheck (${tag}) failed: ${error?.message ?? error}`);
528
+ }
529
+ };
530
+ setTimeout(() => void check("20s"), 20_000);
531
+ setTimeout(() => void check("75s"), 75_000);
532
+ };
533
+
534
+ /** Manual handover final step — wake the freshly compacted session: one
450
535
  * followup user message (the same delivery channel the schedule service
451
- * uses: agent.followup + sessions.flush) instructing the agent to fill
536
+ * uses: agent.followup + sessions.flush) instructs the agent to fill
452
537
  * in the handover document from the compaction summary now in context
453
- * and continue the unfinished task. */
454
- const armContinuation = async (agent, backupId, docPath, running) => {
538
+ * and continue the unfinished task. `live` is the post-compaction
539
+ * enumeration; `appeared` are its members that were NOT in the last
540
+ * pre-compaction capture — dispatches from the triggering turn that
541
+ * only materialized during the compaction window, called out so the
542
+ * agent can never mistake them for un-dispatched work. */
543
+ const armContinuation = async (agent, backupId, docFile, live, appeared) => {
455
544
  if (typeof agent?.followup !== "function") {
456
545
  warn("handover continuation skipped: agent exposes no followup()");
457
546
  return;
458
547
  }
459
- const runningCount = Array.isArray(running) ? running.length : 0;
460
- const runningSentence =
461
- runningCount === 0
462
- ? `No background subagents were detected running at handover time.`
463
- : docPath !== null
464
- ? `${runningCount} background subagent(s) dispatched earlier are STILL RUNNING and are listed in the handover document — collect their results (in-session completion notices, or their session records) instead of re-dispatching duplicate work.`
465
- : `${runningCount} background subagent(s) dispatched earlier are STILL RUNNING (the handover document could not be written; inline list): ${running
548
+ const liveCount = Array.isArray(live) ? live.length : 0;
549
+ const appearedCount = Array.isArray(appeared) ? appeared.length : 0;
550
+ const liveSentence =
551
+ liveCount === 0
552
+ ? `No dispatched background subagents were live when the compaction landed.`
553
+ : docFile !== null
554
+ ? `${liveCount} dispatched background subagent(s) are LIVE and listed in the handover document — collect their results (in-session completion notices, or their session records) instead of re-dispatching duplicate work. The document explains the exact liveness re-check (record.rows paths plus a paste-ready one-liner) and each transcript tail; a send_message "active teammate not found" error is NOT evidence of death.`
555
+ : `${liveCount} dispatched background subagent(s) are LIVE (the handover document could not be written; inline list): ${live
466
556
  .map(
467
557
  (child) =>
468
- `${child.id}${child.label ? ` (${child.label})` : ""} → record ${sessionRecordPath(child.id)}`,
558
+ `${child.id}${child.label ? ` (${child.label})` : ""} [${child.state}] → record ${sessionRecordPath(child.id)}`,
469
559
  )
470
560
  .join("; ")} — collect their results instead of re-dispatching duplicate work (records live under ${sessionRecordsDir()}/<childId>.json).`;
561
+ const appearedSentence =
562
+ appearedCount > 0
563
+ ? `${appearedCount} of them were dispatched before the handover but only MATERIALIZED during the compaction window (catalog entries stamped late) — they are this session's own deferred dispatches, alive; collect them, never re-dispatch their tasks.`
564
+ : `If the compaction summary mentions dispatched subagents that are NOT listed above, they materialized after this enumeration — a post-wake recheck will announce them within seconds; do not re-dispatch anything before it fires.`;
471
565
  const instruction = [
472
566
  "[SWITCHMAN:HANDOVER] A context handover just completed for this session.",
473
567
  backupId !== null
474
568
  ? `A dormant fork backup holding the full pre-handover state is session ${backupId}.`
475
569
  : `No fork backup was created; the append-only session log still holds the full history.`,
476
- docPath !== null
477
- ? `The handover document skeleton is at ${docPath}.`
570
+ docFile !== null
571
+ ? `The handover document is at ${docFile}.`
478
572
  : `Create the handover document under .dsh-switchman/handover/ in the working directory.`,
479
- runningSentence,
573
+ liveSentence,
574
+ appearedSentence,
480
575
  "Using the compaction summary now in your context, fill in the handover document (goal, completed, in progress, next steps, files), then continue the unfinished task — do not wait for further instructions.",
481
576
  ].join(" ");
482
577
  try {
@@ -517,14 +612,17 @@ function applyContextWatch(ctx, config) {
517
612
  recordHandover(sessionId, { phase: "backup", source, at: Date.now() });
518
613
  const backup = await forkBackup(agent, sessionId, signal);
519
614
  const backupId = backup.childId;
520
- // Snapshot the live background children BEFORE compaction — a
521
- // dispatch racing the handover would otherwise be missed, and the
522
- // same snapshot feeds both the document section and the
523
- // continuation instruction. The sanctioned services answer first
524
- // (live registry, no flush lag); the projcache disk scan below is
525
- // the fail-open fallback.
526
- const runningSubagents = await enumerateRunningSubagents(ctx, sessionId);
527
- const docPath = writeHandoverSkeleton(agent, sessionId, backupId, measureNow(ctx, agent), runningSubagents);
615
+ // One document path per handover run (the stamp freezes here so
616
+ // refreshes overwrite the same file). The document itself is
617
+ // written as the LAST action before every compaction attempt —
618
+ // see attempt() below. Writing it here and then sitting through
619
+ // the idle-retry loop is exactly how the 2026-10-05 incident
620
+ // lost deferred dispatches: the triggering turn kept executing
621
+ // after the snapshot, and its subagent dispatches materialized
622
+ // into the catalog only around the post-compaction resume.
623
+ const docPath = handoverDocPath(agent, sessionId);
624
+ let docFile = null; // last successful document write
625
+ let lastLive = []; // last pre-compaction enumeration
528
626
  const delays = [5_000, 15_000, 30_000, 60_000, 120_000, 300_000];
529
627
  const outcome = await new Promise((resolve) => {
530
628
  let attempts = 0;
@@ -547,7 +645,7 @@ function applyContextWatch(ctx, config) {
547
645
  }
548
646
  resolve({ ok, reason });
549
647
  };
550
- const attempt = () => {
648
+ const attempt = async () => {
551
649
  if (finished || attempting) return;
552
650
  attempting = true;
553
651
  attempts += 1;
@@ -556,6 +654,19 @@ function applyContextWatch(ctx, config) {
556
654
  timer = null;
557
655
  }
558
656
  recordHandover(sessionId, { phase: "compacting", source, attempt: attempts, at: Date.now() });
657
+ // The LAST pre-compaction action: re-enumerate and (re)write
658
+ // the handover document, then immediately compact — nothing
659
+ // mutating in between. On retries this re-captures anything
660
+ // the triggering turn did while the loop waited for the
661
+ // agent to go idle; the document is therefore truthful at
662
+ // the exact moment compaction lands, yet still strictly
663
+ // pre-compaction state.
664
+ try {
665
+ lastLive = await enumerateLiveSubagents(ctx, sessionId);
666
+ docFile = writeHandoverDoc(docPath, sessionId, backupId, measureNow(ctx, agent), lastLive) ?? docFile;
667
+ } catch {
668
+ /* enumeration and the document must never block compaction */
669
+ }
559
670
  let promise;
560
671
  try {
561
672
  promise = sessionCompact(agent, commandId ?? `switchman-${source}-${sessionId}`, signal);
@@ -618,11 +729,41 @@ function applyContextWatch(ctx, config) {
618
729
  text: `/ctx-handover: compaction failed after backup ${backupId ?? "n/a"} — ${outcome.reason}`,
619
730
  };
620
731
  recordHandover(sessionId, { phase: "continuation", source, at: Date.now() });
621
- await armContinuation(agent, backupId, docPath, runningSubagents);
732
+ // Final enumeration right after compaction settled: anything that
733
+ // materialized during the compaction window (the deferred-dispatch
734
+ // race the document refresh cannot fully close) is announced in
735
+ // the wake instruction and appended to the document.
736
+ const live = await enumerateLiveSubagents(ctx, sessionId);
737
+ const knownIds = new Set([...lastLive.map((child) => child.id), backupId ?? ""]);
738
+ const appeared = live.filter((child) => !knownIds.has(child.id));
739
+ if (docFile !== null && appeared.length > 0) {
740
+ try {
741
+ appendFileSync(
742
+ docFile,
743
+ [
744
+ ``,
745
+ `## Materialized during the compaction window (${new Date().toISOString()})`,
746
+ ``,
747
+ `- ${appeared.length} dispatched subagent(s) appeared after the last pre-compaction capture:`,
748
+ ...appeared.map(
749
+ (child) =>
750
+ `- \`${child.id}\`${child.label ? ` — ${child.label}` : ""} — ${child.state === "running" ? "RUNNING" : "PENDING"} → record ${sessionRecordPath(child.id)}`,
751
+ ),
752
+ `- Dispatched before the handover, materialized late — this session's own work, alive. Collect their results; do NOT re-dispatch.`,
753
+ ``,
754
+ ].join("\n"),
755
+ "utf8",
756
+ );
757
+ } catch {
758
+ /* append is best-effort; the wake instruction lists them too */
759
+ }
760
+ }
761
+ await armContinuation(agent, backupId, docFile, live, appeared);
762
+ scheduleMaterializationRecheck(agent, sessionId, backupId, docFile, live);
622
763
  const backupText = backupId ?? `n/a (${backup.reason ?? "log retains full history"})`;
623
764
  return {
624
765
  kind: "success",
625
- text: `[SWITCHMAN:WATERMARK] handover complete — backup ${backupText}, document ${docPath ?? "agent-created"}, compaction done, continuation armed.`,
766
+ text: `[SWITCHMAN:WATERMARK] handover complete — backup ${backupText}, document ${docFile ?? "agent-created"}, compaction done, continuation armed.`,
626
767
  };
627
768
  } catch (error) {
628
769
  return { kind: "error", text: `/ctx-handover failed: ${error?.message ?? error}` };
package/host/routes.js CHANGED
@@ -81,14 +81,14 @@ const NUMBER_FIELDS = [
81
81
  const BOOLEAN_FIELDS = [
82
82
  "wmAutoHandover",
83
83
  "wmSubagentCap",
84
- // 以下六个 mirrors host/config.js 的 pool*Manual 开关
84
+ // The following six mirror the pool*Manual switches in host/config.js
85
85
  "poolEconomyManual",
86
86
  "poolMechanicalManual",
87
87
  "poolMainManual",
88
88
  "poolHardManual",
89
89
  "poolVisionManual",
90
90
  "poolReviewManual",
91
- // teams 开关(mirrors host/config.js 的 teams* 组)
91
+ // Teams switches (mirrors the teams* group in host/config.js)
92
92
  "teamsMode",
93
93
  "syncWhitelist",
94
94
  ];
@@ -102,6 +102,7 @@ const EFFORTS_FIELDS = [
102
102
  "poolReviewEfforts",
103
103
  ];
104
104
  const ENUM_FIELDS = {
105
+ uiLocale: ["auto", "en", "zh", "zh-TW", "ja", "ko", "de", "es", "fr", "it", "pt", "ru"],
105
106
  langScope: ["global", "project"],
106
107
  dispatchEnforce: ["off", "advice", "enforce"],
107
108
  wmDenyMode: ["cap", "deny"],
@@ -246,6 +247,7 @@ export function snapshotOf(config) {
246
247
  values[field] = Array.isArray(rawOf(config?.[field]))
247
248
  ? rawOf(config[field]).map(cleanEffortEntry).filter(Boolean)
248
249
  : [];
250
+ values.uiLocale = ENUM_FIELDS.uiLocale.includes(rawOf(config?.uiLocale)) ? rawOf(config.uiLocale) : "auto";
249
251
  values.langScope = ENUM_FIELDS.langScope.includes(rawOf(config?.langScope)) ? rawOf(config.langScope) : "global";
250
252
  values.dispatchEnforce = ENUM_FIELDS.dispatchEnforce.includes(rawOf(config?.dispatchEnforce))
251
253
  ? rawOf(config.dispatchEnforce)
@@ -294,6 +296,8 @@ function coerceValues(input) {
294
296
  if (efforts.some((entry) => entry === null)) return null;
295
297
  values[field] = efforts;
296
298
  }
299
+ if (!ENUM_FIELDS.uiLocale.includes(input.uiLocale)) return null;
300
+ values.uiLocale = input.uiLocale;
297
301
  if (!ENUM_FIELDS.langScope.includes(input.langScope)) return null;
298
302
  values.langScope = input.langScope;
299
303
  if (!ENUM_FIELDS.dispatchEnforce.includes(input.dispatchEnforce)) return null;
package/host/subagents.js CHANGED
@@ -1,11 +1,27 @@
1
- /** Enumeration of this session's still-running background subagents.
1
+ /** Enumeration of this session's dispatched background subagents.
2
2
  *
3
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.
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.
9
25
  *
10
26
  * Disk format (verified against a packaged host's live records under
11
27
  * <DSH_HOME>/storages/session_projcache/sessions/):
@@ -14,14 +30,25 @@
14
30
  * prefix) -> { record: { rows: { subagentCatalog: { val: { head:
15
31
  * { values: [{ childId, childCreatedAt, mode, label }, ...] } } } } } }
16
32
  * — 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" }.
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.
22
41
  *
23
42
  * Every failure mode is fail-open to [] — enumeration is advisory
24
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. */
25
52
  import { readFileSync } from "node:fs";
26
53
  import { join } from "node:path";
27
54
  import { dshHome } from "./lib/dsh-home.js";
@@ -61,84 +88,73 @@ function recordPaths(directory, id) {
61
88
  return [...names].map((name) => join(directory, name));
62
89
  }
63
90
 
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) {
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) {
70
110
  if (typeof sessionId !== "string" || sessionId === "") return [];
71
111
  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;
112
+ const parentRows = rowsOf(directory, sessionId);
113
+ const entries = parentRows?.subagentCatalog?.val?.head?.values;
77
114
  if (!Array.isArray(entries)) return [];
78
- const running = [];
115
+ const children = [];
79
116
  for (const entry of entries) {
80
117
  if (entry === null || typeof entry !== "object") continue;
81
118
  const childId = entry.childId;
82
119
  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({
120
+ const state = childDiskState(rowsOf(directory, childId));
121
+ children.push({
89
122
  id: childId,
90
123
  label: typeof entry.label === "string" ? entry.label : "",
91
124
  mode: typeof entry.mode === "string" ? entry.mode : "",
125
+ childCreatedAt: typeof entry.childCreatedAt === "number" ? entry.childCreatedAt : null,
126
+ state,
92
127
  });
93
128
  }
94
- return running;
129
+ return children;
95
130
  }
96
131
 
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) {
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) {
105
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 [];
106
148
  try {
107
- const subagents = typeof ctx?.get === "function" ? ctx.get("subagents") : null;
108
149
  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
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";
138
154
  }
139
155
  }
140
156
  } catch {
141
- /* fall through to the disk scan */
157
+ /* registry overlay is best-effort */
142
158
  }
143
- return runningSubagentsOf(sessionId);
159
+ return live;
144
160
  }