claude-code-session-manager 0.13.0 → 0.14.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.
Files changed (43) hide show
  1. package/dist/assets/{TiptapBody-ZBlGHTXg.js → TiptapBody-CzNkD0kT.js} +1 -1
  2. package/dist/assets/{cssMode-Co9Kuq5g.js → cssMode-DUMBBgBO.js} +1 -1
  3. package/dist/assets/{freemarker2-B9Y0PjVt.js → freemarker2-BjnoO8uS.js} +1 -1
  4. package/dist/assets/{handlebars-BGL0s1rN.js → handlebars-CggfdTjZ.js} +1 -1
  5. package/dist/assets/{html-C8BuG-2M.js → html-Bcnp_pNA.js} +1 -1
  6. package/dist/assets/{htmlMode-BfCigMol.js → htmlMode-CFws8rf5.js} +1 -1
  7. package/dist/assets/{index-CkI_4Vt7.js → index-0geebEag.js} +1684 -1383
  8. package/dist/assets/{index-DewIHWBs.css → index-DOo1Vtox.css} +1 -1
  9. package/dist/assets/{javascript-sES7Ewin.js → javascript-DI4e0TwT.js} +1 -1
  10. package/dist/assets/{jsonMode-C7txz4wN.js → jsonMode-Ca0fgEAF.js} +1 -1
  11. package/dist/assets/{liquid-B4qxgigx.js → liquid-E7hk4hx9.js} +1 -1
  12. package/dist/assets/{lspLanguageFeatures-B5xgGS7m.js → lspLanguageFeatures-Wh7EFavn.js} +1 -1
  13. package/dist/assets/{mdx-De-8TTNY.js → mdx-iXIhpMBJ.js} +1 -1
  14. package/dist/assets/{python-SfayDkVl.js → python-laxy9eG1.js} +1 -1
  15. package/dist/assets/{razor-BRCdaeYo.js → razor-CatPL_eG.js} +1 -1
  16. package/dist/assets/{tsMode-DBTPpTr8.js → tsMode-D2WL55Cf.js} +1 -1
  17. package/dist/assets/{typescript-Bxbq3Vd7.js → typescript-B5DFbVoi.js} +1 -1
  18. package/dist/assets/{xml-DQBPiVNP.js → xml-BsStStQW.js} +1 -1
  19. package/dist/assets/{yaml-lyIgZXXz.js → yaml-rg2IIDpu.js} +1 -1
  20. package/dist/index.html +2 -2
  21. package/package.json +2 -1
  22. package/src/main/__tests__/runVerify.test.cjs +388 -0
  23. package/src/main/config.cjs +1 -7
  24. package/src/main/files.cjs +4 -37
  25. package/src/main/historyAggregator.cjs +37 -14
  26. package/src/main/index.cjs +53 -134
  27. package/src/main/ipcSchemas.cjs +4 -0
  28. package/src/main/lib/childWithLog.cjs +218 -0
  29. package/src/main/lib/expandHome.cjs +21 -0
  30. package/src/main/lib/insideHome.cjs +74 -21
  31. package/src/main/lib/openExternalApp.cjs +141 -0
  32. package/src/main/pluginInstall.cjs +39 -1
  33. package/src/main/pty.cjs +11 -4
  34. package/src/main/queueOps.cjs +2 -2
  35. package/src/main/runVerify.cjs +527 -0
  36. package/src/main/scheduler.cjs +335 -286
  37. package/src/main/search.cjs +1 -6
  38. package/src/main/superagent.cjs +10 -0
  39. package/src/main/supervisor.cjs +16 -8
  40. package/src/main/transcripts.cjs +6 -0
  41. package/src/main/watchers.cjs +2 -2
  42. package/src/preload/api.d.ts +16 -7
  43. package/src/preload/index.cjs +1 -0
@@ -44,7 +44,6 @@ const fs = require('node:fs');
44
44
  const fsp = require('node:fs/promises');
45
45
  const path = require('node:path');
46
46
  const os = require('node:os');
47
- const { spawn } = require('node:child_process');
48
47
  const { randomUUID } = require('node:crypto');
49
48
  const { ipcMain } = require('electron');
50
49
  const billing = require('./usage.cjs');
@@ -52,10 +51,12 @@ const { cleanChildEnv } = require('./lib/cleanEnv.cjs');
52
51
  const supervisor = require('./supervisor.cjs');
53
52
  const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
54
53
  const { readTail } = require('./lib/fileTail.cjs');
54
+ const { openLog, withChildAndLog } = require('./lib/childWithLog.cjs');
55
55
  const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
56
56
  const prdParser = require('./scheduler/prdParser.cjs');
57
+ const { verifyRun } = require('./runVerify.cjs');
57
58
  const logs = require('./logs.cjs');
58
- const { schemas } = require('./ipcSchemas.cjs');
59
+ const { schemas, validated } = require('./ipcSchemas.cjs');
59
60
  const {
60
61
  POLL_INTERVAL_MS,
61
62
  USAGE_REFRESH_INTERVAL_MS,
@@ -146,7 +147,6 @@ function ensureDirs() {
146
147
  // would deadlock the exit path.
147
148
  const config = require('./config.cjs');
148
149
  const atomicWriteJsonSync = (p, data) => config.writeJsonSync(p, data);
149
- const atomicWriteJson = (p, data) => config.writeJson(p, data);
150
150
 
151
151
  // ---------- scheduler-state.json (sidecar) ----------
152
152
 
@@ -168,7 +168,7 @@ function persistSchedulerState() {
168
168
  // require threading awaits through pause/resume bookkeeping for negligible
169
169
  // benefit — the file is well under one page.
170
170
  try {
171
- atomicWriteJsonSync(SCHEDULER_STATE_PATH, {
171
+ config.writeJsonSync(SCHEDULER_STATE_PATH, {
172
172
  version: 1,
173
173
  lastObservedReset: cachedNextReset,
174
174
  lastResetObservedAt: cachedNextReset ? Date.now() : null,
@@ -242,7 +242,7 @@ async function readQueue() {
242
242
 
243
243
  async function writeQueue(state) {
244
244
  ensureDirs();
245
- await atomicWriteJson(QUEUE_PATH, state);
245
+ await config.writeJson(QUEUE_PATH, state);
246
246
  }
247
247
 
248
248
  // ---------- serialized mutation queue ----------
@@ -608,21 +608,16 @@ function resetJobFields(job, errorMsg) {
608
608
  job.exitCode = null;
609
609
  job.error = errorMsg ?? null;
610
610
  delete job.runtime;
611
+ delete job.verifierVerdict;
611
612
  }
612
613
 
613
614
  /** Scan the tail of a job's log for the canonical rate-limit signal. We look
614
- * at the last 16 KB — final result event always lands at the end. */
615
+ * at the last 16 KB — final result event always lands at the end.
616
+ * Uses readTail() so no raw fd lifecycle is needed here. */
615
617
  function detectRateLimitInLog(logPath) {
616
618
  try {
617
- const stat = fs.statSync(logPath);
618
- const start = Math.max(0, stat.size - 16384);
619
- const len = stat.size - start;
620
- if (len <= 0) return false;
621
- const fd = fs.openSync(logPath, 'r');
622
- const buf = Buffer.alloc(len);
623
- fs.readSync(fd, buf, 0, len, start);
624
- fs.closeSync(fd);
625
- const text = buf.toString('utf8');
619
+ const text = readTail(logPath, 16384);
620
+ if (!text) return false;
626
621
  return /"rateLimitType":"five_hour"/.test(text)
627
622
  || /"api_error_status":429/.test(text)
628
623
  || /You'?ve hit your limit/.test(text);
@@ -644,6 +639,10 @@ function pickRunDir() {
644
639
  * Execute a single PRD job. Writes stdout/stderr to a log file and a meta
645
640
  * JSON sidecar. Accepts an optional onPid(pid) callback called synchronously
646
641
  * after spawn so callers can persist the pid before the job finishes.
642
+ *
643
+ * Uses withChildAndLog for the child lifecycle (fd open/close, watchdog timers).
644
+ * Watchdogs are declared as an array; the result-tailer's exit-code mapping
645
+ * (success+killedBySignal → 0) is scheduler-specific and lives in onExit.
647
646
  */
648
647
  async function executeJob(job, runDir, defaultCwd, onPid) {
649
648
  const logPath = path.join(runDir, `${job.slug}.log`);
@@ -652,17 +651,10 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
652
651
  const startedAt = Date.now();
653
652
  const sessionId = randomUUID();
654
653
 
655
- const fd = fs.openSync(logPath, 'a');
656
- let fdClosed = false;
657
- const closeFd = () => { if (fdClosed) return; fdClosed = true; try { fs.closeSync(fd); } catch { /* */ } };
658
- // safeLog: no-op once the fd is closed, never throws on the watchdog timer
659
- // path. Pre-fix, a post-result/idle watchdog firing AFTER closeFd would
660
- // throw EBADF and crash the host. Every fs.writeSync(fd, …) below goes
661
- // through this helper.
662
- const safeLog = (msg) => {
663
- if (fdClosed) return;
664
- try { fs.writeSync(fd, msg); } catch { /* fd vanished mid-write */ }
665
- };
654
+ // Phase 1: open log fd so we can emit pre-spawn diagnostics (early-exit
655
+ // error paths) before the child is created. withChildAndLog takes ownership
656
+ // of fd/safeLog/closeFd from the point it is called.
657
+ const { fd, safeLog, closeFd } = openLog(logPath);
666
658
 
667
659
  safeLog(`[scheduler] starting ${job.slug} at ${new Date().toISOString()}\n[scheduler] cwd=${cwd}\n\n`);
668
660
 
@@ -676,7 +668,7 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
676
668
  // Sync write: this is an early-exit error path inside an async function,
677
669
  // so we could await, but using the sync variant keeps the error path
678
670
  // ordering identical to the spawn-failed branch below (also sync).
679
- atomicWriteJsonSync(metaPath, { slug: job.slug, cwd, sessionId, exitCode: -1, error: errMsg, startedAt, finishedAt: Date.now(), durationMs: 0 });
671
+ config.writeJsonSync(metaPath, { slug: job.slug, cwd, sessionId, exitCode: -1, error: errMsg, startedAt, finishedAt: Date.now(), durationMs: 0 });
680
672
  return { exitCode: -1, durationMs: 0, error: errMsg, sessionId };
681
673
  }
682
674
 
@@ -696,7 +688,7 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
696
688
  if (!promptCheck.ok) {
697
689
  safeLog(`[scheduler] ${promptCheck.error}\n`);
698
690
  closeFd();
699
- atomicWriteJsonSync(metaPath, { slug: job.slug, cwd, sessionId, exitCode: -1, error: promptCheck.error, startedAt, finishedAt: Date.now(), durationMs: 0 });
691
+ config.writeJsonSync(metaPath, { slug: job.slug, cwd, sessionId, exitCode: -1, error: promptCheck.error, startedAt, finishedAt: Date.now(), durationMs: 0 });
700
692
  return { exitCode: -1, durationMs: 0, error: promptCheck.error, sessionId };
701
693
  }
702
694
 
@@ -706,178 +698,182 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
706
698
  // launched from a `claude` shell. CLAUDE_EFFORT=xhigh forces Opus and
707
699
  // overrides `--model sonnet`, so scheduled jobs burn Opus credits silently.
708
700
  const childEnv = cleanChildEnv();
709
- // Guard against synchronous spawn failures (EAGAIN, ENOMEM on fork).
710
- // Without this, the throw bubbles out of the Promise executor and the
711
- // outer await rejects — but the open fd is leaked.
712
- let child;
713
- try {
714
- child = spawn(claudeBin, [
715
- '-p', prompt,
716
- '--model', 'sonnet',
717
- '--dangerously-skip-permissions',
718
- '--output-format', 'stream-json',
719
- '--verbose',
720
- '--session-id', sessionId,
721
- ], {
722
- cwd,
723
- env: childEnv,
724
- stdio: ['ignore', fd, fd],
725
- // detached:true puts the child in its own process group so we can kill
726
- // the entire descendant tree (including any stray background bashes the
727
- // agent spawned) with `process.kill(-pid)`. Without this, child.kill()
728
- // only kills the immediate `claude` process, leaving orphaned subprocs
729
- // that keep the parent alive (the 2026-05-10 cellar-publish hang).
730
- detached: true,
731
- });
732
- } catch (e) {
733
- const errMsg = `spawn failed: ${e?.message ?? String(e)}`;
734
- safeLog(`[scheduler] ${errMsg}\n`);
735
- closeFd();
736
- const durationMs = Date.now() - startedAt;
737
- // Sync write: inside the Promise executor, before resolve(). Awaiting
738
- // here would require restructuring the executor; the meta file is tiny
739
- // and this is an error path, not the IPC hot path.
740
- atomicWriteJsonSync(metaPath, { slug: job.slug, cwd, sessionId, exitCode: -1, error: errMsg, startedAt, finishedAt: Date.now(), durationMs });
741
- resolve({ exitCode: -1, durationMs, error: errMsg, sessionId });
742
- return;
743
- }
744
-
745
- safeLog(`[scheduler] spawned pid=${child.pid} sessionId=${sessionId} (process group)\n\n`);
746
-
747
- // Fire-and-forget pid persistence — best effort.
748
- if (onPid) onPid(child.pid, sessionId, cwd).catch(() => {});
749
701
 
750
702
  // Track whether the agent has emitted a `result` event in its JSONL stream.
751
- // null until seen; then one of "success" | "error_max_turns" | ... per the
703
+ // null until seen; then one of "success" | "error_max_turns" | per the
752
704
  // claude harness's result subtype taxonomy.
705
+ // Declared here (outer scope) so the onExit handler can reference it for
706
+ // the success+killedBySignal → exitCode:0 mapping.
753
707
  let agentResultSubtype = null;
754
- let postResultTimer = null;
755
- let postResultKillTimer = null;
756
-
757
- const killTree = (signal) => {
758
- // Kill the whole process group. Negative pid targets the group leader's
759
- // group (only works because we spawned with detached:true).
760
- try { process.kill(-child.pid, signal); return true; }
761
- catch {
762
- try { process.kill(child.pid, signal); return true; }
763
- catch { return false; /* already dead */ }
764
- }
765
- };
766
708
 
767
- // Tail the log for {"type":"result","subtype":"..."} events. When we see
768
- // one, start the post-result grace timer — the agent has declared done,
769
- // so the process should exit promptly. If not, something is hanging
770
- // (the cellar-publish failure mode).
771
- const resultTailer = setInterval(() => {
772
- if (agentResultSubtype) return; // already seen; tailer will be cleared below
773
- try {
774
- const stat = fs.statSync(logPath);
775
- if (stat.size === 0) return;
776
- const n = Math.min(stat.size, RESULT_TAIL_BYTES);
777
- const buf = Buffer.alloc(n);
778
- const fdR = fs.openSync(logPath, 'r');
779
- fs.readSync(fdR, buf, 0, n, stat.size - n);
780
- fs.closeSync(fdR);
781
- const m = buf.toString('utf8').match(/\{"type":"result","subtype":"([a-z_]+)"/);
782
- if (!m) return;
783
- agentResultSubtype = m[1];
784
- safeLog(`\n[scheduler] result event detected (subtype=${agentResultSubtype}); ` +
709
+ // ---------- watchdog declarations ----------
710
+ //
711
+ // All three use fire-once-on-condition semantics (auto-clear on first fire).
712
+ // Secondary timers created inside action() are registered via ctx.addTimer()
713
+ // so they are cleared on child exit even if fired after the primary watchdog.
714
+
715
+ // Result-tailer: scan the log tail for a {"type":"result"} event. On
716
+ // detection, start a grace timer — the agent declared done, so it should
717
+ // exit promptly. If it doesn't, SIGTERM the process group (the
718
+ // cellar-publish failure mode: unbounded background bashes kept the parent
719
+ // alive 22 min after the agent emitted result=success).
720
+ // Note: killedByWatchdog is set inside the cascade timer, NOT when the
721
+ // result is first detected, so a clean exit during the grace period leaves
722
+ // killedByWatchdog null (not misattributed to this watchdog).
723
+ const resultTailWatchdog = {
724
+ label: 'result-tail',
725
+ intervalMs: RESULT_TAIL_POLL_MS,
726
+ shouldFire(ctx) {
727
+ try {
728
+ const tail = readTail(ctx.logPath, RESULT_TAIL_BYTES);
729
+ if (!tail) return false;
730
+ const m = tail.match(/\{"type":"result","subtype":"([a-z_]+)"/);
731
+ if (!m) return false;
732
+ agentResultSubtype = m[1];
733
+ return true;
734
+ } catch { return false; }
735
+ },
736
+ action(ctx) {
737
+ ctx.safeLog(`\n[scheduler] result event detected (subtype=${agentResultSubtype}); ` +
785
738
  `starting ${Math.round(POST_RESULT_GRACE_MS/1000)}s exit-grace timer\n`);
786
- clearInterval(resultTailer);
787
- postResultTimer = setTimeout(() => {
788
- safeLog(`\n[scheduler] post-result grace expired (${Math.round(POST_RESULT_GRACE_MS/1000)}s); ` +
739
+ const postResultTimer = setTimeout(() => {
740
+ ctx.safeLog(`\n[scheduler] post-result grace expired (${Math.round(POST_RESULT_GRACE_MS/1000)}s); ` +
789
741
  `child still alive — SIGTERM process group\n`);
790
- killTree('SIGTERM');
791
- postResultKillTimer = setTimeout(() => {
792
- safeLog(`\n[scheduler] still alive ${Math.round(POST_RESULT_KILL_MS/1000)}s after SIGTERM — SIGKILL\n`);
793
- killTree('SIGKILL');
742
+ ctx.killedByWatchdog = 'result-tail';
743
+ ctx.killTree('SIGTERM');
744
+ const postResultKillTimer = setTimeout(() => {
745
+ ctx.safeLog(`\n[scheduler] still alive ${Math.round(POST_RESULT_KILL_MS/1000)}s after SIGTERM — SIGKILL\n`);
746
+ ctx.killTree('SIGKILL');
794
747
  }, POST_RESULT_KILL_MS);
795
748
  if (postResultKillTimer.unref) postResultKillTimer.unref();
749
+ ctx.addTimer(postResultKillTimer);
796
750
  }, POST_RESULT_GRACE_MS);
797
751
  if (postResultTimer.unref) postResultTimer.unref();
798
- } catch { /* log not readable yet; try again */ }
799
- }, RESULT_TAIL_POLL_MS);
800
- if (resultTailer.unref) resultTailer.unref();
801
-
802
- // Kill the child if it runs past the maximum allowed duration.
803
- const watchdog = setTimeout(() => {
804
- safeLog(`\n[scheduler] watchdog SIGKILL after ${MAX_JOB_DURATION_MS}ms\n`);
805
- killTree('SIGKILL');
806
- }, MAX_JOB_DURATION_MS);
807
- if (watchdog.unref) watchdog.unref();
808
-
809
- // Idle-output watchdog: poll log mtime every IDLE_CHECK_INTERVAL_MS; if
810
- // it hasn't advanced in IDLE_OUTPUT_KILL_MS, presume the agent is stuck
811
- // and SIGTERM the process group.
812
- let idleKillTimer = null;
813
- const idleChecker = setInterval(() => {
814
- try {
815
- const stat = fs.statSync(logPath);
816
- const idleMs = Date.now() - stat.mtimeMs;
817
- if (idleMs > IDLE_OUTPUT_KILL_MS) {
818
- safeLog(`\n[scheduler] idle-output watchdog: log mtime stalled ` +
819
- `${Math.round(idleMs/1000)}s (> ${Math.round(IDLE_OUTPUT_KILL_MS/1000)}s threshold) — SIGTERM process group\n`);
820
- clearInterval(idleChecker);
821
- killTree('SIGTERM');
822
- idleKillTimer = setTimeout(() => {
823
- safeLog(`\n[scheduler] idle watchdog: still alive ${Math.round(POST_RESULT_KILL_MS/1000)}s after SIGTERM — SIGKILL\n`);
824
- killTree('SIGKILL');
825
- }, POST_RESULT_KILL_MS);
826
- if (idleKillTimer.unref) idleKillTimer.unref();
827
- }
828
- } catch { /* log not statable; skip */ }
829
- }, IDLE_CHECK_INTERVAL_MS);
830
- if (idleChecker.unref) idleChecker.unref();
831
-
832
- const clearAllTimers = () => {
833
- clearTimeout(watchdog);
834
- clearInterval(resultTailer);
835
- clearInterval(idleChecker);
836
- if (postResultTimer) clearTimeout(postResultTimer);
837
- if (postResultKillTimer) clearTimeout(postResultKillTimer);
838
- if (idleKillTimer) clearTimeout(idleKillTimer);
752
+ ctx.addTimer(postResultTimer);
753
+ },
839
754
  };
840
755
 
841
- child.on('error', (err) => {
842
- clearAllTimers();
843
- const durationMs = Date.now() - startedAt;
844
- safeLog(`\n[scheduler] error: ${err.message}\n`);
845
- closeFd();
846
- // Sync write: child event handler must flush meta before resolve().
847
- atomicWriteJsonSync(metaPath, { slug: job.slug, cwd, sessionId, exitCode: -1, error: err.message, startedAt, finishedAt: Date.now(), durationMs });
848
- resolve({ exitCode: -1, durationMs, error: err.message, sessionId });
849
- });
756
+ // Deadman: kill the child unconditionally after MAX_JOB_DURATION_MS.
757
+ // shouldFire: () => true means the interval fires once at intervalMs then
758
+ // auto-clears (fire-once-on-condition with a condition that's always true).
759
+ const deadmanWatchdog = {
760
+ label: 'deadman',
761
+ intervalMs: MAX_JOB_DURATION_MS,
762
+ shouldFire: () => true,
763
+ action(ctx) {
764
+ ctx.safeLog(`\n[scheduler] watchdog SIGKILL after ${MAX_JOB_DURATION_MS}ms\n`);
765
+ ctx.killedByWatchdog = 'deadman';
766
+ ctx.killTree('SIGKILL');
767
+ },
768
+ };
850
769
 
851
- child.on('exit', (code, signal) => {
852
- clearAllTimers();
853
- const durationMs = Date.now() - startedAt;
854
- // If we SIGTERM'd because of the post-result watchdog AND the agent had
855
- // emitted result=success, the work succeeded; only the cleanup hung.
856
- // Map the kill exit code to 0 so the job is marked completed, not failed.
857
- // Node's child.on('exit') reports either code (normal) or signal (killed);
858
- // when killed by signal, code is null. We also check 143 (128+SIGTERM)
859
- // and 137 (128+SIGKILL) in case the process exited via signal-as-code.
860
- let effectiveCode = code;
861
- const killedBySignal = signal === 'SIGTERM' || signal === 'SIGKILL' || code === 143 || code === 137 || code === null;
862
- const mappedToSuccess = agentResultSubtype === 'success' && killedBySignal;
863
- if (mappedToSuccess) {
864
- effectiveCode = 0;
865
- safeLog(`\n[scheduler] mapping exit code=${code} signal=${signal} 0 ` +
866
- `(result=success was emitted before kill)\n`);
867
- }
868
- safeLog(`\n[scheduler] exit code=${effectiveCode} (raw code=${code} signal=${signal}) ` +
869
- `duration=${Math.round(durationMs / 1000)}s\n`);
870
- closeFd();
871
- const rateLimited = effectiveCode !== 0 && detectRateLimitInLog(logPath);
872
- // Sync write: child 'exit' handler must flush meta before resolve()
873
- // so the spawnJob mutate() that follows sees the persisted exit code.
874
- atomicWriteJsonSync(metaPath, {
875
- slug: job.slug, cwd, sessionId, exitCode: effectiveCode, rateLimited,
876
- startedAt, finishedAt: Date.now(), durationMs,
877
- agentResultSubtype, mappedFromSignal: mappedToSuccess ? signal || `code=${code}` : null,
878
- });
879
- resolve({ exitCode: effectiveCode, durationMs, rateLimited, sessionId });
770
+ // Idle-output watchdog: if log mtime stalls for IDLE_OUTPUT_KILL_MS the
771
+ // agent is presumed stuck (network stall, infinite tool loop, compaction
772
+ // wedge). SIGTERM the group, SIGKILL after POST_RESULT_KILL_MS.
773
+ const idleTailWatchdog = {
774
+ label: 'idle-tail',
775
+ intervalMs: IDLE_CHECK_INTERVAL_MS,
776
+ shouldFire(ctx) {
777
+ try {
778
+ const stat = fs.statSync(ctx.logPath);
779
+ return Date.now() - stat.mtimeMs > IDLE_OUTPUT_KILL_MS;
780
+ } catch { return false; }
781
+ },
782
+ action(ctx) {
783
+ let idleMs = 0;
784
+ try { idleMs = Date.now() - fs.statSync(ctx.logPath).mtimeMs; } catch { /* */ }
785
+ ctx.safeLog(`\n[scheduler] idle-output watchdog: log mtime stalled ` +
786
+ `${Math.round(idleMs/1000)}s (> ${Math.round(IDLE_OUTPUT_KILL_MS/1000)}s threshold) — SIGTERM process group\n`);
787
+ ctx.killedByWatchdog = 'idle-tail';
788
+ ctx.killTree('SIGTERM');
789
+ const idleKillTimer = setTimeout(() => {
790
+ ctx.safeLog(`\n[scheduler] idle watchdog: still alive ${Math.round(POST_RESULT_KILL_MS/1000)}s after SIGTERM — SIGKILL\n`);
791
+ ctx.killTree('SIGKILL');
792
+ }, POST_RESULT_KILL_MS);
793
+ if (idleKillTimer.unref) idleKillTimer.unref();
794
+ ctx.addTimer(idleKillTimer);
795
+ },
796
+ };
797
+
798
+ // ---------- spawn ----------
799
+
800
+ const { child } = withChildAndLog({
801
+ fd,
802
+ logPath,
803
+ safeLog,
804
+ closeFd,
805
+ spawn: {
806
+ command: claudeBin,
807
+ args: [
808
+ '-p', prompt,
809
+ '--model', 'sonnet',
810
+ '--dangerously-skip-permissions',
811
+ '--output-format', 'stream-json',
812
+ '--verbose',
813
+ '--session-id', sessionId,
814
+ ],
815
+ options: {
816
+ cwd,
817
+ env: childEnv,
818
+ // detached:true puts the child in its own process group so we can kill
819
+ // the entire descendant tree (including any stray background bashes the
820
+ // agent spawned) with `process.kill(-pid)`. Without this, child.kill()
821
+ // only kills the immediate `claude` process, leaving orphaned subprocs
822
+ // that keep the parent alive (the 2026-05-10 cellar-publish hang).
823
+ detached: true,
824
+ },
825
+ },
826
+ watchdogs: [resultTailWatchdog, deadmanWatchdog, idleTailWatchdog],
827
+ onExit({ exitCode, signal, killedByWatchdog: _kbw, error, spawnFailed, safeLog: sl }) {
828
+ const durationMs = Date.now() - startedAt;
829
+
830
+ if (error) {
831
+ // Covers both synchronous spawn failure and child 'error' events.
832
+ const errMsg = spawnFailed
833
+ ? `spawn failed: ${error?.message ?? String(error)}`
834
+ : error.message;
835
+ sl(`\n[scheduler] ${errMsg}\n`);
836
+ // Sync write: inside a Promise executor callback; must flush meta
837
+ // before resolve() so the spawnJob mutate() that follows sees it.
838
+ config.writeJsonSync(metaPath, { slug: job.slug, cwd, sessionId, exitCode: -1, error: errMsg, startedAt, finishedAt: Date.now(), durationMs });
839
+ resolve({ exitCode: -1, durationMs, error: errMsg, sessionId });
840
+ return;
841
+ }
842
+
843
+ // If we killed the child (via any watchdog or externally) AND the agent
844
+ // had already emitted result=success, the work succeeded; only the
845
+ // cleanup hung. Map the kill exit code to 0 so the job is marked
846
+ // completed, not failed.
847
+ // Node's child.on('exit') reports either code (normal) or signal (killed);
848
+ // when killed by signal, code is null. We also check 143 (128+SIGTERM)
849
+ // and 137 (128+SIGKILL) in case the process exited via signal-as-code.
850
+ let effectiveCode = exitCode;
851
+ const killedBySignal = signal === 'SIGTERM' || signal === 'SIGKILL' || exitCode === 143 || exitCode === 137 || exitCode === null;
852
+ const mappedToSuccess = agentResultSubtype === 'success' && killedBySignal;
853
+ if (mappedToSuccess) {
854
+ effectiveCode = 0;
855
+ sl(`\n[scheduler] mapping exit code=${exitCode} signal=${signal} → 0 ` +
856
+ `(result=success was emitted before kill)\n`);
857
+ }
858
+ sl(`\n[scheduler] exit code=${effectiveCode} (raw code=${exitCode} signal=${signal}) ` +
859
+ `duration=${Math.round(durationMs / 1000)}s\n`);
860
+ const rateLimited = effectiveCode !== 0 && detectRateLimitInLog(logPath);
861
+ // Sync write: child 'exit' handler must flush meta before resolve()
862
+ // so the spawnJob mutate() that follows sees the persisted exit code.
863
+ config.writeJsonSync(metaPath, {
864
+ slug: job.slug, cwd, sessionId, exitCode: effectiveCode, rateLimited,
865
+ startedAt, finishedAt: Date.now(), durationMs,
866
+ agentResultSubtype, mappedFromSignal: mappedToSuccess ? signal || `code=${exitCode}` : null,
867
+ });
868
+ resolve({ exitCode: effectiveCode, durationMs, rateLimited, sessionId });
869
+ },
880
870
  });
871
+
872
+ if (child) {
873
+ safeLog(`[scheduler] spawned pid=${child.pid} sessionId=${sessionId} (process group)\n\n`);
874
+ // Fire-and-forget pid persistence — best effort.
875
+ if (onPid) onPid(child.pid, sessionId, cwd).catch(() => {});
876
+ }
881
877
  });
882
878
  }
883
879
 
@@ -920,7 +916,8 @@ function pickNextBatch(allJobs, running, cap) {
920
916
  // unblock the gate, but the default is to halt until the failure is
921
917
  // acknowledged.
922
918
  const blockingFailures = allJobs.filter((j) =>
923
- j.status === 'failed' && (j.parallelGroup ?? 99) < lowestPendingGroup,
919
+ (j.status === 'failed' || j.status === 'needs_review') &&
920
+ (j.parallelGroup ?? 99) < lowestPendingGroup,
924
921
  );
925
922
  if (blockingFailures.length > 0) {
926
923
  const slugs = blockingFailures.map((j) => j.slug).join(', ');
@@ -1096,66 +1093,75 @@ ${logTail}
1096
1093
 
1097
1094
  DO NOT attempt the fix. ONLY write the file. When the file exists, exit immediately.`;
1098
1095
 
1099
- const fd = fs.openSync(investigationLogPath, 'a');
1096
+ // Phase 1: open log fd for pre-spawn diagnostics.
1097
+ const { fd, safeLog, closeFd } = openLog(investigationLogPath);
1100
1098
  const sessionId = randomUUID();
1101
- try {
1102
- fs.writeSync(fd, `[scheduler] investigation starting for ${failedJob.slug} at ${new Date().toISOString()}\n[scheduler] target fix PRD: ${fixPath}\n[scheduler] sessionId=${sessionId}\n\n`);
1103
- } catch { /* */ }
1099
+ safeLog(`[scheduler] investigation starting for ${failedJob.slug} at ${new Date().toISOString()}\n[scheduler] target fix PRD: ${fixPath}\n[scheduler] sessionId=${sessionId}\n\n`);
1104
1100
 
1105
- const claudeBin = resolveClaudeBin();
1106
- const childEnv = cleanChildEnv();
1107
1101
  const investigationPromptCheck = validatePromptForSpawn(prompt, `<investigation prompt for ${failedJob.slug}>`);
1108
1102
  if (!investigationPromptCheck.ok) {
1109
- try { fs.writeSync(fd, `\n[scheduler] ${investigationPromptCheck.error}\n`); } catch { /* */ }
1110
- try { fs.closeSync(fd); } catch { /* */ }
1111
- return;
1112
- }
1113
- let child;
1114
- try {
1115
- child = spawn(claudeBin, [
1116
- '-p', prompt,
1117
- '--model', 'opus',
1118
- '--dangerously-skip-permissions',
1119
- '--output-format', 'stream-json',
1120
- '--verbose',
1121
- '--session-id', sessionId,
1122
- ], {
1123
- cwd,
1124
- env: childEnv,
1125
- stdio: ['ignore', fd, fd],
1126
- });
1127
- } catch (e) {
1128
- try { fs.writeSync(fd, `\n[scheduler] investigation spawn failed: ${e?.message ?? e}\n`); } catch { /* */ }
1129
- try { fs.closeSync(fd); } catch { /* */ }
1103
+ safeLog(`\n[scheduler] ${investigationPromptCheck.error}\n`);
1104
+ closeFd();
1130
1105
  return;
1131
1106
  }
1132
1107
 
1133
- try { fs.writeSync(fd, `[scheduler] investigation pid=${child.pid}\n\n`); } catch { /* */ }
1108
+ const claudeBin = resolveClaudeBin();
1109
+ const childEnv = cleanChildEnv();
1134
1110
 
1135
- const watchdog = setTimeout(() => {
1136
- try { fs.writeSync(fd, `\n[scheduler] investigation watchdog SIGKILL after ${MAX_INVESTIGATION_DURATION_MS}ms\n`); } catch { /* */ }
1137
- try { child.kill('SIGKILL'); } catch { /* already dead */ }
1138
- }, MAX_INVESTIGATION_DURATION_MS);
1139
- if (watchdog.unref) watchdog.unref();
1111
+ // Investigation needs only a deadman watchdog no idle-tail or result-tail
1112
+ // since investigations are short-running Opus probes with a hard ceiling.
1113
+ const deadmanWatchdog = {
1114
+ label: 'deadman',
1115
+ intervalMs: MAX_INVESTIGATION_DURATION_MS,
1116
+ shouldFire: () => true,
1117
+ action(ctx) {
1118
+ ctx.safeLog(`\n[scheduler] investigation watchdog SIGKILL after ${MAX_INVESTIGATION_DURATION_MS}ms\n`);
1119
+ ctx.killedByWatchdog = 'deadman';
1120
+ ctx.killTree('SIGKILL');
1121
+ },
1122
+ };
1140
1123
 
1141
- child.on('error', (err) => {
1142
- clearTimeout(watchdog);
1143
- try { fs.writeSync(fd, `\n[scheduler] investigation error: ${err.message}\n`); } catch { /* */ }
1144
- try { fs.closeSync(fd); } catch { /* */ }
1124
+ // Phase 2: spawn with lifecycle managed by withChildAndLog.
1125
+ const { child } = withChildAndLog({
1126
+ fd,
1127
+ logPath: investigationLogPath,
1128
+ safeLog,
1129
+ closeFd,
1130
+ spawn: {
1131
+ command: claudeBin,
1132
+ args: [
1133
+ '-p', prompt,
1134
+ '--model', 'opus',
1135
+ '--dangerously-skip-permissions',
1136
+ '--output-format', 'stream-json',
1137
+ '--verbose',
1138
+ '--session-id', sessionId,
1139
+ ],
1140
+ options: { cwd, env: childEnv },
1141
+ },
1142
+ watchdogs: [deadmanWatchdog],
1143
+ onExit({ exitCode, error, spawnFailed, safeLog: sl }) {
1144
+ if (error) {
1145
+ const errMsg = spawnFailed
1146
+ ? `investigation spawn failed: ${error?.message ?? String(error)}`
1147
+ : `investigation error: ${error.message}`;
1148
+ sl(`\n[scheduler] ${errMsg}\n`);
1149
+ return;
1150
+ }
1151
+ sl(`\n[scheduler] investigation exit code=${exitCode}\n`);
1152
+ if (fs.existsSync(fixPath)) {
1153
+ console.log(`[scheduler] investigation produced fix plan: ${fixSlug}`);
1154
+ } else {
1155
+ console.log(`[scheduler] investigation finished WITHOUT producing fix plan (slug=${failedJob.slug}, code=${exitCode})`);
1156
+ }
1157
+ // Trigger a tick so the new fix plan is reconciled into the queue and fired.
1158
+ tickQueue().catch(() => {});
1159
+ },
1145
1160
  });
1146
1161
 
1147
- child.on('exit', (code) => {
1148
- clearTimeout(watchdog);
1149
- try { fs.writeSync(fd, `\n[scheduler] investigation exit code=${code}\n`); } catch { /* */ }
1150
- try { fs.closeSync(fd); } catch { /* */ }
1151
- if (fs.existsSync(fixPath)) {
1152
- console.log(`[scheduler] investigation produced fix plan: ${fixSlug}`);
1153
- } else {
1154
- console.log(`[scheduler] investigation finished WITHOUT producing fix plan (slug=${failedJob.slug}, code=${code})`);
1155
- }
1156
- // Trigger a tick so the new fix plan is reconciled into the queue and fired.
1157
- tickQueue().catch(() => {});
1158
- });
1162
+ if (child) {
1163
+ safeLog(`[scheduler] investigation pid=${child.pid}\n\n`);
1164
+ }
1159
1165
  }
1160
1166
 
1161
1167
  async function spawnJob(job, runId, runDir, defaultCwd) {
@@ -1187,6 +1193,33 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
1187
1193
  await setPaused('rate_limit', resetIso);
1188
1194
  }
1189
1195
 
1196
+ // Post-run verification: for exit=0 runs, scan the transcript and check
1197
+ // dependency prerequisites before stamping 'completed'. This catches the
1198
+ // false-positive class where an agent exits cleanly while leaving failures
1199
+ // in its tool output (see incidents: PRD 39, 44, 56 on 2026-05-23→24).
1200
+ // Called outside mutate() so the queue lock is not held during I/O.
1201
+ let verifyResult = null;
1202
+ if (res.exitCode === 0 && !res.rateLimited) {
1203
+ const prdPath = path.join(PRDS_DIR, `${job.slug}.md`);
1204
+ const stateForDeps = await readQueue();
1205
+ verifyResult = await verifyRun({
1206
+ runDir,
1207
+ prdPath,
1208
+ queueEntry: job,
1209
+ allJobs: stateForDeps.jobs,
1210
+ }).catch((e) => ({
1211
+ verdict: 'verify_unavailable',
1212
+ reason: `verifier threw: ${e?.message ?? String(e)}`,
1213
+ downgradeTo: 'needs_review',
1214
+ }));
1215
+ if (verifyResult.verdict !== 'clean') {
1216
+ console.log(
1217
+ `[scheduler] verifier: ${job.slug} verdict=${verifyResult.verdict}` +
1218
+ ` → ${verifyResult.downgradeTo ?? 'completed'}: ${verifyResult.reason}`,
1219
+ );
1220
+ }
1221
+ }
1222
+
1190
1223
  let actuallyFailed = false;
1191
1224
  let failedJobSnapshot = null;
1192
1225
  await mutate((s) => {
@@ -1196,32 +1229,74 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
1196
1229
  if (treatAsPending) {
1197
1230
  resetJobFields(s.jobs[i2], res.rateLimited ? 'paused: rate limit' : 'paused: queue halted');
1198
1231
  } else {
1199
- s.jobs[i2].status = res.exitCode === 0 ? 'completed' : 'failed';
1232
+ // Determine effective status, applying the verifier verdict for exit=0 runs.
1233
+ let effectiveStatus;
1234
+ if (res.exitCode !== 0) {
1235
+ effectiveStatus = 'failed';
1236
+ } else if (!verifyResult || verifyResult.verdict === 'clean') {
1237
+ effectiveStatus = 'completed';
1238
+ } else if (verifyResult.downgradeTo === 'pending') {
1239
+ // HALT or deps_unmet: reset to pending so the job re-fires.
1240
+ resetJobFields(s.jobs[i2], verifyResult.reason);
1241
+ return; // job already mutated by resetJobFields; skip the rest
1242
+ } else {
1243
+ // transcript_errors or verify_unavailable: escalate to needs_review.
1244
+ effectiveStatus = 'needs_review';
1245
+ }
1246
+
1247
+ s.jobs[i2].status = effectiveStatus;
1200
1248
  s.jobs[i2].finishedAt = new Date().toISOString();
1201
1249
  s.jobs[i2].exitCode = res.exitCode;
1202
- s.jobs[i2].error = res.error || null;
1250
+ s.jobs[i2].error = effectiveStatus === 'needs_review'
1251
+ ? (verifyResult?.reason ?? null)
1252
+ : (res.error || null);
1253
+ // Persist the verifier's verdict string so the renderer can show it.
1254
+ if (verifyResult?.verdict && verifyResult.verdict !== 'clean') {
1255
+ s.jobs[i2].verifierVerdict = verifyResult.verdict;
1256
+ } else {
1257
+ delete s.jobs[i2].verifierVerdict;
1258
+ }
1203
1259
  delete s.jobs[i2].runtime;
1204
- if (s.jobs[i2].status === 'failed') {
1260
+
1261
+ if (effectiveStatus === 'failed') {
1205
1262
  actuallyFailed = true;
1206
1263
  failedJobSnapshot = { ...s.jobs[i2] };
1207
1264
  }
1265
+ // Auto-promote: when a fix-* PRD completes successfully, the original
1266
+ // failed PRD's work is logically done. Flip its status to 'completed'
1267
+ // so the cross-group failure gate in pickNextBatch releases. Without
1268
+ // this, the queue stalls indefinitely behind a stale failure even
1269
+ // though the auto-recovery did its job.
1270
+ if (effectiveStatus === 'completed' && isFixPlanSlug(job.slug)) {
1271
+ const originalSlug = job.slug.replace(/^(\d+)-fix-/, '$1-');
1272
+ const orig = s.jobs.findIndex((x) => x.slug === originalSlug && x.status === 'failed');
1273
+ if (orig >= 0) {
1274
+ console.log(`[scheduler] auto-promote: ${originalSlug} (failed) → completed because ${job.slug} succeeded`);
1275
+ s.jobs[orig].status = 'completed';
1276
+ s.jobs[orig].exitCode = 0;
1277
+ s.jobs[orig].error = null;
1278
+ s.jobs[orig].completedBy = job.slug;
1279
+ }
1280
+ }
1208
1281
  }
1209
1282
  }
1210
1283
  });
1211
1284
  await broadcast();
1212
1285
 
1213
1286
  if (actuallyFailed && failedJobSnapshot) {
1214
- // Transient-failure detector: SIGTERM/SIGKILL within 30s = almost
1287
+ // Transient-failure detector: SIGTERM/SIGKILL within 45s = almost
1215
1288
  // always external kill (user-initiated app restart, OOM-kill, manual
1216
- // process kill). The PRD itself didn't fail; the run was interrupted
1217
- // before it could do meaningful work. Spawning an Opus investigator on
1218
- // these is wasted tokens AND pollutes the queue with redundant fix-PRDs
1219
- // (real example 2026-05-21: 07-agent-view-robot-rename-lasttool got
1289
+ // process kill, Electron HMR). The PRD itself didn't fail; the run was
1290
+ // interrupted before it could do meaningful work. Spawning an Opus
1291
+ // investigator on these is wasted tokens AND pollutes the queue with
1292
+ // redundant fix-PRDs (real example 2026-05-21: 07-agent-view-... got
1220
1293
  // SIGTERMed at 10s by an app restart, the rename had already been done
1221
- // anyway, and the auto-generated fix-PRD just sat in queue.json as
1222
- // noise). Auto-retry up to 2x before falling through to investigation.
1294
+ // anyway). The 45s cutoff (was 30s) catches Electron-HMR borderline
1295
+ // cases like PRD 26 SIGTERM'd at 33s that was 3s over the old cutoff
1296
+ // and fell through to a fix-PRD that just acknowledged the work was
1297
+ // already done. Auto-retry up to 2x before falling through to investigation.
1223
1298
  const ec = failedJobSnapshot.exitCode;
1224
- const transient = (ec === 143 || ec === 137) && res.durationMs < 30_000;
1299
+ const transient = (ec === 143 || ec === 137) && res.durationMs < 45_000;
1225
1300
  const retries = failedJobSnapshot.transientRetries ?? 0;
1226
1301
  if (transient && retries < 2) {
1227
1302
  console.log(`[scheduler] transient failure (exit=${ec} dur=${res.durationMs}ms) — auto-retry ${retries + 1}/2 for ${job.slug}`);
@@ -1449,15 +1524,10 @@ function registerScheduleHandlers() {
1449
1524
  return { ok: true };
1450
1525
  });
1451
1526
 
1452
- ipcMain.handle('schedule:set-config', async (_e, partial) => {
1453
- let validated;
1454
- try {
1455
- validated = schemas.setConfigSchema.parse(partial || {});
1456
- } catch (e) {
1457
- return { ok: false, error: e?.message ?? 'invalid config' };
1458
- }
1527
+ // .default({}) so callers may omit the payload entirely (same as the old `partial || {}`).
1528
+ ipcMain.handle('schedule:set-config', validated(schemas.setConfigSchema.default({}), async (data) => {
1459
1529
  const config = await mutate((state) => {
1460
- const { supervisor: supPartial, ...rest } = validated;
1530
+ const { supervisor: supPartial, ...rest } = data;
1461
1531
  state.config = { ...state.config, ...rest };
1462
1532
  if (supPartial !== undefined) {
1463
1533
  state.config.supervisor = { ...(state.config.supervisor ?? {}), ...supPartial };
@@ -1466,15 +1536,9 @@ function registerScheduleHandlers() {
1466
1536
  });
1467
1537
  await rescheduleTimer();
1468
1538
  return { ok: true, config };
1469
- });
1539
+ }));
1470
1540
 
1471
- ipcMain.handle('schedule:reset-job', async (_e, payload) => {
1472
- let slug;
1473
- try {
1474
- ({ slug } = schemas.scheduleSlug.parse(payload));
1475
- } catch (e) {
1476
- return { ok: false, error: 'invalid slug' };
1477
- }
1541
+ ipcMain.handle('schedule:reset-job', validated(schemas.scheduleSlug, async ({ slug }) => {
1478
1542
  if (!safeSlugPath(slug)) return { ok: false, error: 'invalid slug' };
1479
1543
  const found = await mutate((state) => {
1480
1544
  const idx = state.jobs.findIndex((j) => j.slug === slug);
@@ -1485,7 +1549,7 @@ function registerScheduleHandlers() {
1485
1549
  if (!found) return { ok: false, error: 'not found' };
1486
1550
  await broadcast();
1487
1551
  return { ok: true };
1488
- });
1552
+ }));
1489
1553
 
1490
1554
  ipcMain.handle('schedule:run-now', async () => {
1491
1555
  // Manual run-now overrides any auto-pause. Clear it first.
@@ -1563,13 +1627,7 @@ function registerScheduleHandlers() {
1563
1627
  return { ok: true };
1564
1628
  });
1565
1629
 
1566
- ipcMain.handle('schedule:read-prd', async (_e, payload) => {
1567
- let slug;
1568
- try {
1569
- ({ slug } = schemas.scheduleSlug.parse(payload));
1570
- } catch {
1571
- return { ok: false, error: 'invalid slug' };
1572
- }
1630
+ ipcMain.handle('schedule:read-prd', validated(schemas.scheduleSlug, async ({ slug }) => {
1573
1631
  const filePath = safeSlugPath(slug);
1574
1632
  if (!filePath) return { ok: false, error: 'invalid slug' };
1575
1633
  try {
@@ -1578,15 +1636,9 @@ function registerScheduleHandlers() {
1578
1636
  } catch (e) {
1579
1637
  return { ok: false, error: e?.message };
1580
1638
  }
1581
- });
1639
+ }));
1582
1640
 
1583
- ipcMain.handle('schedule:read-log', async (_e, payload) => {
1584
- let slug, runId;
1585
- try {
1586
- ({ slug, runId } = schemas.scheduleReadLog.parse(payload));
1587
- } catch {
1588
- return { ok: false, error: 'invalid slug or runId' };
1589
- }
1641
+ ipcMain.handle('schedule:read-log', validated(schemas.scheduleReadLog, async ({ slug, runId }) => {
1590
1642
  // Defense-in-depth: re-check containment after path.resolve even though
1591
1643
  // SLUG_RE / RUN_ID_RE already forbid path separators.
1592
1644
  const logPath = path.resolve(path.join(RUNS_DIR, runId, `${slug}.log`));
@@ -1599,16 +1651,13 @@ function registerScheduleHandlers() {
1599
1651
  } catch (e) {
1600
1652
  return { ok: false, error: e?.message };
1601
1653
  }
1602
- });
1654
+ }));
1603
1655
 
1604
- ipcMain.handle('schedule:write-prd', async (_e, payload) => {
1605
- let parsed;
1606
- try { parsed = schemas.scheduleWritePrd.parse(payload); }
1607
- catch (e) { return { ok: false, error: e?.message ?? 'invalid payload' }; }
1608
- const resolved = safeSlugPath(parsed.slug);
1656
+ ipcMain.handle('schedule:write-prd', validated(schemas.scheduleWritePrd, async (data) => {
1657
+ const resolved = safeSlugPath(data.slug);
1609
1658
  if (!resolved) return { ok: false, error: 'invalid slug' };
1610
1659
  try {
1611
- await config.writeTextAtomic(resolved, parsed.body);
1660
+ await config.writeTextAtomic(resolved, data.body);
1612
1661
  } catch (e) {
1613
1662
  return { ok: false, error: e?.message ?? 'write failed' };
1614
1663
  }
@@ -1618,7 +1667,7 @@ function registerScheduleHandlers() {
1618
1667
  } catch (e) {
1619
1668
  return { ok: false, error: e?.message ?? 'stat failed' };
1620
1669
  }
1621
- });
1670
+ }));
1622
1671
 
1623
1672
  ipcMain.handle('schedule:list-prds', async () => {
1624
1673
  ensureDirs();