claude-code-session-manager 0.4.0 → 0.5.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 (31) hide show
  1. package/dist/assets/{cssMode-D33VcoUU.js → cssMode-DuceD2Ek.js} +1 -1
  2. package/dist/assets/{editor.main-Ci9p2pYg.js → editor.main-W7kZjY3Y.js} +3 -3
  3. package/dist/assets/{freemarker2-uWXQzH8G.js → freemarker2-BrfVQxqM.js} +1 -1
  4. package/dist/assets/{handlebars-Dlo4_0Ou.js → handlebars-CEk4GZAW.js} +1 -1
  5. package/dist/assets/{html-BwTZAQQp.js → html-Dsr1hOJo.js} +1 -1
  6. package/dist/assets/{htmlMode-CTw3jPUf.js → htmlMode-DTyxWkAs.js} +1 -1
  7. package/dist/assets/index-DUYNLg5N.js +2973 -0
  8. package/dist/assets/index-QriiiRo1.css +32 -0
  9. package/dist/assets/{javascript-CXqrvCxj.js → javascript-DDnXRxuX.js} +1 -1
  10. package/dist/assets/{jsonMode-6AzuDE4-.js → jsonMode-BFDUayfd.js} +1 -1
  11. package/dist/assets/{liquid-g2_3tZyL.js → liquid-BcvXX-ei.js} +1 -1
  12. package/dist/assets/{lspLanguageFeatures-sffb1qdE.js → lspLanguageFeatures-D6rzws04.js} +1 -1
  13. package/dist/assets/{mdx-Bis1dYhL.js → mdx-DnY5OLKT.js} +1 -1
  14. package/dist/assets/{python-BhUyYPeZ.js → python-BA4bdGM0.js} +1 -1
  15. package/dist/assets/{razor-CjFmPYdP.js → razor-VjEf8dER.js} +1 -1
  16. package/dist/assets/{tsMode-B5Vq_w-B.js → tsMode-BzXie6uX.js} +1 -1
  17. package/dist/assets/{typescript-DYiI4Tub.js → typescript-BEjKh90W.js} +1 -1
  18. package/dist/assets/{xml-1xG1mvf4.js → xml-C64Hq61M.js} +1 -1
  19. package/dist/assets/{yaml-wjhmrAxI.js → yaml-BvsE9PT3.js} +1 -1
  20. package/dist/index.html +2 -2
  21. package/package.json +7 -1
  22. package/src/main/index.cjs +135 -1
  23. package/src/main/otel.cjs +248 -0
  24. package/src/main/otelSettings.cjs +119 -0
  25. package/src/main/scheduler.cjs +183 -15
  26. package/src/main/transcripts.cjs +10 -0
  27. package/src/main/watchers.cjs +154 -0
  28. package/src/preload/api.d.ts +114 -0
  29. package/src/preload/index.cjs +24 -0
  30. package/dist/assets/index-BandVNio.js +0 -2971
  31. package/dist/assets/index-CAtVp2FL.css +0 -32
@@ -54,10 +54,17 @@ const QUEUE_PATH = path.join(ROOT, 'queue.json');
54
54
  const DEFAULT_PROJECT_CWD = path.join(os.homedir(), 'Projects', 'session-manager');
55
55
 
56
56
  const DEFAULT_CONFIG = {
57
+ // Legacy on/off retained for backwards compat; v0.5+ uses firePolicy.
57
58
  enabled: false,
58
59
  offsetMinutes: 15,
59
60
  concurrencyCap: 5,
60
61
  defaultCwd: DEFAULT_PROJECT_CWD,
62
+ // 'when-available' = poll usage and fire whenever utilization < threshold.
63
+ // 'on-reset' = fire offsetMinutes after the next 5h reset (legacy).
64
+ // 'manual' = only fire on explicit Run-now click.
65
+ firePolicy: 'when-available',
66
+ // For 'when-available'. Fire only when five_hour utilization < this percent.
67
+ utilizationThreshold: 90,
61
68
  schemaVersion: 1,
62
69
  };
63
70
 
@@ -83,9 +90,10 @@ function readQueue() {
83
90
  jobs: Array.isArray(data.jobs) ? data.jobs : [],
84
91
  scheduledFor: data.scheduledFor ?? null,
85
92
  lastRunAt: data.lastRunAt ?? null,
93
+ paused: data.paused ?? null,
86
94
  };
87
95
  } catch {
88
- return { config: { ...DEFAULT_CONFIG }, jobs: [], scheduledFor: null, lastRunAt: null };
96
+ return { config: { ...DEFAULT_CONFIG }, jobs: [], scheduledFor: null, lastRunAt: null, paused: null };
89
97
  }
90
98
  }
91
99
 
@@ -213,12 +221,14 @@ function reconcile(state) {
213
221
  // ---------- next-reset detection ----------
214
222
 
215
223
  let cachedNextReset = { at: null, fetchedAt: 0 };
224
+ let cachedUtilization = null; // five_hour utilization %, 0–100, or null if unknown
216
225
 
217
226
  async function refreshNextReset() {
218
227
  try {
219
228
  const r = await billing.fetchUsage();
220
229
  const at = r?.usage?.five_hour?.resets_at ?? null;
221
230
  cachedNextReset = { at, fetchedAt: Date.now() };
231
+ cachedUtilization = r?.usage?.five_hour?.utilization ?? cachedUtilization;
222
232
  return at;
223
233
  } catch {
224
234
  return cachedNextReset.at;
@@ -233,7 +243,10 @@ function getNextResetCached() {
233
243
 
234
244
  let mainWindow = null;
235
245
  let fireTimer = null;
246
+ let resumeTimer = null;
247
+ let pollTimer = null;
236
248
  let isExecuting = false;
249
+ let cancelToken = { cancelled: false };
237
250
  let claudeBinPathCached = null;
238
251
 
239
252
  function attachWindow(w) { mainWindow = w; }
@@ -249,6 +262,8 @@ function broadcast() {
249
262
  scheduledFor: state.scheduledFor,
250
263
  lastRunAt: state.lastRunAt,
251
264
  nextReset: getNextResetCached(),
265
+ paused: state.paused,
266
+ utilization: cachedUtilization,
252
267
  });
253
268
  }
254
269
 
@@ -260,7 +275,10 @@ function clearFireTimer() {
260
275
  }
261
276
 
262
277
  function computeFireAt(state, nextResetIso) {
263
- if (!state.config.enabled) return null;
278
+ // Only the legacy 'on-reset' policy uses scheduled fire times. Other
279
+ // policies fire either immediately on demand ('manual') or via the
280
+ // when-available poll loop.
281
+ if (state.config.firePolicy !== 'on-reset') return null;
264
282
  if (!nextResetIso) return null;
265
283
  const reset = new Date(nextResetIso).getTime();
266
284
  if (Number.isNaN(reset)) return null;
@@ -292,6 +310,67 @@ async function rescheduleTimer() {
292
310
  console.log(`[scheduler] next fire in ${Math.round(delay / 1000)}s @ ${state.scheduledFor}`);
293
311
  }
294
312
 
313
+ // ---------- pause / resume ----------
314
+
315
+ function setPaused(reason, resumeAtIso) {
316
+ const s = readQueue();
317
+ if (s.paused && s.paused.reason === reason) {
318
+ // already paused for this reason; just refresh resumeAt if newer
319
+ if (resumeAtIso) s.paused.resumeAt = resumeAtIso;
320
+ } else {
321
+ s.paused = { reason, since: new Date().toISOString(), resumeAt: resumeAtIso || null };
322
+ }
323
+ writeQueue(s);
324
+ broadcast();
325
+ cancelToken.cancelled = true;
326
+ if (resumeTimer) { clearTimeout(resumeTimer); resumeTimer = null; }
327
+ if (resumeAtIso) {
328
+ // Resume 30s after the reset to give the auth/billing endpoint time to flip.
329
+ const delay = Math.max(30_000, new Date(resumeAtIso).getTime() - Date.now() + 30_000);
330
+ if (delay <= 0x7fffffff) {
331
+ resumeTimer = setTimeout(() => {
332
+ clearPause('resume-timer');
333
+ runDueJobs().catch(() => {});
334
+ }, delay);
335
+ console.log(`[scheduler] paused (${reason}); auto-resume in ${Math.round(delay/1000)}s`);
336
+ } else {
337
+ console.warn(`[scheduler] paused (${reason}); resumeAt too far in future for setTimeout (${delay}ms)`);
338
+ }
339
+ }
340
+ }
341
+
342
+ function clearPause(source) {
343
+ if (resumeTimer) { clearTimeout(resumeTimer); resumeTimer = null; }
344
+ const s = readQueue();
345
+ if (s.paused) {
346
+ console.log(`[scheduler] clearPause (${source || 'manual'})`);
347
+ s.paused = null;
348
+ writeQueue(s);
349
+ broadcast();
350
+ }
351
+ }
352
+
353
+ /** Scan the tail of a job's log for the canonical rate-limit signal. We look
354
+ * at the last 16 KB — final result event always lands at the end. */
355
+ function detectRateLimitInLog(logPath) {
356
+ try {
357
+ const stat = fs.statSync(logPath);
358
+ const start = Math.max(0, stat.size - 16384);
359
+ const len = stat.size - start;
360
+ if (len <= 0) return false;
361
+ const fd = fs.openSync(logPath, 'r');
362
+ const buf = Buffer.alloc(len);
363
+ fs.readSync(fd, buf, 0, len, start);
364
+ fs.closeSync(fd);
365
+ const text = buf.toString('utf8');
366
+ return /"rateLimitType":"five_hour"/.test(text)
367
+ || /"api_error_status":429/.test(text)
368
+ || /You'?ve hit your limit/.test(text);
369
+ } catch {
370
+ return false;
371
+ }
372
+ }
373
+
295
374
  // ---------- claude binary ----------
296
375
 
297
376
  function resolveClaudeBin() {
@@ -366,8 +445,9 @@ async function executeJob(job, runDir, defaultCwd) {
366
445
  const durationMs = Date.now() - startedAt;
367
446
  fs.writeSync(fd, `\n[scheduler] exit code=${code} duration=${Math.round(durationMs / 1000)}s\n`);
368
447
  fs.closeSync(fd);
369
- atomicWriteJson(metaPath, { slug: job.slug, cwd, exitCode: code, startedAt, finishedAt: Date.now(), durationMs });
370
- resolve({ exitCode: code, durationMs });
448
+ const rateLimited = code !== 0 && detectRateLimitInLog(logPath);
449
+ atomicWriteJson(metaPath, { slug: job.slug, cwd, exitCode: code, rateLimited, startedAt, finishedAt: Date.now(), durationMs });
450
+ resolve({ exitCode: code, durationMs, rateLimited });
371
451
  });
372
452
  });
373
453
  }
@@ -375,12 +455,16 @@ async function executeJob(job, runDir, defaultCwd) {
375
455
  async function runDueJobs() {
376
456
  if (isExecuting) return;
377
457
  isExecuting = true;
458
+ cancelToken = { cancelled: false };
378
459
  try {
379
460
  const state = readQueue();
461
+ if (state.paused) {
462
+ console.log('[scheduler] runDueJobs skipped: paused');
463
+ return;
464
+ }
380
465
  reconcile(state);
381
466
  const pending = state.jobs.filter((j) => j.status === 'pending');
382
467
  if (pending.length === 0) {
383
- isExecuting = false;
384
468
  return;
385
469
  }
386
470
  const { runId, dir: runDir } = pickRunDir();
@@ -400,6 +484,7 @@ async function runDueJobs() {
400
484
  broadcast();
401
485
 
402
486
  for (const gk of groupKeys) {
487
+ if (cancelToken.cancelled) break;
403
488
  const groupJobs = groups.get(gk);
404
489
  // Within a group: cap concurrency and run waves until all done.
405
490
  const cap = Math.max(1, Math.min(state.config.concurrencyCap, groupJobs.length));
@@ -416,14 +501,31 @@ async function runDueJobs() {
416
501
  writeQueue(s);
417
502
  broadcast();
418
503
  }
419
- const promise = executeJob(job, runDir, state.config.defaultCwd).then((res) => {
504
+ const promise = executeJob(job, runDir, state.config.defaultCwd).then(async (res) => {
505
+ // Rate-limit OR pause-already-set means we treat this job as
506
+ // unfinished — bounce it back to 'pending' so the next run
507
+ // (after token reset) picks it up.
508
+ if (res.rateLimited) {
509
+ const resetIso = await refreshNextReset();
510
+ setPaused('rate_limit', resetIso);
511
+ }
420
512
  const sn = readQueue();
421
513
  const i2 = sn.jobs.findIndex((x) => x.slug === job.slug);
422
514
  if (i2 >= 0) {
423
- sn.jobs[i2].status = res.exitCode === 0 ? 'completed' : 'failed';
424
- sn.jobs[i2].finishedAt = new Date().toISOString();
425
- sn.jobs[i2].exitCode = res.exitCode;
426
- sn.jobs[i2].error = res.error || null;
515
+ const treatAsPending = res.rateLimited || (sn.paused && sn.paused.reason === 'rate_limit');
516
+ if (treatAsPending) {
517
+ sn.jobs[i2].status = 'pending';
518
+ sn.jobs[i2].runId = null;
519
+ sn.jobs[i2].startedAt = null;
520
+ sn.jobs[i2].finishedAt = null;
521
+ sn.jobs[i2].exitCode = null;
522
+ sn.jobs[i2].error = res.rateLimited ? 'paused: rate limit' : 'paused: queue halted';
523
+ } else {
524
+ sn.jobs[i2].status = res.exitCode === 0 ? 'completed' : 'failed';
525
+ sn.jobs[i2].finishedAt = new Date().toISOString();
526
+ sn.jobs[i2].exitCode = res.exitCode;
527
+ sn.jobs[i2].error = res.error || null;
528
+ }
427
529
  writeQueue(sn);
428
530
  broadcast();
429
531
  }
@@ -433,24 +535,64 @@ async function runDueJobs() {
433
535
  };
434
536
 
435
537
  // Prime up to cap
436
- while (queue.length && inFlight.size < cap) launch(queue.shift());
437
- // Drain
538
+ while (queue.length && inFlight.size < cap && !cancelToken.cancelled) launch(queue.shift());
539
+ // Drain. If cancelled mid-group, stop launching new jobs but let
540
+ // already-launched ones settle (they're rate-limited too — short).
438
541
  while (inFlight.size > 0) {
439
542
  await Promise.race(inFlight);
543
+ if (cancelToken.cancelled) {
544
+ await Promise.allSettled([...inFlight]);
545
+ break;
546
+ }
440
547
  while (queue.length && inFlight.size < cap) launch(queue.shift());
441
548
  }
442
549
  }
443
550
  } finally {
444
551
  isExecuting = false;
445
- // After a run, disable auto-fire so the user has to opt-in for the next reset.
552
+ // No longer auto-disable after a run. The firePolicy now governs whether
553
+ // the next batch fires automatically. Just clear the one-shot scheduledFor.
446
554
  const s = readQueue();
447
- s.config.enabled = false;
448
555
  s.scheduledFor = null;
449
556
  writeQueue(s);
450
557
  broadcast();
451
558
  }
452
559
  }
453
560
 
561
+ // ---------- when-available poll loop ----------
562
+
563
+ async function pollWhenAvailable() {
564
+ try {
565
+ const state = readQueue();
566
+ if (state.config.firePolicy !== 'when-available') return;
567
+ if (state.paused) return;
568
+ if (isExecuting) return;
569
+ const pending = state.jobs.filter((j) => j.status === 'pending');
570
+ if (pending.length === 0) return;
571
+
572
+ // Refresh utilization. If the call fails, don't fire blindly — wait
573
+ // for the next tick.
574
+ let util;
575
+ try {
576
+ const r = await billing.fetchUsage();
577
+ util = r?.usage?.five_hour?.utilization ?? null;
578
+ cachedUtilization = util;
579
+ cachedNextReset = { at: r?.usage?.five_hour?.resets_at ?? cachedNextReset.at, fetchedAt: Date.now() };
580
+ } catch {
581
+ return;
582
+ }
583
+ if (util === null || util === undefined) return;
584
+ if (util >= state.config.utilizationThreshold) {
585
+ // Tokens too high — broadcast so the UI shows current util but don't fire.
586
+ broadcast();
587
+ return;
588
+ }
589
+ console.log(`[scheduler] when-available: util=${util}%, ${pending.length} pending — firing`);
590
+ runDueJobs().catch((e) => console.error('[scheduler] runDueJobs error', e));
591
+ } catch (e) {
592
+ console.error('[scheduler] poll error', e);
593
+ }
594
+ }
595
+
454
596
  // ---------- IPC ----------
455
597
 
456
598
  function registerScheduleHandlers() {
@@ -466,6 +608,8 @@ function registerScheduleHandlers() {
466
608
  scheduledFor: state.scheduledFor,
467
609
  lastRunAt: state.lastRunAt,
468
610
  nextReset: getNextResetCached(),
611
+ paused: state.paused,
612
+ utilization: cachedUtilization,
469
613
  paths: { root: ROOT, prds: PRDS_DIR, runs: RUNS_DIR, queue: QUEUE_PATH },
470
614
  };
471
615
  });
@@ -500,10 +644,17 @@ function registerScheduleHandlers() {
500
644
  });
501
645
 
502
646
  ipcMain.handle('schedule:run-now', async () => {
647
+ // Manual run-now overrides any auto-pause. Clear it first.
648
+ clearPause('run-now');
503
649
  runDueJobs().catch((e) => console.error('[scheduler] runDueJobs error', e));
504
650
  return { ok: true };
505
651
  });
506
652
 
653
+ ipcMain.handle('schedule:resume', async () => {
654
+ clearPause('manual');
655
+ return { ok: true };
656
+ });
657
+
507
658
  ipcMain.handle('schedule:refresh-reset', async () => {
508
659
  const at = await refreshNextReset();
509
660
  await rescheduleTimer();
@@ -539,11 +690,28 @@ function registerScheduleHandlers() {
539
690
  async function init() {
540
691
  ensureDirs();
541
692
  // Ensure queue.json exists with defaults so the renderer can read it.
542
- if (!fs.existsSync(QUEUE_PATH)) writeQueue({ config: { ...DEFAULT_CONFIG }, jobs: [], scheduledFor: null, lastRunAt: null });
693
+ if (!fs.existsSync(QUEUE_PATH)) writeQueue({ config: { ...DEFAULT_CONFIG }, jobs: [], scheduledFor: null, lastRunAt: null, paused: null });
694
+
695
+ // If we boot up while paused with a resumeAt in the past, clear it. This
696
+ // happens when the app was closed across the reset window.
697
+ const boot = readQueue();
698
+ if (boot.paused && boot.paused.resumeAt && new Date(boot.paused.resumeAt).getTime() <= Date.now()) {
699
+ clearPause('boot-elapsed');
700
+ } else if (boot.paused && boot.paused.resumeAt) {
701
+ // Re-arm the resume timer (lost across restart).
702
+ setPaused(boot.paused.reason, boot.paused.resumeAt);
703
+ }
704
+
543
705
  await rescheduleTimer();
544
706
  // Refresh next-reset every 10 minutes — billing window can shift if usage
545
707
  // resets early or the auth token rotates.
546
708
  setInterval(() => { rescheduleTimer().catch(() => {}); }, 10 * 60_000);
709
+ // when-available poll loop. Tick every 2 minutes; the function itself is
710
+ // a no-op when policy != 'when-available' or queue is empty/paused.
711
+ if (pollTimer) clearInterval(pollTimer);
712
+ pollTimer = setInterval(() => { pollWhenAvailable().catch(() => {}); }, 2 * 60_000);
713
+ // First tick fires after a short delay so billing is warmed up.
714
+ setTimeout(() => { pollWhenAvailable().catch(() => {}); }, 15_000);
547
715
  }
548
716
 
549
717
  module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR };
@@ -23,6 +23,7 @@ const fsp = require('node:fs/promises');
23
23
  const path = require('node:path');
24
24
  const os = require('node:os');
25
25
  const chokidar = require('chokidar');
26
+ const otel = require('./otel.cjs');
26
27
 
27
28
  let window = null;
28
29
 
@@ -132,6 +133,15 @@ async function flush(sub, { emit = true } = {}) {
132
133
  if (emit && window && !window.isDestroyed()) {
133
134
  window.webContents.send(`transcript:event:${sub.tabId}`, ev);
134
135
  }
136
+ // Mirror to OTEL — no-op when disabled. We emit on the initial drain too
137
+ // so backfilled transcripts show up in the trace store.
138
+ otel.recordTranscriptEvent({
139
+ tabId: sub.tabId,
140
+ tabCwd: sub.cwd,
141
+ kind: ev.kind,
142
+ data: ev.data,
143
+ ts: Date.now(),
144
+ });
135
145
  }
136
146
  }
137
147
 
@@ -0,0 +1,154 @@
1
+ /**
2
+ * watchers.cjs — per-tab background shell watchers.
3
+ *
4
+ * Each watcher spawns a shell command, splits stdout into lines, and forwards
5
+ * them to the renderer via `watcher:line`. Stderr is folded into the same
6
+ * stream so users see compile errors / curl progress without a second channel.
7
+ * Lifetime is process-bound: killed on `before-quit` and on explicit remove.
8
+ * No persistence across restarts (deliberately ephemeral — the user re-adds).
9
+ */
10
+
11
+ const { ipcMain } = require('electron');
12
+ const { spawn } = require('node:child_process');
13
+ const readline = require('node:readline');
14
+ const crypto = require('node:crypto');
15
+
16
+ class WatcherManager {
17
+ constructor() {
18
+ // watcherId -> { tabId, label, command, cwd, child, startedAt, lineCount }
19
+ this.watchers = new Map();
20
+ this.window = null;
21
+ }
22
+
23
+ attachWindow(window) {
24
+ this.window = window;
25
+ }
26
+
27
+ add({ tabId, label, command, cwd }) {
28
+ const watcherId = crypto.randomUUID();
29
+ const trimmedLabel = (label && label.trim()) || command.slice(0, 40);
30
+ const child = spawn(command, [], {
31
+ cwd: cwd || process.cwd(),
32
+ shell: true,
33
+ stdio: ['ignore', 'pipe', 'pipe'],
34
+ env: process.env,
35
+ });
36
+
37
+ const entry = {
38
+ watcherId,
39
+ tabId,
40
+ label: trimmedLabel,
41
+ command,
42
+ cwd: cwd || process.cwd(),
43
+ child,
44
+ startedAt: Date.now(),
45
+ lineCount: 0,
46
+ pid: child.pid ?? null,
47
+ };
48
+ this.watchers.set(watcherId, entry);
49
+
50
+ const emitLine = (line) => {
51
+ entry.lineCount++;
52
+ if (this.window && !this.window.isDestroyed()) {
53
+ this.window.webContents.send('watcher:line', {
54
+ tabId,
55
+ watcherId,
56
+ line,
57
+ ts: Date.now(),
58
+ });
59
+ }
60
+ };
61
+
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);
66
+
67
+ child.on('error', (err) => {
68
+ emitLine(`[watcher error] ${err?.message ?? String(err)}`);
69
+ });
70
+
71
+ child.on('close', (code, signal) => {
72
+ stdoutRl.close();
73
+ stderrRl.close();
74
+ // Only emit close if still registered (remove() suppresses by deleting first).
75
+ if (this.watchers.has(watcherId)) {
76
+ emitLine(`[exited code=${code ?? 'null'}${signal ? ` signal=${signal}` : ''}]`);
77
+ this.watchers.delete(watcherId);
78
+ if (this.window && !this.window.isDestroyed()) {
79
+ this.window.webContents.send('watcher:closed', { tabId, watcherId });
80
+ }
81
+ }
82
+ });
83
+
84
+ return {
85
+ watcherId,
86
+ tabId,
87
+ label: trimmedLabel,
88
+ command,
89
+ cwd: entry.cwd,
90
+ pid: entry.pid,
91
+ startedAt: entry.startedAt,
92
+ };
93
+ }
94
+
95
+ list({ tabId }) {
96
+ const out = [];
97
+ for (const w of this.watchers.values()) {
98
+ if (w.tabId !== tabId) continue;
99
+ out.push({
100
+ watcherId: w.watcherId,
101
+ tabId: w.tabId,
102
+ label: w.label,
103
+ command: w.command,
104
+ cwd: w.cwd,
105
+ pid: w.pid,
106
+ startedAt: w.startedAt,
107
+ lineCount: w.lineCount,
108
+ });
109
+ }
110
+ return out;
111
+ }
112
+
113
+ remove({ watcherId }) {
114
+ const w = this.watchers.get(watcherId);
115
+ if (!w) return { ok: false };
116
+ // Delete first so the close handler treats this as a quiet shutdown.
117
+ this.watchers.delete(watcherId);
118
+ try { w.child.kill('SIGTERM'); } catch { /* already dead */ }
119
+ // Ensure the child can't survive a stuck SIGTERM.
120
+ setTimeout(() => {
121
+ try { if (w.child.exitCode === null && w.child.signalCode === null) w.child.kill('SIGKILL'); } catch { /* */ }
122
+ }, 2000).unref?.();
123
+ if (this.window && !this.window.isDestroyed()) {
124
+ this.window.webContents.send('watcher:closed', { tabId: w.tabId, watcherId });
125
+ }
126
+ return { ok: true };
127
+ }
128
+
129
+ killAll() {
130
+ for (const watcherId of [...this.watchers.keys()]) {
131
+ this.remove({ watcherId });
132
+ }
133
+ }
134
+ }
135
+
136
+ const manager = new WatcherManager();
137
+
138
+ function registerWatcherHandlers() {
139
+ const { z } = require('zod');
140
+ const addSchema = z.object({
141
+ tabId: z.string().min(1).max(128),
142
+ label: z.string().max(256).optional().default(''),
143
+ command: z.string().min(1).max(8192),
144
+ cwd: z.string().max(4096).optional().nullable(),
145
+ });
146
+ const listSchema = z.object({ tabId: z.string().min(1).max(128) });
147
+ const removeSchema = z.object({ watcherId: z.string().min(1).max(128) });
148
+
149
+ ipcMain.handle('watchers:add', (_e, payload) => manager.add(addSchema.parse(payload)));
150
+ ipcMain.handle('watchers:list', (_e, payload) => manager.list(listSchema.parse(payload)));
151
+ ipcMain.handle('watchers:remove', (_e, payload) => manager.remove(removeSchema.parse(payload)));
152
+ }
153
+
154
+ module.exports = { manager, registerWatcherHandlers };
@@ -179,11 +179,61 @@ export interface VoiceSetTurnDetectorResult {
179
179
  state: VoiceTurnDetectorState;
180
180
  }
181
181
 
182
+ export interface TestFireHookArgs {
183
+ command: string;
184
+ env?: Record<string, string>;
185
+ payload: string;
186
+ timeoutMs?: number;
187
+ }
188
+
189
+ export interface TestFireHookResult {
190
+ exitCode: number;
191
+ stdout: string;
192
+ stderr: string;
193
+ durationMs: number;
194
+ }
195
+
196
+ export interface OtelConfig {
197
+ enabled: boolean;
198
+ endpoint: string;
199
+ /** Newline-separated "Key: Value" pairs. */
200
+ headers: string;
201
+ serviceName: string;
202
+ /** Mirrors upstream OTEL_LOG_USER_PROMPTS — opt-in PII. */
203
+ includeContent: boolean;
204
+ schemaVersion: 1;
205
+ }
206
+
207
+ export interface OtelStatus {
208
+ enabled: boolean;
209
+ initialized: boolean;
210
+ error: string | null;
211
+ includeContent: boolean;
212
+ }
213
+
214
+ export interface OtelSetConfigResult {
215
+ ok: boolean;
216
+ error: string | null;
217
+ config: OtelConfig;
218
+ status: OtelStatus;
219
+ }
220
+
221
+ export type ScheduleFirePolicy = 'manual' | 'on-reset' | 'when-available';
222
+
182
223
  export interface ScheduleConfig {
224
+ /** Legacy on/off — kept for backwards compat with v0.4 queue.json. New
225
+ * installs derive enablement from firePolicy ('manual' === disabled). */
183
226
  enabled: boolean;
227
+ /** Minutes to wait after the 5h reset before firing pending jobs.
228
+ * Used by 'on-reset'. Ignored by 'when-available' and 'manual'. */
184
229
  offsetMinutes: number;
185
230
  concurrencyCap: number;
186
231
  defaultCwd: string;
232
+ /** Auto-fire policy. Default 'when-available'. */
233
+ firePolicy: ScheduleFirePolicy;
234
+ /** When firePolicy='when-available', fire only if five_hour utilization is
235
+ * strictly below this percent. 0–100. Default 90. */
236
+ utilizationThreshold: number;
187
237
  schemaVersion: 1;
188
238
  }
189
239
 
@@ -211,16 +261,64 @@ export interface SchedulePaths {
211
261
  queue: string;
212
262
  }
213
263
 
264
+ export type SchedulePauseReason = 'rate_limit';
265
+
266
+ export interface SchedulePauseInfo {
267
+ reason: SchedulePauseReason;
268
+ /** When the pause was first set (ISO). */
269
+ since: string;
270
+ /** ISO timestamp at which auto-resume will fire (typically next 5h reset).
271
+ * null means "indefinite — wait for manual Run now". */
272
+ resumeAt: string | null;
273
+ }
274
+
214
275
  export interface ScheduleStateSnapshot {
215
276
  config: ScheduleConfig;
216
277
  jobs: ScheduleJob[];
217
278
  scheduledFor: string | null;
218
279
  lastRunAt: string | null;
219
280
  nextReset: string | null;
281
+ /** Set when scheduler self-paused (rate-limit detected). null when running normally. */
282
+ paused: SchedulePauseInfo | null;
283
+ /** Latest five_hour utilization percent (0–100) cached from billing.fetchUsage. null if unknown. */
284
+ utilization: number | null;
220
285
  /** Returned only by the initial state() call, not the broadcast event. */
221
286
  paths?: SchedulePaths;
222
287
  }
223
288
 
289
+ export interface WatcherInfo {
290
+ watcherId: string;
291
+ tabId: string;
292
+ label: string;
293
+ command: string;
294
+ cwd: string;
295
+ pid: number | null;
296
+ startedAt: number;
297
+ lineCount: number;
298
+ }
299
+
300
+ export interface WatcherAddResult {
301
+ watcherId: string;
302
+ tabId: string;
303
+ label: string;
304
+ command: string;
305
+ cwd: string;
306
+ pid: number | null;
307
+ startedAt: number;
308
+ }
309
+
310
+ export interface WatcherLineEvent {
311
+ tabId: string;
312
+ watcherId: string;
313
+ line: string;
314
+ ts: number;
315
+ }
316
+
317
+ export interface WatcherClosedEvent {
318
+ tabId: string;
319
+ watcherId: string;
320
+ }
321
+
224
322
  export interface SessionManagerAPI {
225
323
  app: {
226
324
  version: () => Promise<string>;
@@ -228,7 +326,9 @@ export interface SessionManagerAPI {
228
326
  cwd: () => Promise<string>;
229
327
  engageRulesPath: () => Promise<string | null>;
230
328
  pickDirectory: () => Promise<string | null>;
329
+ gitBranch: (cwd: string) => Promise<string | null>;
231
330
  rebootApp: () => void;
331
+ testFireHook: (args: TestFireHookArgs) => Promise<TestFireHookResult>;
232
332
  /** F7 — true under SM_E2E=1; renderer uses this to suppress wizard auto-trigger. */
233
333
  isE2E: () => Promise<boolean>;
234
334
  onNewSession: (handler: () => void) => () => void;
@@ -291,11 +391,25 @@ export interface SessionManagerAPI {
291
391
  /** F8 — persist turn-detector settings. */
292
392
  setTurnDetector: (state: VoiceTurnDetectorState) => Promise<VoiceSetTurnDetectorResult>;
293
393
  };
394
+ watchers: {
395
+ add: (payload: { tabId: string; label?: string; command: string; cwd?: string | null }) => Promise<WatcherAddResult>;
396
+ list: (tabId: string) => Promise<WatcherInfo[]>;
397
+ remove: (watcherId: string) => Promise<{ ok: boolean }>;
398
+ onLine: (handler: (ev: WatcherLineEvent) => void) => () => void;
399
+ onClosed: (handler: (ev: WatcherClosedEvent) => void) => () => void;
400
+ };
401
+ otel: {
402
+ getConfig: () => Promise<OtelConfig>;
403
+ setConfig: (cfg: OtelConfig) => Promise<OtelSetConfigResult>;
404
+ status: () => Promise<OtelStatus>;
405
+ configPath: () => Promise<string>;
406
+ };
294
407
  schedule: {
295
408
  state: () => Promise<ScheduleStateSnapshot>;
296
409
  setConfig: (partial: Partial<ScheduleConfig>) => Promise<{ ok: boolean; config: ScheduleConfig }>;
297
410
  resetJob: (slug: string) => Promise<{ ok: boolean; error?: string }>;
298
411
  runNow: () => Promise<{ ok: boolean }>;
412
+ resume: () => Promise<{ ok: boolean }>;
299
413
  refreshReset: () => Promise<{ ok: boolean; nextReset: string | null }>;
300
414
  openFolder: () => Promise<{ ok: boolean }>;
301
415
  readPrd: (slug: string) => Promise<{ ok: boolean; text?: string; error?: string }>;