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.
@@ -18,14 +18,24 @@
18
18
  * compaction only inside each preset's isolated per-agent group
19
19
  * (isolate: { compaction: true }), so no root-level inject or lookup can
20
20
  * reach it — the borrowed handler's closure carries the right composition.
21
- * Auto handover is fire-and-forget with a session-level in-flight flag
22
- * plus a 10-minute cooldown. Manual /ctx-handover is the full four-step
23
- * flow: fork a dormant backup session (subagents' fork provider, economy
24
- * pool model), write the handover document skeleton, compact, then wake
25
- * this session (agent.followup + sessions.flush, the schedule service's
26
- * delivery channel) to fill in the document and continue the unfinished
27
- * task. /ctx-pause and /ctx-resume toggle process-lifetime pause
28
- * state (a restart resumes enforcement — the safe side).
21
+ * Every handover trigger — the force-tier automation (fire-and-forget,
22
+ * session-level in-flight flag plus a 10-minute cooldown) and manual
23
+ * /ctx-handover — shares one four-step flow: fork a dormant backup
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.
35
+ * The live handover phase feeds the force-tier banner so progress is
36
+ * visible instead of silent. /ctx-pause and /ctx-resume toggle
37
+ * process-lifetime pause state (a restart resumes enforcement — the
38
+ * safe side).
29
39
  *
30
40
  * All session state is in-process Maps; every path is fail-open (a failing
31
41
  * measurement or missing service degrades the banner to "" or the gate to
@@ -33,8 +43,10 @@
33
43
  */
34
44
 
35
45
  import { randomUUID } from "node:crypto";
36
- import { mkdirSync, writeFileSync } from "node:fs";
37
- import { join } from "node:path";
46
+ import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
47
+ import { dirname, join } from "node:path";
48
+ import { clearHandover, handoverOf, recordHandover } from "./handover-state.js";
49
+ import { enumerateLiveSubagents, sessionRecordPath, sessionRecordsDir } from "./subagents.js";
38
50
 
39
51
  /** Tools whose result text is accounted against the per-turn read budget. */
40
52
  const ACCOUNT_TOOLS = new Set(["read", "glob", "grep", "bash"]);
@@ -140,8 +152,9 @@ function measureNow(ctx, agent) {
140
152
 
141
153
  // ---- banner rendering (pure given state + measurement) ---------------------
142
154
 
143
- /** Root-session banner: byte-stable anchor line + tier advice lines. */
144
- function renderRootBanner(level, used, th, autoHandover) {
155
+ /** Root-session banner: byte-stable anchor line + tier advice lines plus
156
+ * the live handover progress (phase/attempt) and auto-cooldown state. */
157
+ function renderRootBanner(level, used, th, status) {
145
158
  const head =
146
159
  `[SWITCHMAN:WATERMARK] level=${level} used=${kilo(used)}` +
147
160
  ` budget=${kilo(th.soft)}/${kilo(th.hard)}/${kilo(th.force)}`;
@@ -153,11 +166,22 @@ function renderRootBanner(level, used, th, autoHandover) {
153
166
  if (level === "hard") lines.push(`hard tier: stop broad reading (read/glob/grep are budget-limited this turn) and start wrapping up.`);
154
167
  if (level === "force") {
155
168
  lines.push(`force tier: MANDATORY wrap-up now — produce the final answer from what you already hold.`);
156
- lines.push(
157
- autoHandover
158
- ? `an automatic context handover (compaction) is armed and will fire at the next step boundary.`
159
- : `run /ctx-handover to trigger a manual context handover.`,
160
- );
169
+ const phase = status?.phase;
170
+ if (phase === "backup") {
171
+ lines.push(`context handover running: forking the backup session and writing the handover document…`);
172
+ } else if (phase === "compacting") {
173
+ lines.push(`context handover running: compaction attempt ${status?.attempt ?? "?"} (retries while the turn is still running)…`);
174
+ } else if (phase === "continuation") {
175
+ lines.push(`context handover completing: waking this session to fill in the document and continue the task…`);
176
+ } else if (status?.autoHandover) {
177
+ lines.push(
178
+ status?.cooldownMin > 0
179
+ ? `automatic handover cooldown: ${status.cooldownMin} min left; run /ctx-handover${status?.tool ? " or call the ctx_handover tool" : ""} to force one now.`
180
+ : `an automatic context handover (backup + document + compaction + continuation) is armed and will fire at the next step boundary${status?.tool ? "; calling the ctx_handover tool triggers it immediately" : ""}.`,
181
+ );
182
+ } else {
183
+ lines.push(`run /ctx-handover${status?.tool ? " or call the ctx_handover tool" : ""} to trigger a manual context handover.`);
184
+ }
161
185
  }
162
186
  return lines.join("\n");
163
187
  }
@@ -199,7 +223,15 @@ function renderBanner(ctx, config, state, agent) {
199
223
  return renderSubBanner(used, subTh);
200
224
  }
201
225
  const level = state.paused.has(sessionId) ? "paused" : levelOf(used, th);
202
- return renderRootBanner(level, used, th, readBool(config.wmAutoHandover, true));
226
+ const live = handoverOf(sessionId);
227
+ const elapsed = Date.now() - (state.lastHandover.get(sessionId) ?? 0);
228
+ return renderRootBanner(level, used, th, {
229
+ autoHandover: readBool(config.wmAutoHandover, true),
230
+ phase: live?.phase,
231
+ attempt: live?.attempt,
232
+ cooldownMin: Math.max(0, Math.ceil((HANDOVER_COOLDOWN_MS - elapsed) / 60_000)),
233
+ tool: state.handoverTool ?? undefined,
234
+ });
203
235
  } catch {
204
236
  return "";
205
237
  }
@@ -260,6 +292,7 @@ function applyContextWatch(ctx, config) {
260
292
  const state = {
261
293
  paused: new Set(),
262
294
  inflight: new Set(),
295
+ handoverTool: null,
263
296
  lastHandover: new Map(),
264
297
  readUsed: new Map(),
265
298
  };
@@ -282,46 +315,32 @@ function applyContextWatch(ctx, config) {
282
315
  // ctx.commands.find(agent, "compact") merges that agent's scoped layer and
283
316
  // returns the definition whose handler closure already carries the right
284
317
  // composition context with compaction injected.
285
- const sessionCompact = (agent, commandId, signal) => {
286
- let definition;
318
+ /** Find the agent's scoped /compact definition without invoking it. */
319
+ const compactDefinition = (agent) => {
287
320
  try {
288
- definition = ctx.commands?.find?.(agent, "compact");
321
+ const definition = ctx.commands?.find?.(agent, "compact");
322
+ return typeof definition?.handler === "function" ? definition : undefined;
289
323
  } catch {
290
- definition = undefined;
324
+ return undefined;
291
325
  }
292
- if (definition === undefined || typeof definition.handler !== "function") return undefined;
326
+ };
327
+
328
+ const sessionCompact = (agent, commandId, signal) => {
329
+ const definition = compactDefinition(agent);
330
+ if (definition === undefined) return undefined;
293
331
  const invocation = Object.freeze({
294
332
  commandId,
295
333
  agent,
296
334
  rawInput: "",
297
335
  attachments: Object.freeze([]),
298
- signal,
336
+ // The borrowed /compact handler (and compactNow behind it) touches
337
+ // signal.throwIfAborted()/aborted unconditionally; the auto path
338
+ // has no real signal, so always carry a fresh un-aborted one.
339
+ signal: signal ?? new AbortController().signal,
299
340
  });
300
341
  return Promise.resolve(definition.handler(invocation));
301
342
  };
302
343
 
303
- const startHandover = (agent, sessionId) => {
304
- state.inflight.add(sessionId);
305
- let promise;
306
- try {
307
- promise = sessionCompact(agent, `switchman-auto-${sessionId}`, undefined);
308
- if (promise === undefined)
309
- warn(`auto handover found no per-agent /compact command for session ${sessionId} — nothing to do`);
310
- } catch (error) {
311
- warn(`handover failed to start: ${error?.message ?? error}`);
312
- promise = undefined;
313
- }
314
- const settle = () => state.inflight.delete(sessionId);
315
- if (promise !== undefined && typeof promise.then === "function") {
316
- promise.then(
317
- () => ctx.logger?.info?.(`dsh-switchman: context handover settled for session ${sessionId}`),
318
- (error) => warn(`context handover failed for session ${sessionId}: ${error?.message ?? error}`),
319
- ).then(settle, settle);
320
- } else {
321
- settle();
322
- }
323
- };
324
-
325
344
  /** First {provider, model} route of a volatile pool field, or null. */
326
345
  const firstPoolRoute = (field) => {
327
346
  const raw = readValue(field);
@@ -374,29 +393,67 @@ function applyContextWatch(ctx, config) {
374
393
  }
375
394
  };
376
395
 
377
- /** Manual handover step 2 — write the handover document skeleton into
378
- * the session's working directory. The mechanical facts land now (before
379
- * compaction); the agent's continuation turn fills in the task-state
380
- * summary using the compaction summary it can still see. Null when the
381
- * session exposes no usable cwd or the write fails. */
382
- const writeHandoverSkeleton = (agent, sessionId, backupId, used) => {
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) => {
383
400
  const cwd = agent?.session?.header?.cwd;
384
401
  if (typeof cwd !== "string" || cwd === "") return null;
385
402
  const stamp = new Date().toISOString().replace(/[:.]/gu, "-");
386
- const directory = join(cwd, ".dsh-switchman", "handover");
387
403
  const short = sessionId.replace(/^session-/u, "").slice(0, 8) || sessionId;
388
- const path = join(directory, `${stamp}-${short}.md`);
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
424
+ ? [
425
+ `## Live background subagents (captured immediately before the compaction attempt — still alive)`,
426
+ ``,
427
+ ...live.map(
428
+ (child) =>
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.`,
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.`,
437
+ ``,
438
+ ]
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
+ ];
389
445
  try {
390
- mkdirSync(directory, { recursive: true });
446
+ mkdirSync(dirname(path), { recursive: true });
391
447
  writeFileSync(
392
448
  path,
393
449
  [
394
450
  `# SWITCHMAN handover — ${sessionId}`,
395
451
  ``,
396
- `- handed over at: ${new Date().toISOString()}`,
452
+ `- handed over at: ${new Date().toISOString()} (document refreshed before every compaction attempt)`,
397
453
  `- context used before compaction: ~${used ?? "unknown"} tokens`,
398
454
  `- backup fork session: ${backupId ?? "(fork failed — the full history remains in this session's append-only log)"}`,
399
455
  ``,
456
+ ...liveSection,
400
457
  `## Task state (filled in by the agent right after the handover)`,
401
458
  ``,
402
459
  `- goal / objective in flight:`,
@@ -415,24 +472,106 @@ function applyContextWatch(ctx, config) {
415
472
  }
416
473
  };
417
474
 
418
- /** 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
419
535
  * followup user message (the same delivery channel the schedule service
420
- * uses: agent.followup + sessions.flush) instructing the agent to fill
536
+ * uses: agent.followup + sessions.flush) instructs the agent to fill
421
537
  * in the handover document from the compaction summary now in context
422
- * and continue the unfinished task. */
423
- const armContinuation = async (agent, backupId, docPath) => {
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) => {
424
544
  if (typeof agent?.followup !== "function") {
425
545
  warn("handover continuation skipped: agent exposes no followup()");
426
546
  return;
427
547
  }
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
556
+ .map(
557
+ (child) =>
558
+ `${child.id}${child.label ? ` (${child.label})` : ""} [${child.state}] → record ${sessionRecordPath(child.id)}`,
559
+ )
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.`;
428
565
  const instruction = [
429
566
  "[SWITCHMAN:HANDOVER] A context handover just completed for this session.",
430
567
  backupId !== null
431
568
  ? `A dormant fork backup holding the full pre-handover state is session ${backupId}.`
432
569
  : `No fork backup was created; the append-only session log still holds the full history.`,
433
- docPath !== null
434
- ? `The handover document skeleton is at ${docPath}.`
570
+ docFile !== null
571
+ ? `The handover document is at ${docFile}.`
435
572
  : `Create the handover document under .dsh-switchman/handover/ in the working directory.`,
573
+ liveSentence,
574
+ appearedSentence,
436
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.",
437
576
  ].join(" ");
438
577
  try {
@@ -448,46 +587,188 @@ function applyContextWatch(ctx, config) {
448
587
  }
449
588
  };
450
589
 
451
- /** Manual /ctx-handover — the full opencode-switchman handover flow on
452
- * DSH primitives: (1) fork a dormant backup session, (2) write the
453
- * handover document skeleton, (3) compact via the session's own
454
- * per-agent /compact handler (borrowed through ctx.commands.find; the
455
- * preset isolates compaction per agent, so no root-level access exists),
456
- * and (4) wake this session to fill in the document and continue the
457
- * unfinished task. Every step degrades independently; compaction is the
458
- * only mandatory one. */
459
- const manualHandover = async (agent, sessionId, commandId, signal) => {
590
+ /** The shared four-step handover (see the header comment), used by
591
+ * manual /ctx-handover and the force-tier automation alike. The
592
+ * compaction step retries on a growing backoff: both triggers usually
593
+ * fire mid-turn, the runMaintenance behind the borrowed /compact only
594
+ * accepts an IDLE agent, and "busy" arrives as a RESOLVED
595
+ * {kind:"error"} (not a rejection) — so both branches funnel into
596
+ * retryOrFail. Once the agent goes idle the compact lands before the
597
+ * next turn's driver (maintenance latches wake requests by design).
598
+ * The per-session phase map feeds the force-tier banner so the wait is
599
+ * visible instead of silent. */
600
+ const runHandover = async (agent, sessionId, commandId, signal, source = "manual") => {
460
601
  if (sessionId === null || agent?.session === undefined)
461
602
  return { kind: "error", text: "/ctx-handover: no active session" };
462
603
  if (state.inflight.has(sessionId))
463
604
  return { kind: "success", text: "[SWITCHMAN:WATERMARK] a handover is already in progress for this session." };
464
- const borrowed = sessionCompact(agent, commandId, signal);
465
- if (borrowed === undefined)
605
+ if (compactDefinition(agent) === undefined)
466
606
  return {
467
607
  kind: "error",
468
608
  text: "/ctx-handover: this session's preset exposes no /compact command, so no compaction backend is reachable.",
469
609
  };
470
610
  state.inflight.add(sessionId);
471
611
  try {
612
+ recordHandover(sessionId, { phase: "backup", source, at: Date.now() });
472
613
  const backup = await forkBackup(agent, sessionId, signal);
473
614
  const backupId = backup.childId;
474
- const docPath = writeHandoverSkeleton(agent, sessionId, backupId, measureNow(ctx, agent));
475
- const result = await borrowed;
476
- if (result?.kind === "error")
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
626
+ const delays = [5_000, 15_000, 30_000, 60_000, 120_000, 300_000];
627
+ const outcome = await new Promise((resolve) => {
628
+ let attempts = 0;
629
+ let attempting = false;
630
+ let timer = null;
631
+ let watchdog = null;
632
+ let finished = false;
633
+ let offStatus = null;
634
+ const finish = (ok, reason) => {
635
+ if (finished) return;
636
+ finished = true;
637
+ if (timer !== null) clearTimeout(timer);
638
+ if (watchdog !== null) clearTimeout(watchdog);
639
+ if (offStatus !== null) {
640
+ try {
641
+ offStatus();
642
+ } catch {
643
+ /* best-effort listener dispose */
644
+ }
645
+ }
646
+ resolve({ ok, reason });
647
+ };
648
+ const attempt = async () => {
649
+ if (finished || attempting) return;
650
+ attempting = true;
651
+ attempts += 1;
652
+ if (timer !== null) {
653
+ clearTimeout(timer);
654
+ timer = null;
655
+ }
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
+ }
670
+ let promise;
671
+ try {
672
+ promise = sessionCompact(agent, commandId ?? `switchman-${source}-${sessionId}`, signal);
673
+ } catch (error) {
674
+ attempting = false;
675
+ finish(false, `handover failed to start: ${error?.message ?? error}`);
676
+ return;
677
+ }
678
+ if (promise === undefined) {
679
+ attempting = false;
680
+ finish(false, "the per-agent /compact command disappeared");
681
+ return;
682
+ }
683
+ const settleAttempt = (ok, reason) => {
684
+ attempting = false;
685
+ if (ok) {
686
+ finish(true);
687
+ return;
688
+ }
689
+ if (attempts <= delays.length) timer = setTimeout(attempt, delays[attempts - 1]);
690
+ else finish(false, String(reason));
691
+ };
692
+ promise.then(
693
+ (value) => {
694
+ if (value !== null && typeof value === "object" && value.kind === "error") {
695
+ settleAttempt(false, value.text ?? "compact returned an error result");
696
+ return;
697
+ }
698
+ settleAttempt(true);
699
+ },
700
+ (error) => settleAttempt(false, error?.message ?? String(error)),
701
+ );
702
+ };
703
+ // Promptness: the agent/status idle transition (the same event
704
+ // the official compaction layer uses for overflow-retry
705
+ // cleanup) attempts the moment the turn really ends; the
706
+ // timers above stay as the backstop for missed or filtered
707
+ // events. Status reads "idle" externally while our own
708
+ // maintenance runs, and the `attempting` latch collapses
709
+ // those re-entrant events.
710
+ try {
711
+ offStatus = ctx.on("agent/status", (payload) => {
712
+ if (payload?.agent === agent && payload?.status === "idle") attempt();
713
+ });
714
+ } catch {
715
+ /* timer-only fallback */
716
+ }
717
+ // Watchdog: a borrowed /compact that never settles would hold
718
+ // the in-flight latch (and this listener) forever; 20 min is
719
+ // far beyond any real compaction (~2 min observed) yet finite.
720
+ watchdog = setTimeout(
721
+ () => finish(false, "handover watchdog timeout — the borrowed /compact never settled"),
722
+ 20 * 60_000,
723
+ );
724
+ attempt();
725
+ });
726
+ if (!outcome.ok)
477
727
  return {
478
728
  kind: "error",
479
- text: `/ctx-handover: compaction failed after backup ${backupId ?? "n/a"} — ${result.text ?? "unknown error"}`,
729
+ text: `/ctx-handover: compaction failed after backup ${backupId ?? "n/a"} — ${outcome.reason}`,
480
730
  };
481
- await armContinuation(agent, backupId, docPath);
482
- const backupText =
483
- backupId ?? `n/a (${backup.reason ?? "log retains full history"})`;
731
+ recordHandover(sessionId, { phase: "continuation", source, at: Date.now() });
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);
763
+ const backupText = backupId ?? `n/a (${backup.reason ?? "log retains full history"})`;
484
764
  return {
485
765
  kind: "success",
486
- 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.`,
487
767
  };
488
768
  } catch (error) {
489
769
  return { kind: "error", text: `/ctx-handover failed: ${error?.message ?? error}` };
490
770
  } finally {
771
+ clearHandover(sessionId);
491
772
  state.inflight.delete(sessionId);
492
773
  }
493
774
  };
@@ -504,7 +785,13 @@ function applyContextWatch(ctx, config) {
504
785
  const used = measureNow(ctx, agent);
505
786
  if (used === null || levelOf(used, readThresholds(config)) !== "force") return;
506
787
  state.lastHandover.set(sessionId, now);
507
- startHandover(agent, sessionId);
788
+ runHandover(agent, sessionId, `switchman-auto-${sessionId}`, undefined, "auto").then(
789
+ (result) =>
790
+ ctx.logger?.[result?.kind === "error" ? "warn" : "info"]?.(
791
+ `dsh-switchman: ${result?.text ?? "handover ended"}`,
792
+ ),
793
+ (error) => warn(`auto handover crashed: ${error?.message ?? error}`),
794
+ );
508
795
  } catch {
509
796
  /* fail-open: the banner still reports force */
510
797
  }
@@ -611,10 +898,63 @@ function applyContextWatch(ctx, config) {
611
898
  });
612
899
  ctx.commands.register({
613
900
  name: "ctx-handover",
614
- description: "Trigger a manual context handover (compaction) for this session now.",
901
+ description: "Trigger a full context handover for this session now (backup fork + document + compaction + continuation).",
615
902
  handler: (invocation) =>
616
- manualHandover(invocation?.agent, sessionIdOf(invocation?.agent), invocation?.commandId, invocation?.signal),
903
+ runHandover(invocation?.agent, sessionIdOf(invocation?.agent), invocation?.commandId, invocation?.signal, "manual"),
617
904
  });
905
+
906
+ // --- E. ctx_handover: the agent-callable front door to the same
907
+ // four-step handover. The definition uses the register-level shape —
908
+ // bare ctx.tools.register does not run defineTool's DSL compilation,
909
+ // so parameters is a full object-root JSON Schema — and registers
910
+ // best-effort: a validation rejection only skips the tool, never the
911
+ // plugin. execute returns
912
+ // fast on purpose — awaiting the compaction here would keep the agent
913
+ // busy and starve the idle-only /compact — so the flow runs detached
914
+ // and the result tells the model to end its turn immediately.
915
+ try {
916
+ ctx.tools?.register?.({
917
+ name: "ctx_handover",
918
+ description:
919
+ "Trigger a full dsh-switchman context handover for this session NOW: fork a dormant backup session, write the handover document, compact this session at the idle boundary, then wake it to fill in the document and continue the task. Call it when the watermark banner reaches the force tier or the user asks for a handover, then END the turn immediately.",
920
+ parameters: {
921
+ type: "object",
922
+ properties: {
923
+ reason: {
924
+ type: "string",
925
+ description: "Why the handover is triggered now (e.g. 'force tier watermark reached', 'user request'). Optional.",
926
+ },
927
+ },
928
+ },
929
+ output: {
930
+ schema: { type: "string" },
931
+ render: (_args, value) => [{ type: "text", text: value }],
932
+ },
933
+ async execute(_args, exec) {
934
+ const agent = exec?.agent;
935
+ const sessionId = sessionIdOf(agent);
936
+ // No exec.signal here on purpose: the compact must outlive the
937
+ // calling turn, and a turn-scoped AbortSignal could abort it.
938
+ runHandover(agent, sessionId, `switchman-tool-${sessionId ?? "session"}`, undefined, "tool").then(
939
+ (result) =>
940
+ ctx.logger?.[result?.kind === "error" ? "warn" : "info"]?.(
941
+ `dsh-switchman: ${result?.text ?? "handover ended"}`,
942
+ ),
943
+ (error) => warn(`ctx_handover handover crashed: ${error?.message ?? error}`),
944
+ );
945
+ return "[SWITCHMAN:WATERMARK] ctx_handover scheduled: backup fork + handover document + compaction + continuation are running. END this turn now — reply briefly and call no more tools, so the compaction lands at the idle boundary. Your closing line must tell the user the handover is running in the background and will continue automatically (the session-header badge shows the live state) — otherwise the GUI looks idle during fork+compact.";
946
+ },
947
+ presentCall: (args) => ({
948
+ card: "generic",
949
+ title: "Switchman context handover",
950
+ kind: "other",
951
+ rawInput: args,
952
+ }),
953
+ });
954
+ state.handoverTool = "ctx_handover";
955
+ } catch (error) {
956
+ warn(`ctx_handover tool registration failed: ${error?.message ?? error}`);
957
+ }
618
958
  }
619
959
 
620
960
  export { applyContextWatch };