claude-code-session-manager 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{TiptapBody-ZBlGHTXg.js → TiptapBody-CzNkD0kT.js} +1 -1
- package/dist/assets/{cssMode-Co9Kuq5g.js → cssMode-DUMBBgBO.js} +1 -1
- package/dist/assets/{freemarker2-B9Y0PjVt.js → freemarker2-BjnoO8uS.js} +1 -1
- package/dist/assets/{handlebars-BGL0s1rN.js → handlebars-CggfdTjZ.js} +1 -1
- package/dist/assets/{html-C8BuG-2M.js → html-Bcnp_pNA.js} +1 -1
- package/dist/assets/{htmlMode-BfCigMol.js → htmlMode-CFws8rf5.js} +1 -1
- package/dist/assets/{index-CkI_4Vt7.js → index-0geebEag.js} +1684 -1383
- package/dist/assets/{index-DewIHWBs.css → index-DOo1Vtox.css} +1 -1
- package/dist/assets/{javascript-sES7Ewin.js → javascript-DI4e0TwT.js} +1 -1
- package/dist/assets/{jsonMode-C7txz4wN.js → jsonMode-Ca0fgEAF.js} +1 -1
- package/dist/assets/{liquid-B4qxgigx.js → liquid-E7hk4hx9.js} +1 -1
- package/dist/assets/{lspLanguageFeatures-B5xgGS7m.js → lspLanguageFeatures-Wh7EFavn.js} +1 -1
- package/dist/assets/{mdx-De-8TTNY.js → mdx-iXIhpMBJ.js} +1 -1
- package/dist/assets/{python-SfayDkVl.js → python-laxy9eG1.js} +1 -1
- package/dist/assets/{razor-BRCdaeYo.js → razor-CatPL_eG.js} +1 -1
- package/dist/assets/{tsMode-DBTPpTr8.js → tsMode-D2WL55Cf.js} +1 -1
- package/dist/assets/{typescript-Bxbq3Vd7.js → typescript-B5DFbVoi.js} +1 -1
- package/dist/assets/{xml-DQBPiVNP.js → xml-BsStStQW.js} +1 -1
- package/dist/assets/{yaml-lyIgZXXz.js → yaml-rg2IIDpu.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +2 -1
- package/src/main/__tests__/runVerify.test.cjs +388 -0
- package/src/main/config.cjs +1 -7
- package/src/main/files.cjs +4 -37
- package/src/main/historyAggregator.cjs +37 -14
- package/src/main/index.cjs +53 -134
- package/src/main/ipcSchemas.cjs +4 -0
- package/src/main/lib/childWithLog.cjs +218 -0
- package/src/main/lib/expandHome.cjs +21 -0
- package/src/main/lib/insideHome.cjs +74 -21
- package/src/main/lib/openExternalApp.cjs +141 -0
- package/src/main/pluginInstall.cjs +39 -1
- package/src/main/pty.cjs +11 -4
- package/src/main/queueOps.cjs +2 -2
- package/src/main/runVerify.cjs +527 -0
- package/src/main/scheduler.cjs +335 -286
- package/src/main/search.cjs +1 -6
- package/src/main/superagent.cjs +10 -0
- package/src/main/supervisor.cjs +16 -8
- package/src/main/transcripts.cjs +6 -0
- package/src/main/watchers.cjs +2 -2
- package/src/preload/api.d.ts +16 -7
- 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 + '
|
|
19
|
-
d.
|
|
20
|
-
return d
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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)
|
|
100
|
-
: new Date(stat.mtimeMs)
|
|
117
|
+
? localDate(new Date(firstTs))
|
|
118
|
+
: localDate(new Date(stat.mtimeMs));
|
|
101
119
|
} catch {
|
|
102
|
-
acc.sessionDate = new Date(stat.mtimeMs)
|
|
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
|
-
|
|
130
|
-
|
|
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()
|
|
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
|
-
|
|
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)) {
|
package/src/main/index.cjs
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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 (
|
|
468
|
-
//
|
|
469
|
-
//
|
|
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
|
|
483
|
-
//
|
|
484
|
-
//
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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-
|
|
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 (
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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 {
|
|
615
|
-
});
|
|
616
|
-
|
|
617
|
-
ipcMain.handle('app:
|
|
618
|
-
const
|
|
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 =
|
|
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
|
}
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -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 };
|