claude-code-session-manager 0.21.1 → 0.21.3

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 (54) hide show
  1. package/bin/cli.cjs +5 -0
  2. package/dist/assets/{TiptapBody-C46DacIO.js → TiptapBody-PdmsfUCQ.js} +2 -2
  3. package/dist/assets/cssMode-DfqZGMQs.js +1 -0
  4. package/dist/assets/{freemarker2-BxIPNQn-.js → freemarker2-XTPYh37h.js} +1 -1
  5. package/dist/assets/handlebars-DKUF5VyH.js +1 -0
  6. package/dist/assets/html-uqoqsIeI.js +1 -0
  7. package/dist/assets/htmlMode-aMTQs1su.js +1 -0
  8. package/dist/assets/index-DO3ROR11.js +3525 -0
  9. package/dist/assets/index-DeQI4oVI.css +32 -0
  10. package/dist/assets/javascript-BVxRZMds.js +1 -0
  11. package/dist/assets/{jsonMode-1FAJaHiX.js → jsonMode-D04xP2s5.js} +4 -4
  12. package/dist/assets/liquid-BkQHTH2P.js +1 -0
  13. package/dist/assets/lspLanguageFeatures-By9uLznH.js +4 -0
  14. package/dist/assets/mdx-Du1IlbjV.js +1 -0
  15. package/dist/assets/{index-oGyPFfYZ.css → monaco-editor-BTnBOi8r.css} +1 -32
  16. package/dist/assets/monaco-editor-BW5C4Iv1.js +908 -0
  17. package/dist/assets/python-DSlImqXd.js +1 -0
  18. package/dist/assets/razor-BmUVyvSK.js +1 -0
  19. package/dist/assets/{tsMode-CLQIVays.js → tsMode-Btj0TTH7.js} +1 -1
  20. package/dist/assets/typescript-Bzelq9vO.js +1 -0
  21. package/dist/assets/xml-Whd9EaSd.js +1 -0
  22. package/dist/assets/yaml-QYf0-IN8.js +1 -0
  23. package/dist/index.html +4 -2
  24. package/package.json +1 -1
  25. package/src/main/__tests__/runVerify.test.cjs +101 -0
  26. package/src/main/config.cjs +36 -4
  27. package/src/main/historyAggregator.cjs +400 -149
  28. package/src/main/index.cjs +8 -0
  29. package/src/main/ipcSchemas.cjs +42 -13
  30. package/src/main/kg.cjs +87 -30
  31. package/src/main/lib/credentials.cjs +7 -0
  32. package/src/main/lib/e2eStateMachine.cjs +39 -0
  33. package/src/main/runVerify.cjs +28 -5
  34. package/src/main/scheduler/prdParser.cjs +16 -1
  35. package/src/main/scheduler.cjs +97 -13
  36. package/src/main/transcripts.cjs +141 -19
  37. package/src/main/usageMatrix.cjs +7 -3
  38. package/src/main/webRemote.cjs +190 -29
  39. package/src/preload/api.d.ts +40 -0
  40. package/src/preload/index.cjs +7 -0
  41. package/dist/assets/cssMode-CauFS5Bp.js +0 -1
  42. package/dist/assets/handlebars-DnEVFUsu.js +0 -1
  43. package/dist/assets/html-S8NXUTqc.js +0 -1
  44. package/dist/assets/htmlMode-rSEyII9x.js +0 -1
  45. package/dist/assets/index-DMobTczM.js +0 -4431
  46. package/dist/assets/javascript-BiWR68QP.js +0 -1
  47. package/dist/assets/liquid-CEtOkbwI.js +0 -1
  48. package/dist/assets/lspLanguageFeatures-CRF3U0x3.js +0 -4
  49. package/dist/assets/mdx-C7C95Bzt.js +0 -1
  50. package/dist/assets/python-CXvKcjLk.js +0 -1
  51. package/dist/assets/razor-tzZHfRy2.js +0 -1
  52. package/dist/assets/typescript-LxhyM9W2.js +0 -1
  53. package/dist/assets/xml-VS_m20VE.js +0 -1
  54. package/dist/assets/yaml-BsjggdVD.js +0 -1
@@ -394,35 +394,64 @@ function validated(schema, handler) {
394
394
  }
395
395
 
396
396
  // ──────────────────────────────────────────── Web Remote command allowlist
397
- // Single source of truth imported by webRemote.cjs and by the unit test.
398
- // Only these type strings will ever reach a handler; all others are silently
399
- // dropped without leaking error details back to the relay (ADR §6.2).
400
- const ALLOWED_COMMANDS = new Set([
397
+ // Commands are split into three tiers:
398
+ // READ_COMMANDS — return data; allowed when remoteEnabled=true.
399
+ // SAS_GATED_READS — return sensitive user data (sessions, PRDs, logs,
400
+ // transcript summaries); additionally require
401
+ // _e2eAuthenticated=true (SAS confirmed by user).
402
+ // A compromised relay cannot exfiltrate this data from
403
+ // a session that has not been SAS-confirmed.
404
+ // MUTATE_COMMANDS — write files, spawn processes, or mutate persisted
405
+ // state; gated behind remoteControlEnabled=true AND
406
+ // _e2eAuthenticated=true.
407
+ // ALLOWED_COMMANDS is the union, kept for existing import compatibility.
408
+ //
409
+ // Ungated READ_COMMANDS (justify each):
410
+ // cmd:app:version — exposes only the app semver string; no user data.
411
+ // cmd:session:unsubscribe — teardown lifecycle; returns nothing sensitive.
412
+ const READ_COMMANDS = new Set([
413
+ 'cmd:app:version',
414
+ // v2 mobile: unsubscribe is a teardown lifecycle call with no data payload.
415
+ 'cmd:session:unsubscribe',
416
+ ]);
417
+
418
+ // Sensitive reads — return user data; require SAS confirmation same as MUTATE.
419
+ const SAS_GATED_READS = new Set([
401
420
  'cmd:sessions:load',
421
+ 'cmd:schedule:state',
422
+ 'cmd:schedule:read-prd',
423
+ 'cmd:schedule:read-log',
424
+ 'cmd:history:aggregate',
425
+ // subscribe initiates a live stream of session state/summary — sensitive.
426
+ 'cmd:session:subscribe',
427
+ ]);
428
+
429
+ const MUTATE_COMMANDS = new Set([
402
430
  'cmd:sessions:save',
403
431
  'cmd:pty:spawn',
404
432
  'cmd:pty:write',
405
- 'cmd:pty:resize',
433
+ // pty:kill terminates a live session; pty:resize drives the geometry of the
434
+ // user's interactive PTY — both write live process state, so they are gated
435
+ // behind remoteControlEnabled + SAS like every other mutation. A read-only
436
+ // mobile mirror has no business killing or resizing the desktop's session.
406
437
  'cmd:pty:kill',
407
- 'cmd:schedule:state',
408
- 'cmd:schedule:read-prd',
409
- 'cmd:schedule:read-log',
438
+ 'cmd:pty:resize',
410
439
  'cmd:schedule:write-prd',
411
440
  'cmd:schedule:reset-job',
412
441
  'cmd:schedule:run-now',
413
442
  'cmd:schedule:set-config',
414
- 'cmd:history:aggregate',
415
- 'cmd:app:version',
416
- // v2 mobile: per-session live state + summary push (ARCHITECTURE-V2-MOBILE.md §3)
417
- 'cmd:session:subscribe',
418
- 'cmd:session:unsubscribe',
419
443
  ]);
420
444
 
445
+ const ALLOWED_COMMANDS = new Set([...READ_COMMANDS, ...SAS_GATED_READS, ...MUTATE_COMMANDS]);
446
+
421
447
  module.exports = {
422
448
  // Centralized slug regex — used by scheduler.cjs and queueOps.cjs for
423
449
  // direct test()/match() containment checks alongside the zod parses.
424
450
  SCHEDULE_SLUG_RE,
425
451
  SCHEDULE_RUN_ID_RE,
452
+ READ_COMMANDS,
453
+ SAS_GATED_READS,
454
+ MUTATE_COMMANDS,
426
455
  ALLOWED_COMMANDS,
427
456
  schemas: {
428
457
  webRemotePair,
package/src/main/kg.cjs CHANGED
@@ -39,16 +39,24 @@ const path = require('node:path');
39
39
  const os = require('node:os');
40
40
  const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
41
41
  const { encodeCwd } = require('./lib/encodeCwd.cjs');
42
+ const { writeJson } = require('./config.cjs');
42
43
 
43
44
  const HOME = os.homedir();
44
45
  const KG_DIR = path.join(HOME, '.claude', 'knowledge-log');
45
46
  const LOG_PATH = path.join(KG_DIR, 'prompts.jsonl');
46
47
  const GRAPHS_DIR = path.join(KG_DIR, 'graphs');
47
48
  const INGEST_STATE_PATH = path.join(KG_DIR, 'ingest-state.json');
49
+ const PROMPT_INDEX_PATH = path.join(KG_DIR, 'prompt-index.json');
48
50
  const BATCH = 20; // prompts per extraction call (also a per-project cap)
49
51
  const KNOWN_VOCAB = 200; // top node names pre-seeded for dedup-at-extraction
50
52
  const MAX_TAIL_BYTES = 8 * 1024 * 1024; // bound bytes scanned per ingest run
51
53
  const MAX_EXTRACTIONS_PER_RUN = 30; // bound claude calls per run (cost/time)
54
+ // Coalescing window before an auto-ingest after new prompts land. Units never
55
+ // mix projects, and a project switch in the log closes the current batch — so
56
+ // with concurrent sessions a short window yields 1-2-prompt batches and one
57
+ // claude spawn each (~1.2K extraction runs in one 48h period). A long window
58
+ // lets prompts accumulate into fuller batches; the KG tab tolerates the lag.
59
+ const WATCH_COALESCE_MS = 5 * 60_000;
52
60
 
53
61
  const ENTITY_TYPES = ['project', 'feature', 'tool', 'tech', 'concept', 'goal', 'person'];
54
62
 
@@ -137,11 +145,7 @@ async function loadGraphFor(cwd) {
137
145
  }
138
146
 
139
147
  async function saveGraph(g) {
140
- await fsp.mkdir(GRAPHS_DIR, { recursive: true });
141
- const p = graphPath(g.cwd);
142
- const tmp = `${p}.tmp`;
143
- await fsp.writeFile(tmp, JSON.stringify(g, null, 2));
144
- await fsp.rename(tmp, p); // atomic
148
+ await writeJson(graphPath(g.cwd), g);
145
149
  }
146
150
 
147
151
  async function loadIngestState() {
@@ -152,10 +156,20 @@ async function loadIngestState() {
152
156
  }
153
157
 
154
158
  async function saveIngestState(s) {
155
- await fsp.mkdir(KG_DIR, { recursive: true });
156
- const tmp = `${INGEST_STATE_PATH}.tmp`;
157
- await fsp.writeFile(tmp, JSON.stringify(s, null, 2));
158
- await fsp.rename(tmp, INGEST_STATE_PATH);
159
+ await writeJson(INGEST_STATE_PATH, s);
160
+ }
161
+
162
+ /**
163
+ * Per-project prompt-count sidecar: { [encodedCwd]: { count: number, cwd: string } }
164
+ * Returns null when the file does not yet exist (triggers a one-time migration scan).
165
+ */
166
+ async function readPromptIndex() {
167
+ try { return JSON.parse(await fsp.readFile(PROMPT_INDEX_PATH, 'utf8')); }
168
+ catch { return null; }
169
+ }
170
+
171
+ async function savePromptIndex(idx) {
172
+ await writeJson(PROMPT_INDEX_PATH, idx);
159
173
  }
160
174
 
161
175
  /** Canonical dedup key: lowercase, strip leading article, collapse whitespace. */
@@ -337,6 +351,7 @@ async function ingest() {
337
351
  broadcast('kg:ingest-progress', { phase: 'start', ingesting: true });
338
352
  try {
339
353
  const st = await loadIngestState();
354
+ const promptIdx = await readPromptIndex() ?? {};
340
355
  let stat;
341
356
  try { stat = await fsp.stat(LOG_PATH); }
342
357
  catch { broadcast('kg:ingest-progress', { phase: 'done', ingesting: false, added: 0 }); return { ok: true, added: 0, note: 'no log yet' }; }
@@ -423,6 +438,14 @@ async function ingest() {
423
438
  st.lastOffset += u.bytes;
424
439
  st.lastTs = u.entries[u.entries.length - 1].ts || st.lastTs;
425
440
  st.updatedAt = new Date().toISOString();
441
+ // Write index before advancing watermark: if we crash between these two
442
+ // writes, the watermark hasn't moved so the batch will be re-processed
443
+ // (the index count may be slightly high) rather than advanced past a
444
+ // batch whose index entry was never written.
445
+ if (!promptIdx[u.enc]) promptIdx[u.enc] = { count: 0, cwd: u.cwd };
446
+ promptIdx[u.enc].count += u.entries.length;
447
+ promptIdx[u.enc].cwd = u.cwd;
448
+ await savePromptIndex(promptIdx);
426
449
  await saveIngestState(st);
427
450
  if (extractions >= MAX_EXTRACTIONS_PER_RUN) { capped = true; break; }
428
451
  continue;
@@ -435,12 +458,19 @@ async function ingest() {
435
458
  g.updatedAt = new Date().toISOString();
436
459
 
437
460
  // Commit this batch: graph first (so a crash can't advance the watermark
438
- // past unsaved work), then the watermark.
461
+ // past unsaved work), then the watermark + sidecar index.
439
462
  await saveGraph(g);
440
463
  st.lastOffset += u.bytes;
441
464
  st.promptCount += u.entries.length;
442
465
  st.lastTs = batchTs;
443
466
  st.updatedAt = new Date().toISOString();
467
+ // Write index before advancing watermark so a crash between the two
468
+ // leaves the watermark un-advanced (re-processable) rather than
469
+ // advancing past a batch whose index entry was never committed.
470
+ if (!promptIdx[u.enc]) promptIdx[u.enc] = { count: 0, cwd: u.cwd };
471
+ promptIdx[u.enc].count += u.entries.length;
472
+ promptIdx[u.enc].cwd = u.cwd;
473
+ await savePromptIndex(promptIdx);
444
474
  await saveIngestState(st);
445
475
 
446
476
  committedPrompts += u.entries.length;
@@ -473,25 +503,29 @@ async function ingest() {
473
503
 
474
504
  /** Enumerate projects seen in the log, enriched with per-project graph stats. */
475
505
  async function listProjects() {
476
- const prompts = await readAllPrompts();
477
- const byEnc = new Map();
478
- for (const p of prompts) {
479
- if (!p.cwd) continue;
480
- const enc = encodeCwd(p.cwd);
481
- let e = byEnc.get(enc);
482
- if (!e) { e = { cwd: p.cwd, enc, total: 0 }; byEnc.set(enc, e); }
483
- e.total++;
484
- e.cwd = p.cwd; // keep most recent spelling
506
+ let idx = await readPromptIndex();
507
+ if (idx === null) {
508
+ // One-time migration: build sidecar from the full log.
509
+ idx = {};
510
+ const prompts = await readAllPrompts();
511
+ for (const p of prompts) {
512
+ if (!p.cwd) continue;
513
+ const enc = encodeCwd(p.cwd);
514
+ if (!idx[enc]) idx[enc] = { count: 0, cwd: p.cwd };
515
+ idx[enc].count++;
516
+ idx[enc].cwd = p.cwd;
517
+ }
518
+ await savePromptIndex(idx).catch(() => {});
485
519
  }
486
520
  const out = [];
487
- for (const e of byEnc.values()) {
488
- const g = await loadGraphFor(e.cwd);
521
+ for (const [enc, entry] of Object.entries(idx)) {
522
+ const g = await loadGraphFor(entry.cwd);
489
523
  out.push({
490
- cwd: e.cwd,
491
- label: shortLabel(e.cwd),
492
- total: e.total,
524
+ cwd: entry.cwd,
525
+ label: shortLabel(entry.cwd),
526
+ total: entry.count,
493
527
  processed: g.promptCount || 0,
494
- pending: Math.max(0, e.total - (g.promptCount || 0)),
528
+ pending: Math.max(0, entry.count - (g.promptCount || 0)),
495
529
  nodes: g.nodes.length,
496
530
  edges: g.edges.length,
497
531
  lastIngest: g.updatedAt,
@@ -510,7 +544,24 @@ async function getState(cwd) {
510
544
  const target = cwd || await defaultCwd();
511
545
  const enc = encodeCwd(target);
512
546
  const g = await loadGraphFor(target);
513
- const prompts = (await readAllPrompts()).filter((p) => encodeCwd(p.cwd) === enc);
547
+ let idx = await readPromptIndex();
548
+ let totalPrompts;
549
+ if (idx === null) {
550
+ // One-time migration fallback — build from full log.
551
+ idx = {};
552
+ const prompts = await readAllPrompts();
553
+ for (const p of prompts) {
554
+ if (!p.cwd) continue;
555
+ const e2 = encodeCwd(p.cwd);
556
+ if (!idx[e2]) idx[e2] = { count: 0, cwd: p.cwd };
557
+ idx[e2].count++;
558
+ idx[e2].cwd = p.cwd;
559
+ }
560
+ await savePromptIndex(idx).catch(() => {});
561
+ totalPrompts = idx[enc]?.count ?? 0;
562
+ } else {
563
+ totalPrompts = idx[enc]?.count ?? 0;
564
+ }
514
565
  return {
515
566
  cwd: target,
516
567
  label: shortLabel(target),
@@ -518,8 +569,8 @@ async function getState(cwd) {
518
569
  edges: g.edges,
519
570
  status: {
520
571
  promptCount: g.promptCount || 0,
521
- totalPrompts: prompts.length,
522
- pending: Math.max(0, prompts.length - (g.promptCount || 0)),
572
+ totalPrompts,
573
+ pending: Math.max(0, totalPrompts - (g.promptCount || 0)),
523
574
  lastIngest: g.updatedAt,
524
575
  ingesting,
525
576
  logPath: LOG_PATH,
@@ -584,8 +635,14 @@ function init(opts = {}) {
584
635
  fs.mkdirSync(KG_DIR, { recursive: true });
585
636
  fs.watch(KG_DIR, (_evt, file) => {
586
637
  if (file && file !== 'prompts.jsonl') return;
587
- if (watchTimer) clearTimeout(watchTimer);
588
- watchTimer = setTimeout(() => { ingest().catch(() => {}); }, 8_000);
638
+ // Leading-edge coalesce: first new prompt arms the timer; later prompts
639
+ // ride along instead of resetting it, so busy periods can't starve
640
+ // ingest and every run sees a full window's worth of prompts.
641
+ if (watchTimer) return;
642
+ watchTimer = setTimeout(() => {
643
+ watchTimer = null;
644
+ ingest().catch(() => {});
645
+ }, WATCH_COALESCE_MS);
589
646
  });
590
647
  } catch { /* watch is best-effort */ }
591
648
  }
@@ -168,6 +168,13 @@ async function refreshIfNeeded(forceRefresh = false) {
168
168
  }
169
169
 
170
170
  if (alreadyExpired) {
171
+ // Re-read from disk in case credentials were externally refreshed (e.g. via
172
+ // `claude login`) between our initial read and the failed OAuth attempt.
173
+ const recheckCr = await readCredentials();
174
+ if (recheckCr.kind === 'ok' && !isExpired(recheckCr.creds)) {
175
+ appendRefreshLog({ event: 'externally_refreshed_ok', recheckExpiresAt: recheckCr.creds.expiresAt ?? null });
176
+ return { kind: 'ok', creds: recheckCr.creds };
177
+ }
171
178
  const ms = expiresAtMs(creds);
172
179
  appendRefreshLog({ event: 'auth_failed_expired', expiredAtMs: ms });
173
180
  return {
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Pure E2E session state machine for the web-remote relay.
3
+ * No Electron, no I/O — importable in unit tests.
4
+ *
5
+ * State transitions:
6
+ * idle → pending_sas : successful deriveSessionKey + deriveSas
7
+ * idle → failed : crypto derivation error
8
+ * pending_sas → authenticated : user confirms SAS
9
+ * pending_sas → failed : deriveSas threw after sessionKey succeeded
10
+ * any → idle : disconnect / reset
11
+ */
12
+
13
+ /** @returns {{ state: string, sessionKey: Buffer|null, pendingSas: string|null }} */
14
+ function makeState(state = 'idle', sessionKey = null, pendingSas = null) {
15
+ return { state, sessionKey, pendingSas };
16
+ }
17
+
18
+ /**
19
+ * Attempt to confirm the SAS. Pure — does not mutate; returns the next state.
20
+ * @param {{ state: string, sessionKey: Buffer|null, pendingSas: string|null }} e2eState
21
+ * @returns {{ ok: boolean, error?: string, next: { state: string, sessionKey: Buffer|null, pendingSas: string|null } }}
22
+ */
23
+ function confirmSas(e2eState) {
24
+ if (e2eState.state !== 'pending_sas') {
25
+ const errorMap = {
26
+ idle: 'no_e2e_session',
27
+ failed: 'e2e_failed',
28
+ authenticated: 'already_authenticated',
29
+ };
30
+ const error = errorMap[e2eState.state] ?? 'unexpected_state';
31
+ return { ok: false, error, next: e2eState };
32
+ }
33
+ return {
34
+ ok: true,
35
+ next: makeState('authenticated', e2eState.sessionKey, null),
36
+ };
37
+ }
38
+
39
+ module.exports = { makeState, confirmSas };
@@ -58,20 +58,24 @@ function detectPattern(content) {
58
58
  return { verdict: 'transcript_errors', pattern: 'FAIL/FATAL at line start' };
59
59
  }
60
60
 
61
- // (2) Python Traceback + Error line within next 10 lines.
61
+ // (2) Python Traceback + exception line within next 10 lines. Both anchored
62
+ // to line starts: reviewer prose quoting "will crash with ImportError" or
63
+ // embedding "...Error:" mid-sentence must not match (feedback 2026-06-10-01).
62
64
  const lines = content.split('\n');
63
65
  for (let i = 0; i < lines.length; i++) {
64
- if (lines[i].includes('Traceback (most recent call last):')) {
66
+ if (/^\s*Traceback \(most recent call last\):/.test(lines[i])) {
65
67
  for (let j = i + 1; j < Math.min(i + 11, lines.length); j++) {
66
- if (lines[j].includes('Error:')) {
68
+ if (/^\s*[A-Za-z_][\w.]*(?:Error|Exception)\s*:/.test(lines[j])) {
67
69
  return { verdict: 'transcript_errors', pattern: 'Traceback + Error within 10 lines' };
68
70
  }
69
71
  }
70
72
  }
71
73
  }
72
74
 
73
- // (3) Import / module errors (verification was skipped).
74
- if (content.includes('ModuleNotFoundError') || content.includes('ImportError')) {
75
+ // (3) Import / module errors (verification was skipped). Line-anchored:
76
+ // real interpreter output starts the line with the exception name
77
+ // ("ModuleNotFoundError: No module named 'x'"); prose never does.
78
+ if (/^\s*(?:ModuleNotFoundError|ImportError)\s*(?::|$)/m.test(content)) {
75
79
  return { verdict: 'verify_unavailable', pattern: 'ModuleNotFoundError/ImportError' };
76
80
  }
77
81
 
@@ -195,6 +199,18 @@ function toolUseDesc(events, toolUseId) {
195
199
  return '';
196
200
  }
197
201
 
202
+ /**
203
+ * Return the tool name of the tool_use that produced a given tool_result.
204
+ * Returns '' if not found.
205
+ */
206
+ function toolUseName(events, toolUseId) {
207
+ if (!toolUseId) return '';
208
+ for (const ev of events) {
209
+ if (ev.kind === 'tool_use' && ev.toolUseId === toolUseId) return ev.toolName ?? '';
210
+ }
211
+ return '';
212
+ }
213
+
198
214
  /**
199
215
  * Check whether the next ≤5 tool_use calls after `fromSeq` include a package
200
216
  * install command (pip install, pip3 install, uv sync, uv pip install).
@@ -471,6 +487,12 @@ async function verifyRun({ runDir, prdPath, queueEntry, allJobs = [] }) {
471
487
 
472
488
  if (!ev.content) continue;
473
489
 
490
+ // Subagent (Task) results are structured prose — review findings that
491
+ // *describe* exceptions ("will crash with ImportError") are the dominant
492
+ // false-positive source (feedback 2026-06-10-01). Real runtime errors
493
+ // surface through Bash/test tool_results, which are still scanned.
494
+ if (toolUseName(events, ev.toolUseId) === 'Task') continue;
495
+
474
496
  const hit = detectPattern(ev.content);
475
497
  if (!hit) continue;
476
498
 
@@ -520,6 +542,7 @@ module.exports = {
520
542
  verifyRun,
521
543
  // Exposed for unit tests.
522
544
  detectPattern,
545
+ toolUseName,
523
546
  extractSoakFromBody,
524
547
  parsePrdBodyDepFragments,
525
548
  checkDeps,
@@ -15,9 +15,24 @@
15
15
 
16
16
  const fs = require('node:fs');
17
17
  const fsp = require('node:fs/promises');
18
+ const os = require('node:os');
18
19
  const path = require('node:path');
19
20
  const { splitFrontmatter } = require('../lib/prdFrontmatter.cjs');
20
21
 
22
+ /**
23
+ * Expand a PRD `cwd` value to an absolute path.
24
+ * - `~/...` or `~` alone → absolute under os.homedir()
25
+ * - Already-absolute paths pass through unchanged.
26
+ * - Bare relative paths → joined onto os.homedir().
27
+ * null/empty returns null (caller falls back to defaultCwd).
28
+ */
29
+ function expandCwd(cwd) {
30
+ if (!cwd) return null;
31
+ if (cwd === '~' || cwd.startsWith('~/')) return path.join(os.homedir(), cwd.slice(1));
32
+ if (path.isAbsolute(cwd)) return cwd;
33
+ return path.join(os.homedir(), cwd);
34
+ }
35
+
21
36
  // Hard cap to keep one malformed PRD (e.g. a binary blob accidentally renamed
22
37
  // .md) from wedging the main thread. PRDs are PRDs, not media files; 1 MB is
23
38
  // already ~25k lines and well beyond any legitimate authored doc.
@@ -46,7 +61,7 @@ async function parsePrdRaw(filePath) {
46
61
  slug: base,
47
62
  path: filePath,
48
63
  title: fm.title || base,
49
- cwd: fm.cwd || null,
64
+ cwd: expandCwd(fm.cwd || null),
50
65
  estimateMinutes: fm.estimateMinutes ? Number(fm.estimateMinutes) || null : null,
51
66
  parallelGroup: (fm.parallelGroup ? Number(fm.parallelGroup) || null : null) ?? groupFromName ?? 99,
52
67
  body: body.trim(),
@@ -180,12 +180,16 @@ const HEARTBEAT_MAX_BYTES = 1024 * 1024;
180
180
  // DEFAULT_PROJECT_CWD imported from lib/schedulerBatch.cjs (single source of truth).
181
181
 
182
182
  const ENV_CAP = process.env.SM_SCHEDULER_MAX_CONCURRENCY
183
- ? Math.max(1, Math.min(20, parseInt(process.env.SM_SCHEDULER_MAX_CONCURRENCY, 10) || 4))
183
+ ? Math.max(1, Math.min(20, parseInt(process.env.SM_SCHEDULER_MAX_CONCURRENCY, 10) || 3))
184
184
  : null;
185
185
 
186
+ // Each headless claude -p process can grow past 1 GB; require 1.5 GB headroom
187
+ // per running+pending slot to avoid OOM (incident 2026-06-10).
188
+ const MIN_FREE_MB_PER_JOB = 1500;
189
+
186
190
  const DEFAULT_CONFIG = {
187
191
  offsetMinutes: 15,
188
- concurrencyCap: ENV_CAP ?? 4,
192
+ concurrencyCap: ENV_CAP ?? 3,
189
193
  defaultCwd: DEFAULT_PROJECT_CWD,
190
194
  // 'when-available' = poll usage and fire whenever utilization < threshold.
191
195
  // 'on-reset' = fire offsetMinutes after the next 5h reset (legacy).
@@ -202,6 +206,39 @@ const DEFAULT_CONFIG = {
202
206
  },
203
207
  };
204
208
 
209
+ // ---------- memory gate ----------
210
+
211
+ /**
212
+ * Returns available system memory in MB. Reads /proc/meminfo on Linux; fails
213
+ * open (returns Infinity) on darwin or on any parse/read error so the gate
214
+ * never blocks scheduling on unsupported platforms.
215
+ */
216
+ function getAvailableMemMb() {
217
+ if (process.platform !== 'linux') return Infinity;
218
+ try {
219
+ const raw = fs.readFileSync('/proc/meminfo', 'utf8');
220
+ const m = raw.match(/^MemAvailable:\s+(\d+)\s+kB/m);
221
+ if (!m) return Infinity;
222
+ return Math.floor(parseInt(m[1], 10) / 1024);
223
+ } catch {
224
+ return Infinity;
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Pure helper: clamp a batch down so launching `toLaunch` more jobs doesn't
230
+ * drop available memory below MIN_FREE_MB_PER_JOB per active slot.
231
+ * Exported for unit tests.
232
+ */
233
+ function memoryLimitedBatchSize(availableMb, minPerJob, runningCount, batchLen) {
234
+ if (availableMb === Infinity) return batchLen;
235
+ let allowed = batchLen;
236
+ while (allowed > 0 && availableMb < minPerJob * (runningCount + allowed)) {
237
+ allowed--;
238
+ }
239
+ return allowed;
240
+ }
241
+
205
242
  // ---------- fs helpers ----------
206
243
 
207
244
  /**
@@ -539,6 +576,8 @@ let heartbeatInterval = null;
539
576
  // double-spawn when runDueJobs() is called while jobs are in flight.
540
577
  const runningSet = new Set();
541
578
  let cancelToken = { cancelled: false };
579
+ // Last memory-gate observation; included in snapshot for renderer visibility.
580
+ let lastMemGate = null;
542
581
 
543
582
  function attachWindow(w) { mainWindow = w; }
544
583
 
@@ -557,6 +596,13 @@ function buildScheduleStatePayload(state, { withPaths = false } = {}) {
557
596
  nextReset: getNextResetCached(),
558
597
  paused: state.paused,
559
598
  utilization: cachedUtilization,
599
+ pollHealth: {
600
+ lastPollAt,
601
+ lastPollOk,
602
+ consecutiveFailures,
603
+ lastFailureKind,
604
+ },
605
+ memGate: lastMemGate,
560
606
  };
561
607
  if (withPaths) {
562
608
  payload.paths = { root: ROOT, prds: PRDS_DIR, runs: RUNS_DIR, queue: QUEUE_PATH };
@@ -743,7 +789,7 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
743
789
  // before handing it to the child process.
744
790
  try { fs.accessSync(cwd, fs.constants.X_OK); }
745
791
  catch {
746
- const errMsg = `cwd no longer exists: ${cwd}`;
792
+ const errMsg = `cwd does not exist on this machine: ${cwd}`;
747
793
  safeLog(`[scheduler] ${errMsg}\n`);
748
794
  closeFd();
749
795
  // Sync write: this is an early-exit error path inside an async function,
@@ -1356,11 +1402,29 @@ function tickQueue() {
1356
1402
  const batch = pickNextBatch(state.jobs, runningSet, cap);
1357
1403
  if (batch.length === 0) return;
1358
1404
 
1405
+ const availableMb = getAvailableMemMb();
1406
+ const allowed = memoryLimitedBatchSize(availableMb, MIN_FREE_MB_PER_JOB, runningSet.size, batch.length);
1407
+ if (allowed === 0) {
1408
+ const threshold = MIN_FREE_MB_PER_JOB * (runningSet.size + 1);
1409
+ console.log(`[scheduler] memory gate: available=${availableMb} MB < threshold=${threshold} MB — deferring ${batch.length} job(s)`);
1410
+ lastMemGate = { availableMb, threshold, deferred: true, at: new Date().toISOString() };
1411
+ return;
1412
+ }
1413
+ const gatedBatch = batch.slice(0, allowed);
1414
+ if (gatedBatch.length < batch.length) {
1415
+ console.log(`[scheduler] memory gate: available=${availableMb} MB — clamped batch ${batch.length} → ${gatedBatch.length}`);
1416
+ lastMemGate = { availableMb, threshold: MIN_FREE_MB_PER_JOB * (runningSet.size + gatedBatch.length), deferred: false, clamped: true, at: new Date().toISOString() };
1417
+ } else {
1418
+ // Ungated full batch: clear stale gate snapshot so status doesn't show
1419
+ // a stale deferral from a previous tick.
1420
+ lastMemGate = null;
1421
+ }
1422
+
1359
1423
  await mutate((s) => { s.lastRunAt = new Date().toISOString(); });
1360
1424
  await broadcast();
1361
1425
 
1362
1426
  const { runId, dir: runDir } = pickRunDir();
1363
- for (const job of batch) {
1427
+ for (const job of gatedBatch) {
1364
1428
  if (cancelToken.cancelled) break;
1365
1429
  // spawnJob is fire-and-forget; it calls tickQueue() on completion.
1366
1430
  spawnJob(job, runId, runDir, state.config.defaultCwd).catch(() => {});
@@ -1450,6 +1514,18 @@ async function reapDeadRunningJobs() {
1450
1514
 
1451
1515
  // ---------- poll loop with exponential backoff ----------
1452
1516
 
1517
+ /**
1518
+ * Pure: given the current pause reason and whether a reset timestamp is cached,
1519
+ * return which clearPause source to pass after a successful billing poll, or null.
1520
+ * Exported for unit testing.
1521
+ */
1522
+ function pollRecoveryClearSource(pauseReason, hasCachedReset) {
1523
+ if (pauseReason === 'network') return 'network-recovered';
1524
+ if (pauseReason === 'auth') return 'auth-recovered';
1525
+ if (pauseReason === 'reset_failure' && hasCachedReset) return 'reset-recovered';
1526
+ return null;
1527
+ }
1528
+
1453
1529
  async function pollLoop() {
1454
1530
  try {
1455
1531
  await reapDeadRunningJobs().catch(() => {});
@@ -1468,15 +1544,10 @@ async function pollLoop() {
1468
1544
  lastPollOk = true;
1469
1545
  persistSchedulerState();
1470
1546
 
1471
- // If a 'network' pause resolved, clear it now that we have a good reading.
1547
+ // Clear any pause that was waiting for a successful billing read.
1472
1548
  const cur = await readQueue();
1473
- if (cur.paused?.reason === 'network') {
1474
- await clearPause('network-recovered');
1475
- }
1476
- // If 'reset_failure' was set and we now have a valid reset, clear it.
1477
- if (cur.paused?.reason === 'reset_failure' && cachedNextReset) {
1478
- await clearPause('reset-recovered');
1479
- }
1549
+ const clearSrc = pollRecoveryClearSource(cur.paused?.reason ?? null, !!cachedNextReset);
1550
+ if (clearSrc) await clearPause(clearSrc);
1480
1551
 
1481
1552
  await maybeLaunchWhenAvailable(cur);
1482
1553
  await broadcast();
@@ -1961,6 +2032,19 @@ const remote = {
1961
2032
  const resolved = safeSlugPath(slug);
1962
2033
  if (!resolved) return { ok: false, error: 'invalid slug' };
1963
2034
  try {
2035
+ // Symlink defense, matching readPrd/readLog: safeSlugPath is lexical and
2036
+ // does NOT resolve symlinks, so a rogue job could plant prds/x.md → an
2037
+ // arbitrary $HOME path and have writeTextAtomic clobber it. Resolve the
2038
+ // real parent dir (the file itself may not exist yet) and re-assert
2039
+ // containment; also reject the target if it is already a symlink.
2040
+ const realParent = await fsp.realpath(path.dirname(resolved));
2041
+ if (realParent !== PRDS_DIR && !realParent.startsWith(PRDS_DIR + path.sep)) {
2042
+ return { ok: false, error: 'invalid slug' };
2043
+ }
2044
+ const existing = await fsp.lstat(resolved).catch(() => null);
2045
+ if (existing && existing.isSymbolicLink()) {
2046
+ return { ok: false, error: 'invalid slug' };
2047
+ }
1964
2048
  await config.writeTextAtomic(resolved, body);
1965
2049
  const stat = await fsp.stat(resolved);
1966
2050
  return { ok: true, bytesWritten: stat.size };
@@ -2005,4 +2089,4 @@ const remote = {
2005
2089
  },
2006
2090
  };
2007
2091
 
2008
- module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs };
2092
+ module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize };