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.
Files changed (43) hide show
  1. package/dist/assets/{TiptapBody-ZBlGHTXg.js → TiptapBody-CzNkD0kT.js} +1 -1
  2. package/dist/assets/{cssMode-Co9Kuq5g.js → cssMode-DUMBBgBO.js} +1 -1
  3. package/dist/assets/{freemarker2-B9Y0PjVt.js → freemarker2-BjnoO8uS.js} +1 -1
  4. package/dist/assets/{handlebars-BGL0s1rN.js → handlebars-CggfdTjZ.js} +1 -1
  5. package/dist/assets/{html-C8BuG-2M.js → html-Bcnp_pNA.js} +1 -1
  6. package/dist/assets/{htmlMode-BfCigMol.js → htmlMode-CFws8rf5.js} +1 -1
  7. package/dist/assets/{index-CkI_4Vt7.js → index-0geebEag.js} +1684 -1383
  8. package/dist/assets/{index-DewIHWBs.css → index-DOo1Vtox.css} +1 -1
  9. package/dist/assets/{javascript-sES7Ewin.js → javascript-DI4e0TwT.js} +1 -1
  10. package/dist/assets/{jsonMode-C7txz4wN.js → jsonMode-Ca0fgEAF.js} +1 -1
  11. package/dist/assets/{liquid-B4qxgigx.js → liquid-E7hk4hx9.js} +1 -1
  12. package/dist/assets/{lspLanguageFeatures-B5xgGS7m.js → lspLanguageFeatures-Wh7EFavn.js} +1 -1
  13. package/dist/assets/{mdx-De-8TTNY.js → mdx-iXIhpMBJ.js} +1 -1
  14. package/dist/assets/{python-SfayDkVl.js → python-laxy9eG1.js} +1 -1
  15. package/dist/assets/{razor-BRCdaeYo.js → razor-CatPL_eG.js} +1 -1
  16. package/dist/assets/{tsMode-DBTPpTr8.js → tsMode-D2WL55Cf.js} +1 -1
  17. package/dist/assets/{typescript-Bxbq3Vd7.js → typescript-B5DFbVoi.js} +1 -1
  18. package/dist/assets/{xml-DQBPiVNP.js → xml-BsStStQW.js} +1 -1
  19. package/dist/assets/{yaml-lyIgZXXz.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
@@ -1,16 +1,25 @@
1
1
  /**
2
- * insideHome.cjs — security invariant: every renderer-controlled cwd must
3
- * resolve (after realpath, symlink-safe) to a path inside the user's home
4
- * directory. Four call sites used to reimplement this: pty.cjs spawn,
5
- * watchers.cjs add, index.cjs checkInsideHome, ipcSchemas.cjs setConfigSchema
6
- * defaultCwd refine. The check is a single chokepoint here.
2
+ * insideHome.cjs — single chokepoint for "is this renderer-controlled path
3
+ * inside $HOME?" Security boundary: bare `startsWith(home)` matches
4
+ * `/home/bilkoEVIL` when home=`/home/bilko` (the classic prefix-trap). Both
5
+ * the target and the home directory are resolved via realpath, and the
6
+ * boundary is either exact equality or a path-separator boundary.
7
7
  *
8
- * Returns { ok: true, realCwd } on success or { ok: false, error } on rejection.
9
- * Realpath of the cwd is returned so callers can use it without recomputing.
8
+ * Two exports, two policies:
9
+ * assertInsideHome(p) { realPath } — throws on failure
10
+ * checkInsideHome(p) → { ok, realPath } | { ok, error } — never throws
10
11
  *
11
- * NOTE: schemas can't easily call this (zod refines are inline); they retain
12
- * a simpler `startsWith` check that does NOT symlink-resolve. The runtime
13
- * resolution here is the authoritative one schemas are belt-and-suspenders.
12
+ * Both follow the same algorithm:
13
+ * 1. realpath the target. On ENOENT, fall back to path.resolve(p) so callers
14
+ * can validate "this is where I'm about to write" before the file exists.
15
+ * 2. Compare against the cached realpath of os.homedir() with the
16
+ * equality-or-separator boundary check.
17
+ *
18
+ * The cached home realpath is computed once at module load. If $HOME can't be
19
+ * resolved at startup we keep going with the literal value — boot-time
20
+ * self-check in index.cjs surfaces that to the user.
21
+ *
22
+ * Linux + darwin only. No Windows path handling.
14
23
  */
15
24
  'use strict';
16
25
 
@@ -18,21 +27,65 @@ const fs = require('node:fs');
18
27
  const path = require('node:path');
19
28
  const os = require('node:os');
20
29
 
21
- function assertCwdInsideHome(cwd) {
22
- if (typeof cwd !== 'string' || !cwd) {
23
- return { ok: false, error: 'cwd must be a non-empty string' };
30
+ // Resolve home once. Falls back to literal homedir if realpath fails
31
+ // (which would be a misconfigured system; boot self-check in index.cjs
32
+ // surfaces it). Cached for the lifetime of the process — home doesn't
33
+ // move under us at runtime.
34
+ const HOME_LITERAL = os.homedir();
35
+ let HOME_REAL;
36
+ try {
37
+ HOME_REAL = fs.realpathSync(HOME_LITERAL);
38
+ } catch {
39
+ HOME_REAL = HOME_LITERAL;
40
+ }
41
+
42
+ function resolveTarget(p) {
43
+ // realpath fails with ENOENT for paths that don't exist yet (writes,
44
+ // mkdir destinations). Fall through to lexical resolve so the containment
45
+ // check still applies — prevents info-disclosure via stat probes on
46
+ // /etc/secrets/... that would be rejected later but reveal existence first.
47
+ try {
48
+ return fs.realpathSync(p);
49
+ } catch (err) {
50
+ if (err && err.code === 'ENOENT') return path.resolve(p);
51
+ throw err;
52
+ }
53
+ }
54
+
55
+ function isContained(realPath) {
56
+ return realPath === HOME_REAL || realPath.startsWith(HOME_REAL + path.sep);
57
+ }
58
+
59
+ function assertInsideHome(p) {
60
+ if (typeof p !== 'string' || !p) {
61
+ throw new Error('path outside home');
62
+ }
63
+ let realPath;
64
+ try {
65
+ realPath = resolveTarget(p);
66
+ } catch {
67
+ throw new Error('path outside home');
68
+ }
69
+ if (!isContained(realPath)) {
70
+ throw new Error('path outside home');
71
+ }
72
+ return { realPath };
73
+ }
74
+
75
+ function checkInsideHome(p) {
76
+ if (typeof p !== 'string' || !p) {
77
+ return { ok: false, error: 'path outside home' };
24
78
  }
25
- const home = os.homedir();
26
- let realCwd;
79
+ let realPath;
27
80
  try {
28
- realCwd = fs.realpathSync(cwd);
81
+ realPath = resolveTarget(p);
29
82
  } catch {
30
- realCwd = path.resolve(cwd);
83
+ return { ok: false, error: 'path outside home' };
31
84
  }
32
- if (realCwd !== home && !realCwd.startsWith(home + path.sep)) {
33
- return { ok: false, error: `cwd outside home directory: ${realCwd}` };
85
+ if (!isContained(realPath)) {
86
+ return { ok: false, error: 'path outside home' };
34
87
  }
35
- return { ok: true, realCwd };
88
+ return { ok: true, realPath };
36
89
  }
37
90
 
38
- module.exports = { assertCwdInsideHome };
91
+ module.exports = { assertInsideHome, checkInsideHome };
@@ -0,0 +1,141 @@
1
+ /**
2
+ * openExternalApp.cjs — helpers for launching editor / file manager / terminal.
3
+ *
4
+ * Security invariant: callers (IPC handlers in index.cjs) MUST run containment
5
+ * checks before delegating here. These functions receive already-validated paths.
6
+ *
7
+ * All four functions return:
8
+ * { ok: true; opener: string } — name of the program that was launched
9
+ * { ok: false; error: string } — human-readable failure reason
10
+ */
11
+ 'use strict';
12
+
13
+ const { execFileSync, spawn } = require('node:child_process');
14
+ const path = require('node:path');
15
+ const fsp = require('node:fs/promises');
16
+ const { shell } = require('electron');
17
+ const { cleanChildEnv } = require('./cleanEnv.cjs');
18
+
19
+ // IMAGE_RE — extensions opened in the OS default viewer, not a code editor.
20
+ // Keep in sync with the comment in index.cjs open-file-in-editor handler.
21
+ const IMAGE_RE = /\.(png|jpe?g|gif|webp|bmp|svg|tiff?|avif|heic|ico)$/i;
22
+
23
+ // EDITOR_CANDIDATES — single source for the default editor resolution order (AC #5).
24
+ // process.env.VISUAL / EDITOR are read lazily so they pick up any runtime overrides.
25
+ const editorCandidates = () =>
26
+ [process.env.VISUAL, process.env.EDITOR, 'code', 'cursor', 'subl', 'nano'].filter(Boolean);
27
+
28
+ /**
29
+ * Returns the resolved path of a command, or null if not found on $PATH.
30
+ * Moved here from index.cjs (was the only caller of the local helper there).
31
+ */
32
+ function findCommand(name) {
33
+ try {
34
+ const out = execFileSync(
35
+ process.platform === 'win32' ? 'where' : 'which',
36
+ [name],
37
+ { encoding: 'utf8', env: process.env, timeout: 500 },
38
+ ).trim().split(/\r?\n/)[0];
39
+ if (out) return out;
40
+ } catch { /* not found */ }
41
+ return null;
42
+ }
43
+
44
+ /**
45
+ * Open a project root directory in the user's editor.
46
+ *
47
+ * @param {{ cwd: string, editor?: string | null }} opts
48
+ * @returns {Promise<{ ok: true, opener: string } | { ok: false, error: string }>}
49
+ */
50
+ async function openInEditor({ cwd, editor }) {
51
+ const candidates = (editor && editor !== 'auto') ? [editor] : editorCandidates();
52
+ for (const cmd of candidates) {
53
+ if (!findCommand(cmd)) continue;
54
+ spawn(cmd, [cwd], { detached: true, stdio: 'ignore', env: cleanChildEnv() }).unref();
55
+ return { ok: true, opener: cmd };
56
+ }
57
+ return { ok: false, error: 'no editor found' };
58
+ }
59
+
60
+ /**
61
+ * Open a specific file (with optional line:col) in the user's editor.
62
+ * Image files are routed to the OS default viewer via shell.openPath.
63
+ *
64
+ * The path MUST already have passed containment checks at the IPC boundary.
65
+ * `abs` must be an absolute path.
66
+ *
67
+ * Goto-line flag behavior (AC #6):
68
+ * code | cursor | subl → spawn with ['-g', 'file:line:col']
69
+ * everything else → spawn with ['file'] (bare path, no goto support)
70
+ *
71
+ * @param {{ path: string, line?: number, col?: number, editor?: string | null }} opts
72
+ * @returns {Promise<{ ok: true, opener: string } | { ok: false, error: string }>}
73
+ */
74
+ async function openFileInEditor({ path: abs, line, col, editor }) {
75
+ try { await fsp.access(abs); } catch { return { ok: false, error: `file not found: ${abs}` }; }
76
+
77
+ // Image fast path — use the OS default viewer instead of a code editor.
78
+ // path.basename avoids .tar.gz-style extension ambiguities (AC implementation note).
79
+ if (IMAGE_RE.test(path.basename(abs))) {
80
+ const errStr = await shell.openPath(abs);
81
+ if (errStr) return { ok: false, error: errStr };
82
+ return { ok: true, opener: 'shell' };
83
+ }
84
+
85
+ const candidates = (editor && editor !== 'auto') ? [editor] : editorCandidates();
86
+ for (const cmd of candidates) {
87
+ if (!findCommand(cmd)) continue;
88
+ // Only code/cursor/subl understand the -g goto-line flag (AC #6).
89
+ const supportsGoto = /^(code|cursor|subl)$/.test(cmd);
90
+ const target = (supportsGoto && line) ? `${abs}:${line}${col ? `:${col}` : ''}` : abs;
91
+ const args = supportsGoto ? ['-g', target] : [abs];
92
+ spawn(cmd, args, { detached: true, stdio: 'ignore', env: cleanChildEnv() }).unref();
93
+ return { ok: true, opener: cmd };
94
+ }
95
+ return { ok: false, error: 'no editor found' };
96
+ }
97
+
98
+ /**
99
+ * Open a directory in the OS file manager (Finder on macOS, Nautilus etc on Linux).
100
+ *
101
+ * @param {{ cwd: string }} opts
102
+ * @returns {Promise<{ ok: true, opener: string } | { ok: false, error: string }>}
103
+ */
104
+ async function openInFinder({ cwd }) {
105
+ const errStr = await shell.openPath(cwd);
106
+ if (errStr) return { ok: false, error: errStr };
107
+ return { ok: true, opener: 'shell' };
108
+ }
109
+
110
+ /**
111
+ * Open a terminal emulator in the given directory.
112
+ *
113
+ * Linux: tries gnome-terminal → konsole → xfce4-terminal → xterm.
114
+ * macOS: delegates to Terminal.app via `open -a`.
115
+ *
116
+ * Arg shapes per terminal are preserved verbatim from index.cjs (AC impl note):
117
+ * gnome-terminal → ['--working-directory=<cwd>']
118
+ * others → ['-e', "bash -c \"cd '<cwd>' && exec bash\""]
119
+ *
120
+ * @param {{ cwd: string }} opts
121
+ * @returns {Promise<{ ok: true, opener: string } | { ok: false, error: string }>}
122
+ */
123
+ async function openInTerminal({ cwd }) {
124
+ if (process.platform === 'linux') {
125
+ const terms = ['gnome-terminal', 'konsole', 'xfce4-terminal', 'xterm'];
126
+ for (const t of terms) {
127
+ if (!findCommand(t)) continue;
128
+ const args = t === 'gnome-terminal'
129
+ ? ['--working-directory=' + cwd]
130
+ : ['-e', `bash -c "cd '${cwd.replace(/'/g, "'\\''")}' && exec bash"`];
131
+ spawn(t, args, { detached: true, stdio: 'ignore', env: cleanChildEnv() }).unref();
132
+ return { ok: true, opener: t };
133
+ }
134
+ } else if (process.platform === 'darwin') {
135
+ spawn('open', ['-a', 'Terminal', cwd], { detached: true, stdio: 'ignore', env: cleanChildEnv() }).unref();
136
+ return { ok: true, opener: 'Terminal.app' };
137
+ }
138
+ return { ok: false, error: 'no terminal found' };
139
+ }
140
+
141
+ module.exports = { findCommand, openInEditor, openFileInEditor, openInFinder, openInTerminal };
@@ -28,6 +28,7 @@ const { schemas } = require('./ipcSchemas.cjs');
28
28
  const SLUG_RE = /^[a-z0-9\-/]+$/;
29
29
  const MAX_LINE_BYTES = 16 * 1024;
30
30
  const KILL_AFTER_MS = 5 * 60 * 1000; // 5 min hard ceiling per install
31
+ const KILL_GRACE_MS = 5_000; // SIGTERM → SIGKILL escalation window
31
32
 
32
33
  let mainWindow = null;
33
34
  const inFlight = new Map(); // slug -> proc
@@ -81,9 +82,28 @@ function install({ slug }) {
81
82
  inFlight.set(slug, proc);
82
83
 
83
84
  let lineBuf = '';
85
+ let settled = false;
86
+
84
87
  const killTimer = setTimeout(() => {
85
- try { proc.kill(); } catch { /* */ }
88
+ try { proc.kill('SIGTERM'); } catch { /* */ }
89
+ // Escalate to SIGKILL after KILL_GRACE_MS if the pty hasn't exited.
90
+ const escalate = setTimeout(() => {
91
+ try { proc.kill('SIGKILL'); } catch { /* already dead */ }
92
+ }, KILL_GRACE_MS);
93
+ if (escalate.unref) escalate.unref();
86
94
  }, KILL_AFTER_MS);
95
+ if (killTimer.unref) killTimer.unref();
96
+
97
+ // Belt-and-suspenders: if onExit never fires (broken pty event path after
98
+ // SIGKILL — analogous to anthropics/claude-code #61735's unreachable pts),
99
+ // force-release the inFlight lock so the slug isn't permanently stuck.
100
+ const deadman = setTimeout(() => {
101
+ if (settled) return;
102
+ settled = true;
103
+ inFlight.delete(slug);
104
+ resolve({ ok: false, exitCode: -1, error: 'install hung — pty onExit never fired' });
105
+ }, KILL_AFTER_MS + KILL_GRACE_MS + 30_000);
106
+ if (deadman.unref) deadman.unref();
87
107
 
88
108
  proc.onData((data) => {
89
109
  lineBuf += data;
@@ -101,7 +121,10 @@ function install({ slug }) {
101
121
  });
102
122
 
103
123
  proc.onExit(({ exitCode }) => {
124
+ if (settled) return;
125
+ settled = true;
104
126
  clearTimeout(killTimer);
127
+ clearTimeout(deadman);
105
128
  if (lineBuf.length > 0) {
106
129
  send('plugins:install-progress', { slug, line: lineBuf });
107
130
  lineBuf = '';
@@ -124,6 +147,21 @@ function registerPluginInstallHandlers() {
124
147
  }
125
148
  return install({ slug: parsed.data.slug });
126
149
  });
150
+
151
+ // plugins:abort — send SIGKILL to a stuck install and release the inFlight
152
+ // lock immediately, analogous to pty:kill. UI wiring is a follow-up PRD.
153
+ ipcMain.handle('plugins:abort', async (_e, payload) => {
154
+ const parsed = schemas.pluginsAbort.safeParse(payload);
155
+ if (!parsed.success) {
156
+ return { ok: false, error: 'invalid slug' };
157
+ }
158
+ const { slug } = parsed.data;
159
+ const proc = inFlight.get(slug);
160
+ if (!proc) return { ok: false, error: 'no install in progress for slug' };
161
+ try { proc.kill('SIGKILL'); } catch { /* */ }
162
+ inFlight.delete(slug);
163
+ return { ok: true };
164
+ });
127
165
  }
128
166
 
129
167
  module.exports = { registerPluginInstallHandlers, attachWindow };
package/src/main/pty.cjs CHANGED
@@ -5,7 +5,7 @@ const os = require('node:os');
5
5
  const fs = require('node:fs');
6
6
  const { addAllowedRoot } = require('./config.cjs');
7
7
  const { cleanChildEnv } = require('./lib/cleanEnv.cjs');
8
- const { assertCwdInsideHome } = require('./lib/insideHome.cjs');
8
+ const { checkInsideHome } = require('./lib/insideHome.cjs');
9
9
  const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
10
10
 
11
11
  /**
@@ -28,9 +28,9 @@ class PtyManager {
28
28
 
29
29
  // Validate that cwd is inside homedir before widening the allowed-root set.
30
30
  if (cwd) {
31
- const r = assertCwdInsideHome(cwd);
31
+ const r = checkInsideHome(cwd);
32
32
  if (!r.ok) throw new Error(`pty ${r.error}`);
33
- addAllowedRoot(r.realCwd);
33
+ addAllowedRoot(r.realPath);
34
34
  }
35
35
 
36
36
  // Idempotent reattach: renderer reloads (HMR/Ctrl+R) re-run App.tsx's
@@ -164,6 +164,9 @@ class PtyManager {
164
164
  }
165
165
  this.sessions.delete(tabId);
166
166
  }
167
+ // Always drop superagent run state — a run can be started before the pty
168
+ // finishes spawning, so clean up regardless of whether a session existed.
169
+ try { require('./superagent.cjs').dropTab(tabId); } catch { /* superagent not loaded (e2e) */ }
167
170
  }
168
171
 
169
172
  killAll() {
@@ -178,7 +181,11 @@ function registerPtyHandlers() {
178
181
  ipcMain.handle('pty:spawn', v(s.ptySpawn, (payload) => manager.spawn(payload)));
179
182
  ipcMain.on('pty:write', (_e, payload) => { try { manager.write(s.ptyWrite.parse(payload)); } catch { /* ignore */ } });
180
183
  ipcMain.on('pty:resize', (_e, payload) => { try { manager.resize(s.ptyResize.parse(payload)); } catch { /* ignore */ } });
181
- ipcMain.on('pty:kill', (_e, tabId) => { if (typeof tabId === 'string') manager.kill(tabId); });
184
+ ipcMain.on('pty:kill', (_e, tabId) => {
185
+ if (typeof tabId !== 'string') return;
186
+ manager.kill(tabId);
187
+ try { require('./superagent.cjs').dropTab(tabId); } catch { /* superagent module not initialized (e2e) */ }
188
+ });
182
189
  }
183
190
 
184
191
  module.exports = { manager, registerPtyHandlers };
@@ -37,6 +37,7 @@ const { ipcMain } = require('electron');
37
37
  const { SCHEDULE_SLUG_RE: SLUG_RE, schemas } = require('./ipcSchemas.cjs');
38
38
  const logs = require('./logs.cjs');
39
39
  const config = require('./config.cjs');
40
+ const { expandHome } = require('./lib/expandHome.cjs');
40
41
 
41
42
  const ROOT = path.join(os.homedir(), '.claude', 'session-manager', 'scheduled-plans');
42
43
  const PRDS_DIR = path.join(ROOT, 'prds');
@@ -114,8 +115,7 @@ function lintParsed(slug, raw) {
114
115
  findings.push({ rule: 'missing-cwd', line: 1, snippet: 'frontmatter "cwd" is required', severity: 'error' });
115
116
  } else {
116
117
  // cwd existence — only if it looks like an absolute path. ~ -> homedir.
117
- let candidate = fm.cwd;
118
- if (candidate.startsWith('~/')) candidate = path.join(os.homedir(), candidate.slice(2));
118
+ const candidate = expandHome(fm.cwd);
119
119
  if (path.isAbsolute(candidate)) {
120
120
  try {
121
121
  fs.accessSync(candidate, fs.constants.F_OK);