claude-code-session-manager 0.8.0 → 0.8.2

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 (39) hide show
  1. package/dist/assets/{cssMode-CDGOCAW5.js → cssMode-30PYohIN.js} +1 -1
  2. package/dist/assets/{editor.main-zj3Myqhk.js → editor.main-CZ_l_CSt.js} +3 -3
  3. package/dist/assets/{freemarker2-Dh6-Vi35.js → freemarker2-DA5xODSz.js} +1 -1
  4. package/dist/assets/{handlebars-KgXZ3LUu.js → handlebars-BgJKogMf.js} +1 -1
  5. package/dist/assets/{html-D12q2PkL.js → html-D3DAPwAR.js} +1 -1
  6. package/dist/assets/{htmlMode-CCaSY5vs.js → htmlMode-mS5mzFjU.js} +1 -1
  7. package/dist/assets/index-Bs-mHiD-.js +2976 -0
  8. package/dist/assets/index-DCK87t79.css +32 -0
  9. package/dist/assets/{javascript-ClkFzW_a.js → javascript-CJ-Uxk_I.js} +1 -1
  10. package/dist/assets/{jsonMode-BV7azwkW.js → jsonMode-DbcDRati.js} +1 -1
  11. package/dist/assets/{liquid-C9Id9V-K.js → liquid-I4DHwPR_.js} +1 -1
  12. package/dist/assets/{lspLanguageFeatures-COD69jzF.js → lspLanguageFeatures-BntDl6Xn.js} +1 -1
  13. package/dist/assets/{mdx-D8YvWtiq.js → mdx-DWI58irx.js} +1 -1
  14. package/dist/assets/{python-YtsitwE4.js → python-DPx3c0QA.js} +1 -1
  15. package/dist/assets/{razor-T48OHh5u.js → razor-BcxFqE_H.js} +1 -1
  16. package/dist/assets/{tsMode-DHTMR4b8.js → tsMode-CGTi49DJ.js} +1 -1
  17. package/dist/assets/{typescript-Ckq032Ud.js → typescript-CE9RqBjC.js} +1 -1
  18. package/dist/assets/{xml-B7dYWEXB.js → xml-DsrLAWcV.js} +1 -1
  19. package/dist/assets/{yaml-DUufEgrd.js → yaml-CA8rRsQI.js} +1 -1
  20. package/dist/index.html +2 -2
  21. package/package.json +1 -1
  22. package/src/main/config.cjs +93 -19
  23. package/src/main/index.cjs +163 -31
  24. package/src/main/ipcSchemas.cjs +59 -2
  25. package/src/main/lib/cleanEnv.cjs +20 -0
  26. package/src/main/lib/credentials.cjs +184 -0
  27. package/src/main/lib/schedulerConfig.cjs +10 -0
  28. package/src/main/logs.cjs +1 -1
  29. package/src/main/otelSettings.cjs +1 -1
  30. package/src/main/pty.cjs +53 -6
  31. package/src/main/scheduler.cjs +521 -148
  32. package/src/main/transcripts.cjs +26 -21
  33. package/src/main/usage.cjs +76 -25
  34. package/src/main/voiceSettings.cjs +1 -1
  35. package/src/main/watchers.cjs +69 -11
  36. package/src/preload/api.d.ts +53 -11
  37. package/src/preload/index.cjs +13 -0
  38. package/dist/assets/index-Dejlz0I1.js +0 -2972
  39. package/dist/assets/index-DsC4vT8M.css +0 -32
@@ -84,40 +84,45 @@ function classifyLine(obj) {
84
84
  }
85
85
 
86
86
  /**
87
- * Read new bytes from the file starting at `offset`, returning {lines, newOffset}.
88
- * Tolerates partial last lines (keeps them as pending for the next read).
87
+ * Read new bytes from sub.filePath into sub.offset/pending/inode in place.
88
+ * Resets offset+pending when the file inode changes (rename+replace rotation).
89
+ * Returns parsed line strings ready for JSON.parse.
89
90
  */
90
- async function readDelta(filePath, offset, pending) {
91
- let stat;
92
- try {
93
- stat = await fsp.stat(filePath);
94
- } catch {
95
- return { lines: [], newOffset: offset, pending };
91
+ async function readDelta(sub) {
92
+ const stat = await fsp.stat(sub.filePath).catch(() => null);
93
+ if (!stat) return [];
94
+ // Inode changed → file was replaced underfoot; restart from the top.
95
+ if (sub.inode !== undefined && stat.ino !== sub.inode) {
96
+ sub.offset = 0;
97
+ sub.pending = '';
96
98
  }
97
- if (stat.size < offset) {
99
+ if (stat.size < sub.offset) {
98
100
  // File was truncated/rotated — start over.
99
- offset = 0;
100
- pending = '';
101
+ sub.offset = 0;
102
+ sub.pending = '';
103
+ }
104
+ if (stat.size === sub.offset) {
105
+ sub.inode = stat.ino;
106
+ return [];
101
107
  }
102
- if (stat.size === offset) return { lines: [], newOffset: offset, pending };
103
- const fd = await fsp.open(filePath, 'r');
108
+ const fd = await fsp.open(sub.filePath, 'r');
104
109
  try {
105
- const length = stat.size - offset;
110
+ const length = stat.size - sub.offset;
106
111
  const buf = Buffer.alloc(length);
107
- await fd.read(buf, 0, length, offset);
108
- const text = pending + buf.toString('utf8');
112
+ await fd.read(buf, 0, length, sub.offset);
113
+ const text = sub.pending + buf.toString('utf8');
109
114
  const parts = text.split('\n');
110
- const newPending = parts.pop() ?? '';
111
- return { lines: parts.filter(Boolean), newOffset: stat.size, pending: newPending };
115
+ sub.pending = parts.pop() ?? '';
116
+ sub.offset = stat.size;
117
+ sub.inode = stat.ino;
118
+ return parts.filter(Boolean);
112
119
  } finally {
113
120
  await fd.close();
114
121
  }
115
122
  }
116
123
 
117
124
  async function flush(sub, { emit = true } = {}) {
118
- const { lines, newOffset, pending } = await readDelta(sub.filePath, sub.offset, sub.pending);
119
- sub.offset = newOffset;
120
- sub.pending = pending;
125
+ const lines = await readDelta(sub);
121
126
  for (const line of lines) {
122
127
  let obj;
123
128
  try {
@@ -20,48 +20,99 @@ const fsp = require('node:fs/promises');
20
20
  const path = require('node:path');
21
21
  const os = require('node:os');
22
22
  const { ipcMain } = require('electron');
23
+ const { refreshIfNeeded, expiresAtMs } = require('./lib/credentials.cjs');
23
24
 
24
- const CREDS_PATH = path.join(os.homedir(), '.claude', '.credentials.json');
25
25
  const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
26
+ const CACHE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'billing-cache.json');
26
27
 
27
- async function readCredentials() {
28
- const raw = await fsp.readFile(CREDS_PATH, 'utf8');
29
- const data = JSON.parse(raw);
30
- const oa = data?.claudeAiOauth;
31
- if (!oa?.accessToken) throw new Error('not signed in (missing accessToken)');
32
- return oa;
28
+ let cache = null;
29
+ let hydrationPromise = null;
30
+
31
+ async function hydrateCache() {
32
+ try {
33
+ cache = JSON.parse(await fsp.readFile(CACHE_PATH, 'utf8'));
34
+ } catch {
35
+ cache = null;
36
+ }
37
+ }
38
+
39
+ async function persistCache(c) {
40
+ await fsp.mkdir(path.dirname(CACHE_PATH), { recursive: true });
41
+ const tmp = `${CACHE_PATH}.${process.pid}.${Date.now()}.tmp`;
42
+ await fsp.writeFile(tmp, JSON.stringify(c), { encoding: 'utf8', mode: 0o600 });
43
+ await fsp.rename(tmp, CACHE_PATH);
33
44
  }
34
45
 
35
46
  async function fetchUsage() {
36
- const creds = await readCredentials();
37
- const r = await fetch(USAGE_URL, {
38
- headers: {
39
- Authorization: `Bearer ${creds.accessToken}`,
40
- 'anthropic-beta': 'oauth-2025-04-20',
41
- 'User-Agent': 'claude-code-session-manager',
42
- },
43
- });
47
+ // Check expiry and attempt proactive refresh before touching the network.
48
+ const refresh = await refreshIfNeeded();
49
+ if (refresh.kind === 'auth') {
50
+ return { kind: 'auth', message: refresh.message, httpStatus: 401, expiredAt: refresh.expiredAt ?? null };
51
+ }
52
+ if (refresh.kind === 'config') return refresh;
53
+ // 'ok' or 'unsupported' — creds present and not yet expired
54
+ const creds = refresh.creds;
55
+
56
+ let r;
57
+ try {
58
+ r = await fetch(USAGE_URL, {
59
+ headers: {
60
+ Authorization: `Bearer ${creds.accessToken}`,
61
+ 'anthropic-beta': 'oauth-2025-04-20',
62
+ 'User-Agent': 'claude-code-session-manager',
63
+ },
64
+ signal: AbortSignal.timeout(10_000),
65
+ });
66
+ } catch (e) {
67
+ return { kind: 'transient', message: e.message || String(e), httpStatus: null };
68
+ }
69
+ if (r.status === 401 || r.status === 403) {
70
+ const body = await r.text().catch(() => '');
71
+ const ms = expiresAtMs(creds);
72
+ return { kind: 'auth', message: body.slice(0, 200) || `HTTP ${r.status}`, httpStatus: r.status, expiredAt: ms };
73
+ }
74
+ if (r.status === 408 || r.status === 429 || r.status >= 500) {
75
+ const body = await r.text().catch(() => '');
76
+ return { kind: 'transient', message: body.slice(0, 200) || `HTTP ${r.status}`, httpStatus: r.status };
77
+ }
44
78
  if (!r.ok) {
45
79
  const body = await r.text().catch(() => '');
46
- throw new Error(`usage HTTP ${r.status}: ${body.slice(0, 200)}`);
80
+ return { kind: 'transient', message: body.slice(0, 200) || `HTTP ${r.status}`, httpStatus: r.status };
47
81
  }
48
82
  const usage = await r.json();
49
83
  return {
50
- usage,
51
- subscriptionType: creds.subscriptionType ?? null,
52
- rateLimitTier: creds.rateLimitTier ?? null,
53
- credentialsExpiresAt: creds.expiresAt ?? null,
54
- fetchedAt: Date.now(),
84
+ kind: 'ok',
85
+ data: {
86
+ usage,
87
+ subscriptionType: creds.subscriptionType ?? null,
88
+ rateLimitTier: creds.rateLimitTier ?? null,
89
+ credentialsExpiresAt: creds.expiresAt ?? null,
90
+ fetchedAt: Date.now(),
91
+ },
55
92
  };
56
93
  }
57
94
 
58
95
  function registerBillingHandlers() {
96
+ hydrationPromise = hydrateCache();
97
+
59
98
  ipcMain.handle('billing:fetch', async () => {
60
- try {
61
- return { ok: true, data: await fetchUsage() };
62
- } catch (e) {
63
- return { ok: false, error: e?.message ?? String(e) };
99
+ if (hydrationPromise) { await hydrationPromise; hydrationPromise = null; }
100
+
101
+ const r = await fetchUsage();
102
+ if (r.kind === 'ok') {
103
+ cache = { data: r.data, fetchedAt: Date.now(), sourceCredsExpiresAt: r.data.credentialsExpiresAt };
104
+ persistCache(cache).catch(() => {});
105
+ return { kind: 'ok', data: r.data };
106
+ }
107
+ if (r.kind === 'auth') {
108
+ if (cache) return { kind: 'auth', message: r.message, httpStatus: r.httpStatus, expiredAt: r.expiredAt, cached: cache.data, staleSince: cache.fetchedAt };
109
+ return r;
110
+ }
111
+ if (r.kind === 'transient') {
112
+ if (cache) return { kind: 'ok-stale', data: cache.data, staleSince: cache.fetchedAt, lastError: r.message };
113
+ return r;
64
114
  }
115
+ return r; // config
65
116
  });
66
117
  }
67
118
 
@@ -109,7 +109,7 @@ async function writeMerged(patch) {
109
109
  const next = { ...existing, ...patch };
110
110
  const body = JSON.stringify(next, null, 2) + '\n';
111
111
  const tmp = `${p}.tmp-${process.pid}-${Date.now()}`;
112
- await fsp.writeFile(tmp, body, 'utf8', { mode: 0o600 });
112
+ await fsp.writeFile(tmp, body, { encoding: 'utf8', mode: 0o600 });
113
113
  // chmod tmp explicitly because some platforms ignore the mode arg on
114
114
  // writeFile when the file pre-exists. Then rename for atomicity.
115
115
  try { await fsp.chmod(tmp, 0o600); } catch { /* */ }
@@ -10,8 +10,40 @@
10
10
 
11
11
  const { ipcMain } = require('electron');
12
12
  const { spawn } = require('node:child_process');
13
- const readline = require('node:readline');
14
13
  const crypto = require('node:crypto');
14
+ const path = require('node:path');
15
+ const os = require('node:os');
16
+ const fs = require('node:fs');
17
+ const { cleanChildEnv } = require('./lib/cleanEnv.cjs');
18
+
19
+ // Splits a readable stream into lines capped at maxLineBytes. Prevents OOM
20
+ // from commands that produce no newlines (e.g. cat /dev/urandom | base64).
21
+ function lineSplit(stream, onLine, maxLineBytes = 4096) {
22
+ let buf = Buffer.alloc(0);
23
+ stream.on('data', (chunk) => {
24
+ buf = Buffer.concat([buf, chunk]);
25
+ while (true) {
26
+ const nl = buf.indexOf(0x0a);
27
+ if (nl === -1) {
28
+ if (buf.length > maxLineBytes) {
29
+ onLine(buf.slice(0, maxLineBytes).toString('utf8') + '…[truncated]');
30
+ buf = Buffer.alloc(0);
31
+ }
32
+ return;
33
+ }
34
+ const line = buf.slice(0, nl).toString('utf8');
35
+ buf = buf.slice(nl + 1);
36
+ onLine(line.length > maxLineBytes ? line.slice(0, maxLineBytes) + '…[truncated]' : line);
37
+ }
38
+ });
39
+ stream.on('end', () => {
40
+ if (buf.length > 0) {
41
+ onLine(buf.length > maxLineBytes
42
+ ? buf.slice(0, maxLineBytes).toString('utf8') + '…[truncated]'
43
+ : buf.toString('utf8'));
44
+ }
45
+ });
46
+ }
15
47
 
16
48
  class WatcherManager {
17
49
  constructor() {
@@ -25,13 +57,27 @@ class WatcherManager {
25
57
  }
26
58
 
27
59
  add({ tabId, label, command, cwd }) {
60
+ const resolvedCwd = cwd || process.cwd();
61
+
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
+ }
73
+
28
74
  const watcherId = crypto.randomUUID();
29
75
  const trimmedLabel = (label && label.trim()) || command.slice(0, 40);
30
76
  const child = spawn(command, [], {
31
- cwd: cwd || process.cwd(),
77
+ cwd: resolvedCwd,
32
78
  shell: true,
33
79
  stdio: ['ignore', 'pipe', 'pipe'],
34
- env: process.env,
80
+ env: cleanChildEnv(),
35
81
  });
36
82
 
37
83
  const entry = {
@@ -39,7 +85,7 @@ class WatcherManager {
39
85
  tabId,
40
86
  label: trimmedLabel,
41
87
  command,
42
- cwd: cwd || process.cwd(),
88
+ cwd: resolvedCwd,
43
89
  child,
44
90
  startedAt: Date.now(),
45
91
  lineCount: 0,
@@ -59,18 +105,16 @@ class WatcherManager {
59
105
  }
60
106
  };
61
107
 
62
- const stdoutRl = readline.createInterface({ input: child.stdout });
63
- stdoutRl.on('line', emitLine);
64
- const stderrRl = readline.createInterface({ input: child.stderr });
65
- stderrRl.on('line', emitLine);
108
+ lineSplit(child.stdout, emitLine);
109
+ lineSplit(child.stderr, emitLine);
66
110
 
67
111
  child.on('error', (err) => {
68
112
  emitLine(`[watcher error] ${err?.message ?? String(err)}`);
113
+ // Remove from map so list() doesn't return ghosts after a spawn failure.
114
+ this.watchers.delete(watcherId);
69
115
  });
70
116
 
71
117
  child.on('close', (code, signal) => {
72
- stdoutRl.close();
73
- stderrRl.close();
74
118
  // Only emit close if still registered (remove() suppresses by deleting first).
75
119
  if (this.watchers.has(watcherId)) {
76
120
  emitLine(`[exited code=${code ?? 'null'}${signal ? ` signal=${signal}` : ''}]`);
@@ -135,6 +179,12 @@ class WatcherManager {
135
179
 
136
180
  const manager = new WatcherManager();
137
181
 
182
+ // Top-level export so index.cjs can call watchers.attachWindow(mainWindow)
183
+ // without going through the manager instance directly.
184
+ function attachWindow(window) {
185
+ manager.attachWindow(window);
186
+ }
187
+
138
188
  function registerWatcherHandlers() {
139
189
  const { z } = require('zod');
140
190
  const addSchema = z.object({
@@ -145,10 +195,18 @@ function registerWatcherHandlers() {
145
195
  });
146
196
  const listSchema = z.object({ tabId: z.string().min(1).max(128) });
147
197
  const removeSchema = z.object({ watcherId: z.string().min(1).max(128) });
198
+ const killTabSchema = z.object({ tabId: z.string().min(1).max(128) });
148
199
 
149
200
  ipcMain.handle('watchers:add', (_e, payload) => manager.add(addSchema.parse(payload)));
150
201
  ipcMain.handle('watchers:list', (_e, payload) => manager.list(listSchema.parse(payload)));
151
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);
205
+ for (const [id, w] of manager.watchers) {
206
+ if (w.tabId === tabId) manager.remove({ watcherId: id });
207
+ }
208
+ return { ok: true };
209
+ });
152
210
  }
153
211
 
154
- module.exports = { manager, registerWatcherHandlers };
212
+ module.exports = { manager, attachWindow, registerWatcherHandlers };
@@ -10,6 +10,11 @@ export interface PtyExit {
10
10
  signal?: number;
11
11
  }
12
12
 
13
+ export interface WriteErrorEvent {
14
+ tabId: string;
15
+ reason: string;
16
+ }
17
+
13
18
  export interface ReadJsonResult {
14
19
  exists: boolean;
15
20
  raw: string;
@@ -84,6 +89,8 @@ export interface PersistedTab {
84
89
  export interface LoadedSessions {
85
90
  tabs: PersistedTab[];
86
91
  activeTabId: string | null;
92
+ /** True when the main process rotated all session IDs on boot (e.g. force-fresh). */
93
+ freshStart?: boolean;
87
94
  }
88
95
 
89
96
  export interface UsageWindow {
@@ -107,18 +114,21 @@ export interface UsageSnapshot {
107
114
  [key: string]: unknown;
108
115
  }
109
116
 
110
- export interface BillingFetchResult {
111
- ok: boolean;
112
- data?: {
113
- usage: UsageSnapshot;
114
- subscriptionType: string | null;
115
- rateLimitTier: string | null;
116
- credentialsExpiresAt: number | null;
117
- fetchedAt: number;
118
- };
119
- error?: string;
117
+ export interface BillingData {
118
+ usage: UsageSnapshot;
119
+ subscriptionType: string | null;
120
+ rateLimitTier: string | null;
121
+ credentialsExpiresAt: string | null;
122
+ fetchedAt: number;
120
123
  }
121
124
 
125
+ export type BillingFetchResult =
126
+ | { kind: 'ok'; data: BillingData }
127
+ | { kind: 'ok-stale'; data: BillingData; staleSince: number; lastError: string }
128
+ | { kind: 'auth'; message: string; httpStatus: number; expiredAt?: number | null; cached?: BillingData; staleSince?: number }
129
+ | { kind: 'transient'; message: string; httpStatus: number | null }
130
+ | { kind: 'config'; message: string };
131
+
122
132
  export interface VoiceHotkeyConfig {
123
133
  accelerator: string;
124
134
  mode: 'hold' | 'toggle';
@@ -259,7 +269,7 @@ export interface SchedulePaths {
259
269
  queue: string;
260
270
  }
261
271
 
262
- export type SchedulePauseReason = 'rate_limit';
272
+ export type SchedulePauseReason = 'rate_limit' | 'auth' | 'network' | 'manual' | 'reset_failure';
263
273
 
264
274
  export interface SchedulePauseInfo {
265
275
  reason: SchedulePauseReason;
@@ -270,6 +280,27 @@ export interface SchedulePauseInfo {
270
280
  resumeAt: string | null;
271
281
  }
272
282
 
283
+ export interface ScheduleHealthSnapshot {
284
+ bootedAt: number;
285
+ lastPollAt: number | null;
286
+ lastPollOk: boolean;
287
+ consecutiveFailures: number;
288
+ backoffNextAt: number | null;
289
+ nextResetCached: string | null;
290
+ pausedSince: number | null;
291
+ pauseReason: SchedulePauseReason | null;
292
+ runningJobs: { slug: string; startedAt: number; pid: number }[];
293
+ }
294
+
295
+ export interface PrdListItem {
296
+ slug: string;
297
+ parallelGroup: number;
298
+ title: string;
299
+ cwd: string;
300
+ estimateMinutes: number | null;
301
+ mtimeMs: number;
302
+ }
303
+
273
304
  export interface ScheduleStateSnapshot {
274
305
  config: ScheduleConfig;
275
306
  jobs: ScheduleJob[];
@@ -331,6 +362,10 @@ export interface SessionManagerAPI {
331
362
  isE2E: () => Promise<boolean>;
332
363
  onNewSession: (handler: () => void) => () => void;
333
364
  onRebootSession: (handler: () => void) => () => void;
365
+ openInEditor: (cwd: string, editor?: string | null) => Promise<{ ok: boolean; editor?: string; error?: string }>;
366
+ openInFinder: (cwd: string) => Promise<{ ok: boolean; error?: string }>;
367
+ openInTerminal: (cwd: string) => Promise<{ ok: boolean; terminal?: string; error?: string }>;
368
+ archiveProject: (encoded: string) => Promise<{ ok: boolean; error?: string }>;
334
369
  };
335
370
  pty: {
336
371
  spawn: (payload: { tabId: string; cwd: string; cols?: number; rows?: number }) => Promise<SpawnResult>;
@@ -339,6 +374,7 @@ export interface SessionManagerAPI {
339
374
  kill: (tabId: string) => void;
340
375
  onData: (tabId: string, handler: (data: string) => void) => () => void;
341
376
  onExit: (tabId: string, handler: (info: PtyExit) => void) => () => void;
377
+ onWriteError: (handler: (ev: WriteErrorEvent) => void) => () => void;
342
378
  };
343
379
  transcripts: {
344
380
  subscribe: (payload: { tabId: string; cwd: string; sessionUuid: string }) => Promise<SubscribeResult>;
@@ -393,6 +429,7 @@ export interface SessionManagerAPI {
393
429
  add: (payload: { tabId: string; label?: string; command: string; cwd?: string | null }) => Promise<WatcherAddResult>;
394
430
  list: (tabId: string) => Promise<WatcherInfo[]>;
395
431
  remove: (watcherId: string) => Promise<{ ok: boolean }>;
432
+ killTab: (tabId: string) => Promise<{ ok: boolean }>;
396
433
  onLine: (handler: (ev: WatcherLineEvent) => void) => () => void;
397
434
  onClosed: (handler: (ev: WatcherClosedEvent) => void) => () => void;
398
435
  };
@@ -412,6 +449,9 @@ export interface SessionManagerAPI {
412
449
  openFolder: () => Promise<{ ok: boolean }>;
413
450
  readPrd: (slug: string) => Promise<{ ok: boolean; text?: string; error?: string }>;
414
451
  readLog: (runId: string, slug: string) => Promise<{ ok: boolean; text?: string; error?: string }>;
452
+ writePrd: (slug: string, body: string) => Promise<{ ok: boolean; bytesWritten: number }>;
453
+ listPrds: () => Promise<PrdListItem[]>;
454
+ health: () => Promise<ScheduleHealthSnapshot>;
415
455
  onState: (handler: (snapshot: ScheduleStateSnapshot) => void) => () => void;
416
456
  };
417
457
  }
@@ -420,6 +460,8 @@ declare global {
420
460
  interface Window {
421
461
  api: SessionManagerAPI;
422
462
  }
463
+ // Injected by Vite's `define` from package.json at build time.
464
+ const __APP_VERSION__: string;
423
465
  }
424
466
 
425
467
  export {};
@@ -9,6 +9,10 @@ contextBridge.exposeInMainWorld('api', {
9
9
  pickDirectory: () => ipcRenderer.invoke('app:pick-directory'),
10
10
  gitBranch: (cwd) => ipcRenderer.invoke('app:git-branch', { cwd }),
11
11
  rebootApp: () => ipcRenderer.send('app:reboot-app'),
12
+ openInEditor: (cwd, editor) => ipcRenderer.invoke('app:open-in-editor', { cwd, editor }),
13
+ openInFinder: (cwd) => ipcRenderer.invoke('app:open-in-finder', { cwd }),
14
+ openInTerminal: (cwd) => ipcRenderer.invoke('app:open-in-terminal', { cwd }),
15
+ archiveProject: (encoded) => ipcRenderer.invoke('app:archive-project', { encoded }),
12
16
  testFireHook: (args) => ipcRenderer.invoke('app:test-fire-hook', args),
13
17
  // F7: lets the renderer suppress the wizard auto-trigger under SM_E2E=1.
14
18
  isE2E: () => ipcRenderer.invoke('app:is-e2e'),
@@ -40,6 +44,11 @@ contextBridge.exposeInMainWorld('api', {
40
44
  ipcRenderer.on(channel, listener);
41
45
  return () => ipcRenderer.removeListener(channel, listener);
42
46
  },
47
+ onWriteError: (handler) => {
48
+ const listener = (_e, payload) => handler(payload);
49
+ ipcRenderer.on('pty:write-error', listener);
50
+ return () => ipcRenderer.removeListener('pty:write-error', listener);
51
+ },
43
52
  },
44
53
  transcripts: {
45
54
  subscribe: (payload) => ipcRenderer.invoke('transcript:subscribe', payload),
@@ -111,6 +120,7 @@ contextBridge.exposeInMainWorld('api', {
111
120
  add: (payload) => ipcRenderer.invoke('watchers:add', payload),
112
121
  list: (tabId) => ipcRenderer.invoke('watchers:list', { tabId }),
113
122
  remove: (watcherId) => ipcRenderer.invoke('watchers:remove', { watcherId }),
123
+ killTab: (tabId) => ipcRenderer.invoke('watchers:kill-tab', { tabId }),
114
124
  onLine: (handler) => {
115
125
  const listener = (_e, payload) => handler(payload);
116
126
  ipcRenderer.on('watcher:line', listener);
@@ -138,6 +148,9 @@ contextBridge.exposeInMainWorld('api', {
138
148
  openFolder: () => ipcRenderer.invoke('schedule:open-folder'),
139
149
  readPrd: (slug) => ipcRenderer.invoke('schedule:read-prd', { slug }),
140
150
  readLog: (runId, slug) => ipcRenderer.invoke('schedule:read-log', { runId, slug }),
151
+ writePrd: (slug, body) => ipcRenderer.invoke('schedule:write-prd', { slug, body }),
152
+ listPrds: () => ipcRenderer.invoke('schedule:list-prds'),
153
+ health: () => ipcRenderer.invoke('schedule:health'),
141
154
  onState: (handler) => {
142
155
  const listener = (_e, payload) => handler(payload);
143
156
  ipcRenderer.on('schedule:state', listener);