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
@@ -12,72 +12,78 @@ const path = require('node:path');
12
12
  const { readTail } = require('../util/fileTail.cjs');
13
13
  const { detectRateLimitInLog } = require('../claude/rateLimitDetect.cjs');
14
14
  const { pidAlive } = require('../process/pidAlive.cjs');
15
+ const { smArgv0 } = require('../process/procName.cjs');
15
16
 
16
17
  /**
17
- * Return true if pid is alive AND its cmdline looks like a claude process.
18
+ * isClaudeArgv(argv) — strict claude-executor identity. Pure.
18
19
  *
19
- * Guards against PID recycling: on Linux we read /proc/<pid>/cmdline and
20
- * require /\bclaude\b/ in the command. On macOS (no /proc) we can't read
21
- * cmdline, so we conservatively return true — never false-reap a live PID
22
- * just because we can't verify its identity.
23
- *
24
- * Existence check now delegates to the shared pidAlive() (pidAlive.cjs) so
25
- * EPERM (pid exists, owned by another user) counts as alive here too — the
26
- * previous bare `process.kill(pid, 0)` + catch-all-false treated EPERM the
27
- * same as ESRCH (no such process), silently false-reaping a live-but-
28
- * unsignalable pid.
20
+ * Judges argv[0] (and argv[1] only for a node/bun-launched CLI) — never a
21
+ * substring of the whole command line. 2026-10-25: a stray
22
+ * `tail -F /tmp/claude-1000/.../x.log` matched /\bclaude\b/ and was adopted as
23
+ * the executor of two pidless rows for up to 25 hours.
24
+ */
25
+ function isClaudeArgv(argv) {
26
+ if (!Array.isArray(argv) || argv.length === 0 || typeof argv[0] !== 'string') return false;
27
+ const base = path.basename(argv[0]);
28
+ if (base === 'claude' || base.startsWith('sm-claude-')) return true;
29
+ if (base === 'node' || base === 'bun') {
30
+ const a1 = typeof argv[1] === 'string' ? argv[1] : '';
31
+ // A shebang-script claude launched through an sm-claude-* alias shows the
32
+ // alias path as argv[1] (the kernel rewrites script argv), so accept it too.
33
+ return /\/claude(\.js|\.mjs|\.cjs)?$/.test(a1) || a1.includes('/@anthropic-ai/claude-code/')
34
+ || path.basename(a1).startsWith('sm-claude-');
35
+ }
36
+ return false;
37
+ }
38
+
39
+ /** Read /proc/<pid>/cmdline as an argv array; null when unreadable. */
40
+ function readArgv(pid) {
41
+ try {
42
+ const parts = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0');
43
+ if (parts.length && parts[parts.length - 1] === '') parts.pop();
44
+ return parts;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Return true if pid is alive AND its argv looks like a claude process
52
+ * (isClaudeArgv). Guards against PID recycling. Where /proc/<pid>/cmdline
53
+ * cannot be read (macOS, permission denied) or on win32, conservatively
54
+ * returns true — never false-reap a live PID we cannot identify.
29
55
  *
30
- * Conservative by design: a false negative (live process treated as dead) is
31
- * far worse than a late reap.
56
+ * Existence check delegates to the shared pidAlive() so EPERM counts as alive.
32
57
  */
33
58
  function claudePidAlive(pid, { platform = process.platform } = {}) {
34
59
  if (!pid || typeof pid !== 'number' || pid <= 1) return false;
35
60
  if (!pidAlive(pid)) return false;
36
- // Windows has no /proc — cannot verify identity, so never call it dead.
37
61
  if (platform === 'win32') return true;
38
- try {
39
- const cmd = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ');
40
- return /\bclaude\b/.test(cmd);
41
- } catch {
42
- // Can't read cmdline (macOS, permission denied) → assume alive.
43
- return true;
44
- }
62
+ const argv = readArgv(pid);
63
+ if (argv === null) return true;
64
+ return isClaudeArgv(argv);
45
65
  }
46
66
 
47
67
  /**
48
- * findLiveProcessForJob(job, { worktreeDir, runCwd }) → pid | null
68
+ * findLiveProcessForJob(job, { worktreeDir }) → pid | null
49
69
  *
50
- * Positive liveness scan for a 'running' row whose `runtime.pid` is missing —
51
- * the case a missing pid must NOT be read as "the process is gone" (2026-09-06
52
- * incident: 234-uranus-eight-tails-ox marked failed/never_ran while PID
53
- * 2174739 was a live `claude -p` still writing to that job's own worktree).
70
+ * Positive liveness scan for a 'running' row whose `runtime.pid` is missing
71
+ * (2026-09-06: a pidless row was reaped while its claude was still alive).
54
72
  *
55
- * Linux-`/proc` only. Scans every numeric `/proc/<pid>` entry and matches any
56
- * of, in order:
57
- * - `/proc/<pid>/cwd` resolves to `worktreeDir` (or a path nested under it)
58
- * — sufficient alone, no cmdline check needed.
59
- * - `/proc/<pid>/cmdline` contains both `claude` and the job's slug —
60
- * fallback for a worktree-disabled/in-place run with no dedicated
61
- * worktreeDir to match against.
62
- * - `/proc/<pid>/cwd` resolves to `runCwd` (or nested under it) AND
63
- * `/proc/<pid>/cmdline` contains `claude` (2026-09-13 incident:
64
- * runId 2026-09-13T16-15-56-818Z ran in-place in the shared tree — no
65
- * worktree, and dispatch never puts the slug into argv — so neither of
66
- * the above could ever match a live pid 3138458). `runCwd` is deliberately
67
- * NEVER matched alone: several concurrent jobs commonly share one tree's
68
- * cwd, so a bare cwd match without the process-identity check would
69
- * cross-match the wrong job's process.
73
+ * Linux-`/proc` only. A candidate must pass isClaudeArgv AND either:
74
+ * - `/proc/<pid>/cwd` is `worktreeDir` or nested under it, or
75
+ * - argv[0] === smArgv0('job', job.slug) (every scheduler job spawn carries
76
+ * the slug in argv[0]).
77
+ * There is deliberately no runCwd-nested match: it adopted a stray `tail -F`
78
+ * on 2026-10-25 and is redundant now that argv[0] carries the slug.
70
79
  *
71
- * Returns the first matching pid, or null if none is found.
72
- *
73
- * Safe fallback by construction: on any platform without `/proc` (macOS,
74
- * Windows) `fs.readdirSync('/proc')` throws and this returns null immediately
75
- * — i.e. exactly today's behaviour (fail toward terminalizing), never a hang
76
- * or a thrown error propagating to the caller.
80
+ * Returns the first matching pid, or null. On platforms without `/proc` it
81
+ * returns null immediately.
77
82
  */
78
- function findLiveProcessForJob(job, { worktreeDir, runCwd, platform = process.platform } = {}) {
83
+ function findLiveProcessForJob(job, { worktreeDir, platform = process.platform } = {}) {
79
84
  if (platform === 'win32') return null; // no /proc — skip every read
80
85
  const slug = job?.slug;
86
+ const want = slug ? smArgv0('job', slug) : null;
81
87
  let entries;
82
88
  try {
83
89
  entries = fs.readdirSync('/proc');
@@ -89,34 +95,15 @@ function findLiveProcessForJob(job, { worktreeDir, runCwd, platform = process.pl
89
95
  const pid = Number(name);
90
96
  if (!pid || pid <= 1) continue;
91
97
 
92
- let cwdLink = null;
93
- if (worktreeDir || runCwd) {
94
- try {
95
- cwdLink = fs.readlinkSync(`/proc/${pid}/cwd`);
96
- } catch {
97
- // Process exited mid-scan, or permission denied — try the argv
98
- // fallback below before giving up on this pid.
99
- }
100
- }
101
-
102
- if (worktreeDir && cwdLink && (cwdLink === worktreeDir || cwdLink.startsWith(worktreeDir + path.sep))) {
103
- return pid;
104
- }
105
-
106
- let cmd = null;
107
- if (slug || (runCwd && cwdLink)) {
108
- try {
109
- cmd = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ');
110
- } catch {
111
- // Same as above — process gone or unreadable, keep scanning.
112
- }
113
- }
98
+ const argv = readArgv(pid);
99
+ if (!argv || !isClaudeArgv(argv)) continue;
114
100
 
115
- if (slug && cmd && /\bclaude\b/.test(cmd) && cmd.includes(slug)) return pid;
101
+ if (want && argv[0] === want) return pid;
116
102
 
117
- if (runCwd && cwdLink && cmd && /\bclaude\b/.test(cmd) &&
118
- (cwdLink === runCwd || cwdLink.startsWith(runCwd + path.sep))) {
119
- return pid;
103
+ if (worktreeDir) {
104
+ let cwdLink = null;
105
+ try { cwdLink = fs.readlinkSync(`/proc/${pid}/cwd`); } catch { /* exited / denied */ }
106
+ if (cwdLink && (cwdLink === worktreeDir || cwdLink.startsWith(worktreeDir + path.sep))) return pid;
120
107
  }
121
108
  }
122
109
  return null;
@@ -413,6 +400,11 @@ function selectReapableJobs(jobs, now, {
413
400
  const reapable = [];
414
401
  const warnings = [];
415
402
  const recovered = [];
403
+ /** pid -> slug that records/was recovered with it */
404
+ const claimedPids = new Map();
405
+ for (const r of jobs ?? []) {
406
+ if (r.status === 'running' && r.runtime?.pid) claimedPids.set(r.runtime.pid, r.slug);
407
+ }
416
408
  for (const j of jobs ?? []) {
417
409
  if (j.status !== 'running') continue;
418
410
  const pid = j.runtime?.pid;
@@ -448,10 +440,22 @@ function selectReapableJobs(jobs, now, {
448
440
  continue;
449
441
  }
450
442
 
451
- const livePid = typeof findLiveProcess === 'function' ? findLiveProcess(j) : null;
443
+ // A row with no run log never reached the spawn — a /proc scan can only
444
+ // misattribute a stray process to it.
445
+ const livePid = (typeof findLiveProcess === 'function' && Number.isFinite(logMtimeMs))
446
+ ? findLiveProcess(j) : null;
452
447
  if (livePid) {
453
- recovered.push({ slug: j.slug, pid: livePid });
454
- continue;
448
+ const holder = claimedPids.get(livePid);
449
+ if (holder && holder !== j.slug) {
450
+ warnings.push({
451
+ slug: j.slug,
452
+ reason: `pid ${livePid} not recovered for ${j.slug} — already claimed by ${holder}`,
453
+ });
454
+ } else {
455
+ claimedPids.set(livePid, j.slug);
456
+ recovered.push({ slug: j.slug, pid: livePid });
457
+ continue;
458
+ }
455
459
  }
456
460
  reapable.push({
457
461
  slug: j.slug,
@@ -520,6 +524,7 @@ function resolveCommitGuardOutcome(guardVerdict, satisfyingCommits) {
520
524
 
521
525
  module.exports = {
522
526
  claudePidAlive,
527
+ isClaudeArgv,
523
528
  classifyRunOutcome,
524
529
  mapOutcomeToGateOutcome,
525
530
  ORPHAN_REQUEUE_CAP,
@@ -153,8 +153,6 @@ function selectDueReviewNotices(jobs = [], { now = Date.now(), holdMs = DEFAULT_
153
153
  const isDue = (j) => {
154
154
  if (j.status === 'skipped' && j.needsReviewAutoResolvedSkip) return true;
155
155
  if (j.status !== 'needs_review') return false;
156
- // A question for the human has nothing for the ladder to heal — no hold.
157
- if (j.verifierVerdict === 'needs_human') return true;
158
156
  const firstParkedAt = j.reviewNotice.firstParkedAt;
159
157
  if (!firstParkedAt) return false;
160
158
  const parkedMs = Date.parse(firstParkedAt);
@@ -116,7 +116,7 @@ const LEGAL_TRANSITIONS = {
116
116
  pending: ['running', 'completed', 'failed', 'skipped'],
117
117
  running: ['completed', 'failed', 'needs_review', 'skipped', 'pending'],
118
118
  investigating: ['failed', 'needs_review', 'completed', 'pending', 'skipped'],
119
- failed: ['investigating', 'pending', 'completed', 'needs_review'],
119
+ failed: ['investigating', 'pending', 'completed', 'needs_review', 'skipped'],
120
120
  needs_review: ['investigating', 'pending', 'completed', 'skipped', 'running'],
121
121
  completed: ['pending'],
122
122
  skipped: ['pending'],
@@ -360,7 +360,7 @@ function pickForProject(projectJobs, runningSlugsInProject, slots, heldSlugs = n
360
360
  for (const [rootSlug, { rootStatus, slugs }] of heldByRoot) {
361
361
  const names = [...slugs].sort().join(', ');
362
362
  if (rootStatus === 'needs_review') {
363
- reasons.push(`holding ${slugs.size} job(s) transitively behind root blocker ${rootSlug} (needs_review) [${names}]. ${rootSlug} is a QUESTION awaiting a human — the chain resumes once it is retired or reset; it will not clear on its own.`);
363
+ reasons.push(`holding ${slugs.size} job(s) transitively behind root blocker ${rootSlug} (needs_review) [${names}]. the chain resumes when the recovery ladder resolves ${rootSlug} (or it is retired or reset).`);
364
364
  } else if (rootStatus === 'failed') {
365
365
  reasons.push(`holding ${slugs.size} job(s) transitively behind root blocker ${rootSlug} (failed) [${names}]. Reset or archive ${rootSlug} to unblock the chain.`);
366
366
  } else if (rootStatus === 'skipped') {
@@ -20,9 +20,21 @@
20
20
  * webContents state and wrap the send in try/catch.
21
21
  */
22
22
  function sendIfAlive(window, channel, payload) {
23
+ sendToWindow(window, channel, payload);
24
+ // Remote fanout runs even when the window is gone (web clients outlive it) and never throws.
25
+ if (_remoteFanout) {
26
+ try {
27
+ _remoteFanout(channel, payload);
28
+ } catch {
29
+ // A broken remote subscriber must never break the Electron send path.
30
+ }
31
+ }
32
+ }
33
+
34
+ function sendToWindow(window, channel, payload) {
23
35
  if (!window || window.isDestroyed()) return;
24
36
  const wc = window.webContents;
25
- if (!wc || wc.isDestroyed() || wc.isCrashed()) return;
37
+ if (!wc || wc.isDestroyed() || wc.isCrashed?.()) return;
26
38
  try {
27
39
  wc.send(channel, payload);
28
40
  } catch {
@@ -31,4 +43,12 @@ function sendIfAlive(window, channel, payload) {
31
43
  }
32
44
  }
33
45
 
34
- module.exports = { sendIfAlive };
46
+ /** @type {((channel: string, payload: unknown) => void) | null} */
47
+ let _remoteFanout = null;
48
+
49
+ /** Register (or clear with null) the fanout that mirrors every push to E2E web clients. */
50
+ function setRemoteFanout(fn) {
51
+ _remoteFanout = typeof fn === 'function' ? fn : null;
52
+ }
53
+
54
+ module.exports = { sendIfAlive, setRemoteFanout };
@@ -63,4 +63,20 @@ function shouldBlockFrameNavigation({ isMainFrame, currentUrl, targetUrl }) {
63
63
  return true;
64
64
  }
65
65
 
66
- module.exports = { DEMO_VIDEO_CSP_HEADER, smfileResponseHeaders, shouldBlockFrameNavigation };
66
+ /**
67
+ * Convert an smfile:// URL pathname (still percent-encoded) into a filesystem
68
+ * path. On win32 the pathname is `/C:/Users/...`; the leading '/' before the
69
+ * drive letter is stripped and the result is path.win32-normalized. Posix input
70
+ * is only decoded.
71
+ * @param {string} pathname
72
+ * @param {string} [platform]
73
+ * @returns {string}
74
+ */
75
+ function smfileUrlToPath(pathname, platform = process.platform) {
76
+ const decoded = decodeURIComponent(pathname);
77
+ if (platform !== 'win32') return decoded;
78
+ const stripped = /^[\\/][A-Za-z]:/.test(decoded) ? decoded.slice(1) : decoded;
79
+ return require('node:path').win32.normalize(stripped);
80
+ }
81
+
82
+ module.exports = { DEMO_VIDEO_CSP_HEADER, smfileResponseHeaders, shouldBlockFrameNavigation, smfileUrlToPath };
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Channels:
9
9
  * ipcMain.handle('plugins:install', { slug }) -> { ok, exitCode }
10
- * webContents.send('plugins:install-progress', { slug, line })
10
+ * sendIfAlive('plugins:install-progress', { slug, line })
11
11
  *
12
12
  * Safety:
13
13
  * - slug must match /^[a-z0-9\-/]+$/ (lowercase, hyphen, slash for owner/name).
@@ -1,4 +1,5 @@
1
1
  'use strict';
2
+ const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
2
3
 
3
4
  /**
4
5
  * Main-process counterpart to promptSessions.ts's event chain, used ONLY by
@@ -30,11 +31,7 @@ let mainWindow = null;
30
31
  function attachWindow(win) { mainWindow = win; }
31
32
 
32
33
  function broadcast(channel, payload) {
33
- try {
34
- if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents && !mainWindow.webContents.isDestroyed()) {
35
- mainWindow.webContents.send(channel, payload);
36
- }
37
- } catch { /* render frame may be gone */ }
34
+ sendIfAlive(mainWindow, channel, payload);
38
35
  }
39
36
 
40
37
  let seq = 0;
package/src/main/pty.cjs CHANGED
@@ -16,10 +16,10 @@ const { ipcMain } = require('electron');
16
16
  const path = require('node:path');
17
17
  const os = require('node:os');
18
18
  const fs = require('node:fs');
19
- const { addAllowedRoot } = require('./config.cjs');
19
+ const { addAllowedRoot, getAllowedRoots } = require('./config.cjs');
20
20
  const { aliasedShellBin } = require('./lib/process/smProcNames.cjs');
21
21
  const { cleanChildEnv, pathWithUserBins } = require('./lib/process/cleanEnv.cjs');
22
- const { checkInsideHome } = require('./lib/insideHome.cjs');
22
+ const { checkInsideHomeOrRoots } = require('./lib/insideHome.cjs');
23
23
  const { defaultShell } = require('./lib/process/defaultShell.cjs');
24
24
  const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
25
25
  const opsErrorLog = require('./lib/telemetry/opsErrorLog.cjs');
@@ -151,9 +151,10 @@ class PtyManager {
151
151
  return { pid: null, cwd, reattached: false, error: 'node-pty unavailable' };
152
152
  }
153
153
 
154
- // Validate that cwd is inside homedir before widening the allowed-root set.
154
+ // Validate that cwd is inside homedir or an already-registered project root
155
+ // (never a renderer-supplied one) before widening the allowed-root set.
155
156
  if (cwd) {
156
- const r = checkInsideHome(cwd);
157
+ const r = checkInsideHomeOrRoots(cwd, getAllowedRoots());
157
158
  if (!r.ok) throw new Error(`pty ${r.error}`);
158
159
  addAllowedRoot(r.realPath);
159
160
  }
@@ -269,21 +269,22 @@ function checkDeps(queueEntry, allJobs) {
269
269
  // ─── sentinel scanner ─────────────────────────────────────────────────────────
270
270
 
271
271
  /**
272
- * Scan for a `SCHEDULER_VERDICT: PASS|FAIL|BLOCKED_BY_FOREIGN_WIP|NEEDS_HUMAN` sentinel
272
+ * Scan for a `SCHEDULER_VERDICT: PASS|FAIL|BLOCKED_BY_FOREIGN_WIP` sentinel (a legacy NEEDS_HUMAN line is classed as FAIL)
273
273
  * line in the run output.
274
274
  *
275
275
  * Checks `resultEvent.resultText` first (the agent's final message), then the
276
276
  * last tool_result content. Anchored to line-start so prose mentioning the
277
277
  * string in mid-sentence does not match.
278
278
  *
279
- * Returns 'pass', 'fail', 'blocked_by_foreign_wip', 'needs_human', or null.
279
+ * Returns 'pass', 'fail', 'blocked_by_foreign_wip', or null.
280
280
  */
281
281
  function scanSentinel(resultEvent, events) {
282
282
  const RE = /^SCHEDULER_VERDICT:\s*(PASS|FAIL|BLOCKED_BY_FOREIGN_WIP|NEEDS_HUMAN)\b/m;
283
+ const norm = (v) => { const l = v.toLowerCase(); return l === 'needs_human' ? 'fail' : l; };
283
284
 
284
285
  if (resultEvent) {
285
286
  const m = resultEvent.resultText.match(RE);
286
- if (m) return m[1].toLowerCase();
287
+ if (m) return norm(m[1]);
287
288
  }
288
289
 
289
290
  let lastToolResult = null;
@@ -292,7 +293,7 @@ function scanSentinel(resultEvent, events) {
292
293
  }
293
294
  if (lastToolResult && lastToolResult.content) {
294
295
  const m = lastToolResult.content.match(RE);
295
- if (m) return m[1].toLowerCase();
296
+ if (m) return norm(m[1]);
296
297
  }
297
298
 
298
299
  return null;
@@ -327,29 +328,7 @@ function scanHaltLine(text) {
327
328
  * as scanSentinel). Trimmed, capped at 300 chars; '' when absent.
328
329
  */
329
330
  function scanSentinelReason(resultEvent, events) {
330
- const RE = /^SCHEDULER_VERDICT:\s*FAIL\b[ \t]*([^\n]*)/m;
331
- const clip = (m) => m[1].trim().slice(0, 300);
332
- if (resultEvent && resultEvent.resultText) {
333
- const m = resultEvent.resultText.match(RE);
334
- if (m) return clip(m);
335
- }
336
- let lastToolResult = null;
337
- for (const ev of events) {
338
- if (ev.kind === 'tool_result') lastToolResult = ev;
339
- }
340
- if (lastToolResult && lastToolResult.content) {
341
- const m = lastToolResult.content.match(RE);
342
- if (m) return clip(m);
343
- }
344
- return '';
345
- }
346
-
347
- /**
348
- * One-line question after `SCHEDULER_VERDICT: NEEDS_HUMAN` (same two-source
349
- * scan order as scanSentinel). Trimmed, capped at 300 chars; '' when absent.
350
- */
351
- function scanHumanQuestion(resultEvent, events) {
352
- const RE = /^SCHEDULER_VERDICT:\s*NEEDS_HUMAN\b[ \t]*([^\n]*)/m;
331
+ const RE = /^SCHEDULER_VERDICT:\s*(?:FAIL|NEEDS_HUMAN)\b[ \t]*([^\n]*)/m;
353
332
  const clip = (m) => m[1].trim().slice(0, 300);
354
333
  if (resultEvent && resultEvent.resultText) {
355
334
  const m = resultEvent.resultText.match(RE);
@@ -816,19 +795,6 @@ async function verifyRun({
816
795
  );
817
796
  }
818
797
 
819
- // The executor needs a decision/action only the human can take. Parks as
820
- // needs_review with the question; every automatic retry rung skips it.
821
- if (sentinel === 'needs_human') {
822
- const humanQuestion = scanHumanQuestion(resultEvent, events);
823
- const kept = [...annotations, ...issues.map((i) => ({ verdict: i.verdict, reason: i.reason }))];
824
- return conclude(
825
- 'needs_human',
826
- 'executor reported SCHEDULER_VERDICT: NEEDS_HUMAN' + (humanQuestion ? ': ' + humanQuestion : ''),
827
- 'needs_review',
828
- { sentinel, humanQuestion, ...(kept.length ? { annotations: kept } : {}) },
829
- );
830
- }
831
-
832
798
  const sentinelFields = sentinel ? { sentinel } : {};
833
799
  const extras = (annotations.length || sentinel)
834
800
  ? { ...(annotations.length ? { annotations } : {}), ...sentinelFields }
@@ -1050,7 +1016,6 @@ module.exports = {
1050
1016
  scanSentinel,
1051
1017
  scanHaltLine,
1052
1018
  scanSentinelReason,
1053
- scanHumanQuestion,
1054
1019
  scanForeignWipPathsClaim,
1055
1020
  isMergeMainSlug,
1056
1021
  extractMergeMainPrNumber,
@@ -225,12 +225,12 @@ function classifyBudgetKill(res, landedCommitEvidence) {
225
225
  /**
226
226
  * isJobBudgetExempt(job) → boolean
227
227
  *
228
- * Pure. `quietMachine: true` PRDs (their whole point is running alone,
229
- * un-contended, for a timing-sensitive measurement) and any PRD with an
230
- * explicit `budgetExempt: true` opt-out have no wall-clock kill ceiling.
228
+ * Pure. Only an explicit `budgetExempt: true` opt-out has no wall-clock kill
229
+ * ceiling. `quietMachine` jobs are NOT exempt: they hold an exclusive
230
+ * machine-wide lease, so they must always be killed by the normal budget.
231
231
  */
232
232
  function isJobBudgetExempt(job) {
233
- return job?.quietMachine === true || job?.budgetExempt === true;
233
+ return job?.budgetExempt === true;
234
234
  }
235
235
 
236
236
  /** Pure predicate the budget watchdog's shouldFire calls — single source of
@@ -137,9 +137,8 @@ async function parsePrdRaw(filePath) {
137
137
  quietMachine: fm.quietMachine === 'true',
138
138
  // Explicit opt-out of the wall-clock budget kill (scheduler.cjs's budget
139
139
  // watchdog) — only a literal `true` opts out, matching quietMachine's
140
- // parse semantics above. quietMachine jobs are exempt regardless of this
141
- // field; this is for a job that isn't quiet-machine-eligible but still
142
- // needs no wall-clock kill ceiling.
140
+ // parse semantics above. quietMachine jobs are NOT exempt; this is the
141
+ // only way to get no wall-clock kill ceiling.
143
142
  budgetExempt: fm.budgetExempt === 'true',
144
143
  body: body.trim(),
145
144
  };
@@ -34,7 +34,6 @@ const { DEFAULT_PROJECT_CWD } = require('../lib/scheduler/schedulerBatch.cjs');
34
34
  * Kill-switch: SM_RESUME_RECOVERY_DISABLE=1 restores today's behaviour
35
35
  * exactly (always returns null), mirroring SM_RCA_DISABLE/SM_DOD_DISABLE.
36
36
  */
37
- /** A row parked on a question only the human can answer — no automatic rung may touch it. */
38
37
  // PRD refire-cap: a row parked by the post-run re-fire cap stays parked for the
39
38
  // Epic's grouped notice. Every automated rung (reset, re-verify, auto-fix,
40
39
  // auto-skip) consults this ONE predicate.
@@ -42,14 +41,10 @@ function isRefireCapped(job) {
42
41
  return !!job && job.verifierVerdict === 'refire_capped';
43
42
  }
44
43
 
45
- function isNeedsHuman(job) {
46
- return !!job && job.verifierVerdict === 'needs_human';
47
- }
48
44
 
49
45
  function selectResumeRecoveryTarget(job) {
50
46
  if (process.env.SM_RESUME_RECOVERY_DISABLE === '1') return null;
51
47
  if (!job || job.status !== 'needs_review') return null;
52
- if (isNeedsHuman(job)) return null;
53
48
  if (job.verifierVerdict !== 'uncommitted_changes') return null;
54
49
  if (typeof job.sessionId !== 'string' || job.sessionId.length === 0) return null;
55
50
  if (job.resumeRecoveryAttempted === true) return null;
@@ -113,7 +108,6 @@ function isMechanicalRecoveryFutile(job, currentHeadSha) {
113
108
  function selectMechanicalRecoveryTarget(job, currentHeadSha = null) {
114
109
  if (process.env.SM_MECHANICAL_RECOVERY_DISABLE === '1') return null;
115
110
  if (!job || job.status !== 'needs_review') return null;
116
- if (isNeedsHuman(job)) return null;
117
111
  if (!MECHANICALLY_RESOLVABLE_VERDICTS.has(job.verifierVerdict)) return null;
118
112
  if (job.mechanicalRecoveryAttempted === true) return null;
119
113
  if (isMechanicalRecoveryFutile(job, currentHeadSha)) return null;
@@ -827,7 +821,6 @@ function selectExhaustedNeedsReviewTargets(jobs, now, thresholdMs) {
827
821
  for (const j of jobs ?? []) {
828
822
  if (isCancelledJob(j)) continue;
829
823
  if (j.status !== 'needs_review') continue;
830
- if (isNeedsHuman(j)) continue;
831
824
  if (!isEligibleForNeedsReviewAutoResolve(j, jobs)) continue;
832
825
  if ((j.exhaustedResolveAttempts ?? 0) > NEEDS_REVIEW_RESOLVE_CAP) continue;
833
826
  const history = j.statusHistory || [];
@@ -872,7 +865,6 @@ function selectAutoFixTargets(jobs, { fixSlugExists, resolveJobRunId = resolveRu
872
865
  if (isCancelledJob(job)) return false;
873
866
  if (isRefireCapped(job)) return false;
874
867
  if (job.status !== 'needs_review') return false;
875
- if (isNeedsHuman(job)) return false;
876
868
  // A job parked here because it was blocked by a sibling's foreign WIP
877
869
  // three times in a row (never its own regression) has nothing for a
878
870
  // fix-plan investigation to diagnose — there is no code defect to
@@ -1013,7 +1005,6 @@ function selectGateShadowTarget(jobs, headByCwd, { authorityDisabled = false, ba
1013
1005
  }
1014
1006
 
1015
1007
  module.exports = {
1016
- isNeedsHuman,
1017
1008
  isRefireCapped,
1018
1009
  isCancelledJob,
1019
1010
  selectResumeRecoveryTarget,