claude-code-session-manager 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/assets/{TiptapBody-Btu-_mZq.js → TiptapBody-CzNkD0kT.js} +1 -1
  2. package/dist/assets/{cssMode-EkBJI3CN.js → cssMode-DUMBBgBO.js} +1 -1
  3. package/dist/assets/{freemarker2-DFbgmGC_.js → freemarker2-BjnoO8uS.js} +1 -1
  4. package/dist/assets/{handlebars-CUy9oTnL.js → handlebars-CggfdTjZ.js} +1 -1
  5. package/dist/assets/{html-CUFKxIqK.js → html-Bcnp_pNA.js} +1 -1
  6. package/dist/assets/{htmlMode-hBSk7Fab.js → htmlMode-CFws8rf5.js} +1 -1
  7. package/dist/assets/{index-BGIAQ9_i.js → index-0geebEag.js} +1494 -1193
  8. package/dist/assets/{index-D4dlTF2R.css → index-DOo1Vtox.css} +1 -1
  9. package/dist/assets/{javascript-CAH2ooMO.js → javascript-DI4e0TwT.js} +1 -1
  10. package/dist/assets/{jsonMode-BSMCRMaw.js → jsonMode-Ca0fgEAF.js} +1 -1
  11. package/dist/assets/{liquid-CSMs05cs.js → liquid-E7hk4hx9.js} +1 -1
  12. package/dist/assets/{lspLanguageFeatures-DEyYbu0m.js → lspLanguageFeatures-Wh7EFavn.js} +1 -1
  13. package/dist/assets/{mdx-BocaqTLI.js → mdx-iXIhpMBJ.js} +1 -1
  14. package/dist/assets/{python-Dj6u2CWq.js → python-laxy9eG1.js} +1 -1
  15. package/dist/assets/{razor-D5OWmIwh.js → razor-CatPL_eG.js} +1 -1
  16. package/dist/assets/{tsMode-Cy6xJd5e.js → tsMode-D2WL55Cf.js} +1 -1
  17. package/dist/assets/{typescript-CsWWOqVn.js → typescript-B5DFbVoi.js} +1 -1
  18. package/dist/assets/{xml-DiTZK7ii.js → xml-BsStStQW.js} +1 -1
  19. package/dist/assets/{yaml-pdLhZQr9.js → yaml-rg2IIDpu.js} +1 -1
  20. package/dist/index.html +2 -2
  21. package/package.json +2 -1
  22. package/src/main/__tests__/runVerify.test.cjs +388 -0
  23. package/src/main/config.cjs +1 -7
  24. package/src/main/files.cjs +4 -37
  25. package/src/main/historyAggregator.cjs +37 -14
  26. package/src/main/index.cjs +53 -134
  27. package/src/main/ipcSchemas.cjs +4 -0
  28. package/src/main/lib/childWithLog.cjs +218 -0
  29. package/src/main/lib/expandHome.cjs +21 -0
  30. package/src/main/lib/insideHome.cjs +74 -21
  31. package/src/main/lib/openExternalApp.cjs +141 -0
  32. package/src/main/pluginInstall.cjs +39 -1
  33. package/src/main/pty.cjs +11 -4
  34. package/src/main/queueOps.cjs +2 -2
  35. package/src/main/runVerify.cjs +527 -0
  36. package/src/main/scheduler.cjs +335 -286
  37. package/src/main/search.cjs +1 -6
  38. package/src/main/superagent.cjs +10 -0
  39. package/src/main/supervisor.cjs +16 -8
  40. package/src/main/transcripts.cjs +6 -0
  41. package/src/main/watchers.cjs +2 -2
  42. package/src/preload/api.d.ts +16 -7
  43. package/src/preload/index.cjs +1 -0
@@ -14,10 +14,20 @@ function decodeCwd(encoded) {
14
14
  return '/' + encoded.replace(/-+/g, '/');
15
15
  }
16
16
 
17
+ // All date strings in this module are LOCAL-TZ YYYY-MM-DD. A previous version
18
+ // used UTC (toISOString().slice(0,10)) which silently shifted late-evening
19
+ // sessions a day forward for Pacific-time users, then the >= effectiveTo
20
+ // filter dropped them entirely. en-CA locale yields ISO-format dates in the
21
+ // JS environment's TZ. Parse with 'T12:00:00' (local noon) so DST boundaries
22
+ // don't shift the date by a day.
23
+ function localDate(d) {
24
+ return d.toLocaleDateString('en-CA');
25
+ }
26
+
17
27
  function subtractDays(dateStr, days) {
18
- const d = new Date(dateStr + 'T00:00:00Z');
19
- d.setUTCDate(d.getUTCDate() - days);
20
- return d.toISOString().slice(0, 10);
28
+ const d = new Date(dateStr + 'T12:00:00');
29
+ d.setDate(d.getDate() - days);
30
+ return localDate(d);
21
31
  }
22
32
 
23
33
  async function parseJSONL(filePath, stat) {
@@ -69,10 +79,18 @@ async function parseJSONL(filePath, stat) {
69
79
 
70
80
  const usage = obj.usage ?? obj.message?.usage;
71
81
  if (usage && typeof usage === 'object') {
72
- if (typeof usage.inputTokens === 'number') acc.inputTokens += usage.inputTokens;
73
- if (typeof usage.outputTokens === 'number') acc.outputTokens += usage.outputTokens;
74
- if (typeof usage.cacheReadInputTokens === 'number') acc.cacheReadTokens += usage.cacheReadInputTokens;
75
- if (typeof usage.cacheCreationInputTokens === 'number') acc.cacheCreationTokens += usage.cacheCreationInputTokens;
82
+ // Claude Code JSONLs use snake_case (matching the Anthropic API). The
83
+ // previous camelCase-only check meant every token count read as 0.
84
+ // Accept both shapes for forward-compat with any future renderer-side
85
+ // emitter (live.ts already normalizes both).
86
+ const inT = usage.input_tokens ?? usage.inputTokens;
87
+ const outT = usage.output_tokens ?? usage.outputTokens;
88
+ const cacheR = usage.cache_read_input_tokens ?? usage.cacheReadInputTokens;
89
+ const cacheC = usage.cache_creation_input_tokens ?? usage.cacheCreationInputTokens;
90
+ if (typeof inT === 'number') acc.inputTokens += inT;
91
+ if (typeof outT === 'number') acc.outputTokens += outT;
92
+ if (typeof cacheR === 'number') acc.cacheReadTokens += cacheR;
93
+ if (typeof cacheC === 'number') acc.cacheCreationTokens += cacheC;
76
94
  }
77
95
 
78
96
  const content = obj.message?.content ?? obj.content;
@@ -96,10 +114,10 @@ async function parseJSONL(filePath, stat) {
96
114
 
97
115
  try {
98
116
  acc.sessionDate = firstTs
99
- ? new Date(firstTs).toISOString().slice(0, 10)
100
- : new Date(stat.mtimeMs).toISOString().slice(0, 10);
117
+ ? localDate(new Date(firstTs))
118
+ : localDate(new Date(stat.mtimeMs));
101
119
  } catch {
102
- acc.sessionDate = new Date(stat.mtimeMs).toISOString().slice(0, 10);
120
+ acc.sessionDate = localDate(new Date(stat.mtimeMs));
103
121
  }
104
122
 
105
123
  return acc;
@@ -126,8 +144,10 @@ async function parseConversationMeta(filePath, stat) {
126
144
  }
127
145
  const usage = obj.usage ?? obj.message?.usage;
128
146
  if (usage && typeof usage === 'object') {
129
- if (typeof usage.inputTokens === 'number') meta.inputTokens += usage.inputTokens;
130
- if (typeof usage.outputTokens === 'number') meta.outputTokens += usage.outputTokens;
147
+ const inT = usage.input_tokens ?? usage.inputTokens;
148
+ const outT = usage.output_tokens ?? usage.outputTokens;
149
+ if (typeof inT === 'number') meta.inputTokens += inT;
150
+ if (typeof outT === 'number') meta.outputTokens += outT;
131
151
  }
132
152
  }
133
153
  return meta;
@@ -142,7 +162,7 @@ function registerHistoryAggregatorHandlers() {
142
162
  const parsed = schemas.historyAggregate.safeParse(rawReq);
143
163
  const req = parsed.success ? (parsed.data ?? {}) : {};
144
164
  const t0 = Date.now();
145
- const today = new Date().toISOString().slice(0, 10);
165
+ const today = localDate(new Date());
146
166
  let effectiveTo = req?.toDate ? req.toDate : today;
147
167
  if (effectiveTo > today) effectiveTo = today;
148
168
  const effectiveFrom = req?.fromDate ? req.fromDate : subtractDays(today, 30);
@@ -185,7 +205,10 @@ function registerHistoryAggregatorHandlers() {
185
205
  if (parsed.skipped) { skippedLargeFiles++; continue; }
186
206
 
187
207
  const { sessionDate } = parsed;
188
- if (!sessionDate || sessionDate < effectiveFrom || sessionDate >= effectiveTo) continue;
208
+ // Inclusive upper bound `>=` here previously meant "today's data is
209
+ // always dropped", which combined with the (then-UTC) date bucket to
210
+ // hide a Pacific-time user's most recent activity entirely.
211
+ if (!sessionDate || sessionDate < effectiveFrom || sessionDate > effectiveTo) continue;
189
212
 
190
213
  const key = `${sessionDate}|${encodedCwd}`;
191
214
  if (!buckets.has(key)) {
@@ -34,7 +34,8 @@ const searchIpc = require('./search.cjs');
34
34
  const repoAnalyzer = require('./repoAnalyzer.cjs');
35
35
  const hivesIpc = require('./hives.cjs');
36
36
  const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
37
- const { assertCwdInsideHome } = require('./lib/insideHome.cjs');
37
+ const { checkInsideHome } = require('./lib/insideHome.cjs');
38
+ const { openInEditor, openFileInEditor, openInFinder, openInTerminal } = require('./lib/openExternalApp.cjs');
38
39
 
39
40
  let mainWindow = null;
40
41
  let rebooting = false;
@@ -81,7 +82,7 @@ function writeFirstPaintFailureLog() {
81
82
  const ymd = new Date().toISOString().slice(0, 10);
82
83
  const logPath = path.join(logDir, `boot-${ymd}.log`);
83
84
 
84
- const homeCheck = assertCwdInsideHome(os.homedir());
85
+ const homeCheck = checkInsideHome(os.homedir());
85
86
  const lines = [
86
87
  `=== first-paint deadline exceeded @ ${new Date().toISOString()} ===`,
87
88
  `process.versions: ${JSON.stringify(process.versions)}`,
@@ -464,12 +465,9 @@ ipcMain.handle('app:test-fire-hook', async (_e, payload) => {
464
465
  // for non-git dirs, detached HEAD, missing git, or timeouts so callers can
465
466
  // render `—` without branching on error shape. 1s ceiling keeps a wedged git
466
467
  // (network filesystem, hung index lock) from blocking the renderer.
467
- ipcMain.handle('app:git-branch', async (_e, payload) => {
468
- // safeParse (not throw) so a malformed call resolves to null — matches the
469
- // existing semantics where StatusBar renders `—` on any failure.
470
- const parsed = schemas.appGitBranch.safeParse(payload);
471
- if (!parsed.success) return null;
472
- const { cwd } = parsed.data;
468
+ ipcMain.handle('app:git-branch', validated(schemas.appGitBranch, async ({ cwd }) => {
469
+ // Both renderer callsites (AlmanacFooter, AlmanacSidebar) already `.catch`
470
+ // rejections and render `—`, so a ZodError throw is handled correctly.
473
471
  return await new Promise((resolve) => {
474
472
  execFile('git', ['branch', '--show-current'], { cwd, timeout: 1000, windowsHide: true }, (err, stdout) => {
475
473
  if (err) { resolve(null); return; }
@@ -477,152 +475,73 @@ ipcMain.handle('app:git-branch', async (_e, payload) => {
477
475
  resolve(out.length ? out : null);
478
476
  });
479
477
  });
480
- });
478
+ }));
481
479
 
482
- // Containment check used by the open-in-{editor,finder,terminal} handlers.
483
- // Bare `startsWith(home)` matches `/home/bilkoEVIL` when home=`/home/bilko`
484
- // the classic prefix-trap. Resolve real paths (follow symlinks) and require
485
- // either exact equality or a separator boundary. Returns null on success or
486
- // an error string on failure. ENOENT (cwd doesn't exist) is a soft fail —
487
- // downstream `spawn`/`shell.openPath` will surface a more precise error.
488
- function checkInsideHome(cwd) {
489
- if (typeof cwd !== 'string' || !cwd) return 'cwd outside home';
490
- const home = os.homedir();
491
- let realCwd;
492
- let realHome;
493
- try {
494
- realHome = fs.realpathSync(home);
495
- } catch {
496
- return 'home directory unresolved';
497
- }
498
- try {
499
- realCwd = fs.realpathSync(cwd);
500
- } catch (err) {
501
- if (err && err.code === 'ENOENT') {
502
- // Fall through to the existing error path: the resolved-but-nonexistent
503
- // path still has to be home-contained to avoid information disclosure
504
- // via stat-probes (`/etc/secrets/...` → editor errors that reveal
505
- // existence).
506
- const resolved = path.resolve(cwd);
507
- if (resolved !== realHome && !resolved.startsWith(realHome + path.sep)) {
508
- return 'cwd outside home';
509
- }
510
- return null;
511
- }
512
- return 'cwd outside home';
513
- }
514
- if (realCwd !== realHome && !realCwd.startsWith(realHome + path.sep)) {
515
- return 'cwd outside home';
516
- }
517
- return null;
518
- }
480
+ // Containment check for the open-in-{editor,finder,terminal} handlers lives
481
+ // in lib/insideHome.cjs single chokepoint for the /home/bilkoEVIL prefix-trap.
482
+ // Editor / finder / terminal logic lives in lib/openExternalApp.cjs.
519
483
 
520
- // Returns the resolved path of a command, or null if not found on PATH.
521
- function findCommand(name) {
522
- try {
523
- const out = execFileSync(
524
- process.platform === 'win32' ? 'where' : 'which',
525
- [name],
526
- { encoding: 'utf8', env: process.env, timeout: 500 },
527
- ).trim().split(/\r?\n/)[0];
528
- if (out) return out;
529
- } catch { /* not found */ }
530
- return null;
531
- }
484
+ ipcMain.handle('app:open-in-editor', validated(schemas.openInEditor, async ({ cwd, editor }) => {
485
+ const r = checkInsideHome(cwd);
486
+ if (!r.ok) throw new Error(r.error);
487
+ return openInEditor({ cwd, editor });
488
+ }));
532
489
 
533
- ipcMain.handle('app:open-in-editor', async (_e, payload) => {
534
- const { cwd, editor } = schemas.openInEditor.parse(payload);
535
- const err = checkInsideHome(cwd);
536
- if (err) throw new Error(err);
537
- const candidates = (editor && editor !== 'auto')
538
- ? [editor]
539
- : [process.env.VISUAL, process.env.EDITOR, 'code', 'cursor', 'subl', 'nano'].filter(Boolean);
540
- for (const cmd of candidates) {
541
- if (!findCommand(cmd)) continue;
542
- const child = spawn(cmd, [cwd], { detached: true, stdio: 'ignore', env: cleanChildEnv() });
543
- child.unref();
544
- return { ok: true, editor: cmd };
545
- }
546
- return { ok: false, error: 'no editor found' };
547
- });
548
-
549
- ipcMain.handle('app:open-external', async (_e, payload) => {
490
+ ipcMain.handle('app:open-external', validated(schemas.openExternal, async ({ url }) => {
550
491
  // URL filter mirrors setWindowOpenHandler at line ~631: without it, the
551
492
  // renderer could be tricked into asking shell.openExternal to launch
552
493
  // `file:///etc/passwd`, `javascript:…`, or `mailto:…`. Stick to web URLs.
553
- const { url } = schemas.openExternal.parse(payload);
554
494
  if (!url.startsWith('http://') && !url.startsWith('https://')) {
555
495
  return { ok: false, error: 'only http/https URLs are allowed' };
556
496
  }
557
497
  await shell.openExternal(url);
558
498
  return { ok: true };
559
- });
499
+ }));
560
500
 
561
- ipcMain.handle('app:open-file-in-editor', async (_e, payload) => {
562
- // Open a specific file (with optional line:col) in the user's editor.
563
- // Distinct from app:open-in-editor above which opens a project root.
564
- // GUI editors that support the goto-line flag (code/cursor/subl) get
565
- // `-g file:line:col`; everything else falls back to opening the file alone.
566
- const { path: p, line, col, editor } = schemas.openFileInEditor.parse(payload);
501
+ ipcMain.handle('app:open-file-in-editor', validated(schemas.openFileInEditor, async ({ path: p, line, col, editor }) => {
567
502
  const home = os.homedir();
568
503
  const abs = path.isAbsolute(p) ? p : path.resolve(home, p);
569
- const err = checkInsideHome(abs);
570
- if (err) throw new Error(err);
571
- try { await fsp.access(abs); } catch { return { ok: false, error: `file not found: ${abs}` }; }
572
- const candidates = (editor && editor !== 'auto')
573
- ? [editor]
574
- : [process.env.VISUAL, process.env.EDITOR, 'code', 'cursor', 'subl', 'nano'].filter(Boolean);
575
- for (const cmd of candidates) {
576
- if (!findCommand(cmd)) continue;
577
- const supportsGoto = /^(code|cursor|subl)$/.test(cmd);
578
- const target = (supportsGoto && line) ? `${abs}:${line}${col ? `:${col}` : ''}` : abs;
579
- const args = supportsGoto ? ['-g', target] : [abs];
580
- const child = spawn(cmd, args, { detached: true, stdio: 'ignore', env: cleanChildEnv() });
581
- child.unref();
582
- return { ok: true, editor: cmd };
583
- }
584
- return { ok: false, error: 'no editor found' };
585
- });
586
-
587
- ipcMain.handle('app:open-in-finder', async (_e, payload) => {
588
- const { cwd } = schemas.openInFinder.parse(payload);
589
- const err = checkInsideHome(cwd);
590
- if (err) throw new Error(err);
591
- await shell.openPath(cwd);
592
- return { ok: true };
593
- });
594
-
595
- ipcMain.handle('app:open-in-terminal', async (_e, payload) => {
596
- const { cwd } = schemas.openInTerminal.parse(payload);
597
- const err = checkInsideHome(cwd);
598
- if (err) throw new Error(err);
599
- if (process.platform === 'linux') {
600
- const terms = ['gnome-terminal', 'konsole', 'xfce4-terminal', 'xterm'];
601
- for (const t of terms) {
602
- if (!findCommand(t)) continue;
603
- const args = t === 'gnome-terminal'
604
- ? ['--working-directory=' + cwd]
605
- : ['-e', `bash -c "cd '${cwd.replace(/'/g, "'\\''")}' && exec bash"`];
606
- const child = spawn(t, args, { detached: true, stdio: 'ignore', env: cleanChildEnv() });
607
- child.unref();
608
- return { ok: true, terminal: t };
609
- }
610
- } else if (process.platform === 'darwin') {
611
- spawn('open', ['-a', 'Terminal', cwd], { detached: true, stdio: 'ignore', env: cleanChildEnv() }).unref();
612
- return { ok: true, terminal: 'Terminal.app' };
504
+ // Allowed roots: $HOME (the usual case) plus our own clipboard temp dir
505
+ // (clipboard-paste writes PNGs there; clicks on those paths from the
506
+ // terminal must resolve). Resolve symlinks on both sides on macOS
507
+ // /tmp is a symlink to /private/tmp, so a literal prefix check fails.
508
+ const clipboardDirRaw = path.join(os.tmpdir(), 'session-manager-clipboard');
509
+ let clipboardDirReal = clipboardDirRaw;
510
+ try { clipboardDirReal = fs.realpathSync(clipboardDirRaw); } catch { /* not yet created */ }
511
+ let absReal = abs;
512
+ try { absReal = fs.realpathSync(abs); } catch { /* file may not exist yet — fall through to access() below */ }
513
+ const inClipboardTmp =
514
+ absReal === clipboardDirReal ||
515
+ absReal.startsWith(clipboardDirReal + path.sep) ||
516
+ abs === clipboardDirRaw ||
517
+ abs.startsWith(clipboardDirRaw + path.sep);
518
+ if (!inClipboardTmp) {
519
+ const r = checkInsideHome(abs);
520
+ if (!r.ok) throw new Error(r.error);
613
521
  }
614
- return { ok: false, error: 'no terminal found' };
615
- });
616
-
617
- ipcMain.handle('app:archive-project', async (_e, payload) => {
618
- const { encoded } = schemas.archiveProject.parse(payload);
522
+ return openFileInEditor({ path: abs, line, col, editor });
523
+ }));
524
+
525
+ ipcMain.handle('app:open-in-finder', validated(schemas.openInFinder, async ({ cwd }) => {
526
+ const r = checkInsideHome(cwd);
527
+ if (!r.ok) throw new Error(r.error);
528
+ return openInFinder({ cwd });
529
+ }));
530
+
531
+ ipcMain.handle('app:open-in-terminal', validated(schemas.openInTerminal, async ({ cwd }) => {
532
+ const r = checkInsideHome(cwd);
533
+ if (!r.ok) throw new Error(r.error);
534
+ return openInTerminal({ cwd });
535
+ }));
536
+
537
+ ipcMain.handle('app:archive-project', validated(schemas.archiveProject, async ({ encoded }) => {
619
538
  const home = os.homedir();
620
539
  const src = path.join(home, '.claude', 'projects', encoded);
621
540
  const dst = path.join(home, '.claude', 'projects-archive', encoded);
622
541
  await fsp.mkdir(path.dirname(dst), { recursive: true });
623
542
  await fsp.rename(src, dst);
624
543
  return { ok: true };
625
- });
544
+ }));
626
545
 
627
546
  registerPtyHandlers();
628
547
  configMgr.registerConfigHandlers();
@@ -738,7 +657,7 @@ app.whenReady().then(async () => {
738
657
  // Boot-time detection 2: symlinked /Users on macOS can make os.homedir()
739
658
  // realpath to a path outside itself, which breaks every cwd containment
740
659
  // check downstream. Surface here rather than failing on first session spawn.
741
- bootHomeSelfCheck = assertCwdInsideHome(os.homedir());
660
+ bootHomeSelfCheck = checkInsideHome(os.homedir());
742
661
  if (!bootHomeSelfCheck.ok) {
743
662
  console.error(`[insideHome] SELF-CHECK FAILED: ${bootHomeSelfCheck.error}; sessions will not be able to spawn`);
744
663
  }
@@ -333,6 +333,9 @@ const PLUGIN_SLUG_RE = /^[a-z0-9\-/]+$/;
333
333
  const pluginsInstall = z.object({
334
334
  slug: z.string().regex(PLUGIN_SLUG_RE).min(1).max(128),
335
335
  }).passthrough();
336
+ const pluginsAbort = z.object({
337
+ slug: z.string().regex(PLUGIN_SLUG_RE).min(1).max(128),
338
+ }).passthrough();
336
339
 
337
340
  // SuperAgent — "boss" run that writes a structured prompt to the active
338
341
  // tab's PTY. Bounds match the inline schemas in superagent.cjs; centralizing
@@ -404,6 +407,7 @@ module.exports = {
404
407
  gitFileStatus,
405
408
  repoAnalyze,
406
409
  pluginsInstall,
410
+ pluginsAbort,
407
411
  superagentStart,
408
412
  superagentTabId,
409
413
  memoryList,
@@ -0,0 +1,218 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * childWithLog.cjs — spawn a child process with a log fd and watchdog timers.
5
+ *
6
+ * Two-phase API so callers can emit pre-spawn log lines (e.g. "starting …",
7
+ * early-exit error messages) before the child is created, while still having
8
+ * the helper own the fd lifecycle end-to-end.
9
+ *
10
+ * Phase 1 — openLog(logPath)
11
+ * Opens the log file in append mode. Returns { fd, safeLog, closeFd }.
12
+ * safeLog: idempotent after closeFd; never throws (guards EBADF on timers).
13
+ * closeFd: idempotent; closes the fd exactly once.
14
+ * After calling withChildAndLog(), do NOT call closeFd() yourself — the
15
+ * helper takes ownership and closes it once after onExit returns.
16
+ *
17
+ * Phase 2 — withChildAndLog({ fd, logPath, safeLog, closeFd, spawn, watchdogs, onExit })
18
+ * Spawns the child with stdio piped to the open fd.
19
+ * Manages the watchdog timer lifecycle:
20
+ * - Each watchdog uses setInterval. The interval auto-clears the first time
21
+ * shouldFire(ctx) returns true, then calls action(ctx).
22
+ * - action() may call ctx.addTimer(t) to register secondary timers
23
+ * (e.g. SIGTERM → SIGKILL cascades) so they are also cleared on exit.
24
+ * Calls onExit() synchronously inside the child 'exit'/'error' handler,
25
+ * before calling closeFd(). Caller may call ctx.safeLog() inside onExit.
26
+ *
27
+ * WatchdogContext:
28
+ * child — the ChildProcess (null if spawn threw synchronously)
29
+ * logPath — string, used by watchdogs that stat the log file
30
+ * startedAt — Date.now() at spawn time
31
+ * safeLog — same safeLog returned by openLog()
32
+ * killedByWatchdog — string | null, mutable; set by action() to record which
33
+ * watchdog issued the kill
34
+ * killTree(sig) — sends sig to the process group (falls back to child.kill)
35
+ * addTimer(t) — registers a secondary timer (clearTimeout-compatible)
36
+ *
37
+ * onExit info:
38
+ * exitCode — number | null (−1 on spawn failure or child 'error' event)
39
+ * signal — string | null
40
+ * killedByWatchdog — string | null
41
+ * error — Error | undefined (child 'error' event or sync spawn throw)
42
+ * spawnFailed — boolean, true when the error came from a synchronous spawn throw
43
+ * safeLog — same safeLog (usable inside onExit for final log lines)
44
+ *
45
+ * Returns { child, cancel } where cancel() clears all timers + SIGKILLs.
46
+ * child is null when the synchronous spawn threw (onExit has already been called).
47
+ */
48
+
49
+ const fs = require('node:fs');
50
+ const { spawn } = require('node:child_process');
51
+
52
+ // ---------- Phase 1 ----------
53
+
54
+ /**
55
+ * Open a log file in append mode. Returns { fd, safeLog, closeFd }.
56
+ *
57
+ * @param {string} logPath
58
+ * @returns {{ fd: number, safeLog: (msg: string) => void, closeFd: () => void }}
59
+ */
60
+ function openLog(logPath) {
61
+ const fd = fs.openSync(logPath, 'a');
62
+ let fdClosed = false;
63
+
64
+ const closeFd = () => {
65
+ if (fdClosed) return;
66
+ fdClosed = true;
67
+ try { fs.closeSync(fd); } catch { /* already closed */ }
68
+ };
69
+
70
+ // safeLog: no-op once the fd is closed, never throws.
71
+ // Pre-fix for EBADF: a watchdog timer firing after closeFd would throw and
72
+ // crash the host if we used fs.writeSync directly.
73
+ const safeLog = (msg) => {
74
+ if (fdClosed) return;
75
+ try { fs.writeSync(fd, msg); } catch { /* fd vanished mid-write */ }
76
+ };
77
+
78
+ return { fd, safeLog, closeFd };
79
+ }
80
+
81
+ // ---------- Phase 2 ----------
82
+
83
+ /**
84
+ * @typedef {Object} WatchdogSpec
85
+ * @property {string} label
86
+ * @property {number} intervalMs — poll interval
87
+ * @property {(ctx: WatchdogContext) => boolean} shouldFire — return true to
88
+ * fire action + auto-clear the interval (fire-once-on-condition)
89
+ * @property {(ctx: WatchdogContext) => void} action
90
+ */
91
+
92
+ /**
93
+ * @typedef {Object} WatchdogContext
94
+ * @property {import('child_process').ChildProcess | null} child
95
+ * @property {string} logPath
96
+ * @property {number} startedAt
97
+ * @property {(msg: string) => void} safeLog
98
+ * @property {string | null} killedByWatchdog
99
+ * @property {(signal: string) => boolean} killTree
100
+ * @property {(timer: any) => void} addTimer
101
+ */
102
+
103
+ /**
104
+ * @param {{
105
+ * fd: number,
106
+ * logPath: string,
107
+ * safeLog: (msg: string) => void,
108
+ * closeFd: () => void,
109
+ * spawn: { command: string, args: string[], options: object },
110
+ * watchdogs?: WatchdogSpec[],
111
+ * onExit?: (info: {
112
+ * exitCode: number | null,
113
+ * signal: string | null,
114
+ * killedByWatchdog: string | null,
115
+ * error?: Error,
116
+ * spawnFailed?: boolean,
117
+ * safeLog: (msg: string) => void,
118
+ * }) => void,
119
+ * }} opts
120
+ * @returns {{ child: import('child_process').ChildProcess | null, cancel: () => void }}
121
+ */
122
+ function withChildAndLog({ fd, logPath, safeLog, closeFd, spawn: spawnSpec, watchdogs = [], onExit }) {
123
+ const startedAt = Date.now();
124
+
125
+ // Secondary timers registered by watchdog actions (e.g. SIGTERM → SIGKILL cascades).
126
+ // Cleared alongside the primary watchdog timers on child exit.
127
+ // clearTimeout works on both Timeout and Interval handles in Node.js.
128
+ const extraTimers = [];
129
+
130
+ /** @type {WatchdogContext} */
131
+ const ctx = {
132
+ child: null,
133
+ logPath,
134
+ startedAt,
135
+ safeLog,
136
+ killedByWatchdog: null,
137
+ killTree(signal) {
138
+ const c = ctx.child;
139
+ if (!c) return false;
140
+ // Negative pid targets the whole process group (requires detached: true at spawn).
141
+ // Falls back to direct kill if the process is not a group leader.
142
+ try { process.kill(-c.pid, signal); return true; }
143
+ catch {
144
+ try { process.kill(c.pid, signal); return true; }
145
+ catch { return false; /* already dead */ }
146
+ }
147
+ },
148
+ addTimer(t) { extraTimers.push(t); },
149
+ };
150
+
151
+ // Build interval-based watchdog timers. Each fires every intervalMs; the
152
+ // first time shouldFire(ctx) returns true, the interval is auto-cleared and
153
+ // action(ctx) is called (fire-once-on-condition semantics).
154
+ //
155
+ // O(|watchdogs|) setup, O(1) per poll tick.
156
+ const clearFns = watchdogs.map((wd) => {
157
+ const t = setInterval(() => {
158
+ if (wd.shouldFire(ctx)) {
159
+ clearInterval(t);
160
+ wd.action(ctx);
161
+ }
162
+ }, wd.intervalMs);
163
+ if (t.unref) t.unref();
164
+ return () => clearInterval(t);
165
+ });
166
+
167
+ const clearAllTimers = () => {
168
+ clearFns.forEach((fn) => fn());
169
+ extraTimers.forEach((t) => clearTimeout(t));
170
+ };
171
+
172
+ // handleDone: called from both 'error' and 'exit' handlers. Clears timers,
173
+ // calls onExit (caller may still safeLog inside), then closes the fd.
174
+ const handleDone = (exitCode, signal, error, spawnFailed) => {
175
+ clearAllTimers();
176
+ if (onExit) {
177
+ onExit({
178
+ exitCode,
179
+ signal: signal ?? null,
180
+ killedByWatchdog: ctx.killedByWatchdog,
181
+ error,
182
+ spawnFailed: spawnFailed ?? false,
183
+ safeLog,
184
+ });
185
+ }
186
+ closeFd();
187
+ };
188
+
189
+ // Attempt synchronous spawn. On failure, call handleDone immediately so the
190
+ // caller's onExit can resolve any pending Promise before we return null.
191
+ let child;
192
+ try {
193
+ child = spawn(spawnSpec.command, spawnSpec.args, {
194
+ ...spawnSpec.options,
195
+ stdio: ['ignore', fd, fd],
196
+ });
197
+ } catch (e) {
198
+ handleDone(-1, null, e, /* spawnFailed */ true);
199
+ return { child: null, cancel: () => {} };
200
+ }
201
+
202
+ ctx.child = child;
203
+
204
+ child.on('error', (err) => handleDone(-1, null, err, false));
205
+ child.on('exit', (code, signal) => handleDone(code, signal, undefined, false));
206
+
207
+ const cancel = () => {
208
+ clearAllTimers();
209
+ // Best-effort kill; ignore errors (process may already be gone).
210
+ try { process.kill(-child.pid, 'SIGKILL'); } catch {
211
+ try { child.kill('SIGKILL'); } catch { /* */ }
212
+ }
213
+ };
214
+
215
+ return { child, cancel };
216
+ }
217
+
218
+ module.exports = { openLog, withChildAndLog };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * expandHome — tilde expansion. `~` and `~/foo` become $HOME and $HOME/foo.
3
+ * Everything else is returned unchanged. Used by files.cjs, config.cjs,
4
+ * search.cjs, queueOps.cjs — pulled out into a single helper to keep the
5
+ * containment-check grep in insideHome.cjs clean (`startsWith('~/')` followed
6
+ * by `homedir()` on the same line would otherwise create false positives in
7
+ * the security-audit grep).
8
+ */
9
+ 'use strict';
10
+
11
+ const os = require('node:os');
12
+ const path = require('node:path');
13
+
14
+ function expandHome(p) {
15
+ if (!p) return p;
16
+ if (p === '~') return os.homedir();
17
+ if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
18
+ return p;
19
+ }
20
+
21
+ module.exports = { expandHome };