claude-code-session-manager 0.21.2 → 0.21.3
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/bin/cli.cjs +5 -0
- package/dist/assets/{TiptapBody-CepFtp62.js → TiptapBody-PdmsfUCQ.js} +2 -2
- package/dist/assets/cssMode-DfqZGMQs.js +1 -0
- package/dist/assets/{freemarker2-DqQlU_4i.js → freemarker2-XTPYh37h.js} +1 -1
- package/dist/assets/handlebars-DKUF5VyH.js +1 -0
- package/dist/assets/html-uqoqsIeI.js +1 -0
- package/dist/assets/htmlMode-aMTQs1su.js +1 -0
- package/dist/assets/index-DO3ROR11.js +3525 -0
- package/dist/assets/index-DeQI4oVI.css +32 -0
- package/dist/assets/javascript-BVxRZMds.js +1 -0
- package/dist/assets/{jsonMode-CFEryxme.js → jsonMode-D04xP2s5.js} +4 -4
- package/dist/assets/liquid-BkQHTH2P.js +1 -0
- package/dist/assets/lspLanguageFeatures-By9uLznH.js +4 -0
- package/dist/assets/mdx-Du1IlbjV.js +1 -0
- package/dist/assets/{index-CrE67_1W.css → monaco-editor-BTnBOi8r.css} +1 -32
- package/dist/assets/monaco-editor-BW5C4Iv1.js +908 -0
- package/dist/assets/python-DSlImqXd.js +1 -0
- package/dist/assets/razor-BmUVyvSK.js +1 -0
- package/dist/assets/{tsMode-CNLm8WAZ.js → tsMode-Btj0TTH7.js} +1 -1
- package/dist/assets/typescript-Bzelq9vO.js +1 -0
- package/dist/assets/xml-Whd9EaSd.js +1 -0
- package/dist/assets/yaml-QYf0-IN8.js +1 -0
- package/dist/index.html +4 -2
- package/package.json +1 -1
- package/src/main/__tests__/runVerify.test.cjs +101 -0
- package/src/main/config.cjs +36 -4
- package/src/main/historyAggregator.cjs +400 -149
- package/src/main/index.cjs +8 -0
- package/src/main/ipcSchemas.cjs +42 -13
- package/src/main/kg.cjs +87 -30
- package/src/main/lib/credentials.cjs +7 -0
- package/src/main/lib/e2eStateMachine.cjs +39 -0
- package/src/main/runVerify.cjs +28 -5
- package/src/main/scheduler/prdParser.cjs +16 -1
- package/src/main/scheduler.cjs +97 -13
- package/src/main/transcripts.cjs +141 -19
- package/src/main/usageMatrix.cjs +7 -3
- package/src/main/webRemote.cjs +190 -29
- package/src/preload/api.d.ts +40 -0
- package/src/preload/index.cjs +7 -0
- package/dist/assets/cssMode-8hR_Zezu.js +0 -1
- package/dist/assets/handlebars-Ts2NzFcS.js +0 -1
- package/dist/assets/html-QjLxt2p6.js +0 -1
- package/dist/assets/htmlMode-Dst38sy3.js +0 -1
- package/dist/assets/index-XKsJ4Pk3.js +0 -4431
- package/dist/assets/javascript-CNxLjNGz.js +0 -1
- package/dist/assets/liquid-BBfKLTB_.js +0 -1
- package/dist/assets/lspLanguageFeatures-BNyh7ouG.js +0 -4
- package/dist/assets/mdx-SaTyS1xC.js +0 -1
- package/dist/assets/python-C84TNhMd.js +0 -1
- package/dist/assets/razor-BaVJM3L8.js +0 -1
- package/dist/assets/typescript-BdrDpzPy.js +0 -1
- package/dist/assets/xml-CHJ3Xjjj.js +0 -1
- package/dist/assets/yaml-Cg2-K8t3.js +0 -1
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -394,35 +394,64 @@ function validated(schema, handler) {
|
|
|
394
394
|
}
|
|
395
395
|
|
|
396
396
|
// ──────────────────────────────────────────── Web Remote command allowlist
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
|
|
397
|
+
// Commands are split into three tiers:
|
|
398
|
+
// READ_COMMANDS — return data; allowed when remoteEnabled=true.
|
|
399
|
+
// SAS_GATED_READS — return sensitive user data (sessions, PRDs, logs,
|
|
400
|
+
// transcript summaries); additionally require
|
|
401
|
+
// _e2eAuthenticated=true (SAS confirmed by user).
|
|
402
|
+
// A compromised relay cannot exfiltrate this data from
|
|
403
|
+
// a session that has not been SAS-confirmed.
|
|
404
|
+
// MUTATE_COMMANDS — write files, spawn processes, or mutate persisted
|
|
405
|
+
// state; gated behind remoteControlEnabled=true AND
|
|
406
|
+
// _e2eAuthenticated=true.
|
|
407
|
+
// ALLOWED_COMMANDS is the union, kept for existing import compatibility.
|
|
408
|
+
//
|
|
409
|
+
// Ungated READ_COMMANDS (justify each):
|
|
410
|
+
// cmd:app:version — exposes only the app semver string; no user data.
|
|
411
|
+
// cmd:session:unsubscribe — teardown lifecycle; returns nothing sensitive.
|
|
412
|
+
const READ_COMMANDS = new Set([
|
|
413
|
+
'cmd:app:version',
|
|
414
|
+
// v2 mobile: unsubscribe is a teardown lifecycle call with no data payload.
|
|
415
|
+
'cmd:session:unsubscribe',
|
|
416
|
+
]);
|
|
417
|
+
|
|
418
|
+
// Sensitive reads — return user data; require SAS confirmation same as MUTATE.
|
|
419
|
+
const SAS_GATED_READS = new Set([
|
|
401
420
|
'cmd:sessions:load',
|
|
421
|
+
'cmd:schedule:state',
|
|
422
|
+
'cmd:schedule:read-prd',
|
|
423
|
+
'cmd:schedule:read-log',
|
|
424
|
+
'cmd:history:aggregate',
|
|
425
|
+
// subscribe initiates a live stream of session state/summary — sensitive.
|
|
426
|
+
'cmd:session:subscribe',
|
|
427
|
+
]);
|
|
428
|
+
|
|
429
|
+
const MUTATE_COMMANDS = new Set([
|
|
402
430
|
'cmd:sessions:save',
|
|
403
431
|
'cmd:pty:spawn',
|
|
404
432
|
'cmd:pty:write',
|
|
405
|
-
|
|
433
|
+
// pty:kill terminates a live session; pty:resize drives the geometry of the
|
|
434
|
+
// user's interactive PTY — both write live process state, so they are gated
|
|
435
|
+
// behind remoteControlEnabled + SAS like every other mutation. A read-only
|
|
436
|
+
// mobile mirror has no business killing or resizing the desktop's session.
|
|
406
437
|
'cmd:pty:kill',
|
|
407
|
-
'cmd:
|
|
408
|
-
'cmd:schedule:read-prd',
|
|
409
|
-
'cmd:schedule:read-log',
|
|
438
|
+
'cmd:pty:resize',
|
|
410
439
|
'cmd:schedule:write-prd',
|
|
411
440
|
'cmd:schedule:reset-job',
|
|
412
441
|
'cmd:schedule:run-now',
|
|
413
442
|
'cmd:schedule:set-config',
|
|
414
|
-
'cmd:history:aggregate',
|
|
415
|
-
'cmd:app:version',
|
|
416
|
-
// v2 mobile: per-session live state + summary push (ARCHITECTURE-V2-MOBILE.md §3)
|
|
417
|
-
'cmd:session:subscribe',
|
|
418
|
-
'cmd:session:unsubscribe',
|
|
419
443
|
]);
|
|
420
444
|
|
|
445
|
+
const ALLOWED_COMMANDS = new Set([...READ_COMMANDS, ...SAS_GATED_READS, ...MUTATE_COMMANDS]);
|
|
446
|
+
|
|
421
447
|
module.exports = {
|
|
422
448
|
// Centralized slug regex — used by scheduler.cjs and queueOps.cjs for
|
|
423
449
|
// direct test()/match() containment checks alongside the zod parses.
|
|
424
450
|
SCHEDULE_SLUG_RE,
|
|
425
451
|
SCHEDULE_RUN_ID_RE,
|
|
452
|
+
READ_COMMANDS,
|
|
453
|
+
SAS_GATED_READS,
|
|
454
|
+
MUTATE_COMMANDS,
|
|
426
455
|
ALLOWED_COMMANDS,
|
|
427
456
|
schemas: {
|
|
428
457
|
webRemotePair,
|
package/src/main/kg.cjs
CHANGED
|
@@ -39,16 +39,24 @@ const path = require('node:path');
|
|
|
39
39
|
const os = require('node:os');
|
|
40
40
|
const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
|
|
41
41
|
const { encodeCwd } = require('./lib/encodeCwd.cjs');
|
|
42
|
+
const { writeJson } = require('./config.cjs');
|
|
42
43
|
|
|
43
44
|
const HOME = os.homedir();
|
|
44
45
|
const KG_DIR = path.join(HOME, '.claude', 'knowledge-log');
|
|
45
46
|
const LOG_PATH = path.join(KG_DIR, 'prompts.jsonl');
|
|
46
47
|
const GRAPHS_DIR = path.join(KG_DIR, 'graphs');
|
|
47
48
|
const INGEST_STATE_PATH = path.join(KG_DIR, 'ingest-state.json');
|
|
49
|
+
const PROMPT_INDEX_PATH = path.join(KG_DIR, 'prompt-index.json');
|
|
48
50
|
const BATCH = 20; // prompts per extraction call (also a per-project cap)
|
|
49
51
|
const KNOWN_VOCAB = 200; // top node names pre-seeded for dedup-at-extraction
|
|
50
52
|
const MAX_TAIL_BYTES = 8 * 1024 * 1024; // bound bytes scanned per ingest run
|
|
51
53
|
const MAX_EXTRACTIONS_PER_RUN = 30; // bound claude calls per run (cost/time)
|
|
54
|
+
// Coalescing window before an auto-ingest after new prompts land. Units never
|
|
55
|
+
// mix projects, and a project switch in the log closes the current batch — so
|
|
56
|
+
// with concurrent sessions a short window yields 1-2-prompt batches and one
|
|
57
|
+
// claude spawn each (~1.2K extraction runs in one 48h period). A long window
|
|
58
|
+
// lets prompts accumulate into fuller batches; the KG tab tolerates the lag.
|
|
59
|
+
const WATCH_COALESCE_MS = 5 * 60_000;
|
|
52
60
|
|
|
53
61
|
const ENTITY_TYPES = ['project', 'feature', 'tool', 'tech', 'concept', 'goal', 'person'];
|
|
54
62
|
|
|
@@ -137,11 +145,7 @@ async function loadGraphFor(cwd) {
|
|
|
137
145
|
}
|
|
138
146
|
|
|
139
147
|
async function saveGraph(g) {
|
|
140
|
-
await
|
|
141
|
-
const p = graphPath(g.cwd);
|
|
142
|
-
const tmp = `${p}.tmp`;
|
|
143
|
-
await fsp.writeFile(tmp, JSON.stringify(g, null, 2));
|
|
144
|
-
await fsp.rename(tmp, p); // atomic
|
|
148
|
+
await writeJson(graphPath(g.cwd), g);
|
|
145
149
|
}
|
|
146
150
|
|
|
147
151
|
async function loadIngestState() {
|
|
@@ -152,10 +156,20 @@ async function loadIngestState() {
|
|
|
152
156
|
}
|
|
153
157
|
|
|
154
158
|
async function saveIngestState(s) {
|
|
155
|
-
await
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
+
await writeJson(INGEST_STATE_PATH, s);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Per-project prompt-count sidecar: { [encodedCwd]: { count: number, cwd: string } }
|
|
164
|
+
* Returns null when the file does not yet exist (triggers a one-time migration scan).
|
|
165
|
+
*/
|
|
166
|
+
async function readPromptIndex() {
|
|
167
|
+
try { return JSON.parse(await fsp.readFile(PROMPT_INDEX_PATH, 'utf8')); }
|
|
168
|
+
catch { return null; }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function savePromptIndex(idx) {
|
|
172
|
+
await writeJson(PROMPT_INDEX_PATH, idx);
|
|
159
173
|
}
|
|
160
174
|
|
|
161
175
|
/** Canonical dedup key: lowercase, strip leading article, collapse whitespace. */
|
|
@@ -337,6 +351,7 @@ async function ingest() {
|
|
|
337
351
|
broadcast('kg:ingest-progress', { phase: 'start', ingesting: true });
|
|
338
352
|
try {
|
|
339
353
|
const st = await loadIngestState();
|
|
354
|
+
const promptIdx = await readPromptIndex() ?? {};
|
|
340
355
|
let stat;
|
|
341
356
|
try { stat = await fsp.stat(LOG_PATH); }
|
|
342
357
|
catch { broadcast('kg:ingest-progress', { phase: 'done', ingesting: false, added: 0 }); return { ok: true, added: 0, note: 'no log yet' }; }
|
|
@@ -423,6 +438,14 @@ async function ingest() {
|
|
|
423
438
|
st.lastOffset += u.bytes;
|
|
424
439
|
st.lastTs = u.entries[u.entries.length - 1].ts || st.lastTs;
|
|
425
440
|
st.updatedAt = new Date().toISOString();
|
|
441
|
+
// Write index before advancing watermark: if we crash between these two
|
|
442
|
+
// writes, the watermark hasn't moved so the batch will be re-processed
|
|
443
|
+
// (the index count may be slightly high) rather than advanced past a
|
|
444
|
+
// batch whose index entry was never written.
|
|
445
|
+
if (!promptIdx[u.enc]) promptIdx[u.enc] = { count: 0, cwd: u.cwd };
|
|
446
|
+
promptIdx[u.enc].count += u.entries.length;
|
|
447
|
+
promptIdx[u.enc].cwd = u.cwd;
|
|
448
|
+
await savePromptIndex(promptIdx);
|
|
426
449
|
await saveIngestState(st);
|
|
427
450
|
if (extractions >= MAX_EXTRACTIONS_PER_RUN) { capped = true; break; }
|
|
428
451
|
continue;
|
|
@@ -435,12 +458,19 @@ async function ingest() {
|
|
|
435
458
|
g.updatedAt = new Date().toISOString();
|
|
436
459
|
|
|
437
460
|
// Commit this batch: graph first (so a crash can't advance the watermark
|
|
438
|
-
// past unsaved work), then the watermark.
|
|
461
|
+
// past unsaved work), then the watermark + sidecar index.
|
|
439
462
|
await saveGraph(g);
|
|
440
463
|
st.lastOffset += u.bytes;
|
|
441
464
|
st.promptCount += u.entries.length;
|
|
442
465
|
st.lastTs = batchTs;
|
|
443
466
|
st.updatedAt = new Date().toISOString();
|
|
467
|
+
// Write index before advancing watermark so a crash between the two
|
|
468
|
+
// leaves the watermark un-advanced (re-processable) rather than
|
|
469
|
+
// advancing past a batch whose index entry was never committed.
|
|
470
|
+
if (!promptIdx[u.enc]) promptIdx[u.enc] = { count: 0, cwd: u.cwd };
|
|
471
|
+
promptIdx[u.enc].count += u.entries.length;
|
|
472
|
+
promptIdx[u.enc].cwd = u.cwd;
|
|
473
|
+
await savePromptIndex(promptIdx);
|
|
444
474
|
await saveIngestState(st);
|
|
445
475
|
|
|
446
476
|
committedPrompts += u.entries.length;
|
|
@@ -473,25 +503,29 @@ async function ingest() {
|
|
|
473
503
|
|
|
474
504
|
/** Enumerate projects seen in the log, enriched with per-project graph stats. */
|
|
475
505
|
async function listProjects() {
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
506
|
+
let idx = await readPromptIndex();
|
|
507
|
+
if (idx === null) {
|
|
508
|
+
// One-time migration: build sidecar from the full log.
|
|
509
|
+
idx = {};
|
|
510
|
+
const prompts = await readAllPrompts();
|
|
511
|
+
for (const p of prompts) {
|
|
512
|
+
if (!p.cwd) continue;
|
|
513
|
+
const enc = encodeCwd(p.cwd);
|
|
514
|
+
if (!idx[enc]) idx[enc] = { count: 0, cwd: p.cwd };
|
|
515
|
+
idx[enc].count++;
|
|
516
|
+
idx[enc].cwd = p.cwd;
|
|
517
|
+
}
|
|
518
|
+
await savePromptIndex(idx).catch(() => {});
|
|
485
519
|
}
|
|
486
520
|
const out = [];
|
|
487
|
-
for (const
|
|
488
|
-
const g = await loadGraphFor(
|
|
521
|
+
for (const [enc, entry] of Object.entries(idx)) {
|
|
522
|
+
const g = await loadGraphFor(entry.cwd);
|
|
489
523
|
out.push({
|
|
490
|
-
cwd:
|
|
491
|
-
label: shortLabel(
|
|
492
|
-
total:
|
|
524
|
+
cwd: entry.cwd,
|
|
525
|
+
label: shortLabel(entry.cwd),
|
|
526
|
+
total: entry.count,
|
|
493
527
|
processed: g.promptCount || 0,
|
|
494
|
-
pending: Math.max(0,
|
|
528
|
+
pending: Math.max(0, entry.count - (g.promptCount || 0)),
|
|
495
529
|
nodes: g.nodes.length,
|
|
496
530
|
edges: g.edges.length,
|
|
497
531
|
lastIngest: g.updatedAt,
|
|
@@ -510,7 +544,24 @@ async function getState(cwd) {
|
|
|
510
544
|
const target = cwd || await defaultCwd();
|
|
511
545
|
const enc = encodeCwd(target);
|
|
512
546
|
const g = await loadGraphFor(target);
|
|
513
|
-
|
|
547
|
+
let idx = await readPromptIndex();
|
|
548
|
+
let totalPrompts;
|
|
549
|
+
if (idx === null) {
|
|
550
|
+
// One-time migration fallback — build from full log.
|
|
551
|
+
idx = {};
|
|
552
|
+
const prompts = await readAllPrompts();
|
|
553
|
+
for (const p of prompts) {
|
|
554
|
+
if (!p.cwd) continue;
|
|
555
|
+
const e2 = encodeCwd(p.cwd);
|
|
556
|
+
if (!idx[e2]) idx[e2] = { count: 0, cwd: p.cwd };
|
|
557
|
+
idx[e2].count++;
|
|
558
|
+
idx[e2].cwd = p.cwd;
|
|
559
|
+
}
|
|
560
|
+
await savePromptIndex(idx).catch(() => {});
|
|
561
|
+
totalPrompts = idx[enc]?.count ?? 0;
|
|
562
|
+
} else {
|
|
563
|
+
totalPrompts = idx[enc]?.count ?? 0;
|
|
564
|
+
}
|
|
514
565
|
return {
|
|
515
566
|
cwd: target,
|
|
516
567
|
label: shortLabel(target),
|
|
@@ -518,8 +569,8 @@ async function getState(cwd) {
|
|
|
518
569
|
edges: g.edges,
|
|
519
570
|
status: {
|
|
520
571
|
promptCount: g.promptCount || 0,
|
|
521
|
-
totalPrompts
|
|
522
|
-
pending: Math.max(0,
|
|
572
|
+
totalPrompts,
|
|
573
|
+
pending: Math.max(0, totalPrompts - (g.promptCount || 0)),
|
|
523
574
|
lastIngest: g.updatedAt,
|
|
524
575
|
ingesting,
|
|
525
576
|
logPath: LOG_PATH,
|
|
@@ -584,8 +635,14 @@ function init(opts = {}) {
|
|
|
584
635
|
fs.mkdirSync(KG_DIR, { recursive: true });
|
|
585
636
|
fs.watch(KG_DIR, (_evt, file) => {
|
|
586
637
|
if (file && file !== 'prompts.jsonl') return;
|
|
587
|
-
|
|
588
|
-
|
|
638
|
+
// Leading-edge coalesce: first new prompt arms the timer; later prompts
|
|
639
|
+
// ride along instead of resetting it, so busy periods can't starve
|
|
640
|
+
// ingest and every run sees a full window's worth of prompts.
|
|
641
|
+
if (watchTimer) return;
|
|
642
|
+
watchTimer = setTimeout(() => {
|
|
643
|
+
watchTimer = null;
|
|
644
|
+
ingest().catch(() => {});
|
|
645
|
+
}, WATCH_COALESCE_MS);
|
|
589
646
|
});
|
|
590
647
|
} catch { /* watch is best-effort */ }
|
|
591
648
|
}
|
|
@@ -168,6 +168,13 @@ async function refreshIfNeeded(forceRefresh = false) {
|
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
if (alreadyExpired) {
|
|
171
|
+
// Re-read from disk in case credentials were externally refreshed (e.g. via
|
|
172
|
+
// `claude login`) between our initial read and the failed OAuth attempt.
|
|
173
|
+
const recheckCr = await readCredentials();
|
|
174
|
+
if (recheckCr.kind === 'ok' && !isExpired(recheckCr.creds)) {
|
|
175
|
+
appendRefreshLog({ event: 'externally_refreshed_ok', recheckExpiresAt: recheckCr.creds.expiresAt ?? null });
|
|
176
|
+
return { kind: 'ok', creds: recheckCr.creds };
|
|
177
|
+
}
|
|
171
178
|
const ms = expiresAtMs(creds);
|
|
172
179
|
appendRefreshLog({ event: 'auth_failed_expired', expiredAtMs: ms });
|
|
173
180
|
return {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure E2E session state machine for the web-remote relay.
|
|
3
|
+
* No Electron, no I/O — importable in unit tests.
|
|
4
|
+
*
|
|
5
|
+
* State transitions:
|
|
6
|
+
* idle → pending_sas : successful deriveSessionKey + deriveSas
|
|
7
|
+
* idle → failed : crypto derivation error
|
|
8
|
+
* pending_sas → authenticated : user confirms SAS
|
|
9
|
+
* pending_sas → failed : deriveSas threw after sessionKey succeeded
|
|
10
|
+
* any → idle : disconnect / reset
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** @returns {{ state: string, sessionKey: Buffer|null, pendingSas: string|null }} */
|
|
14
|
+
function makeState(state = 'idle', sessionKey = null, pendingSas = null) {
|
|
15
|
+
return { state, sessionKey, pendingSas };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Attempt to confirm the SAS. Pure — does not mutate; returns the next state.
|
|
20
|
+
* @param {{ state: string, sessionKey: Buffer|null, pendingSas: string|null }} e2eState
|
|
21
|
+
* @returns {{ ok: boolean, error?: string, next: { state: string, sessionKey: Buffer|null, pendingSas: string|null } }}
|
|
22
|
+
*/
|
|
23
|
+
function confirmSas(e2eState) {
|
|
24
|
+
if (e2eState.state !== 'pending_sas') {
|
|
25
|
+
const errorMap = {
|
|
26
|
+
idle: 'no_e2e_session',
|
|
27
|
+
failed: 'e2e_failed',
|
|
28
|
+
authenticated: 'already_authenticated',
|
|
29
|
+
};
|
|
30
|
+
const error = errorMap[e2eState.state] ?? 'unexpected_state';
|
|
31
|
+
return { ok: false, error, next: e2eState };
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
ok: true,
|
|
35
|
+
next: makeState('authenticated', e2eState.sessionKey, null),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { makeState, confirmSas };
|
package/src/main/runVerify.cjs
CHANGED
|
@@ -58,20 +58,24 @@ function detectPattern(content) {
|
|
|
58
58
|
return { verdict: 'transcript_errors', pattern: 'FAIL/FATAL at line start' };
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
// (2) Python Traceback +
|
|
61
|
+
// (2) Python Traceback + exception line within next 10 lines. Both anchored
|
|
62
|
+
// to line starts: reviewer prose quoting "will crash with ImportError" or
|
|
63
|
+
// embedding "...Error:" mid-sentence must not match (feedback 2026-06-10-01).
|
|
62
64
|
const lines = content.split('\n');
|
|
63
65
|
for (let i = 0; i < lines.length; i++) {
|
|
64
|
-
if (
|
|
66
|
+
if (/^\s*Traceback \(most recent call last\):/.test(lines[i])) {
|
|
65
67
|
for (let j = i + 1; j < Math.min(i + 11, lines.length); j++) {
|
|
66
|
-
if (
|
|
68
|
+
if (/^\s*[A-Za-z_][\w.]*(?:Error|Exception)\s*:/.test(lines[j])) {
|
|
67
69
|
return { verdict: 'transcript_errors', pattern: 'Traceback + Error within 10 lines' };
|
|
68
70
|
}
|
|
69
71
|
}
|
|
70
72
|
}
|
|
71
73
|
}
|
|
72
74
|
|
|
73
|
-
// (3) Import / module errors (verification was skipped).
|
|
74
|
-
|
|
75
|
+
// (3) Import / module errors (verification was skipped). Line-anchored:
|
|
76
|
+
// real interpreter output starts the line with the exception name
|
|
77
|
+
// ("ModuleNotFoundError: No module named 'x'"); prose never does.
|
|
78
|
+
if (/^\s*(?:ModuleNotFoundError|ImportError)\s*(?::|$)/m.test(content)) {
|
|
75
79
|
return { verdict: 'verify_unavailable', pattern: 'ModuleNotFoundError/ImportError' };
|
|
76
80
|
}
|
|
77
81
|
|
|
@@ -195,6 +199,18 @@ function toolUseDesc(events, toolUseId) {
|
|
|
195
199
|
return '';
|
|
196
200
|
}
|
|
197
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Return the tool name of the tool_use that produced a given tool_result.
|
|
204
|
+
* Returns '' if not found.
|
|
205
|
+
*/
|
|
206
|
+
function toolUseName(events, toolUseId) {
|
|
207
|
+
if (!toolUseId) return '';
|
|
208
|
+
for (const ev of events) {
|
|
209
|
+
if (ev.kind === 'tool_use' && ev.toolUseId === toolUseId) return ev.toolName ?? '';
|
|
210
|
+
}
|
|
211
|
+
return '';
|
|
212
|
+
}
|
|
213
|
+
|
|
198
214
|
/**
|
|
199
215
|
* Check whether the next ≤5 tool_use calls after `fromSeq` include a package
|
|
200
216
|
* install command (pip install, pip3 install, uv sync, uv pip install).
|
|
@@ -471,6 +487,12 @@ async function verifyRun({ runDir, prdPath, queueEntry, allJobs = [] }) {
|
|
|
471
487
|
|
|
472
488
|
if (!ev.content) continue;
|
|
473
489
|
|
|
490
|
+
// Subagent (Task) results are structured prose — review findings that
|
|
491
|
+
// *describe* exceptions ("will crash with ImportError") are the dominant
|
|
492
|
+
// false-positive source (feedback 2026-06-10-01). Real runtime errors
|
|
493
|
+
// surface through Bash/test tool_results, which are still scanned.
|
|
494
|
+
if (toolUseName(events, ev.toolUseId) === 'Task') continue;
|
|
495
|
+
|
|
474
496
|
const hit = detectPattern(ev.content);
|
|
475
497
|
if (!hit) continue;
|
|
476
498
|
|
|
@@ -520,6 +542,7 @@ module.exports = {
|
|
|
520
542
|
verifyRun,
|
|
521
543
|
// Exposed for unit tests.
|
|
522
544
|
detectPattern,
|
|
545
|
+
toolUseName,
|
|
523
546
|
extractSoakFromBody,
|
|
524
547
|
parsePrdBodyDepFragments,
|
|
525
548
|
checkDeps,
|
|
@@ -15,9 +15,24 @@
|
|
|
15
15
|
|
|
16
16
|
const fs = require('node:fs');
|
|
17
17
|
const fsp = require('node:fs/promises');
|
|
18
|
+
const os = require('node:os');
|
|
18
19
|
const path = require('node:path');
|
|
19
20
|
const { splitFrontmatter } = require('../lib/prdFrontmatter.cjs');
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Expand a PRD `cwd` value to an absolute path.
|
|
24
|
+
* - `~/...` or `~` alone → absolute under os.homedir()
|
|
25
|
+
* - Already-absolute paths pass through unchanged.
|
|
26
|
+
* - Bare relative paths → joined onto os.homedir().
|
|
27
|
+
* null/empty returns null (caller falls back to defaultCwd).
|
|
28
|
+
*/
|
|
29
|
+
function expandCwd(cwd) {
|
|
30
|
+
if (!cwd) return null;
|
|
31
|
+
if (cwd === '~' || cwd.startsWith('~/')) return path.join(os.homedir(), cwd.slice(1));
|
|
32
|
+
if (path.isAbsolute(cwd)) return cwd;
|
|
33
|
+
return path.join(os.homedir(), cwd);
|
|
34
|
+
}
|
|
35
|
+
|
|
21
36
|
// Hard cap to keep one malformed PRD (e.g. a binary blob accidentally renamed
|
|
22
37
|
// .md) from wedging the main thread. PRDs are PRDs, not media files; 1 MB is
|
|
23
38
|
// already ~25k lines and well beyond any legitimate authored doc.
|
|
@@ -46,7 +61,7 @@ async function parsePrdRaw(filePath) {
|
|
|
46
61
|
slug: base,
|
|
47
62
|
path: filePath,
|
|
48
63
|
title: fm.title || base,
|
|
49
|
-
cwd: fm.cwd || null,
|
|
64
|
+
cwd: expandCwd(fm.cwd || null),
|
|
50
65
|
estimateMinutes: fm.estimateMinutes ? Number(fm.estimateMinutes) || null : null,
|
|
51
66
|
parallelGroup: (fm.parallelGroup ? Number(fm.parallelGroup) || null : null) ?? groupFromName ?? 99,
|
|
52
67
|
body: body.trim(),
|
package/src/main/scheduler.cjs
CHANGED
|
@@ -180,12 +180,16 @@ const HEARTBEAT_MAX_BYTES = 1024 * 1024;
|
|
|
180
180
|
// DEFAULT_PROJECT_CWD imported from lib/schedulerBatch.cjs (single source of truth).
|
|
181
181
|
|
|
182
182
|
const ENV_CAP = process.env.SM_SCHEDULER_MAX_CONCURRENCY
|
|
183
|
-
? Math.max(1, Math.min(20, parseInt(process.env.SM_SCHEDULER_MAX_CONCURRENCY, 10) ||
|
|
183
|
+
? Math.max(1, Math.min(20, parseInt(process.env.SM_SCHEDULER_MAX_CONCURRENCY, 10) || 3))
|
|
184
184
|
: null;
|
|
185
185
|
|
|
186
|
+
// Each headless claude -p process can grow past 1 GB; require 1.5 GB headroom
|
|
187
|
+
// per running+pending slot to avoid OOM (incident 2026-06-10).
|
|
188
|
+
const MIN_FREE_MB_PER_JOB = 1500;
|
|
189
|
+
|
|
186
190
|
const DEFAULT_CONFIG = {
|
|
187
191
|
offsetMinutes: 15,
|
|
188
|
-
concurrencyCap: ENV_CAP ??
|
|
192
|
+
concurrencyCap: ENV_CAP ?? 3,
|
|
189
193
|
defaultCwd: DEFAULT_PROJECT_CWD,
|
|
190
194
|
// 'when-available' = poll usage and fire whenever utilization < threshold.
|
|
191
195
|
// 'on-reset' = fire offsetMinutes after the next 5h reset (legacy).
|
|
@@ -202,6 +206,39 @@ const DEFAULT_CONFIG = {
|
|
|
202
206
|
},
|
|
203
207
|
};
|
|
204
208
|
|
|
209
|
+
// ---------- memory gate ----------
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Returns available system memory in MB. Reads /proc/meminfo on Linux; fails
|
|
213
|
+
* open (returns Infinity) on darwin or on any parse/read error so the gate
|
|
214
|
+
* never blocks scheduling on unsupported platforms.
|
|
215
|
+
*/
|
|
216
|
+
function getAvailableMemMb() {
|
|
217
|
+
if (process.platform !== 'linux') return Infinity;
|
|
218
|
+
try {
|
|
219
|
+
const raw = fs.readFileSync('/proc/meminfo', 'utf8');
|
|
220
|
+
const m = raw.match(/^MemAvailable:\s+(\d+)\s+kB/m);
|
|
221
|
+
if (!m) return Infinity;
|
|
222
|
+
return Math.floor(parseInt(m[1], 10) / 1024);
|
|
223
|
+
} catch {
|
|
224
|
+
return Infinity;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Pure helper: clamp a batch down so launching `toLaunch` more jobs doesn't
|
|
230
|
+
* drop available memory below MIN_FREE_MB_PER_JOB per active slot.
|
|
231
|
+
* Exported for unit tests.
|
|
232
|
+
*/
|
|
233
|
+
function memoryLimitedBatchSize(availableMb, minPerJob, runningCount, batchLen) {
|
|
234
|
+
if (availableMb === Infinity) return batchLen;
|
|
235
|
+
let allowed = batchLen;
|
|
236
|
+
while (allowed > 0 && availableMb < minPerJob * (runningCount + allowed)) {
|
|
237
|
+
allowed--;
|
|
238
|
+
}
|
|
239
|
+
return allowed;
|
|
240
|
+
}
|
|
241
|
+
|
|
205
242
|
// ---------- fs helpers ----------
|
|
206
243
|
|
|
207
244
|
/**
|
|
@@ -539,6 +576,8 @@ let heartbeatInterval = null;
|
|
|
539
576
|
// double-spawn when runDueJobs() is called while jobs are in flight.
|
|
540
577
|
const runningSet = new Set();
|
|
541
578
|
let cancelToken = { cancelled: false };
|
|
579
|
+
// Last memory-gate observation; included in snapshot for renderer visibility.
|
|
580
|
+
let lastMemGate = null;
|
|
542
581
|
|
|
543
582
|
function attachWindow(w) { mainWindow = w; }
|
|
544
583
|
|
|
@@ -557,6 +596,13 @@ function buildScheduleStatePayload(state, { withPaths = false } = {}) {
|
|
|
557
596
|
nextReset: getNextResetCached(),
|
|
558
597
|
paused: state.paused,
|
|
559
598
|
utilization: cachedUtilization,
|
|
599
|
+
pollHealth: {
|
|
600
|
+
lastPollAt,
|
|
601
|
+
lastPollOk,
|
|
602
|
+
consecutiveFailures,
|
|
603
|
+
lastFailureKind,
|
|
604
|
+
},
|
|
605
|
+
memGate: lastMemGate,
|
|
560
606
|
};
|
|
561
607
|
if (withPaths) {
|
|
562
608
|
payload.paths = { root: ROOT, prds: PRDS_DIR, runs: RUNS_DIR, queue: QUEUE_PATH };
|
|
@@ -743,7 +789,7 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
|
|
|
743
789
|
// before handing it to the child process.
|
|
744
790
|
try { fs.accessSync(cwd, fs.constants.X_OK); }
|
|
745
791
|
catch {
|
|
746
|
-
const errMsg = `cwd
|
|
792
|
+
const errMsg = `cwd does not exist on this machine: ${cwd}`;
|
|
747
793
|
safeLog(`[scheduler] ${errMsg}\n`);
|
|
748
794
|
closeFd();
|
|
749
795
|
// Sync write: this is an early-exit error path inside an async function,
|
|
@@ -1356,11 +1402,29 @@ function tickQueue() {
|
|
|
1356
1402
|
const batch = pickNextBatch(state.jobs, runningSet, cap);
|
|
1357
1403
|
if (batch.length === 0) return;
|
|
1358
1404
|
|
|
1405
|
+
const availableMb = getAvailableMemMb();
|
|
1406
|
+
const allowed = memoryLimitedBatchSize(availableMb, MIN_FREE_MB_PER_JOB, runningSet.size, batch.length);
|
|
1407
|
+
if (allowed === 0) {
|
|
1408
|
+
const threshold = MIN_FREE_MB_PER_JOB * (runningSet.size + 1);
|
|
1409
|
+
console.log(`[scheduler] memory gate: available=${availableMb} MB < threshold=${threshold} MB — deferring ${batch.length} job(s)`);
|
|
1410
|
+
lastMemGate = { availableMb, threshold, deferred: true, at: new Date().toISOString() };
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
const gatedBatch = batch.slice(0, allowed);
|
|
1414
|
+
if (gatedBatch.length < batch.length) {
|
|
1415
|
+
console.log(`[scheduler] memory gate: available=${availableMb} MB — clamped batch ${batch.length} → ${gatedBatch.length}`);
|
|
1416
|
+
lastMemGate = { availableMb, threshold: MIN_FREE_MB_PER_JOB * (runningSet.size + gatedBatch.length), deferred: false, clamped: true, at: new Date().toISOString() };
|
|
1417
|
+
} else {
|
|
1418
|
+
// Ungated full batch: clear stale gate snapshot so status doesn't show
|
|
1419
|
+
// a stale deferral from a previous tick.
|
|
1420
|
+
lastMemGate = null;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1359
1423
|
await mutate((s) => { s.lastRunAt = new Date().toISOString(); });
|
|
1360
1424
|
await broadcast();
|
|
1361
1425
|
|
|
1362
1426
|
const { runId, dir: runDir } = pickRunDir();
|
|
1363
|
-
for (const job of
|
|
1427
|
+
for (const job of gatedBatch) {
|
|
1364
1428
|
if (cancelToken.cancelled) break;
|
|
1365
1429
|
// spawnJob is fire-and-forget; it calls tickQueue() on completion.
|
|
1366
1430
|
spawnJob(job, runId, runDir, state.config.defaultCwd).catch(() => {});
|
|
@@ -1450,6 +1514,18 @@ async function reapDeadRunningJobs() {
|
|
|
1450
1514
|
|
|
1451
1515
|
// ---------- poll loop with exponential backoff ----------
|
|
1452
1516
|
|
|
1517
|
+
/**
|
|
1518
|
+
* Pure: given the current pause reason and whether a reset timestamp is cached,
|
|
1519
|
+
* return which clearPause source to pass after a successful billing poll, or null.
|
|
1520
|
+
* Exported for unit testing.
|
|
1521
|
+
*/
|
|
1522
|
+
function pollRecoveryClearSource(pauseReason, hasCachedReset) {
|
|
1523
|
+
if (pauseReason === 'network') return 'network-recovered';
|
|
1524
|
+
if (pauseReason === 'auth') return 'auth-recovered';
|
|
1525
|
+
if (pauseReason === 'reset_failure' && hasCachedReset) return 'reset-recovered';
|
|
1526
|
+
return null;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1453
1529
|
async function pollLoop() {
|
|
1454
1530
|
try {
|
|
1455
1531
|
await reapDeadRunningJobs().catch(() => {});
|
|
@@ -1468,15 +1544,10 @@ async function pollLoop() {
|
|
|
1468
1544
|
lastPollOk = true;
|
|
1469
1545
|
persistSchedulerState();
|
|
1470
1546
|
|
|
1471
|
-
//
|
|
1547
|
+
// Clear any pause that was waiting for a successful billing read.
|
|
1472
1548
|
const cur = await readQueue();
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
}
|
|
1476
|
-
// If 'reset_failure' was set and we now have a valid reset, clear it.
|
|
1477
|
-
if (cur.paused?.reason === 'reset_failure' && cachedNextReset) {
|
|
1478
|
-
await clearPause('reset-recovered');
|
|
1479
|
-
}
|
|
1549
|
+
const clearSrc = pollRecoveryClearSource(cur.paused?.reason ?? null, !!cachedNextReset);
|
|
1550
|
+
if (clearSrc) await clearPause(clearSrc);
|
|
1480
1551
|
|
|
1481
1552
|
await maybeLaunchWhenAvailable(cur);
|
|
1482
1553
|
await broadcast();
|
|
@@ -1961,6 +2032,19 @@ const remote = {
|
|
|
1961
2032
|
const resolved = safeSlugPath(slug);
|
|
1962
2033
|
if (!resolved) return { ok: false, error: 'invalid slug' };
|
|
1963
2034
|
try {
|
|
2035
|
+
// Symlink defense, matching readPrd/readLog: safeSlugPath is lexical and
|
|
2036
|
+
// does NOT resolve symlinks, so a rogue job could plant prds/x.md → an
|
|
2037
|
+
// arbitrary $HOME path and have writeTextAtomic clobber it. Resolve the
|
|
2038
|
+
// real parent dir (the file itself may not exist yet) and re-assert
|
|
2039
|
+
// containment; also reject the target if it is already a symlink.
|
|
2040
|
+
const realParent = await fsp.realpath(path.dirname(resolved));
|
|
2041
|
+
if (realParent !== PRDS_DIR && !realParent.startsWith(PRDS_DIR + path.sep)) {
|
|
2042
|
+
return { ok: false, error: 'invalid slug' };
|
|
2043
|
+
}
|
|
2044
|
+
const existing = await fsp.lstat(resolved).catch(() => null);
|
|
2045
|
+
if (existing && existing.isSymbolicLink()) {
|
|
2046
|
+
return { ok: false, error: 'invalid slug' };
|
|
2047
|
+
}
|
|
1964
2048
|
await config.writeTextAtomic(resolved, body);
|
|
1965
2049
|
const stat = await fsp.stat(resolved);
|
|
1966
2050
|
return { ok: true, bytesWritten: stat.size };
|
|
@@ -2005,4 +2089,4 @@ const remote = {
|
|
|
2005
2089
|
},
|
|
2006
2090
|
};
|
|
2007
2091
|
|
|
2008
|
-
module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs };
|
|
2092
|
+
module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize };
|