claude-code-session-manager 0.123.1 → 0.124.1

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 (80) hide show
  1. package/dist/assets/{AgentLibrary-T14aM8ie.js → AgentLibrary-qmAgadiS.js} +1 -1
  2. package/dist/assets/{Button-Dz1kV8Z_.js → Button-CkuaU6cA.js} +1 -1
  3. package/dist/assets/{DataModel-DdYRn2Ec.js → DataModel-DEVHxW5I.js} +1 -1
  4. package/dist/assets/{History-zQPvqwE7.js → History-C-qw4Gx4.js} +1 -1
  5. package/dist/assets/{Hooks-BTrbT742.js → Hooks-DzAnCyjJ.js} +1 -1
  6. package/dist/assets/{Library-BwQ6GRHR.js → Library-DExZ29wM.js} +1 -1
  7. package/dist/assets/{ListDetail-CunxAhRC.js → ListDetail-DiQk-Hf9.js} +1 -1
  8. package/dist/assets/{McpServers-DpoyxXaO.js → McpServers-C0nqX36_.js} +1 -1
  9. package/dist/assets/{Memory-KDtGYMc3.js → Memory-D4KAWPAC.js} +1 -1
  10. package/dist/assets/{Panel-xXmUBo8l.js → Panel-xv9ToSjl.js} +1 -1
  11. package/dist/assets/{Permissions-lIzTUrW2.js → Permissions-0yEyqwZU.js} +1 -1
  12. package/dist/assets/{Plugins-BA1Zqk69.js → Plugins-CB28SGGJ.js} +2 -2
  13. package/dist/assets/{ProvenanceBadge-CmeJG2Kw.js → ProvenanceBadge-9i4gXDvI.js} +1 -1
  14. package/dist/assets/{SaveBar-BTJg_9Uc.js → SaveBar-mSVRXpml.js} +1 -1
  15. package/dist/assets/{Scheduler-CISTDNMU.js → Scheduler-BoUetIC0.js} +1 -1
  16. package/dist/assets/{ScopeSwitcher-C6NWvzoj.js → ScopeSwitcher-B8tdQIKs.js} +1 -1
  17. package/dist/assets/{Settings-BBoGJ34v.js → Settings-D3jfKwaK.js} +1 -1
  18. package/dist/assets/{SkillReferenceGraph-IJv60QAJ.js → SkillReferenceGraph-DJ4JJRt3.js} +1 -1
  19. package/dist/assets/{Skills-L6nGcNF1.js → Skills-P9HHxDCR.js} +1 -1
  20. package/dist/assets/{SystemPrompt-Cm7I2gJz.js → SystemPrompt-B_tnbBZ2.js} +1 -1
  21. package/dist/assets/{TagLibrary-BxPwoPE6.js → TagLibrary-CI8NHRKi.js} +1 -1
  22. package/dist/assets/{TiptapBody-CR-Zr3Pz.js → TiptapBody-9LktXPiD.js} +1 -1
  23. package/dist/assets/{Toggle-BFhlpZ-n.js → Toggle-BwsUsAA7.js} +1 -1
  24. package/dist/assets/{ViewTabs-CuGlZ9Ed.js → ViewTabs-CY5gMZ_N.js} +1 -1
  25. package/dist/assets/{historyTotals-Dxv9Fzrs.js → historyTotals-_dIaxmPX.js} +1 -1
  26. package/dist/assets/{index-Dx8owDeQ.js → index-d4i3X1x3.js} +419 -419
  27. package/dist/assets/{settingsSchema-DaqFSAQH.js → settingsSchema-Dg0Xa9RR.js} +1 -1
  28. package/dist/assets/{skillFrontmatter-BIJddxVB.js → skillFrontmatter-CpoFkSyj.js} +1 -1
  29. package/dist/assets/{useAgentsDirWatch-1hjZ4knk.js → useAgentsDirWatch-CFPprfWG.js} +1 -1
  30. package/dist/index.html +1 -1
  31. package/dist-web/assets/app.css +32 -0
  32. package/dist-web/assets/app.js +1479 -0
  33. package/dist-web/index.html +16 -0
  34. package/package.json +6 -4
  35. package/plugins/session-manager-dev/skills/builder/3-publish/SKILL.md +28 -10
  36. package/plugins/session-manager-dev/skills/develop/SKILL.md +5 -6
  37. package/src/main/build-info.json +4 -4
  38. package/src/main/chatRunner.cjs +68 -9
  39. package/src/main/config.cjs +23 -29
  40. package/src/main/docEdit.cjs +2 -5
  41. package/src/main/files.cjs +16 -4
  42. package/src/main/index.cjs +26 -4
  43. package/src/main/ipcSchemas.cjs +6 -2
  44. package/src/main/lib/epic/activeIndexMerge.cjs +3 -5
  45. package/src/main/lib/epic/epicReopen.cjs +161 -0
  46. package/src/main/lib/epic/epicSpawnPlan.cjs +9 -1
  47. package/src/main/lib/epic/epicTranscriptRestore.cjs +109 -0
  48. package/src/main/lib/epic/epicValidationHook.cjs +1 -1
  49. package/src/main/lib/epic/epicWorktreeMint.cjs +50 -3
  50. package/src/main/lib/feedback-remote/crossProjectFeedback.cjs +2 -1
  51. package/src/main/lib/feedback-remote/remoteAssets.cjs +98 -0
  52. package/src/main/lib/feedback-remote/remoteIpcDispatch.cjs +216 -0
  53. package/src/main/lib/insideHome.cjs +102 -23
  54. package/src/main/lib/persona-macro/shippedPersonaSeeds.cjs +27 -0
  55. package/src/main/lib/prd/prdWorktreeReach.cjs +9 -1
  56. package/src/main/lib/scheduler/rcaReport.cjs +1 -13
  57. package/src/main/lib/scheduler/reaperHelpers.cjs +82 -77
  58. package/src/main/lib/scheduler/reviewNotice.cjs +0 -2
  59. package/src/main/lib/scheduler/scheduleJobTransitions.cjs +1 -1
  60. package/src/main/lib/scheduler/schedulerBatch.cjs +1 -1
  61. package/src/main/lib/sendToRenderer.cjs +22 -2
  62. package/src/main/lib/smfileHeaders.cjs +17 -1
  63. package/src/main/pluginInstall.cjs +1 -1
  64. package/src/main/promptSessionEvents.cjs +2 -5
  65. package/src/main/pty.cjs +5 -4
  66. package/src/main/runVerify.cjs +6 -41
  67. package/src/main/scheduler/budget.cjs +4 -4
  68. package/src/main/scheduler/prdParser.cjs +2 -3
  69. package/src/main/scheduler/reviewLadder.cjs +0 -9
  70. package/src/main/scheduler.cjs +72 -25
  71. package/src/main/seedAgentPersonas.cjs +1 -1
  72. package/src/main/templates/PRD_AUTHORING.md +2 -2
  73. package/src/main/voiceHotkey.cjs +3 -2
  74. package/src/main/webRemote.cjs +161 -1
  75. package/src/preload/api.d.ts +8 -1
  76. package/src/preload/apiSurface.cjs +19 -0
  77. package/src/preload/apiSurface.json +1689 -0
  78. package/src/preload/index.cjs +2 -0
  79. package/src/seed/agents/architect.md +2 -2
  80. package/src/seed/agents/blender-artist.md +76 -0
@@ -74,7 +74,7 @@ const { readTail } = require('./lib/util/fileTail.cjs');
74
74
  const {
75
75
  claudePidAlive, classifyRunOutcome, mapOutcomeToGateOutcome, ORPHAN_REQUEUE_CAP, selectReapableJobs,
76
76
  findLiveProcessForJob, logHasOutput, resolvePidlessGateOutcome, resolveCommitGuardOutcome,
77
- readSpawnedPidFromLog, readLogMtimeMs,
77
+ readSpawnedPidFromLog, readLogMtimeMs, isClaudeArgv,
78
78
  } = require('./lib/scheduler/reaperHelpers.cjs');
79
79
  const { resolveProjectRoot } = require('./lib/opsOwnership.cjs');
80
80
  const { sweepStrandedJobBranches } = require('./lib/git/branchSweep.cjs');
@@ -359,18 +359,16 @@ ${verdictStepNum}. VERDICT LINE — end your final report with exactly one of th
359
359
  SCHEDULER_VERDICT: PASS
360
360
  SCHEDULER_VERDICT: FAIL <one-line reason>
361
361
  SCHEDULER_VERDICT: BLOCKED_BY_FOREIGN_WIP
362
- SCHEDULER_VERDICT: NEEDS_HUMAN <one-line question>
363
362
  - Print PASS only when the gate is green AND the commit from step ${commitStepNum} landed.
364
- - Print FAIL when the gate is red or the commit could not land.
363
+ - Print FAIL when the gate is red or the commit could not land. Also print
364
+ FAIL when a step needs something this run cannot reach (live runtime
365
+ state, credentials, an account or dashboard action, a host install): do
366
+ everything else, commit it, and name exactly what is missing in the
367
+ reason. Never ask a question.
365
368
  - Never print PASS on a red gate. Why: a false PASS ships broken work that
366
369
  no one notices. A true PASS plus a landed commit lets the scheduler ignore
367
370
  harmless noise in your transcript, such as grep hits for "Error", an early
368
371
  red test run, or debug tracebacks.
369
- - Print NEEDS_HUMAN only when finishing needs a decision or action only the
370
- human can take (credentials, an account/dashboard action, a product
371
- choice, an install on the host) — never for a red gate or a bug you could
372
- fix. Commit any finished work first. Phrase the question so a yes/no or
373
- one-line answer unblocks it.
374
372
  - Print BLOCKED_BY_FOREIGN_WIP ONLY when your gate failed because of a
375
373
  sibling job's unfinished, uncommitted file — never because of your own
376
374
  change — AND every failing path is one this prompt already listed as
@@ -975,8 +973,10 @@ function heartbeatTick(deps = {}) {
975
973
  lastDispatchAttemptAt: s.lastDispatchAttemptAt ?? null,
976
974
  lastRunAt: s.lastRunAt ?? null,
977
975
  lastTickReason: lastTick?.reason ?? null,
976
+ lastTickDetail: lastTick?.detail ? stripTickDetailPrefix(lastTick.detail, { keepWord: true }).slice(0, 200) : null,
978
977
  pendingDispatchable: Math.max(0, counts.pending - blockedPending),
979
978
  runningCount: counts.running,
979
+ quietLeaseHolder: quietMachineLease.isHeld() ? (quietMachineLease.holder() ?? null) : null,
980
980
  paused: Boolean(s.paused),
981
981
  drain: s.drain?.active ? { since: s.drain.since ?? null, requestedAt: s.drain.requestedAt ?? null } : null,
982
982
  };
@@ -1370,16 +1370,17 @@ function killOrphanClaudePid(pid, recordedIdentity = null) {
1370
1370
  }
1371
1371
  // win32 has no /proc or `ps`: the identity heuristic can't run, so never reap.
1372
1372
  if (process.platform === 'win32') return 'unknown';
1373
- let cmdline = '';
1373
+ let argv = [];
1374
1374
  try {
1375
- cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ');
1375
+ argv = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0');
1376
+ if (argv.length && argv[argv.length - 1] === '') argv.pop();
1376
1377
  } catch {
1377
1378
  try {
1378
1379
  const out = require('node:child_process').execSync(`ps -p ${pid} -o command=`, { encoding: 'utf8', stdio: ['ignore','pipe','ignore'] });
1379
- cmdline = out.trim();
1380
+ argv = out.trim().split(/\s+/).filter(Boolean);
1380
1381
  } catch { return 'unknown'; }
1381
1382
  }
1382
- if (!/\bclaude\b/.test(cmdline)) return 'mismatch';
1383
+ if (!isClaudeArgv(argv)) return 'mismatch';
1383
1384
  killTree(pid, 'SIGTERM');
1384
1385
  setTimeout(() => {
1385
1386
  try { process.kill(pid, 0); } catch { return; /* already gone */ }
@@ -2303,6 +2304,13 @@ const loadGate = createLoadGate();
2303
2304
  // actually pinned at 1 with nothing on screen explaining why. This is that
2304
2305
  // explanation, recorded once per tick and broadcast with the rest of the state.
2305
2306
  let lastTick = null;
2307
+ // Strips the leading '[scheduler] <word> [..]: ' prefix from a tick detail.
2308
+ // keepWord=true strips only the '[scheduler] ' tag and keeps '<word>: ' (the
2309
+ // heartbeat wants the hold kind, e.g. 'quiet-machine: lease held ...').
2310
+ function stripTickDetailPrefix(detail, { keepWord = false } = {}) {
2311
+ const re = keepWord ? /^\[scheduler\]\s*/ : /^\[scheduler\]\s*[\w-]+\s*(?:\[[^\]]*\])?:\s*/;
2312
+ return String(detail ?? '').replace(re, '');
2313
+ }
2306
2314
  function recordTick(outcome, extra = {}) {
2307
2315
  lastTick = { ...outcome, ...extra, at: new Date().toISOString() };
2308
2316
  return outcome;
@@ -2820,10 +2828,15 @@ function partitionBootOrphans(jobs, {
2820
2828
  } = {}) {
2821
2829
  const immediate = [];
2822
2830
  const adopted = [];
2831
+ const claimedPids = new Set();
2832
+ for (const r of jobs) {
2833
+ if (r.status === 'running' && r.runtime?.pid) claimedPids.add(r.runtime.pid);
2834
+ }
2823
2835
  for (const j of jobs) {
2824
2836
  if (j.status !== 'running') continue;
2825
2837
  if (isBootRowAlive(j, {
2826
2838
  pidAlive, getLogPid, getLogMtimeMs, logFreshWindowMs, findLiveProcess, readRecord, runsDir, identityOf, now,
2839
+ claimedPids,
2827
2840
  })) adopted.push(j.slug);
2828
2841
  else immediate.push(j.slug);
2829
2842
  }
@@ -2832,6 +2845,7 @@ function partitionBootOrphans(jobs, {
2832
2845
 
2833
2846
  function isBootRowAlive(j, {
2834
2847
  pidAlive, getLogPid, getLogMtimeMs, logFreshWindowMs, findLiveProcess, readRecord, runsDir, identityOf, now,
2848
+ claimedPids = new Set(),
2835
2849
  }) {
2836
2850
  const logMtimeMs = typeof getLogMtimeMs === 'function' ? getLogMtimeMs(j) : null;
2837
2851
  const runDir = j.runId ? path.join(runsDir || schedulerPaths.runsDir(), j.runId) : null;
@@ -2852,7 +2866,12 @@ function isBootRowAlive(j, {
2852
2866
  if (typeof logFreshWindowMs === 'number' && Number.isFinite(logMtimeMs) && now - logMtimeMs <= logFreshWindowMs) return true;
2853
2867
  const logPid = typeof getLogPid === 'function' ? getLogPid(j) : null;
2854
2868
  if (logPid && pidAlive(logPid)) return true;
2855
- return !!(typeof findLiveProcess === 'function' && findLiveProcess(j));
2869
+ // No run log = dispatch never reached the spawn; a /proc scan could only misattribute a stray process.
2870
+ if (typeof findLiveProcess !== 'function' || !Number.isFinite(logMtimeMs)) return false;
2871
+ const livePid = findLiveProcess(j);
2872
+ if (!livePid || claimedPids.has(livePid)) return false;
2873
+ claimedPids.add(livePid);
2874
+ return true;
2856
2875
  }
2857
2876
 
2858
2877
  /**
@@ -3075,7 +3094,7 @@ function defaultScheduleSettle({ cwd, epicId }) {
3075
3094
  return null;
3076
3095
  }
3077
3096
  },
3078
- sendPrompt: enqueueExternalPrompt,
3097
+ sendPrompt: (tabId, prompt) => enqueueExternalPrompt(tabId, prompt, { origin: 'scheduler' }),
3079
3098
  appendResponseEvent: appendResponseEventIfKnown,
3080
3099
  };
3081
3100
  epicValidationHook.scheduleEpicSettle({ cwd, epicId }, (args) => epicValidationHook.runEpicSettle(args, deps));
@@ -3280,7 +3299,7 @@ async function notifyOriginatingTab(job, {
3280
3299
  return;
3281
3300
  }
3282
3301
 
3283
- sendPrompt(targetTabId, message);
3302
+ sendPrompt(targetTabId, message, { origin: 'scheduler' });
3284
3303
  } catch (e) {
3285
3304
  console.error('[scheduler] notifyOriginatingTab error', job?.slug, e);
3286
3305
  }
@@ -4615,15 +4634,15 @@ async function executeJob(job, runDir, defaultCwd, onPid, execCwd, resumeTarget
4615
4634
  // clock time regardless of whether the job keeps writing output — the
4616
4635
  // gap a chatty-but-runaway executor slips through (see
4617
4636
  // computeJobBudgetMs's header for the measured p50/p90/max this budget
4618
- // is calibrated against). `quietMachine` jobs and any PRD with an
4619
- // explicit `budgetExempt: true` opt out entirely — logged once here so
4637
+ // is calibrated against). Only a PRD with an
4638
+ // explicit `budgetExempt: true` opts out entirely — logged once here so
4620
4639
  // an unbounded job is never silently unbounded.
4621
4640
  const jobBudgetMs = budget.computeJobBudgetMs(
4622
4641
  job.estimateMinutes ?? resolvePersonaDefaultEstimate(job.cwd, job.agentType || DEFAULT_PRD_AGENT_TYPE));
4623
4642
  const budgetExempt = budget.isJobBudgetExempt(job);
4624
4643
  if (budgetExempt) {
4625
4644
  safeLog(`[scheduler] wall-clock budget watchdog EXEMPT for ${job.slug} ` +
4626
- `(${job.quietMachine === true ? 'quietMachine' : 'budgetExempt'}) — no wall-clock kill ceiling this run\n`);
4645
+ `(budgetExempt) — no wall-clock kill ceiling this run\n`);
4627
4646
  }
4628
4647
  let budgetWarningStamped = false;
4629
4648
  const budgetWatchdog = {
@@ -7546,7 +7565,7 @@ function forceTickOutcome(result) {
7546
7565
  case 'slots-exhausted':
7547
7566
  return { ok: true, kind: 'warn', message: `Deferred ${result.deferredCount} job(s) — all session slots in use (${(result.holders ?? []).map((h) => h.owner).join(', ')})` };
7548
7567
  case 'held': {
7549
- const detail = String(result.detail ?? '').replace(/^\[scheduler\]\s*[\w-]+\s*(?:\[[^\]]*\])?:\s*/, '');
7568
+ const detail = stripTickDetailPrefix(result.detail);
7550
7569
  return { ok: true, kind: 'warn', message: detail || 'Batch held' };
7551
7570
  }
7552
7571
  default:
@@ -7937,7 +7956,6 @@ async function reapDeadRunningJobs() {
7937
7956
  grace: PIDLESS_SPAWN_GRACE_MS,
7938
7957
  findLiveProcess: (j) => findLiveProcessForJob(j, {
7939
7958
  worktreeDir: jobWorktree.worktreeDirFor(j.cwd || state.config?.defaultCwd || DEFAULT_PROJECT_CWD, j.slug),
7940
- runCwd: j.runtime?.cwd || j.cwd,
7941
7959
  }),
7942
7960
  getLogPid: (j) => readSpawnedPidFromLog(logPathForJob(j)),
7943
7961
  getLogMtimeMs: (j) => readLogMtimeMs(logPathForJob(j)),
@@ -8651,6 +8669,33 @@ function applyNeedsReviewAutoResolve(j, jobsInProject = []) {
8651
8669
  return 'skipped';
8652
8670
  }
8653
8671
 
8672
+ /**
8673
+ * Terminal outcome for a failed row that used every FAILED_AUTORESET_CAP
8674
+ * auto-reset and sat past STUCK_FAILED_ESCALATE_MS: skip it (same marker and
8675
+ * grouped review notice as the rung-6 cap-exhausted skip) so dependents drain
8676
+ * and the Epic's automated turn queues the fix. Mutates in place; returns
8677
+ * 'skipped', or null when the race guard rejects it.
8678
+ */
8679
+ function applyStuckFailedAutoSkip(j, ageMs = 0) {
8680
+ if (!j || j.status !== 'failed' || reviewLadder.isCancelledJob(j)) return null;
8681
+ if ((j.failedAutoResetAttempts ?? 0) < reviewLadder.FAILED_AUTORESET_CAP) return null;
8682
+ const cap = reviewLadder.FAILED_AUTORESET_CAP;
8683
+ j.needsReviewAutoResolvedSkip = true;
8684
+ j.error = `failed auto-reset cap exhausted (${cap}/${cap} attempts) after ${Math.round(ageMs / 3_600_000)}h — auto-skipped to unblock downstream dependsOn rows`;
8685
+ j.reviewNotice = buildReviewNotice({
8686
+ job: j,
8687
+ report: null,
8688
+ epicId: j.epicId ?? j.sourcePromptId ?? null,
8689
+ now: new Date().toISOString(),
8690
+ prior: j.reviewNotice,
8691
+ });
8692
+ transitionJob(j, 'skipped', {
8693
+ reason: `failed auto-resets exhausted (${cap}/${cap}) — auto-skipped`,
8694
+ source: 'stuckFailedAutoSkip',
8695
+ });
8696
+ return 'skipped';
8697
+ }
8698
+
8654
8699
  /**
8655
8700
  * Self-healing pass over needs_review jobs. The verifier runs in-process, so a
8656
8701
  * fix to runVerify.cjs only takes effect for jobs verified AFTER an app
@@ -10086,18 +10131,19 @@ function rescheduleIntervalTick() {
10086
10131
  }
10087
10132
  for (const stuck of stuckFailed) {
10088
10133
  const j = ms.jobs.find((x) => x.slug === stuck.slug);
10089
- if (!j || j.status !== 'failed' || j.stuckFailedNotified === true) continue; // race guard
10134
+ if (!j || j.status !== 'failed') continue; // race guard
10090
10135
  // Still has auto-reset attempts left — it will be (or already was,
10091
10136
  // earlier this same pass) picked up by the loop above instead.
10092
10137
  // Never log "reset it by hand" for a row that isn't actually stuck.
10093
10138
  if ((j.failedAutoResetAttempts ?? 0) < reviewLadder.FAILED_AUTORESET_CAP) continue;
10094
- j.stuckFailedNotified = true;
10139
+ const outcome = applyStuckFailedAutoSkip(j, stuck.ageMs);
10140
+ if (!outcome) continue;
10095
10141
  console.warn(
10096
10142
  `[scheduler] FAILED PRD STUCK: project=${stuck.cwd ?? '(unknown)'} slug=${stuck.slug} `
10097
10143
  + `failed=${Math.round(stuck.ageMs / 3_600_000)}h (>= ${Math.round(reviewLadder.STUCK_FAILED_ESCALATE_MS / 3_600_000)}h threshold) — `
10098
- + `auto-reset cap exhausted (${reviewLadder.FAILED_AUTORESET_CAP}/${reviewLadder.FAILED_AUTORESET_CAP} attempts); reset it by hand via scheduler_reset_job`,
10144
+ + `auto-reset cap exhausted (${reviewLadder.FAILED_AUTORESET_CAP}/${reviewLadder.FAILED_AUTORESET_CAP} attempts); auto-skipped to unblock dependents`,
10099
10145
  );
10100
- appendAuditEvent('job_stuck_failed', { slug: stuck.slug, cwd: stuck.cwd, ageMs: stuck.ageMs });
10146
+ appendAuditEvent('job_stuck_failed', { slug: stuck.slug, cwd: stuck.cwd, ageMs: stuck.ageMs, outcome });
10101
10147
  }
10102
10148
  for (const target of exhaustedNeedsReviewTargets) {
10103
10149
  const j = ms.jobs.find((x) => x.slug === target.slug);
@@ -10230,7 +10276,6 @@ async function init() {
10230
10276
  logFreshWindowMs: IDLE_OUTPUT_KILL_MS,
10231
10277
  findLiveProcess: (j) => findLiveProcessForJob(j, {
10232
10278
  worktreeDir: jobWorktree.worktreeDirFor(j.cwd || DEFAULT_PROJECT_CWD, j.slug),
10233
- runCwd: j.runtime?.cwd || j.cwd,
10234
10279
  }),
10235
10280
  readRecord: supervisorRecord.readSupervisorRecord,
10236
10281
  });
@@ -11130,6 +11175,7 @@ function registerAdminRoutes(adminHttp, remoteObj = remote) {
11130
11175
 
11131
11176
  module.exports = {
11132
11177
  normalizeConfig,
11178
+ recordTick,
11133
11179
  reportSchedulerError,
11134
11180
  finalizeJobWorktree,
11135
11181
  worktreeIntegrationVerdict,
@@ -11173,6 +11219,7 @@ module.exports = {
11173
11219
  runGateShadow,
11174
11220
  awaitGateShadowIdle: async () => { while (gateShadowPending) await gateShadowPending; },
11175
11221
  applyNeedsReviewAutoResolve,
11222
+ applyStuckFailedAutoSkip,
11176
11223
  computeLooksDone,
11177
11224
  attributeLandedCommits,
11178
11225
  isPromotableOriginal,
@@ -80,7 +80,7 @@ const { writeJsonSync, writeTextAtomic, validatePath, validateWrite } = require(
80
80
  const { splitFrontmatter } = require('./lib/prd/prdFrontmatter.cjs');
81
81
  const { SHIPPED_PERSONA_SEEDS } = require('./lib/persona-macro/shippedPersonaSeeds.cjs');
82
82
 
83
- const PERSONAS = ['architect', 'dev-lead', 'project-home-builder', 'validator', 'demo-video-builder'];
83
+ const PERSONAS = ['architect', 'dev-lead', 'project-home-builder', 'validator', 'demo-video-builder', 'blender-artist'];
84
84
  // What `{ done: true }` (the pre-seeded-set marker format) meant: only these two personas existed
85
85
  // in PERSONAS at the time. Fixed, not derived from the current PERSONAS array above — otherwise
86
86
  // every future persona added to PERSONAS would retroactively count as already delivered to a
@@ -1,4 +1,4 @@
1
- <!-- PRD_AUTHORING.md v13 -->
1
+ <!-- PRD_AUTHORING.md v14 -->
2
2
  # PRD Authoring Guide — Scheduler Safety Rules
3
3
 
4
4
  Rule: every PRD you queue follows the rules below.
@@ -226,5 +226,5 @@ Before queueing a new PRD, verify each of these:
226
226
  - [ ] **§10 Negative assertions:** every "should find nothing" check is inverted to exit 0 on the clean case.
227
227
  - [ ] **§11 End green:** the gate runs last; any intentional failure runs early and is captured.
228
228
  - [ ] **Gate baseline:** every gate command ran green once at HEAD in the main checkout; a red suite is scoped to the PRD's own test files and the PRD notes say why. Why: a gate already red on base parks as `sentinel_fail`.
229
- - [ ] **No human-only gates:** no gate or criterion needs a human or the passage of time (cron install, live post wait, live payment, dashboard action); those end `SCHEDULER_VERDICT: NEEDS_HUMAN <question>`. Why: no executor can pass them.
229
+ - [ ] **No human-only gates:** no gate or criterion needs a human or the passage of time (cron install, live post wait, live payment, dashboard action); keep them out of the PRD at authoring time; an executor that meets one ends `SCHEDULER_VERDICT: FAIL` naming what is missing. Why: no executor can pass them.
230
230
  - [ ] **Gate purity:** a gate command must not create or modify tracked files; outputs go to the per-gate TMPDIR or a gitignored path. Why: a dirtied tree fails the clean-tree check.
@@ -18,6 +18,7 @@
18
18
  const { app, globalShortcut, ipcMain } = require('electron');
19
19
  const voiceSettings = require('./voiceSettings.cjs');
20
20
  const { voiceHotkeyLog } = require('./lib/voice-hotkey-log.cjs');
21
+ const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
21
22
  const { schemas, validated } = require('./ipcSchemas.cjs');
22
23
 
23
24
  let mainWindow = null;
@@ -170,7 +171,7 @@ function attachBeforeInput() {
170
171
 
171
172
  function emitHotkey(phase, source) {
172
173
  if (!mainWindow || mainWindow.isDestroyed()) return;
173
- mainWindow.webContents.send('voice:hotkey', { phase, source });
174
+ sendIfAlive(mainWindow, 'voice:hotkey', { phase, source });
174
175
  voiceHotkeyLog('info', phase === 'down' ? 'down' : 'up', { source });
175
176
  }
176
177
 
@@ -308,7 +309,7 @@ function registerHotkeyHandlers() {
308
309
  currentConfig = await voiceSettings.load();
309
310
  applyConfig(currentConfig);
310
311
  if (mainWindow && !mainWindow.isDestroyed()) {
311
- mainWindow.webContents.send('voice:hotkey-changed', currentConfig);
312
+ sendIfAlive(mainWindow, 'voice:hotkey-changed', currentConfig);
312
313
  }
313
314
  return { ok: true, config: currentConfig };
314
315
  }));
@@ -26,7 +26,7 @@ const fsp = require('node:fs/promises');
26
26
  const fs = require('node:fs');
27
27
  const { writeTextAtomic, validatePath } = require('./config.cjs');
28
28
  const logs = require('./logs.cjs');
29
- const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
29
+ const { sendIfAlive, setRemoteFanout } = require('./lib/sendToRenderer.cjs');
30
30
  const { claudeHome } = require('./lib/scheduler/schedulerPaths.cjs');
31
31
  const { schemas } = require('./ipcSchemas.cjs');
32
32
  const { makeState, confirmSas: confirmSasLogic } = require('./lib/e2eStateMachine.cjs');
@@ -902,8 +902,164 @@ async function handleMessage(raw, device) {
902
902
  await dispatchEnvelope(envelope, device, client);
903
903
  }
904
904
 
905
+ const IPC_INVOKE_MAX_CALLS = 50;
906
+ let _ipcDispatcher = null;
907
+
908
+ /** Install the shared renderer-handler dispatcher (built in index.cjs). */
909
+ function setIpcDispatcher(dispatcher) {
910
+ _ipcDispatcher = dispatcher;
911
+ }
912
+
913
+ /** Encrypt + unicast one ipc:result frame to the calling client (never plaintext). */
914
+ function sendIpcResult(callId, frame, client) {
915
+ if (!_ws || _ws.readyState !== WebSocket.OPEN) return;
916
+ if (!client || !client.e2e.sessionKey) return;
917
+ try {
918
+ const inner = { type: 'ipc:result', id: callId, seq: frame.seq, total: frame.total, data: frame.data, ts: Date.now() };
919
+ const { nonce, ciphertext } = encryptBox(JSON.stringify(inner), client.e2e.sessionKey);
920
+ const out = { type: 'e2e:box', id: callId, payload: { nonce, ciphertext }, ts: Date.now() };
921
+ if (client.key !== LEGACY_CLIENT_KEY) out.toConn = client.key;
922
+ _ws.send(JSON.stringify(out));
923
+ } catch (e) {
924
+ logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'ipc:result send failed', meta: { error: e?.message } });
925
+ }
926
+ }
927
+
928
+ /** Batched ipc:invoke — each call runs through the shared dispatcher (same handlers as the renderer). */
929
+ async function dispatchIpcInvoke(envelope, device, client) {
930
+ const { id, payload } = envelope;
931
+ const ts = new Date().toISOString();
932
+ const calls = payload && Array.isArray(payload.calls) ? payload.calls : null;
933
+ const valid = calls && calls.length >= 1 && calls.length <= IPC_INVOKE_MAX_CALLS &&
934
+ calls.every((c) => c && typeof c === 'object' && typeof c.channel === 'string' && c.channel.length > 0 &&
935
+ c.channel.length <= 200 && (c.args === undefined || Array.isArray(c.args)));
936
+ if (!valid || typeof id !== 'string' || !id || !_ipcDispatcher) {
937
+ await auditLog(ts, 'ipc:invoke', device.deviceId, id, 'error:rejected', client);
938
+ respond(id, { error: 'rejected' }, undefined, client);
939
+ return;
940
+ }
941
+ const { chunkFrames } = require('./lib/feedback-remote/remoteIpcDispatch.cjs');
942
+ for (let i = 0; i < calls.length; i++) {
943
+ const { channel, args } = calls[i];
944
+ let out;
945
+ let audit;
946
+ try {
947
+ const r = await _ipcDispatcher.invoke(client, channel, args ?? []);
948
+ if (r.ok) { out = { ok: true, result: r.result }; audit = 'ok'; }
949
+ else { out = { ok: false, error: 'rejected' }; audit = `refused:${r.reason}`; }
950
+ } catch (e) {
951
+ out = { ok: false, error: 'rejected' };
952
+ audit = 'refused:dispatch_error';
953
+ }
954
+ let chunked = chunkFrames(out);
955
+ if (!chunked.ok) {
956
+ chunked = chunkFrames({ ok: false, error: 'rejected' });
957
+ if (out.ok) audit = 'refused:too_large';
958
+ }
959
+ await auditLog(ts, `ipc:${channel}`, device.deviceId, id, audit, client);
960
+ for (const frame of chunked.frames) sendIpcResult(`${id}.${i}`, frame, client);
961
+ }
962
+ }
963
+
964
+ /** asset:manifest / asset:get — E2E-authenticated only; reply is chunked ipc:result-style frames, unicast. */
965
+ async function dispatchAsset(envelope, device, client) {
966
+ const { type, id, payload } = envelope;
967
+ const ts = new Date().toISOString();
968
+ if (typeof id !== 'string' || !id || client.e2e.state !== 'authenticated' || !client.e2e.sessionKey) {
969
+ await auditLog(ts, type, device.deviceId, id, 'refused:e2e_not_authenticated', client);
970
+ respond(id, { error: 'rejected' }, undefined, client);
971
+ return;
972
+ }
973
+ const { chunkFrames } = require('./lib/feedback-remote/remoteIpcDispatch.cjs');
974
+ const assets = require('./lib/feedback-remote/remoteAssets.cjs');
975
+ let out;
976
+ let audit = 'ok';
977
+ try {
978
+ if (type === 'asset:manifest') out = { ok: true, result: assets.assetManifest() };
979
+ else {
980
+ const r = assets.readAsset(payload && payload.name);
981
+ if (r.ok) out = { ok: true, result: r };
982
+ else { out = { ok: false, error: 'rejected' }; audit = `refused:${r.error}`; }
983
+ }
984
+ } catch {
985
+ out = { ok: false, error: 'rejected' };
986
+ audit = 'refused:asset_error';
987
+ }
988
+ let chunked = chunkFrames(out);
989
+ if (!chunked.ok) { chunked = chunkFrames({ ok: false, error: 'rejected' }); audit = 'refused:too_large'; }
990
+ await auditLog(ts, type, device.deviceId, id, audit, client);
991
+ for (const frame of chunked.frames) sendIpcResult(id, frame, client);
992
+ }
993
+
994
+ // ─── ipc:subscribe push fanout ─────────────────────────────────────
995
+ // Mirrors sendIfAlive pushes to E2E-authenticated browser clients that subscribed.
996
+
997
+ const IPC_SUBSCRIBE_MAX_CHANNELS = 50;
998
+
999
+ /** True iff the apiSurface lists `channel` as a push (on / on-dynamic) with remote not deny. */
1000
+ function isPushableChannel(channel) {
1001
+ if (typeof channel !== 'string' || channel.length === 0 || channel.length > 200) return false;
1002
+ const surface = require('../preload/apiSurface.cjs');
1003
+ return surface.some((r) => {
1004
+ if (r.remote === 'deny') return false;
1005
+ if (r.kind === 'on') return r.channel === channel;
1006
+ if (r.kind === 'on-dynamic') {
1007
+ const prefix = r.channel.endsWith('*') ? r.channel.slice(0, -1) : r.channel;
1008
+ return channel.length > prefix.length && channel.startsWith(prefix);
1009
+ }
1010
+ return false;
1011
+ });
1012
+ }
1013
+
1014
+ function handleIpcSubscribe(envelope, client, subscribe) {
1015
+ const channels = envelope.payload && Array.isArray(envelope.payload.channels) ? envelope.payload.channels : [];
1016
+ if (!client.pushChannels) client.pushChannels = new Set();
1017
+ for (const ch of channels.slice(0, IPC_SUBSCRIBE_MAX_CHANNELS)) {
1018
+ if (subscribe) { if (isPushableChannel(ch)) client.pushChannels.add(ch); }
1019
+ else client.pushChannels.delete(ch);
1020
+ }
1021
+ }
1022
+
1023
+ /** Encrypt + unicast one ipc:event frame to one subscribed client. */
1024
+ function sendIpcEvent(client, channel, frame) {
1025
+ if (!_ws || _ws.readyState !== WebSocket.OPEN) return;
1026
+ if (!client.e2e.sessionKey) return;
1027
+ const inner = { type: 'ipc:event', id: crypto.randomUUID(), channel, seq: frame.seq, total: frame.total, data: frame.data, ts: Date.now() };
1028
+ const { nonce, ciphertext } = encryptBox(JSON.stringify(inner), client.e2e.sessionKey);
1029
+ const out = { type: 'e2e:box', id: inner.id, payload: { nonce, ciphertext }, ts: Date.now() };
1030
+ if (client.key !== LEGACY_CLIENT_KEY) out.toConn = client.key;
1031
+ _ws.send(JSON.stringify(out));
1032
+ }
1033
+
1034
+ function fanoutIpcEvent(channel, payload) {
1035
+ const targets = _clients.authenticated().filter((c) => c.pushChannels && c.pushChannels.has(channel));
1036
+ if (targets.length === 0) return;
1037
+ const { chunkFrames } = require('./lib/feedback-remote/remoteIpcDispatch.cjs');
1038
+ const chunked = chunkFrames(payload);
1039
+ if (!chunked.ok) return;
1040
+ for (const client of targets) {
1041
+ try {
1042
+ for (const frame of chunked.frames) sendIpcEvent(client, channel, frame);
1043
+ } catch (e) {
1044
+ logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'ipc:event send failed', meta: { error: e?.message } });
1045
+ }
1046
+ }
1047
+ }
1048
+
905
1049
  async function dispatchEnvelope(envelope, device, client) {
906
1050
  const { type, id, payload } = envelope;
1051
+ if (type === 'ipc:subscribe' || type === 'ipc:unsubscribe') {
1052
+ if (client.e2e.state === 'authenticated') handleIpcSubscribe(envelope, client, type === 'ipc:subscribe');
1053
+ return;
1054
+ }
1055
+ if (type === 'ipc:invoke') {
1056
+ await dispatchIpcInvoke(envelope, device, client);
1057
+ return;
1058
+ }
1059
+ if (type === 'asset:manifest' || type === 'asset:get') {
1060
+ await dispatchAsset(envelope, device, client);
1061
+ return;
1062
+ }
907
1063
  if (typeof type !== 'string' || !type.startsWith('cmd:')) return;
908
1064
 
909
1065
  const ts = new Date().toISOString();
@@ -1293,6 +1449,7 @@ async function revokeAllDevices() {
1293
1449
  // ─── IPC handlers ─────────────────────────────────────────────────────────────
1294
1450
 
1295
1451
  function registerRemoteHandlers() {
1452
+ setRemoteFanout(fanoutIpcEvent);
1296
1453
  removeOrphanedClientFiles(path.join(claudeHome(), 'session-manager'));
1297
1454
  const { validated } = require('./ipcSchemas.cjs');
1298
1455
 
@@ -1430,6 +1587,8 @@ function destroy() {
1430
1587
  }
1431
1588
 
1432
1589
  module.exports = {
1590
+ loadConfig,
1591
+ setIpcDispatcher,
1433
1592
  attachWindow,
1434
1593
  registerRemoteHandlers,
1435
1594
  init,
@@ -1455,6 +1614,7 @@ module.exports = {
1455
1614
  hasReconnectTimer: () => _reconnectTimer !== null,
1456
1615
  cancelReconnect,
1457
1616
  invalidateConfigCache,
1617
+ fanoutIpcEvent,
1458
1618
  setE2eAuthenticated: (key, clientKey = LEGACY_CLIENT_KEY) => {
1459
1619
  _clients.getOrCreate(clientKey).e2e = makeState('authenticated', key, null);
1460
1620
  },
@@ -1,4 +1,4 @@
1
- import type { PromptSession } from '../renderer/state/promptSessions';
1
+ import type { PromptSession, PromptSessionEvent } from '../renderer/state/promptSessions';
2
2
 
3
3
  /** Mirrors the item shape returned by main/lib/prereqs.cjs checkPrereqs(). */
4
4
  export type OpsRetentionSettings = {
@@ -2320,9 +2320,16 @@ export interface SessionManagerAPI {
2320
2320
  setWorktreeDisabled: (payload: PromptSessionsSetWorktreeDisabledPayload) => Promise<PromptSessionsSetWorktreeDisabledResult>;
2321
2321
  /** Archives the raw Claude JSONL to a gzip sidecar main-side; pointer, or null when absent/empty. */
2322
2322
  archiveTranscript: (payload: { cwd: string; epicId: string; claudeSessionId: string }) => Promise<{ path: string; bytes: number; gzBytes: number; sha256: string } | null>;
2323
+ /** Reopens a completed Epic in place (same id + claudeSessionId); never mints an Epic. */
2324
+ reopen: (payload: PromptSessionsReopenPayload) => Promise<PromptSessionsReopenResult>;
2323
2325
  };
2324
2326
  }
2325
2327
 
2328
+ export interface PromptSessionsReopenPayload { cwd: string; epicId: string }
2329
+ export type PromptSessionsReopenResult =
2330
+ | { ok: true; session: PromptSession; events: PromptSessionEvent[]; alreadyOpen: boolean }
2331
+ | { ok: false; code: 'not_found' | 'transcript_missing' | 'invalid_session_id' | 'restore_failed' | 'write_failed'; message: string };
2332
+
2326
2333
  // ─────────────────────────────────── PromptSessions active-index merge
2327
2334
  export interface PromptSessionsMergeActiveIndexPayload {
2328
2335
  cwd: string;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * apiSurface.cjs — single source of truth for the renderer↔main `window.api`
3
+ * surface: one row per method exposed by preload/index.cjs.
4
+ *
5
+ * kind: invoke | send | on | on-dynamic (per-tab channel; `*` = the tabId suffix)
6
+ * remote: policy when the surface is tunnelled to a remote browser
7
+ * read — never writes state, spawns processes, or touches OS UI
8
+ * mutate — everything else that is allowed remotely
9
+ * deny — desktop-only (pty, clipboard, shell, updater, config fs access, …)
10
+ * Classification is conservative: when unsure, mutate; screen/clipboard/fs/shell => deny.
11
+ *
12
+ * Drift is caught by __tests__/apiSurface.test.cjs, which replays the real preload.
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const ROWS = require('./apiSurface.json');
18
+
19
+ module.exports = Object.freeze(ROWS.map((r) => Object.freeze({ ...r })));