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.
- package/dist/assets/{cssMode-D33VcoUU.js → cssMode-DuceD2Ek.js} +1 -1
- package/dist/assets/{editor.main-Ci9p2pYg.js → editor.main-W7kZjY3Y.js} +3 -3
- package/dist/assets/{freemarker2-uWXQzH8G.js → freemarker2-BrfVQxqM.js} +1 -1
- package/dist/assets/{handlebars-Dlo4_0Ou.js → handlebars-CEk4GZAW.js} +1 -1
- package/dist/assets/{html-BwTZAQQp.js → html-Dsr1hOJo.js} +1 -1
- package/dist/assets/{htmlMode-CTw3jPUf.js → htmlMode-DTyxWkAs.js} +1 -1
- package/dist/assets/index-DUYNLg5N.js +2973 -0
- package/dist/assets/index-QriiiRo1.css +32 -0
- package/dist/assets/{javascript-CXqrvCxj.js → javascript-DDnXRxuX.js} +1 -1
- package/dist/assets/{jsonMode-6AzuDE4-.js → jsonMode-BFDUayfd.js} +1 -1
- package/dist/assets/{liquid-g2_3tZyL.js → liquid-BcvXX-ei.js} +1 -1
- package/dist/assets/{lspLanguageFeatures-sffb1qdE.js → lspLanguageFeatures-D6rzws04.js} +1 -1
- package/dist/assets/{mdx-Bis1dYhL.js → mdx-DnY5OLKT.js} +1 -1
- package/dist/assets/{python-BhUyYPeZ.js → python-BA4bdGM0.js} +1 -1
- package/dist/assets/{razor-CjFmPYdP.js → razor-VjEf8dER.js} +1 -1
- package/dist/assets/{tsMode-B5Vq_w-B.js → tsMode-BzXie6uX.js} +1 -1
- package/dist/assets/{typescript-DYiI4Tub.js → typescript-BEjKh90W.js} +1 -1
- package/dist/assets/{xml-1xG1mvf4.js → xml-C64Hq61M.js} +1 -1
- package/dist/assets/{yaml-wjhmrAxI.js → yaml-BvsE9PT3.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +7 -1
- package/src/main/index.cjs +135 -1
- package/src/main/otel.cjs +248 -0
- package/src/main/otelSettings.cjs +119 -0
- package/src/main/scheduler.cjs +183 -15
- package/src/main/transcripts.cjs +10 -0
- package/src/main/watchers.cjs +154 -0
- package/src/preload/api.d.ts +114 -0
- package/src/preload/index.cjs +24 -0
- package/dist/assets/index-BandVNio.js +0 -2971
- package/dist/assets/index-CAtVp2FL.css +0 -32
package/src/main/scheduler.cjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
370
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
//
|
|
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 };
|
package/src/main/transcripts.cjs
CHANGED
|
@@ -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 };
|
package/src/preload/api.d.ts
CHANGED
|
@@ -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 }>;
|