claude-code-session-manager 0.29.0 → 0.31.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/index.html CHANGED
@@ -7,7 +7,7 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
9
  <link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400&family=Geist:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
10
- <script type="module" crossorigin src="./assets/index-qS7GdCsL.js"></script>
10
+ <script type="module" crossorigin src="./assets/index-hreMRE8u.js"></script>
11
11
  <link rel="modulepreload" crossorigin href="./assets/monaco-editor-BW5C4Iv1.js">
12
12
  <link rel="stylesheet" crossorigin href="./assets/monaco-editor-BTnBOi8r.css">
13
13
  <link rel="stylesheet" crossorigin href="./assets/index-DMIi9YZH.css">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-session-manager",
3
- "version": "0.29.0",
3
+ "version": "0.31.0",
4
4
  "description": "Local cockpit for the Claude Code CLI — multi-tab terminal, full config surface, scheduler, voice dictation, and live observability.",
5
5
  "type": "module",
6
6
  "main": "src/main/index.cjs",
@@ -11,6 +11,7 @@
11
11
  "bin/",
12
12
  ".claude-plugin/",
13
13
  "plugins/",
14
+ "scripts/postinstall.cjs",
14
15
  "src/main/",
15
16
  "src/preload/",
16
17
  "dist/index.html",
@@ -26,7 +27,7 @@
26
27
  "build": "vite build",
27
28
  "typecheck": "tsc --noEmit",
28
29
  "start": "electron .",
29
- "postinstall": "electron-rebuild -f -w node-pty",
30
+ "postinstall": "node scripts/postinstall.cjs",
30
31
  "prepublishOnly": "vite build",
31
32
  "test:unit": "vitest run",
32
33
  "test:verify": "node src/main/__tests__/runVerify.test.cjs",
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * postinstall — rebuild node-pty against the bundled Electron's ABI.
6
+ *
7
+ * The terminal needs node-pty's native binary to match the Electron we ship.
8
+ * We run electron-rebuild, but a failure here MUST NOT fail `npm i -g` — that
9
+ * would block the entire app (config tabs, scheduler, etc.) over one native
10
+ * module. So we run it best-effort and, on failure, print the exact manual fix
11
+ * and exit 0. pty.cjs surfaces the same guidance in-app if a tab can't start.
12
+ */
13
+
14
+ const { spawnSync } = require('node:child_process');
15
+
16
+ function run() {
17
+ const r = spawnSync(
18
+ process.platform === 'win32' ? 'npx.cmd' : 'npx',
19
+ ['electron-rebuild', '-f', '-w', 'node-pty'],
20
+ { stdio: 'inherit' },
21
+ );
22
+ return r.status === 0 && !r.error;
23
+ }
24
+
25
+ if (run()) process.exit(0);
26
+
27
+ const mac = process.platform === 'darwin';
28
+ const bar = '─'.repeat(64);
29
+ console.warn(`\n${bar}`);
30
+ console.warn('claude-code-session-manager: node-pty rebuild did not complete.');
31
+ console.warn('The app will still launch, but terminal tabs may not start until');
32
+ console.warn('the native module is rebuilt for this Electron. To fix it:');
33
+ console.warn('');
34
+ console.warn(' cd "' + __dirname.replace(/\/scripts$/, '') + '"');
35
+ console.warn(' npx electron-rebuild -f -w node-pty');
36
+ if (mac) {
37
+ console.warn('');
38
+ console.warn(' # macOS: if the rebuild fails, install the compiler first:');
39
+ console.warn(' xcode-select --install');
40
+ }
41
+ console.warn(`${bar}\n`);
42
+ // Exit 0 — never fail the install over the terminal native module.
43
+ process.exit(0);
package/src/main/kg.cjs CHANGED
@@ -51,6 +51,11 @@ const BATCH = 20; // prompts per extraction call (also a per-pro
51
51
  const KNOWN_VOCAB = 200; // top node names pre-seeded for dedup-at-extraction
52
52
  const MAX_TAIL_BYTES = 8 * 1024 * 1024; // bound bytes scanned per ingest run
53
53
  const MAX_EXTRACTIONS_PER_RUN = 30; // bound claude calls per run (cost/time)
54
+ // prompts.jsonl is append-only and otherwise grows forever (observed 325 MB).
55
+ // Once we're caught up past this size, rotate it to prompts.jsonl.1 (one backup
56
+ // kept) and reset the cursor — extraction only ever needs the new tail, and the
57
+ // already-extracted prompts live in the per-project graphs.
58
+ const MAX_LOG_BYTES = 256 * 1024 * 1024;
54
59
  // Coalescing window before an auto-ingest after new prompts land. Units never
55
60
  // mix projects, and a project switch in the log closes the current batch — so
56
61
  // with concurrent sessions a short window yields 1-2-prompt batches and one
@@ -159,6 +164,30 @@ async function saveIngestState(s) {
159
164
  await writeJson(INGEST_STATE_PATH, s);
160
165
  }
161
166
 
167
+ /**
168
+ * Rotate prompts.jsonl once it grows past MAX_LOG_BYTES. Only call when caught
169
+ * up (lastOffset >= size) so no unconsumed prompts are lost. Renames to a single
170
+ * `.1` backup (overwriting any prior one) and resets the cursor to 0; the
171
+ * UserPromptSubmit hook recreates prompts.jsonl on its next append. Best-effort:
172
+ * a failure leaves the log in place and is retried on the next caught-up cycle.
173
+ */
174
+ async function rotateLogIfTooLarge(st, stat) {
175
+ if (!stat || stat.size < MAX_LOG_BYTES) return false;
176
+ const rotated = LOG_PATH + '.1';
177
+ try {
178
+ try { await fsp.unlink(rotated); } catch { /* no prior backup */ }
179
+ await fsp.rename(LOG_PATH, rotated);
180
+ st.lastOffset = 0;
181
+ st.updatedAt = new Date().toISOString();
182
+ await saveIngestState(st);
183
+ logger.writeLine({ scope: 'kg', level: 'info', message: 'rotated prompts.jsonl', meta: { rotatedBytes: stat.size, backup: rotated } });
184
+ return true;
185
+ } catch (e) {
186
+ logger.writeLine({ scope: 'kg', level: 'warn', message: 'log rotation failed', meta: { error: e?.message } });
187
+ return false;
188
+ }
189
+ }
190
+
162
191
  /**
163
192
  * Per-project prompt-count sidecar: { [encodedCwd]: { count: number, cwd: string } }
164
193
  * Returns null when the file does not yet exist (triggers a one-time migration scan).
@@ -356,7 +385,19 @@ async function ingest() {
356
385
  try { stat = await fsp.stat(LOG_PATH); }
357
386
  catch { broadcast('kg:ingest-progress', { phase: 'done', ingesting: false, added: 0 }); return { ok: true, added: 0, note: 'no log yet' }; }
358
387
 
388
+ // Truncation/rotation recovery: if the log shrank below our watermark it was
389
+ // rotated or truncated out from under us — restart from the top of the new
390
+ // file rather than wedging forever at a stale offset.
391
+ if (stat.size < st.lastOffset) {
392
+ logger.writeLine({ scope: 'kg', level: 'info', message: 'log shrank; resetting ingest cursor', meta: { size: stat.size, lastOffset: st.lastOffset } });
393
+ st.lastOffset = 0;
394
+ st.updatedAt = new Date().toISOString();
395
+ await saveIngestState(st);
396
+ }
397
+
359
398
  if (stat.size <= st.lastOffset) {
399
+ // Caught up — a safe moment to rotate the log if it has grown too large.
400
+ await rotateLogIfTooLarge(st, stat);
360
401
  broadcast('kg:ingest-progress', { phase: 'done', ingesting: false, added: 0 });
361
402
  return { ok: true, added: 0, note: 'up to date' };
362
403
  }
@@ -1,5 +1,36 @@
1
+ const os = require('node:os');
2
+ const path = require('node:path');
3
+
1
4
  const SECRET_KEY_RE = /^(?:.*_)?(TOKEN|API_?KEY|SECRET|PASSWORD|AUTHORIZATION|COOKIE|REFRESH[_-]?TOKEN|ACCESS[_-]?TOKEN)$/i;
2
5
 
6
+ /**
7
+ * Common user + Homebrew bin dirs to PREPEND to a spawned child's PATH.
8
+ * Electron can launch from Finder/Dock with a stripped PATH, and Apple Silicon
9
+ * Homebrew lives under /opt/homebrew — without these, `claude`, git, node, etc.
10
+ * are invisible to ptys and `claude -p` jobs on macOS. Single source of truth
11
+ * so pty.cjs and pluginInstall.cjs can't drift (one of them was missing
12
+ * /opt/homebrew and broke plugin install on Apple Silicon).
13
+ */
14
+ function userBinDirs() {
15
+ const home = os.homedir();
16
+ return [
17
+ path.join(home, '.claude', 'local'),
18
+ path.join(home, '.local', 'bin'),
19
+ path.join(home, '.npm-global', 'bin'),
20
+ '/opt/homebrew/bin',
21
+ '/opt/homebrew/sbin',
22
+ '/usr/local/bin',
23
+ '/usr/bin',
24
+ '/bin',
25
+ ];
26
+ }
27
+
28
+ /** Electron's PATH prefixed with the user/Homebrew bin dirs — the value to put
29
+ * in a spawned child's PATH so `claude`, node, git, etc. resolve on macOS. */
30
+ function pathWithUserBins() {
31
+ return `${userBinDirs().join(':')}:${process.env.PATH || ''}`;
32
+ }
33
+
3
34
  function cleanChildEnv(extra = {}) {
4
35
  const env = { ...process.env, ...extra };
5
36
  for (const k of Object.keys(env)) {
@@ -17,4 +48,4 @@ function cleanChildEnv(extra = {}) {
17
48
  return env;
18
49
  }
19
50
 
20
- module.exports = { cleanChildEnv };
51
+ module.exports = { cleanChildEnv, userBinDirs, pathWithUserBins };
@@ -5,7 +5,8 @@ const fs = require('node:fs');
5
5
  const path = require('node:path');
6
6
  const os = require('node:os');
7
7
  const { spawn, execFileSync } = require('node:child_process');
8
- const { cleanChildEnv } = require('./cleanEnv.cjs');
8
+ const { cleanChildEnv, pathWithUserBins } = require('./cleanEnv.cjs');
9
+ const { resolveClaudeBin } = require('./claudeBin.cjs');
9
10
 
10
11
  const CREDS_PATH = path.join(os.homedir(), '.claude', '.credentials.json');
11
12
 
@@ -185,7 +186,12 @@ function tryCliFallback() {
185
186
  const timer = setTimeout(() => settle({ ok: false, reason: 'timeout' }), 15_000);
186
187
  let child;
187
188
  try {
188
- child = spawn('claude', ['--version'], { stdio: 'ignore', env: cleanChildEnv() });
189
+ // Resolve the absolute binary + inject Homebrew/user bins so this works
190
+ // even when Electron launched from Finder/Dock with a stripped PATH (mac).
191
+ child = spawn(resolveClaudeBin(), ['--version'], {
192
+ stdio: 'ignore',
193
+ env: cleanChildEnv({ PATH: pathWithUserBins() }),
194
+ });
189
195
  } catch (e) {
190
196
  clearTimeout(timer);
191
197
  return settle({ ok: false, reason: e?.message });
@@ -21,7 +21,7 @@ const pty = require('node-pty');
21
21
  const path = require('node:path');
22
22
  const os = require('node:os');
23
23
  const fs = require('node:fs');
24
- const { cleanChildEnv } = require('./lib/cleanEnv.cjs');
24
+ const { cleanChildEnv, pathWithUserBins } = require('./lib/cleanEnv.cjs');
25
25
  const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
26
26
  const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
27
27
  const { schemas } = require('./ipcSchemas.cjs');
@@ -58,16 +58,8 @@ function send(channel, payload) {
58
58
  }
59
59
 
60
60
  function childEnv() {
61
- const home = os.homedir();
62
- const extraPath = [
63
- path.join(home, '.local', 'bin'),
64
- path.join(home, '.npm-global', 'bin'),
65
- '/usr/local/bin',
66
- '/usr/bin',
67
- '/bin',
68
- ].join(':');
69
61
  return cleanChildEnv({
70
- PATH: `${extraPath}:${process.env.PATH || ''}`,
62
+ PATH: pathWithUserBins(),
71
63
  TERM: 'xterm-256color',
72
64
  FORCE_COLOR: '0', // strip ANSI so the renderer doesn't have to.
73
65
  });
package/src/main/pty.cjs CHANGED
@@ -1,13 +1,57 @@
1
- const pty = require('node-pty');
1
+ // Load the native node-pty lazily-guarded: if its prebuilt/rebuilt binary
2
+ // doesn't match this Electron's ABI, a bare top-level require would throw and
3
+ // crash the WHOLE app at startup (index.cjs requires this module). Instead we
4
+ // capture the error and surface an actionable message when a tab is opened, so
5
+ // the app still boots and the user sees exactly how to fix the terminal.
6
+ let pty = null;
7
+ let ptyLoadError = null;
8
+ try {
9
+ pty = require('node-pty');
10
+ } catch (e) {
11
+ ptyLoadError = e;
12
+ // eslint-disable-next-line no-console
13
+ console.error('[pty] node-pty failed to load:', e?.message);
14
+ }
2
15
  const { ipcMain } = require('electron');
3
16
  const path = require('node:path');
4
17
  const os = require('node:os');
5
18
  const fs = require('node:fs');
6
19
  const { addAllowedRoot } = require('./config.cjs');
7
- const { cleanChildEnv } = require('./lib/cleanEnv.cjs');
20
+ const { cleanChildEnv, pathWithUserBins } = require('./lib/cleanEnv.cjs');
8
21
  const { checkInsideHome } = require('./lib/insideHome.cjs');
9
22
  const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
10
23
 
24
+ // Absolute path to the installed package root (src/main/ -> ../../), shown in
25
+ // the remediation message so the user can cd there and rebuild.
26
+ const PKG_DIR = path.join(__dirname, '..', '..');
27
+
28
+ /**
29
+ * ANSI-formatted terminal text explaining a native-module / immediate-exit
30
+ * failure and exactly how to fix it. Written straight into the tab's output so
31
+ * a dead terminal explains itself instead of just going blank.
32
+ */
33
+ function nativeModuleHelp(reason) {
34
+ const mac = process.platform === 'darwin';
35
+ const lines = [
36
+ '',
37
+ `\x1b[1;33m[session-manager] Terminal could not start.\x1b[0m`,
38
+ `\x1b[33m${reason}\x1b[0m`,
39
+ '',
40
+ `This is almost always the node-pty native module not matching this`,
41
+ `Electron build. Rebuild it once:`,
42
+ '',
43
+ ` \x1b[36mcd ${PKG_DIR}\x1b[0m`,
44
+ ` \x1b[36mnpx electron-rebuild -f -w node-pty\x1b[0m`,
45
+ '',
46
+ mac ? `macOS: if the rebuild fails, install the compiler first:` : '',
47
+ mac ? ` \x1b[36mxcode-select --install\x1b[0m` : '',
48
+ mac ? '' : '',
49
+ `Then quit and reopen session-manager.`,
50
+ '',
51
+ ].filter((l) => l !== '');
52
+ return lines.join('\r\n') + '\r\n';
53
+ }
54
+
11
55
  /**
12
56
  * PtyManager — owns every claude PTY process, keyed by tabId (renderer-generated UUID).
13
57
  * Streams output to the renderer via IPC channels scoped by tabId.
@@ -26,6 +70,15 @@ class PtyManager {
26
70
  spawn({ tabId, cwd, cols = 120, rows = 30 }) {
27
71
  console.log('[pty] spawn requested', { tabId });
28
72
 
73
+ // Native module unavailable — explain in the tab and report a clean exit
74
+ // rather than throwing (which would surface as an opaque IPC error).
75
+ if (!pty || ptyLoadError) {
76
+ const reason = `node-pty failed to load: ${ptyLoadError?.message ?? 'module missing'}`;
77
+ sendIfAlive(this.window, `pty:data:${tabId}`, nativeModuleHelp(reason));
78
+ setImmediate(() => sendIfAlive(this.window, `pty:exit:${tabId}`, { exitCode: 1, signal: undefined }));
79
+ return { pid: null, cwd, reattached: false, error: 'node-pty unavailable' };
80
+ }
81
+
29
82
  // Validate that cwd is inside homedir before widening the allowed-root set.
30
83
  if (cwd) {
31
84
  const r = checkInsideHome(cwd);
@@ -62,18 +115,11 @@ class PtyManager {
62
115
  return { pid: existing.proc.pid, cwd: existing.cwd, reattached: true };
63
116
  }
64
117
 
65
- // Prefer `claude` on PATH; extend PATH with common user bin dirs since
66
- // Electron can launch with a stripped environment.
67
- const extraPath = [
68
- path.join(os.homedir(), '.local', 'bin'),
69
- path.join(os.homedir(), '.npm-global', 'bin'),
70
- '/usr/local/bin',
71
- '/usr/bin',
72
- '/bin',
73
- ].join(':');
74
-
118
+ // Prefer `claude` on PATH; extend PATH with common user + Homebrew bin dirs
119
+ // since Electron can launch with a stripped environment (Apple Silicon
120
+ // Homebrew is /opt/homebrew, absent from the default Electron PATH).
75
121
  const env = cleanChildEnv({
76
- PATH: `${extraPath}:${process.env.PATH || ''}`,
122
+ PATH: pathWithUserBins(),
77
123
  TERM: 'xterm-256color',
78
124
  COLORTERM: 'truecolor',
79
125
  FORCE_COLOR: '1',
@@ -95,11 +141,19 @@ class PtyManager {
95
141
  });
96
142
  } catch (err) {
97
143
  console.error('[pty] pty.spawn threw:', err);
98
- throw err;
144
+ // Surface the cause in the tab + report a clean exit rather than throwing
145
+ // an opaque IPC error (covers a missing/invalid cwd or a native fault).
146
+ sendIfAlive(this.window, `pty:data:${tabId}`, nativeModuleHelp(`pty.spawn failed: ${err?.message ?? String(err)}`));
147
+ setImmediate(() => sendIfAlive(this.window, `pty:exit:${tabId}`, { exitCode: 1, signal: undefined }));
148
+ return { pid: null, cwd, reattached: false, error: String(err?.message || 'spawn-failed') };
99
149
  }
100
150
  console.log('[pty] spawned pid=', proc.pid, 'for tabId=', tabId);
101
151
 
152
+ const spawnedAt = Date.now();
153
+ let gotData = false;
154
+
102
155
  proc.onData((data) => {
156
+ gotData = true;
103
157
  sendIfAlive(this.window, `pty:data:${tabId}`, data);
104
158
  });
105
159
 
@@ -112,11 +166,19 @@ class PtyManager {
112
166
  this.sessions.delete(tabId);
113
167
  return;
114
168
  }
169
+ // Fast-exit detector: a shell that dies in <1.2s with a non-zero status
170
+ // and never printed anything almost certainly couldn't exec (broken
171
+ // node-pty spawn-helper / ABI on macOS). Explain it in the tab instead of
172
+ // leaving a blank, instantly-closed terminal. Guarded by !gotData so a
173
+ // real interactive shell (which prints a prompt immediately) is exempt.
174
+ if (!gotData && exitCode !== 0 && Date.now() - spawnedAt < 1200) {
175
+ sendIfAlive(this.window, `pty:data:${tabId}`, nativeModuleHelp(`The shell exited immediately (code ${exitCode}).`));
176
+ }
115
177
  sendIfAlive(this.window, `pty:exit:${tabId}`, { exitCode, signal });
116
178
  this.sessions.delete(tabId);
117
179
  });
118
180
 
119
- this.sessions.set(tabId, { proc, cwd, created: Date.now() });
181
+ this.sessions.set(tabId, { proc, cwd, created: spawnedAt });
120
182
  return { pid: proc.pid, cwd, reattached: false };
121
183
  }
122
184
 
@@ -48,7 +48,7 @@ const { randomUUID } = require('node:crypto');
48
48
  const { execFile } = require('node:child_process');
49
49
  const { ipcMain } = require('electron');
50
50
  const billing = require('./usage.cjs');
51
- const { cleanChildEnv } = require('./lib/cleanEnv.cjs');
51
+ const { cleanChildEnv, pathWithUserBins } = require('./lib/cleanEnv.cjs');
52
52
  const supervisor = require('./supervisor.cjs');
53
53
  const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
54
54
  const { readTail } = require('./lib/fileTail.cjs');
@@ -921,7 +921,9 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
921
921
  // Strip Claude Code env and secrets that leak in when session-manager is
922
922
  // launched from a `claude` shell. CLAUDE_EFFORT=xhigh forces Opus and
923
923
  // overrides `--model sonnet`, so scheduled jobs burn Opus credits silently.
924
- const childEnv = cleanChildEnv();
924
+ // PATH must include Homebrew/user bins or the job's node/git children ENOENT
925
+ // when Electron was launched from Finder/Dock on macOS (stripped PATH).
926
+ const childEnv = cleanChildEnv({ PATH: pathWithUserBins() });
925
927
 
926
928
  // Track whether the agent has emitted a `result` event in its JSONL stream.
927
929
  // null until seen; then one of "success" | "error_max_turns" | … per the
@@ -1230,7 +1232,7 @@ DO NOT attempt the fix. ONLY write the file. When the file exists, exit immediat
1230
1232
  }
1231
1233
 
1232
1234
  const claudeBin = resolveClaudeBin();
1233
- const childEnv = cleanChildEnv();
1235
+ const childEnv = cleanChildEnv({ PATH: pathWithUserBins() }); // Homebrew/user bins for macOS
1234
1236
 
1235
1237
  // Investigation needs only a deadman watchdog — no idle-tail or result-tail
1236
1238
  // since investigations are short-running Opus probes with a hard ceiling.