claude-code-session-manager 0.8.6 → 0.10.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 (54) hide show
  1. package/README.md +95 -65
  2. package/dist/assets/{cssMode-DBg6nxUL.js → cssMode-DWlBzlpW.js} +1 -1
  3. package/dist/assets/{freemarker2-CyjUGY3f.js → freemarker2-Cgg83m-Z.js} +1 -1
  4. package/dist/assets/{handlebars-lhtCWqlB.js → handlebars-C4r4LOI9.js} +1 -1
  5. package/dist/assets/{html-egptHwbZ.js → html-DaxRI5sW.js} +1 -1
  6. package/dist/assets/htmlMode-Bu_8jtXo.js +1 -0
  7. package/dist/assets/{index-DjeqNwqn.js → index-C_tgFedf.js} +1115 -1081
  8. package/dist/assets/{index-DnLtSCQS.css → index-Dj3Db4OA.css} +1 -1
  9. package/dist/assets/{javascript-tZbiID3O.js → javascript-D5Ztx-Ej.js} +1 -1
  10. package/dist/assets/{jsonMode-BGtPN-L-.js → jsonMode-tfsgezVc.js} +1 -1
  11. package/dist/assets/{liquid-DvTeXhev.js → liquid-F2cD9OL0.js} +1 -1
  12. package/dist/assets/{lspLanguageFeatures-D9xoxVlV.js → lspLanguageFeatures-Bz_Eih8F.js} +2 -2
  13. package/dist/assets/{mdx-BQ3Ja4wM.js → mdx-BPlD1clX.js} +1 -1
  14. package/dist/assets/{ort-wasm-simd-threaded.asyncify-CtKKja6V.wasm → ort-wasm-simd-threaded.asyncify-DMmc6YqF.wasm} +0 -0
  15. package/dist/assets/{python-C71RWXaP.js → python-B4gUOWNI.js} +1 -1
  16. package/dist/assets/{razor-w__Mkyns.js → razor-B6pMxVp1.js} +1 -1
  17. package/dist/assets/{tsMode-DOQLQDB3.js → tsMode-C9nq6cHi.js} +1 -1
  18. package/dist/assets/{typescript-DEiub2Jt.js → typescript-Do5Vtwxu.js} +1 -1
  19. package/dist/assets/{whisperWorker-QfIS0sPF.js → whisperWorker-CcsPqZUS.js} +19 -19
  20. package/dist/assets/{xml-RXkLQscS.js → xml-C0mTbVRp.js} +1 -1
  21. package/dist/assets/{yaml-C8HIpJku.js → yaml-D3sePJfA.js} +1 -1
  22. package/dist/index.html +2 -2
  23. package/package.json +18 -10
  24. package/screenshots/.gitkeep +0 -0
  25. package/screenshots/README-screenshots.md +13 -0
  26. package/src/main/config.cjs +47 -9
  27. package/src/main/historyAggregator.cjs +10 -5
  28. package/src/main/index.cjs +85 -14
  29. package/src/main/ipcSchemas.cjs +165 -3
  30. package/src/main/lib/claudeBin.cjs +39 -0
  31. package/src/main/lib/encodeCwd.cjs +19 -0
  32. package/src/main/lib/fileTail.cjs +35 -0
  33. package/src/main/lib/insideHome.cjs +38 -0
  34. package/src/main/lib/prdFrontmatter.cjs +51 -0
  35. package/src/main/lib/sendToRenderer.cjs +21 -0
  36. package/src/main/memoryTool.cjs +203 -0
  37. package/src/main/otelSettings.cjs +2 -7
  38. package/src/main/pluginInstall.cjs +129 -0
  39. package/src/main/pty.cjs +13 -29
  40. package/src/main/queueOps.cjs +404 -0
  41. package/src/main/scheduler/prdParser.cjs +135 -0
  42. package/src/main/scheduler.cjs +291 -250
  43. package/src/main/sessionsStore.cjs +2 -6
  44. package/src/main/supervisor.cjs +3 -35
  45. package/src/main/teams.cjs +95 -0
  46. package/src/main/transcripts.cjs +5 -7
  47. package/src/main/usage.cjs +8 -0
  48. package/src/main/voiceHotkey.cjs +13 -9
  49. package/src/main/voiceSettings.cjs +2 -9
  50. package/src/main/voiceWizard.cjs +4 -11
  51. package/src/main/watchers.cjs +18 -42
  52. package/src/preload/api.d.ts +153 -1
  53. package/src/preload/index.cjs +29 -0
  54. package/dist/assets/htmlMode-tPDeHGOB.js +0 -1
@@ -15,6 +15,7 @@ const fsp = require('node:fs/promises');
15
15
  const path = require('node:path');
16
16
  const os = require('node:os');
17
17
  const { ipcMain } = require('electron');
18
+ const config = require('./config.cjs');
18
19
 
19
20
  function storePath() {
20
21
  return path.join(os.homedir(), '.config', 'session-manager', 'tabs.json');
@@ -38,14 +39,9 @@ async function load() {
38
39
  }
39
40
 
40
41
  async function save({ tabs, activeTabId, freshStart }) {
41
- const p = storePath();
42
- await fsp.mkdir(path.dirname(p), { recursive: true }).catch(() => {});
43
42
  const payload = { tabs, activeTabId, savedAt: Date.now() };
44
43
  if (freshStart) payload.freshStart = true;
45
- const body = JSON.stringify(payload, null, 2) + '\n';
46
- const tmp = `${p}.tmp-${process.pid}-${Date.now()}`;
47
- await fsp.writeFile(tmp, body, 'utf8');
48
- await fsp.rename(tmp, p);
44
+ await config.writeJson(storePath(), payload);
49
45
  return { ok: true };
50
46
  }
51
47
 
@@ -16,6 +16,7 @@ const path = require('node:path');
16
16
  const os = require('node:os');
17
17
  const { spawn, execFileSync } = require('node:child_process');
18
18
  const { ipcMain } = require('electron');
19
+ const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
19
20
 
20
21
  const HOME = os.homedir();
21
22
  const SUPERVISOR_LOG_PATH = path.join(HOME, '.claude', 'session-manager', 'supervisor.log');
@@ -27,7 +28,6 @@ const inFlightProbes = new Set();
27
28
 
28
29
  let supervisorInterval = null;
29
30
  let _readQueue = null;
30
- let _mutate = null;
31
31
 
32
32
  // ─── /proc helpers (Linux-only) ────────────────────────────────────────────
33
33
 
@@ -101,20 +101,7 @@ function getChildBashCmdlines(jobPid) {
101
101
 
102
102
  // ─── Log tail helpers ───────────────────────────────────────────────────────
103
103
 
104
- function readTailBytes(filePath, bytes) {
105
- try {
106
- const stat = fs.statSync(filePath);
107
- const n = Math.min(stat.size, bytes);
108
- if (n <= 0) return '';
109
- const fd = fs.openSync(filePath, 'r');
110
- const buf = Buffer.alloc(n);
111
- fs.readSync(fd, buf, 0, n, stat.size - n);
112
- fs.closeSync(fd);
113
- return buf.toString('utf8');
114
- } catch {
115
- return '';
116
- }
117
- }
104
+ const { readTail: readTailBytes } = require('./lib/fileTail.cjs');
118
105
 
119
106
  /**
120
107
  * Skim the last 16 KB of a run log for the most recent assistant/user/result
@@ -178,24 +165,6 @@ function readSupervisorLog(n) {
178
165
  }
179
166
  }
180
167
 
181
- // ─── Claude binary resolution ────────────────────────────────────────────────
182
-
183
- let claudeBinCached = null;
184
- function resolveClaudeBin() {
185
- if (claudeBinCached) return claudeBinCached;
186
- const candidates = [
187
- path.join(HOME, '.claude', 'local', 'claude'),
188
- '/usr/local/bin/claude',
189
- '/opt/homebrew/bin/claude',
190
- '/usr/bin/claude',
191
- ];
192
- for (const c of candidates) {
193
- try { fs.accessSync(c, fs.constants.X_OK); claudeBinCached = c; return c; } catch { /* */ }
194
- }
195
- claudeBinCached = 'claude';
196
- return claudeBinCached;
197
- }
198
-
199
168
  // ─── Probe ──────────────────────────────────────────────────────────────────
200
169
 
201
170
  function buildProbePrompt({ slug, cwd, startedAt, ageMinutes, lastActivityAge, jobPid, pstreeOutput, childBashCmdlines, logTail }) {
@@ -454,7 +423,7 @@ async function supervisorTick() {
454
423
 
455
424
  // ─── Lifecycle ───────────────────────────────────────────────────────────────
456
425
 
457
- function startSupervisor({ readQueue, mutate }) {
426
+ function startSupervisor({ readQueue }) {
458
427
  if (process.platform !== 'linux') {
459
428
  console.log('[supervisor] non-Linux platform detected; supervisor is a no-op for v1');
460
429
  return;
@@ -465,7 +434,6 @@ function startSupervisor({ readQueue, mutate }) {
465
434
  }
466
435
 
467
436
  _readQueue = readQueue;
468
- _mutate = mutate;
469
437
 
470
438
  stopSupervisor(); // idempotent: clear any existing interval
471
439
 
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Teams enumerator — surfaces ~/.claude/teams/<name>/ to the renderer.
3
+ *
4
+ * Each team is a directory containing:
5
+ * - config.json: { name, description?, members: [{ name, agentType, model, cwd?, ... }], ... }
6
+ * - inboxes/<thread>.json: one file per pending message thread (depth = file count)
7
+ *
8
+ * We treat teams as feature-flagged: enabled when env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === "1"
9
+ * is set in merged Claude settings. The renderer does its own check; this
10
+ * handler always returns the on-disk roster so the UI can keep recovering
11
+ * gracefully even if settings are unparseable.
12
+ *
13
+ * All reads route through config.cjs (validatePath + atomic semantics).
14
+ * Complexity: O(T·M) where T = teams and M = members; both are small (< 20).
15
+ */
16
+
17
+ const { ipcMain } = require('electron');
18
+ const os = require('node:os');
19
+ const path = require('node:path');
20
+ const configMgr = require('./config.cjs');
21
+ const logs = require('./logs.cjs');
22
+
23
+ const TEAMS_ROOT = path.join(os.homedir(), '.claude', 'teams');
24
+
25
+ /**
26
+ * Enumerate teams. Returns { teams: TeamInfo[] }.
27
+ * Failures per-team (unreadable config.json, missing inbox dir) are swallowed —
28
+ * the team is included with whatever data we could recover. The renderer
29
+ * decides how to render incomplete entries.
30
+ */
31
+ async function listTeams() {
32
+ let dir;
33
+ try {
34
+ dir = await configMgr.listDir(TEAMS_ROOT, { dirsOnly: true });
35
+ } catch {
36
+ // listDir throws when path is outside allowed boundaries — should not
37
+ // happen for ~/.claude/teams, but guard anyway.
38
+ return { teams: [] };
39
+ }
40
+ if (!dir.ok) {
41
+ if (dir.error) logs.writeLine({ level: 'warn', scope: 'teams', message: 'listDir failed', meta: { error: dir.error } });
42
+ return { teams: [] };
43
+ }
44
+ if (dir.entries.length === 0) return { teams: [] };
45
+
46
+ const teams = [];
47
+ for (const entry of dir.entries) {
48
+ const teamName = entry.name;
49
+ const configPath = path.join(entry.path, 'config.json');
50
+ const inboxDir = path.join(entry.path, 'inboxes');
51
+
52
+ let config = null;
53
+ try {
54
+ const r = await configMgr.readJson(configPath);
55
+ if (r.exists && !r.parseError) config = r.data;
56
+ } catch { /* unreadable — leave config null */ }
57
+
58
+ let inboxDepth = 0;
59
+ try {
60
+ const inbox = await configMgr.listDir(inboxDir, { filesOnly: true });
61
+ if (inbox.ok) {
62
+ inboxDepth = inbox.entries.filter((f) => f.name.endsWith('.json')).length;
63
+ }
64
+ } catch { /* no inbox dir is fine */ }
65
+
66
+ const members = Array.isArray(config?.members)
67
+ ? config.members.map((m) => ({
68
+ name: typeof m?.name === 'string' ? m.name : 'unnamed',
69
+ agentType: typeof m?.agentType === 'string' ? m.agentType : null,
70
+ model: typeof m?.model === 'string' ? m.model : null,
71
+ }))
72
+ : [];
73
+
74
+ teams.push({
75
+ name: teamName,
76
+ configPath,
77
+ description: typeof config?.description === 'string' ? config.description : null,
78
+ leadAgentId: typeof config?.leadAgentId === 'string' ? config.leadAgentId : null,
79
+ members,
80
+ memberCount: members.length,
81
+ inboxDepth,
82
+ });
83
+ }
84
+ return { teams };
85
+ }
86
+
87
+ function registerTeamsHandlers() {
88
+ ipcMain.handle('teams:list', () => listTeams());
89
+ }
90
+
91
+ module.exports = {
92
+ registerTeamsHandlers,
93
+ // exported for direct use / tests
94
+ listTeams,
95
+ };
@@ -24,6 +24,8 @@ const path = require('node:path');
24
24
  const os = require('node:os');
25
25
  const chokidar = require('chokidar');
26
26
  const otel = require('./otel.cjs');
27
+ const logs = require('./logs.cjs');
28
+ const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
27
29
 
28
30
  let window = null;
29
31
 
@@ -34,9 +36,7 @@ function attachWindow(w) {
34
36
  window = w;
35
37
  }
36
38
 
37
- function encodeCwd(cwd) {
38
- return cwd.replace(/[^a-zA-Z0-9]/g, '-');
39
- }
39
+ const { encodeCwd } = require('./lib/encodeCwd.cjs');
40
40
 
41
41
  function transcriptPath(cwd, sessionUuid) {
42
42
  return path.join(os.homedir(), '.claude', 'projects', encodeCwd(cwd), `${sessionUuid}.jsonl`);
@@ -135,9 +135,7 @@ async function flush(sub, { emit = true } = {}) {
135
135
  // Ring buffer (cap at 500 entries to bound memory).
136
136
  sub.buffer.push(ev);
137
137
  if (sub.buffer.length > 500) sub.buffer.shift();
138
- if (emit && window && !window.isDestroyed()) {
139
- window.webContents.send(`transcript:event:${sub.tabId}`, ev);
140
- }
138
+ if (emit) sendIfAlive(window, `transcript:event:${sub.tabId}`, ev);
141
139
  // Mirror to OTEL — no-op when disabled. We emit on the initial drain too
142
140
  // so backfilled transcripts show up in the trace store.
143
141
  otel.recordTranscriptEvent({
@@ -184,7 +182,7 @@ async function subscribe({ tabId, cwd, sessionUuid }) {
184
182
  });
185
183
  watcher.on('add', () => flush(sub).catch(() => {}));
186
184
  watcher.on('change', () => flush(sub).catch(() => {}));
187
- watcher.on('error', (err) => console.warn('[transcripts] watcher error:', err.message));
185
+ watcher.on('error', (err) => logs.writeLine({ level: 'warn', scope: 'transcripts', message: 'chokidar watcher error', meta: { error: err?.message } }));
188
186
  sub.watcher = watcher;
189
187
  subs.set(tabId, sub);
190
188
  return { ok: true, path: filePath };
@@ -24,6 +24,10 @@ const { refreshIfNeeded, expiresAtMs } = require('./lib/credentials.cjs');
24
24
 
25
25
  const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
26
26
  const CACHE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'billing-cache.json');
27
+ // Coalesce the 4 renderer pollers (Overview/AppStatusBar/StatusBar/Usage). A
28
+ // fresh ok-cache is served directly without touching the network. Auth/
29
+ // transient/config skip this TTL so they retry promptly on next poll.
30
+ const OK_CACHE_TTL_MS = 30_000;
27
31
 
28
32
  /**
29
33
  * Pure: classify a raw HTTP response status + body from the usage endpoint into a
@@ -140,6 +144,10 @@ function registerBillingHandlers() {
140
144
  ipcMain.handle('billing:fetch', async () => {
141
145
  if (hydrationPromise) { await hydrationPromise; hydrationPromise = null; }
142
146
 
147
+ if (cache && cache.fetchedAt && Date.now() - cache.fetchedAt < OK_CACHE_TTL_MS) {
148
+ return { kind: 'ok', data: cache.data };
149
+ }
150
+
143
151
  const r = await fetchUsage();
144
152
  if (r.kind === 'ok') {
145
153
  cache = { data: r.data, fetchedAt: Date.now(), sourceCredsExpiresAt: r.data.credentialsExpiresAt };
@@ -18,6 +18,7 @@
18
18
  const { app, globalShortcut, ipcMain } = require('electron');
19
19
  const voiceSettings = require('./voiceSettings.cjs');
20
20
  const { voiceHotkeyLog } = require('./lib/voice-hotkey-log.cjs');
21
+ const { schemas, validated } = require('./ipcSchemas.cjs');
21
22
 
22
23
  let mainWindow = null;
23
24
  let currentConfig = null;
@@ -297,7 +298,9 @@ function registerHotkeyHandlers() {
297
298
  return currentConfig;
298
299
  });
299
300
 
300
- ipcMain.handle('voice:set-hotkey', async (_e, cfg) => {
301
+ ipcMain.handle('voice:set-hotkey', validated(schemas.voiceSetHotkey, async (cfg) => {
302
+ // voiceSettings.isValidConfig adds one cross-field check zod can't express
303
+ // tersely (global=true + mode=hold is rejected, see PRD F1 v2). Keep it.
301
304
  if (!voiceSettings.isValidConfig(cfg)) {
302
305
  throw new Error('Invalid voice hotkey config');
303
306
  }
@@ -308,7 +311,7 @@ function registerHotkeyHandlers() {
308
311
  mainWindow.webContents.send('voice:hotkey-changed', currentConfig);
309
312
  }
310
313
  return { ok: true, config: currentConfig };
311
- });
314
+ }));
312
315
 
313
316
  ipcMain.handle('voice:get-hotkey-config-path', () => voiceSettings.storePath());
314
317
 
@@ -317,20 +320,21 @@ function registerHotkeyHandlers() {
317
320
  return await voiceSettings.loadDevice();
318
321
  });
319
322
 
320
- ipcMain.handle('voice:set-device-pref', async (_e, pref) => {
321
- if (!voiceSettings.isValidDevicePref(pref)) {
322
- throw new Error('Invalid device pref payload');
323
- }
323
+ ipcMain.handle('voice:set-device-pref', validated(schemas.voiceSetDevicePref, async (pref) => {
324
324
  await voiceSettings.saveDevice(pref);
325
325
  return { ok: true };
326
- });
326
+ }));
327
327
 
328
328
  // Renderer pings this when isRecording flips so we can prefix the window
329
- // title with `● REC — ` (PRD F1 v2 §Security: privacy invariant).
329
+ // title with `● REC — ` (PRD F1 v2 §Security: privacy invariant). This is
330
+ // `ipcMain.on` (not handle), so we can't use the validated() helper —
331
+ // safeParse inline and silently drop malformed payloads.
330
332
  ipcMain.on('voice:set-recording', (_e, recording) => {
333
+ const parsed = schemas.voiceSetRecording.safeParse(recording);
334
+ if (!parsed.success) return;
331
335
  if (!mainWindow || mainWindow.isDestroyed()) return;
332
336
  const baseTitle = 'Claude Session Manager';
333
- const next = recording ? `● REC — ${baseTitle}` : baseTitle;
337
+ const next = parsed.data ? `● REC — ${baseTitle}` : baseTitle;
334
338
  try { mainWindow.setTitle(next); } catch { /* */ }
335
339
  });
336
340
  }
@@ -24,6 +24,7 @@ const fs = require('node:fs');
24
24
  const fsp = require('node:fs/promises');
25
25
  const path = require('node:path');
26
26
  const os = require('node:os');
27
+ const config = require('./config.cjs');
27
28
 
28
29
  const SCHEMA_VERSION = 1;
29
30
  const DEVICE_SCHEMA_VERSION = 1;
@@ -103,17 +104,9 @@ async function readRaw() {
103
104
  let writeQueue = Promise.resolve();
104
105
  async function writeMerged(patch) {
105
106
  const run = async () => {
106
- const p = storePath();
107
- await fsp.mkdir(path.dirname(p), { recursive: true }).catch(() => {});
108
107
  const existing = (await readRaw()) || {};
109
108
  const next = { ...existing, ...patch };
110
- const body = JSON.stringify(next, null, 2) + '\n';
111
- const tmp = `${p}.tmp-${process.pid}-${Date.now()}`;
112
- await fsp.writeFile(tmp, body, { encoding: 'utf8', mode: 0o600 });
113
- // chmod tmp explicitly because some platforms ignore the mode arg on
114
- // writeFile when the file pre-exists. Then rename for atomicity.
115
- try { await fsp.chmod(tmp, 0o600); } catch { /* */ }
116
- await fsp.rename(tmp, p);
109
+ await config.writeTextAtomic(storePath(), JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
117
110
  return { ok: true };
118
111
  };
119
112
  // Tail-promise pattern: each call awaits the previous, so writes are
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * voiceWizard — F7 first-run mic-check wizard, main-process side.
3
3
  *
4
- * Owns three IPC channels:
4
+ * Owns:
5
5
  * - voice:wizard-state → returns persisted state + current schema constant
6
6
  * - voice:wizard-complete → stamps completedAt + completedSchema to voice.json
7
- * - app:is-e2e → exposes process.env.SM_E2E === '1' to the renderer
8
7
  *
9
8
  * No window state held here; persistence is delegated to voiceSettings.cjs
10
9
  * (additive `wizard` subtree on the same file as F1/F5).
@@ -12,6 +11,7 @@
12
11
 
13
12
  const { ipcMain } = require('electron');
14
13
  const voiceSettings = require('./voiceSettings.cjs');
14
+ const { schemas, validated } = require('./ipcSchemas.cjs');
15
15
 
16
16
  function registerWizardHandlers() {
17
17
  ipcMain.handle('voice:wizard-state', async () => {
@@ -32,23 +32,16 @@ function registerWizardHandlers() {
32
32
  return { ok: true, ...next, currentSchema: voiceSettings.WIZARD_SCHEMA };
33
33
  });
34
34
 
35
- // E2E plumbing: tests set SM_E2E=1 to suppress the wizard auto-trigger.
36
- // The renderer reads this once on mount.
37
- ipcMain.handle('app:is-e2e', () => process.env.SM_E2E === '1');
38
-
39
35
  // F8 — turn-detector settings (additive subtree on voice.json).
40
36
  // MVP: persistence only; no model loaded in v1 (see PRD §Loading & inference).
41
37
  ipcMain.handle('voice:get-turn-detector', async () => {
42
38
  return await voiceSettings.loadTurnDetector();
43
39
  });
44
40
 
45
- ipcMain.handle('voice:set-turn-detector', async (_e, state) => {
46
- if (!voiceSettings.isValidTurnDetectorState(state)) {
47
- throw new Error('Invalid turn-detector state payload');
48
- }
41
+ ipcMain.handle('voice:set-turn-detector', validated(schemas.voiceSetTurnDetector, async (state) => {
49
42
  await voiceSettings.saveTurnDetector(state);
50
43
  return { ok: true, state: await voiceSettings.loadTurnDetector() };
51
- });
44
+ }));
52
45
  }
53
46
 
54
47
  module.exports = { registerWizardHandlers };
@@ -15,6 +15,8 @@ const path = require('node:path');
15
15
  const os = require('node:os');
16
16
  const fs = require('node:fs');
17
17
  const { cleanChildEnv } = require('./lib/cleanEnv.cjs');
18
+ const { assertCwdInsideHome } = require('./lib/insideHome.cjs');
19
+ const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
18
20
 
19
21
  // Splits a readable stream into lines capped at maxLineBytes. Prevents OOM
20
22
  // from commands that produce no newlines (e.g. cat /dev/urandom | base64).
@@ -59,17 +61,8 @@ class WatcherManager {
59
61
  add({ tabId, label, command, cwd }) {
60
62
  const resolvedCwd = cwd || process.cwd();
61
63
 
62
- // Require cwd's realpath to be inside homedir.
63
- const home = os.homedir();
64
- let realCwd;
65
- try {
66
- realCwd = fs.realpathSync(resolvedCwd);
67
- } catch {
68
- realCwd = path.resolve(resolvedCwd);
69
- }
70
- if (realCwd !== home && !realCwd.startsWith(home + path.sep)) {
71
- throw new Error(`watcher cwd outside home directory: ${realCwd}`);
72
- }
64
+ const r = assertCwdInsideHome(resolvedCwd);
65
+ if (!r.ok) throw new Error(`watcher ${r.error}`);
73
66
 
74
67
  const watcherId = crypto.randomUUID();
75
68
  const trimmedLabel = (label && label.trim()) || command.slice(0, 40);
@@ -95,14 +88,12 @@ class WatcherManager {
95
88
 
96
89
  const emitLine = (line) => {
97
90
  entry.lineCount++;
98
- if (this.window && !this.window.isDestroyed()) {
99
- this.window.webContents.send('watcher:line', {
100
- tabId,
101
- watcherId,
102
- line,
103
- ts: Date.now(),
104
- });
105
- }
91
+ sendIfAlive(this.window, 'watcher:line', {
92
+ tabId,
93
+ watcherId,
94
+ line,
95
+ ts: Date.now(),
96
+ });
106
97
  };
107
98
 
108
99
  lineSplit(child.stdout, emitLine);
@@ -119,9 +110,7 @@ class WatcherManager {
119
110
  if (this.watchers.has(watcherId)) {
120
111
  emitLine(`[exited code=${code ?? 'null'}${signal ? ` signal=${signal}` : ''}]`);
121
112
  this.watchers.delete(watcherId);
122
- if (this.window && !this.window.isDestroyed()) {
123
- this.window.webContents.send('watcher:closed', { tabId, watcherId });
124
- }
113
+ sendIfAlive(this.window, 'watcher:closed', { tabId, watcherId });
125
114
  }
126
115
  });
127
116
 
@@ -164,9 +153,7 @@ class WatcherManager {
164
153
  setTimeout(() => {
165
154
  try { if (w.child.exitCode === null && w.child.signalCode === null) w.child.kill('SIGKILL'); } catch { /* */ }
166
155
  }, 2000).unref?.();
167
- if (this.window && !this.window.isDestroyed()) {
168
- this.window.webContents.send('watcher:closed', { tabId: w.tabId, watcherId });
169
- }
156
+ sendIfAlive(this.window, 'watcher:closed', { tabId: w.tabId, watcherId });
170
157
  return { ok: true };
171
158
  }
172
159
 
@@ -186,27 +173,16 @@ function attachWindow(window) {
186
173
  }
187
174
 
188
175
  function registerWatcherHandlers() {
189
- const { z } = require('zod');
190
- const addSchema = z.object({
191
- tabId: z.string().min(1).max(128),
192
- label: z.string().max(256).optional().default(''),
193
- command: z.string().min(1).max(8192),
194
- cwd: z.string().max(4096).optional().nullable(),
195
- });
196
- const listSchema = z.object({ tabId: z.string().min(1).max(128) });
197
- const removeSchema = z.object({ watcherId: z.string().min(1).max(128) });
198
- const killTabSchema = z.object({ tabId: z.string().min(1).max(128) });
199
-
200
- ipcMain.handle('watchers:add', (_e, payload) => manager.add(addSchema.parse(payload)));
201
- ipcMain.handle('watchers:list', (_e, payload) => manager.list(listSchema.parse(payload)));
202
- ipcMain.handle('watchers:remove', (_e, payload) => manager.remove(removeSchema.parse(payload)));
203
- ipcMain.handle('watchers:kill-tab', (_e, payload) => {
204
- const { tabId } = killTabSchema.parse(payload);
176
+ const { schemas: s, validated: v } = require('./ipcSchemas.cjs');
177
+ ipcMain.handle('watchers:add', v(s.watchersAdd, (payload) => manager.add(payload)));
178
+ ipcMain.handle('watchers:list', v(s.watchersList, (payload) => manager.list(payload)));
179
+ ipcMain.handle('watchers:remove', v(s.watchersRemove, (payload) => manager.remove(payload)));
180
+ ipcMain.handle('watchers:kill-tab', v(s.watchersKillTab, ({ tabId }) => {
205
181
  for (const [id, w] of manager.watchers) {
206
182
  if (w.tabId === tabId) manager.remove({ watcherId: id });
207
183
  }
208
184
  return { ok: true };
209
- });
185
+ }));
210
186
  }
211
187
 
212
188
  module.exports = { manager, attachWindow, registerWatcherHandlers };