claude-code-session-manager 0.119.1 → 0.121.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/{AgentLibrary-BpW0pGgk.js → AgentLibrary-FO2ei2wB.js} +1 -1
- package/dist/assets/{Button-DZX5guWb.js → Button-CeIOXUPU.js} +1 -1
- package/dist/assets/{DataModel-WG4pG_4z.js → DataModel-D0p__Hil.js} +1 -1
- package/dist/assets/{History-D58whvn_.js → History-DVqQ5azN.js} +1 -1
- package/dist/assets/{Hooks-DCI9ezg4.js → Hooks-DkTnRHyT.js} +1 -1
- package/dist/assets/{Library-YqMi-vsA.js → Library-l7KS6pkE.js} +1 -1
- package/dist/assets/{ListDetail-ap4W5z_Z.js → ListDetail-DDw8fuRT.js} +1 -1
- package/dist/assets/{McpServers-CT0pBTF8.js → McpServers-UUNIxk_3.js} +1 -1
- package/dist/assets/{Memory-BODDUBy4.js → Memory-CNYUoQ8C.js} +1 -1
- package/dist/assets/{Panel-CeOfUS9B.js → Panel-Bb54soc2.js} +1 -1
- package/dist/assets/{Permissions-DyOqpLQx.js → Permissions-D-dNdV3f.js} +1 -1
- package/dist/assets/{Plugins-DBXzKjL2.js → Plugins-DhcIXRhQ.js} +2 -2
- package/dist/assets/{ProvenanceBadge-C47IX1Q6.js → ProvenanceBadge-DEAkDNjA.js} +1 -1
- package/dist/assets/{RemoteMachines-CtkAJvkZ.js → RemoteMachines-xCEgvTGU.js} +1 -1
- package/dist/assets/{SaveBar-YMtzcCzP.js → SaveBar-BSEs7ecA.js} +1 -1
- package/dist/assets/{Scheduler-BNiYg0YD.js → Scheduler-BIKJIU-C.js} +5 -5
- package/dist/assets/{ScopeSwitcher-Dgi8CiDi.js → ScopeSwitcher-DcaFyAV-.js} +1 -1
- package/dist/assets/Settings-vcdWy_Jx.js +4 -0
- package/dist/assets/{SkillReferenceGraph-CaFsccNI.js → SkillReferenceGraph-CLrJWTPd.js} +1 -1
- package/dist/assets/{Skills-BeCMBQ3l.js → Skills-CYM4rBEC.js} +1 -1
- package/dist/assets/{SystemPrompt-C3WmcPEF.js → SystemPrompt-BWNIu-iY.js} +1 -1
- package/dist/assets/{TagLibrary-DP4GH7Dr.js → TagLibrary-CYdwHK6m.js} +1 -1
- package/dist/assets/{TiptapBody-CXCMvRr_.js → TiptapBody-Kt90lHrD.js} +1 -1
- package/dist/assets/{Toggle-TY-Wa8hv.js → Toggle-D1OXWYOz.js} +1 -1
- package/dist/assets/{ViewTabs-CaacRnp6.js → ViewTabs-BeQR3CKh.js} +1 -1
- package/dist/assets/{historyTotals-C28IlVVQ.js → historyTotals-B0qafrRo.js} +1 -1
- package/dist/assets/{index-DdEMkmbi.js → index-Bq9uH08R.js} +201 -201
- package/dist/assets/{index-DAMBm3UN.css → index-Dd7UQabV.css} +1 -1
- package/dist/assets/{settingsSchema-7Yg6dNNB.js → settingsSchema-0ZksSAPB.js} +1 -1
- package/dist/assets/{skillFrontmatter-Dcuys1km.js → skillFrontmatter-C0nh8yfa.js} +1 -1
- package/dist/assets/{useAgentsDirWatch-B0H4M-TT.js → useAgentsDirWatch-DPkoBybS.js} +1 -1
- package/dist/assets/{useRemotePeers-Zp9jchCS.js → useRemotePeers-BDJMUihy.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/build-info.json +4 -4
- package/src/main/chatRunner.cjs +54 -14
- package/src/main/crashDiagnostics.cjs +18 -13
- package/src/main/health.cjs +129 -4
- package/src/main/index.cjs +57 -18
- package/src/main/ipcSchemas.cjs +18 -4
- package/src/main/lib/activeSessions.cjs +51 -34
- package/src/main/lib/claude/agentModelResolve.cjs +5 -3
- package/src/main/lib/claude/personaToolScope.cjs +68 -0
- package/src/main/lib/epic/archivedTranscript.cjs +91 -0
- package/src/main/lib/epic/archivedTranscriptMigration.cjs +84 -0
- package/src/main/lib/epic/delegationReadiness.cjs +2 -0
- package/src/main/lib/epic/epicSettle.cjs +21 -5
- package/src/main/lib/epic/epicValidationHook.cjs +7 -0
- package/src/main/lib/git/depHealth.cjs +161 -0
- package/src/main/lib/git/worktreeDeps.cjs +158 -5
- package/src/main/lib/machineProfile.cjs +2 -0
- package/src/main/lib/mcpToolCatalog.cjs +3 -1
- package/src/main/lib/opsRetentionSettings.cjs +183 -0
- package/src/main/lib/persona-macro/macroLibrary.cjs +75 -0
- package/src/main/lib/prd/prdCreate.cjs +25 -0
- package/src/main/lib/prd/prdWorktreeReach.cjs +2 -2
- package/src/main/lib/prd/workTypeLibrary.cjs +7 -3
- package/src/main/lib/process/procGroupRss.cjs +61 -0
- package/src/main/lib/remoteTranscriptTail.cjs +69 -2
- package/src/main/lib/scheduler/queueHistory.cjs +68 -9
- package/src/main/lib/scheduler/runLogRetention.cjs +129 -43
- package/src/main/lib/scheduler/schedulerBatch.cjs +7 -3
- package/src/main/lib/scheduler/staleTmpSweep.cjs +117 -0
- package/src/main/lib/telemetry/auditLog.cjs +51 -6
- package/src/main/lib/telemetry/crashTelemetry.cjs +49 -1
- package/src/main/lib/telemetry/opsErrorLog.cjs +30 -10
- package/src/main/lib/telemetry/telemetryClient.cjs +5 -2
- package/src/main/scheduler/prdParser.cjs +50 -0
- package/src/main/scheduler/prdPaths.cjs +61 -16
- package/src/main/scheduler/reviewLadder.cjs +24 -1
- package/src/main/scheduler.cjs +214 -37
- package/src/main/templates/PRD_AUTHORING.md +2 -2
- package/src/main/webRemote.cjs +82 -91
- package/src/preload/api.d.ts +41 -3
- package/src/preload/index.cjs +9 -0
- package/dist/assets/Settings-B4LuoRnd.js +0 -4
|
@@ -51,6 +51,13 @@ function isSstHarnessCwd(cwd) {
|
|
|
51
51
|
// Transcript reads only need the last line with a `cwd` field — a few dozen
|
|
52
52
|
// lines is always enough. 64 KB keeps peak RSS proportionate.
|
|
53
53
|
const TAIL_BYTES = 64 * 1024;
|
|
54
|
+
// A single transcript line (base64 render, big tool result) can exceed TAIL_BYTES
|
|
55
|
+
// and leave the tail with no complete `cwd` row. Only on that miss the tail
|
|
56
|
+
// grows (x16 per step) up to MAX_TAIL_BYTES, then up to MAX_FALLBACK_FILES
|
|
57
|
+
// newest transcripts are tried before the project is given up on.
|
|
58
|
+
const MAX_TAIL_BYTES = 16 * 1024 * 1024;
|
|
59
|
+
const MAX_FALLBACK_FILES = 5;
|
|
60
|
+
const TAIL_GROWTH = 16;
|
|
54
61
|
|
|
55
62
|
// Safety cap on distinct cwds to bound result set size. O(1) extra space.
|
|
56
63
|
const MAX_CWDS = 50;
|
|
@@ -139,6 +146,20 @@ function readTailLines(filePath, maxBytes) {
|
|
|
139
146
|
return buf.toString('utf8').split('\n').filter((l) => l.trim());
|
|
140
147
|
}
|
|
141
148
|
|
|
149
|
+
function fileSizeSync(fp) {
|
|
150
|
+
try { return fs.statSync(fp).size; } catch { return 0; }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** lastCwdInLines(lines) → the `cwd` of the last parseable row that has one, or null. */
|
|
154
|
+
function lastCwdInLines(lines) {
|
|
155
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
156
|
+
let row;
|
|
157
|
+
try { row = JSON.parse(lines[i]); } catch { continue; }
|
|
158
|
+
if (row && row.cwd) return row.cwd;
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
142
163
|
/**
|
|
143
164
|
* scanOneProjectDirSync(projDir) → {rawCwd, newestMtimeMs} | null
|
|
144
165
|
* Newest *.jsonl's mtime plus its last parseable `cwd`, for one project dir.
|
|
@@ -149,28 +170,25 @@ function scanOneProjectDirSync(projDir) {
|
|
|
149
170
|
entries = fs.readdirSync(projDir).filter((f) => f.endsWith('.jsonl'));
|
|
150
171
|
} catch { return null; }
|
|
151
172
|
|
|
152
|
-
|
|
153
|
-
let newestMtimeMs = 0;
|
|
173
|
+
const files = [];
|
|
154
174
|
for (const tf of entries) {
|
|
155
175
|
const fp = path.join(projDir, tf);
|
|
156
|
-
try {
|
|
157
|
-
const st = fs.statSync(fp);
|
|
158
|
-
if (st.mtimeMs > newestMtimeMs) {
|
|
159
|
-
newestMtimeMs = st.mtimeMs;
|
|
160
|
-
newestPath = fp;
|
|
161
|
-
}
|
|
162
|
-
} catch { continue; }
|
|
176
|
+
try { files.push({ fp, mtimeMs: fs.statSync(fp).mtimeMs }); } catch { continue; }
|
|
163
177
|
}
|
|
164
|
-
if (!
|
|
178
|
+
if (!files.length) return null;
|
|
179
|
+
files.sort((x, y) => y.mtimeMs - x.mtimeMs);
|
|
180
|
+
const newestMtimeMs = files[0].mtimeMs;
|
|
165
181
|
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
182
|
+
for (const { fp } of files.slice(0, MAX_FALLBACK_FILES)) {
|
|
183
|
+
let size = TAIL_BYTES;
|
|
184
|
+
for (;;) {
|
|
185
|
+
const rawCwd = lastCwdInLines(readTailLines(fp, size));
|
|
186
|
+
if (rawCwd) return { rawCwd, newestMtimeMs };
|
|
187
|
+
if (size >= MAX_TAIL_BYTES || size >= fileSizeSync(fp)) break;
|
|
188
|
+
size = Math.min(size * TAIL_GROWTH, MAX_TAIL_BYTES);
|
|
189
|
+
}
|
|
172
190
|
}
|
|
173
|
-
return
|
|
191
|
+
return null;
|
|
174
192
|
}
|
|
175
193
|
|
|
176
194
|
/**
|
|
@@ -262,28 +280,27 @@ async function scanOneProjectDirAsync(projDir) {
|
|
|
262
280
|
entries = (await fsp.readdir(projDir)).filter((f) => f.endsWith('.jsonl'));
|
|
263
281
|
} catch { return null; }
|
|
264
282
|
|
|
265
|
-
|
|
266
|
-
let newestMtimeMs = 0;
|
|
283
|
+
const files = [];
|
|
267
284
|
for (const tf of entries) {
|
|
268
285
|
const fp = path.join(projDir, tf);
|
|
269
|
-
try {
|
|
270
|
-
const st = await fsp.stat(fp);
|
|
271
|
-
if (st.mtimeMs > newestMtimeMs) {
|
|
272
|
-
newestMtimeMs = st.mtimeMs;
|
|
273
|
-
newestPath = fp;
|
|
274
|
-
}
|
|
275
|
-
} catch { continue; }
|
|
286
|
+
try { files.push({ fp, mtimeMs: (await fsp.stat(fp)).mtimeMs }); } catch { continue; }
|
|
276
287
|
}
|
|
277
|
-
if (!
|
|
288
|
+
if (!files.length) return null;
|
|
289
|
+
files.sort((x, y) => y.mtimeMs - x.mtimeMs);
|
|
290
|
+
const newestMtimeMs = files[0].mtimeMs;
|
|
278
291
|
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
292
|
+
for (const { fp } of files.slice(0, MAX_FALLBACK_FILES)) {
|
|
293
|
+
let size = TAIL_BYTES;
|
|
294
|
+
for (;;) {
|
|
295
|
+
const rawCwd = lastCwdInLines(await readTailLinesAsync(fp, size));
|
|
296
|
+
if (rawCwd) return { rawCwd, newestMtimeMs };
|
|
297
|
+
let fileSize = 0;
|
|
298
|
+
try { fileSize = (await fsp.stat(fp)).size; } catch { /* gone: stop growing */ }
|
|
299
|
+
if (size >= MAX_TAIL_BYTES || size >= fileSize) break;
|
|
300
|
+
size = Math.min(size * TAIL_GROWTH, MAX_TAIL_BYTES);
|
|
301
|
+
}
|
|
285
302
|
}
|
|
286
|
-
return
|
|
303
|
+
return null;
|
|
287
304
|
}
|
|
288
305
|
|
|
289
306
|
/**
|
|
@@ -212,10 +212,10 @@ function buildPersonaBodyText(rawBody, personaPath) {
|
|
|
212
212
|
* same runaway-file protection.
|
|
213
213
|
*
|
|
214
214
|
* @param {{ cwd: string, agentType: string|null, fallbackModel?: string, deps?: object }} opts
|
|
215
|
-
* @returns {Promise<{ model: string, systemPrompt: string|null, personaPath: string|null }>}
|
|
215
|
+
* @returns {Promise<{ model: string, systemPrompt: string|null, personaPath: string|null, tools: string[]|null }>}
|
|
216
216
|
*/
|
|
217
217
|
async function resolvePrdPersonaForSpawn({ cwd, agentType, fallbackModel = FALLBACK_MODEL, deps = {} } = {}) {
|
|
218
|
-
const miss = { model: fallbackModel, systemPrompt: null, personaPath: null };
|
|
218
|
+
const miss = { model: fallbackModel, systemPrompt: null, personaPath: null, tools: null };
|
|
219
219
|
if (!agentType) return miss;
|
|
220
220
|
try {
|
|
221
221
|
const getBody = deps.getPersonaBody || require('../../agentLibrary.cjs').getPersonaBody;
|
|
@@ -230,7 +230,9 @@ async function resolvePrdPersonaForSpawn({ cwd, agentType, fallbackModel = FALLB
|
|
|
230
230
|
const { fm, body } = persona.fm ? persona : splitFrontmatter(persona.text);
|
|
231
231
|
const model = fm.model && fm.model !== 'inherit' ? fm.model : fallbackModel;
|
|
232
232
|
const systemPrompt = buildPersonaBodyText(body, persona.path);
|
|
233
|
-
|
|
233
|
+
const parseTools = deps.parseTools || require('../../agentLibrary.cjs').parseTools;
|
|
234
|
+
const list = parseTools(typeof fm.tools === 'string' ? fm.tools : '');
|
|
235
|
+
return { model, systemPrompt, personaPath: persona.path, tools: list.length ? list : null };
|
|
234
236
|
} catch {
|
|
235
237
|
return miss;
|
|
236
238
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* personaToolScope.cjs — turns a persona's `tools:` list into the claude CLI
|
|
5
|
+
* args that actually ENFORCE it on a headless `claude -p` run.
|
|
6
|
+
*
|
|
7
|
+
* Verified on CLI 2.1.302: under `--dangerously-skip-permissions`,
|
|
8
|
+
* `--allowedTools` restricts nothing and `--tools` only filters built-ins (MCP
|
|
9
|
+
* tools stay). `--agents '<json>' --agent <name>` with a `tools` array enforces
|
|
10
|
+
* exact per-tool scoping, MCP tools included, while the Claude Code harness
|
|
11
|
+
* prompt, CLAUDE.md, `--append-system-prompt` and `--disallowedTools` keep
|
|
12
|
+
* working.
|
|
13
|
+
*
|
|
14
|
+
* Pure: no fs, and no require of scheduler.cjs (cycle) — the caller passes its
|
|
15
|
+
* HEADLESS_DISALLOWED_TOOLS list in.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// Always granted to a scoped run: the finish protocol runs /code-review and
|
|
19
|
+
// /security-review (Skill + Agent); MCP tools are deferred, so ToolSearch is
|
|
20
|
+
// needed to reach them; TodoWrite is the run's own planning aid.
|
|
21
|
+
const HEADLESS_TOOL_FLOOR = Object.freeze(['Agent', 'Skill', 'ToolSearch', 'TodoWrite']);
|
|
22
|
+
|
|
23
|
+
const HEADLESS_SCOPE_AGENT_NAME = 'sm-headless-scoped';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {readonly string[]|null|undefined} tools persona `tools:` list
|
|
27
|
+
* @param {{ disallowed?: readonly string[] }} [opts]
|
|
28
|
+
* @returns {{ tools: string[] }|null} null = persona declares no restriction
|
|
29
|
+
*/
|
|
30
|
+
function buildPersonaToolScope(tools, { disallowed = [] } = {}) {
|
|
31
|
+
if (!Array.isArray(tools) || tools.length === 0) return null;
|
|
32
|
+
const names = tools
|
|
33
|
+
.filter((t) => typeof t === 'string')
|
|
34
|
+
.map((t) => t.trim())
|
|
35
|
+
.filter(Boolean);
|
|
36
|
+
if (names.length === 0 || names.includes('*')) return null;
|
|
37
|
+
const blocked = new Set(disallowed);
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const name of [...names, ...HEADLESS_TOOL_FLOOR]) {
|
|
40
|
+
if (blocked.has(name) || out.includes(name)) continue;
|
|
41
|
+
out.push(name);
|
|
42
|
+
}
|
|
43
|
+
return { tools: out };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {{ tools: string[] }|null} scope result of buildPersonaToolScope
|
|
48
|
+
* @returns {string[]} argv fragment ([] when unscoped)
|
|
49
|
+
*/
|
|
50
|
+
function personaToolScopeArgs(scope) {
|
|
51
|
+
if (!scope) return [];
|
|
52
|
+
const def = {
|
|
53
|
+
description: 'Headless run with a scheduler-enforced tool scope.',
|
|
54
|
+
prompt: 'Tool scope is enforced by the scheduler.',
|
|
55
|
+
tools: scope.tools,
|
|
56
|
+
};
|
|
57
|
+
return [
|
|
58
|
+
'--agents', JSON.stringify({ [HEADLESS_SCOPE_AGENT_NAME]: def }),
|
|
59
|
+
'--agent', HEADLESS_SCOPE_AGENT_NAME,
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = {
|
|
64
|
+
HEADLESS_TOOL_FLOOR,
|
|
65
|
+
HEADLESS_SCOPE_AGENT_NAME,
|
|
66
|
+
buildPersonaToolScope,
|
|
67
|
+
personaToolScopeArgs,
|
|
68
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* archivedTranscript.cjs — gzip sidecar store for the raw Claude JSONL that
|
|
5
|
+
* archived Epic JSONs used to embed as a `transcript` string. The sidecar
|
|
6
|
+
* lives at `prompt-sessions/transcripts-archive/<epicId>.jsonl.gz` (inside the
|
|
7
|
+
* `prompt-sessions` namespace, so the `epics` owner already covers it) and the
|
|
8
|
+
* caller keeps only the small pointer returned by writeSidecar.
|
|
9
|
+
*
|
|
10
|
+
* Electron-free at load (like epicStatusMirror.cjs); registerIpc takes ipcMain.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const crypto = require('node:crypto');
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const zlib = require('node:zlib');
|
|
16
|
+
const { assertOpsWrite, opsPath } = require('../opsOwnership.cjs');
|
|
17
|
+
const atomicFs = require('../util/atomicFs.cjs');
|
|
18
|
+
|
|
19
|
+
const SUBDIR = 'transcripts-archive';
|
|
20
|
+
|
|
21
|
+
function assertSafeEpicId(epicId) {
|
|
22
|
+
if (typeof epicId !== 'string' || !epicId || epicId.includes('..') || /[\\/\0]/.test(epicId)) {
|
|
23
|
+
throw new Error(`archivedTranscript: invalid epicId "${epicId}"`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sidecarPath(cwd, epicId) {
|
|
28
|
+
assertSafeEpicId(epicId);
|
|
29
|
+
return opsPath(cwd, 'prompt-sessions', SUBDIR, `${epicId}.jsonl.gz`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const sha256Of = (text) => crypto.createHash('sha256').update(text, 'utf8').digest('hex');
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* writeSidecar(cwd, epicId, text) -> {path, bytes, gzBytes, sha256}.
|
|
36
|
+
* Idempotent: if the existing sidecar already decompresses to the same text,
|
|
37
|
+
* nothing is rewritten.
|
|
38
|
+
*/
|
|
39
|
+
function writeSidecar(cwd, epicId, text, { writer = 'epics' } = {}) {
|
|
40
|
+
if (typeof text !== 'string') throw new Error('archivedTranscript: text must be a string');
|
|
41
|
+
const file = sidecarPath(cwd, epicId);
|
|
42
|
+
assertOpsWrite(file, writer);
|
|
43
|
+
const sha256 = sha256Of(text);
|
|
44
|
+
const bytes = Buffer.byteLength(text, 'utf8');
|
|
45
|
+
const existing = readSidecar(cwd, epicId);
|
|
46
|
+
if (existing !== null && sha256Of(existing) === sha256) {
|
|
47
|
+
return { path: file, bytes, gzBytes: fs.statSync(file).size, sha256 };
|
|
48
|
+
}
|
|
49
|
+
const gz = zlib.gzipSync(Buffer.from(text, 'utf8'));
|
|
50
|
+
atomicFs.writeTextAtomicSync(file, gz);
|
|
51
|
+
return { path: file, bytes, gzBytes: gz.length, sha256 };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** readSidecar(cwd, epicId) -> string | null (null when absent or unreadable). */
|
|
55
|
+
function readSidecar(cwd, epicId) {
|
|
56
|
+
const file = sidecarPath(cwd, epicId);
|
|
57
|
+
try {
|
|
58
|
+
return zlib.gunzipSync(fs.readFileSync(file)).toString('utf8');
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* archiveTranscript({cwd, epicId, claudeSessionId}, {resolvePath}) -> pointer | null.
|
|
66
|
+
* Reads the raw Claude JSONL main-side and writes the sidecar; null when the
|
|
67
|
+
* JSONL is missing or empty (caller falls back to durableTurns).
|
|
68
|
+
*/
|
|
69
|
+
function archiveTranscript(args, { resolvePath } = {}) {
|
|
70
|
+
const { cwd, epicId, claudeSessionId } = args || {};
|
|
71
|
+
if (typeof cwd !== 'string' || !require('node:path').isAbsolute(cwd)) {
|
|
72
|
+
throw new Error('archivedTranscript: cwd must be an absolute path');
|
|
73
|
+
}
|
|
74
|
+
assertSafeEpicId(epicId);
|
|
75
|
+
if (typeof claudeSessionId !== 'string' || !claudeSessionId) return null;
|
|
76
|
+
const resolve = resolvePath || ((c, s) => require('../../transcripts.cjs').transcriptPath(c, s));
|
|
77
|
+
let text;
|
|
78
|
+
try {
|
|
79
|
+
text = fs.readFileSync(resolve(cwd, claudeSessionId), 'utf8');
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
if (!text) return null;
|
|
84
|
+
return writeSidecar(cwd, epicId, text);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function registerIpc(ipcMain, deps) {
|
|
88
|
+
ipcMain.handle('promptSessions:archiveTranscript', async (_e, payload) => archiveTranscript(payload, deps));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { writeSidecar, readSidecar, sidecarPath, archiveTranscript, registerIpc };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* archivedTranscriptMigration.cjs — one-shot, idempotent, crash-safe move of the
|
|
5
|
+
* embedded `transcript` string in archived Epic JSONs into the gzip sidecar
|
|
6
|
+
* (archivedTranscript.cjs). Per file: write sidecar -> re-read + compare sha ->
|
|
7
|
+
* only then atomically rewrite the JSON with `transcriptRef` and no `transcript`.
|
|
8
|
+
* A crash or failure before the rewrite leaves the original JSON byte-identical.
|
|
9
|
+
*
|
|
10
|
+
* Never touches `active-index.json` or files whose status is 'active'.
|
|
11
|
+
* Writes as the `epics` owner (the sidecar lives inside `prompt-sessions`).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const { assertOpsWrite, opsPath } = require('../opsOwnership.cjs');
|
|
17
|
+
const atomicFs = require('../util/atomicFs.cjs');
|
|
18
|
+
const archivedTranscript = require('./archivedTranscript.cjs');
|
|
19
|
+
|
|
20
|
+
const SKIP_FILES = new Set(['active-index.json']);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* migrateProject(projectRoot, {dryRun, limit, yieldFn, deps})
|
|
24
|
+
* -> Promise<{scanned, migrated, skipped, freedBytes, errors}>
|
|
25
|
+
* `limit` caps files migrated (or failed) per call; dryRun counts what would
|
|
26
|
+
* migrate without writing. `deps` lets tests inject the sidecar functions.
|
|
27
|
+
*/
|
|
28
|
+
async function migrateProject(projectRoot, { dryRun = false, limit = Infinity, yieldFn = null, deps = {} } = {}) {
|
|
29
|
+
const writeSidecar = deps.writeSidecar || archivedTranscript.writeSidecar;
|
|
30
|
+
const readSidecar = deps.readSidecar || archivedTranscript.readSidecar;
|
|
31
|
+
const result = { scanned: 0, migrated: 0, skipped: 0, freedBytes: 0, errors: [] };
|
|
32
|
+
const dir = opsPath(projectRoot, 'prompt-sessions');
|
|
33
|
+
let names;
|
|
34
|
+
try {
|
|
35
|
+
names = fs.readdirSync(dir);
|
|
36
|
+
} catch {
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
let attempted = 0;
|
|
40
|
+
for (const name of names) {
|
|
41
|
+
if (!name.endsWith('.json') || SKIP_FILES.has(name)) continue;
|
|
42
|
+
if (attempted >= limit) break;
|
|
43
|
+
result.scanned += 1;
|
|
44
|
+
const file = path.join(dir, name);
|
|
45
|
+
try {
|
|
46
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
47
|
+
// Cheap pre-check so files without a transcript are never JSON-parsed.
|
|
48
|
+
if (!raw.includes('"transcript"')) { result.skipped += 1; continue; }
|
|
49
|
+
const doc = JSON.parse(raw);
|
|
50
|
+
if (!doc || typeof doc !== 'object' || typeof doc.transcript !== 'string' || doc.status === 'active') {
|
|
51
|
+
result.skipped += 1;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
attempted += 1;
|
|
55
|
+
const epicId = name.slice(0, -'.json'.length);
|
|
56
|
+
if (dryRun) {
|
|
57
|
+
result.migrated += 1;
|
|
58
|
+
result.freedBytes += Buffer.byteLength(doc.transcript, 'utf8');
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
assertOpsWrite(file, 'epics');
|
|
62
|
+
const ref = writeSidecar(projectRoot, epicId, doc.transcript);
|
|
63
|
+
const back = readSidecar(projectRoot, epicId);
|
|
64
|
+
if (typeof back !== 'string' || archivedTranscriptSha(back) !== ref.sha256) {
|
|
65
|
+
throw new Error('sidecar sha mismatch');
|
|
66
|
+
}
|
|
67
|
+
const { transcript: _drop, ...rest } = doc;
|
|
68
|
+
const next = { ...rest, transcriptRef: { path: ref.path, bytes: ref.bytes, gzBytes: ref.gzBytes, sha256: ref.sha256 } };
|
|
69
|
+
atomicFs.writeJsonAtomicSync(file, next);
|
|
70
|
+
result.migrated += 1;
|
|
71
|
+
result.freedBytes += Math.max(0, Buffer.byteLength(raw, 'utf8') - fs.statSync(file).size);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
result.errors.push({ file: name, error: e && e.message ? e.message : String(e) });
|
|
74
|
+
}
|
|
75
|
+
if (yieldFn) await yieldFn();
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function archivedTranscriptSha(text) {
|
|
81
|
+
return require('node:crypto').createHash('sha256').update(text, 'utf8').digest('hex');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = { migrateProject };
|
|
@@ -671,6 +671,7 @@ function logAutoInstallEvent({ cwd, guard, action, error }) {
|
|
|
671
671
|
scope: 'delegationReadiness',
|
|
672
672
|
level: action === 'error' ? 'error' : 'info',
|
|
673
673
|
message: `auto-install ${guard}: ${action}`,
|
|
674
|
+
code: `guard-install-${action}:${guard}`,
|
|
674
675
|
meta: { guard, action, ...(error ? { error } : {}) },
|
|
675
676
|
});
|
|
676
677
|
} catch { /* logging must never break the caller */ }
|
|
@@ -796,6 +797,7 @@ async function ensureGuardsInstalled(cwd, { homeDir = os.homedir() } = {}) {
|
|
|
796
797
|
module.exports = {
|
|
797
798
|
checkDelegationReadiness,
|
|
798
799
|
ensureGuardsInstalled,
|
|
800
|
+
logAutoInstallEvent,
|
|
799
801
|
clearGuardsInstalledCache,
|
|
800
802
|
installPrdWriteGuard,
|
|
801
803
|
installDestructiveGitGuard,
|
|
@@ -21,6 +21,17 @@ const ts = (at) => {
|
|
|
21
21
|
const isValidatorCheckin = (e, bare) =>
|
|
22
22
|
e.agentType === 'validator' || (e.agentType == null && bare.startsWith('validate-'));
|
|
23
23
|
|
|
24
|
+
// Mirrors the scheduler's cancel rule locally (lib/epic stays free of scheduler deps).
|
|
25
|
+
const isCancelledRow = (r) => {
|
|
26
|
+
if (!r || (r.status !== 'failed' && r.status !== 'needs_review')) return false;
|
|
27
|
+
if (typeof r.cancelledAt === 'string' && r.cancelledAt) return true;
|
|
28
|
+
const hist = Array.isArray(r.statusHistory) ? r.statusHistory : [];
|
|
29
|
+
for (let i = hist.length - 1; i >= 0; i--) {
|
|
30
|
+
if (hist[i] && hist[i].to === r.status) return hist[i].source === 'remote:cancelJob';
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
};
|
|
34
|
+
|
|
24
35
|
/**
|
|
25
36
|
* @param {{ epicId: string, cwd?: string|null, events?: any[], jobs?: any[], now?: number, horizonMs?: number }} args
|
|
26
37
|
*/
|
|
@@ -34,8 +45,13 @@ function evaluateEpicSettle({ epicId, cwd, events, jobs, now, horizonMs } = {})
|
|
|
34
45
|
(r) => r && (r.epicId === epicId || r.sourcePromptId === epicId) && (r.cwd == null || cwd == null || r.cwd === cwd),
|
|
35
46
|
);
|
|
36
47
|
const settled = !epicRows.some((r) => r.status === 'pending' || r.status === 'running');
|
|
48
|
+
const cancelled = epicRows
|
|
49
|
+
.filter(isCancelledRow)
|
|
50
|
+
.map((r) => String(r.slug))
|
|
51
|
+
.sort();
|
|
52
|
+
const cancelledSet = new Set(cancelled.flatMap((s) => [s, bareSlug(s)]));
|
|
37
53
|
const parked = epicRows
|
|
38
|
-
.filter((r) => r.status === 'needs_review')
|
|
54
|
+
.filter((r) => r.status === 'needs_review' && !isCancelledRow(r))
|
|
39
55
|
.map((r) => String(r.slug))
|
|
40
56
|
.sort();
|
|
41
57
|
|
|
@@ -83,7 +99,7 @@ function evaluateEpicSettle({ epicId, cwd, events, jobs, now, horizonMs } = {})
|
|
|
83
99
|
const failed = [];
|
|
84
100
|
for (const [bare, { event, t }] of checkins) {
|
|
85
101
|
if (event.outcome === 'failed') {
|
|
86
|
-
failed.push(event.prdSlug);
|
|
102
|
+
if (!cancelledSet.has(event.prdSlug) && !cancelledSet.has(bare)) failed.push(event.prdSlug);
|
|
87
103
|
continue;
|
|
88
104
|
}
|
|
89
105
|
const v = verdicts.get(bare);
|
|
@@ -94,12 +110,12 @@ function evaluateEpicSettle({ epicId, cwd, events, jobs, now, horizonMs } = {})
|
|
|
94
110
|
failed.sort();
|
|
95
111
|
|
|
96
112
|
const digestKey = createHash('sha1')
|
|
97
|
-
.update(JSON.stringify({ verified, refuted, failed, parked, uncovered: uncovered.map((u) => u.slug) }))
|
|
113
|
+
.update(JSON.stringify({ verified, refuted, failed, parked, cancelled, uncovered: uncovered.map((u) => u.slug) }))
|
|
98
114
|
.digest('hex')
|
|
99
115
|
.slice(0, 16);
|
|
100
|
-
const hasContent = [uncovered, verified, refuted, failed, parked].some((l) => l.length > 0);
|
|
116
|
+
const hasContent = [uncovered, verified, refuted, failed, parked, cancelled].some((l) => l.length > 0);
|
|
101
117
|
|
|
102
|
-
return { settled, uncovered, verified, refuted, failed, parked, digestKey, hasContent };
|
|
118
|
+
return { settled, uncovered, verified, refuted, failed, parked, cancelled, digestKey, hasContent };
|
|
103
119
|
}
|
|
104
120
|
|
|
105
121
|
module.exports = { evaluateEpicSettle, DEFAULT_SETTLE_HORIZON_MS };
|
|
@@ -52,6 +52,7 @@ function buildSettledDigest(settle, { epicId, notValidatedReason } = {}) {
|
|
|
52
52
|
const refuted = s.refuted || [];
|
|
53
53
|
const failed = s.failed || [];
|
|
54
54
|
const parked = s.parked || [];
|
|
55
|
+
const cancelled = s.cancelled || [];
|
|
55
56
|
const notValidated = (s.uncovered || []).map((u) => (typeof u === 'string' ? u : u.slug));
|
|
56
57
|
const lines = [
|
|
57
58
|
`PLAN SETTLED — no PRD of this session is pending or running. This is ONE digest, not a per-PRD request.`,
|
|
@@ -70,6 +71,12 @@ function buildSettledDigest(settle, { epicId, notValidatedReason } = {}) {
|
|
|
70
71
|
if (parked.length) {
|
|
71
72
|
lines.push('', `Parked (needs_review) (${parked.length}):`, ...parked.map((p) => `- ${p}`));
|
|
72
73
|
}
|
|
74
|
+
if (cancelled.length) {
|
|
75
|
+
lines.push(
|
|
76
|
+
'', `Cancelled (${cancelled.length}) — stopped by scheduler_cancel_job; only scheduler_reset_job revives them:`,
|
|
77
|
+
...cancelled.map((c) => `- ${c}`),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
73
80
|
if (notValidated.length) {
|
|
74
81
|
lines.push(
|
|
75
82
|
'', `Not validated (${notValidated.length}) — ${notValidatedReason || 'no validator checked them'}:`,
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* depHealth.cjs — detect and repair a broken pnpm node_modules in the main checkout.
|
|
3
|
+
*
|
|
4
|
+
* Incident: a `pnpm install` run through a symlinked worktree node_modules purged
|
|
5
|
+
* the main checkout's modules dir, so every later gate went red with
|
|
6
|
+
* `Cannot find module .../node_modules/typescript/bin/tsc`. Recovery is
|
|
7
|
+
* `pnpm install --offline` in the main checkout; it needs CI=1 plus
|
|
8
|
+
* `--config.confirmModulesPurge=false`, otherwise pnpm prompts and aborts with
|
|
9
|
+
* ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY.
|
|
10
|
+
*
|
|
11
|
+
* Second incident (Bilko, 2026-10-18): the same install rewrote 37 workspace links
|
|
12
|
+
* in the main checkout's node_modules to point into a job worktree; once the
|
|
13
|
+
* worktree was deleted they all dangled. Workspace packages are not in the root
|
|
14
|
+
* package.json deps, so the deps check missed it. findWorktreeEscapingLinks flags
|
|
15
|
+
* top-level links that resolve into a worktree root or dangle.
|
|
16
|
+
*
|
|
17
|
+
* Neither function throws. The check runs on the scheduler main loop, so it is
|
|
18
|
+
* O(deps) stats plus one bounded top-level readdir (no deep walks).
|
|
19
|
+
*/
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
const { execFile } = require('node:child_process');
|
|
25
|
+
|
|
26
|
+
const MAX_DEPS = 200;
|
|
27
|
+
const MAX_MISSING = 20;
|
|
28
|
+
const TAIL_CHARS = 2000;
|
|
29
|
+
const MAX_SCANNED = 2000;
|
|
30
|
+
|
|
31
|
+
function defaultWorktreeRoots() {
|
|
32
|
+
try {
|
|
33
|
+
const sp = require('../scheduler/schedulerPaths.cjs');
|
|
34
|
+
return [sp.worktreeRoot('job'), sp.worktreeRoot('epic')].filter((r) => typeof r === 'string' && r);
|
|
35
|
+
} catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Scan node_modules/* and node_modules/@scope/* (lstat only, no deeper) for symlinks
|
|
42
|
+
* that resolve into a worktree root or whose target is gone.
|
|
43
|
+
* @param {string} mainCwd
|
|
44
|
+
* @param {{ worktreeRoots?: string[] }} [opts]
|
|
45
|
+
* @returns {{ intoWorktree: string[], dangling: string[] }}
|
|
46
|
+
*/
|
|
47
|
+
function findWorktreeEscapingLinks(mainCwd, opts = {}) {
|
|
48
|
+
const intoWorktree = [];
|
|
49
|
+
const dangling = [];
|
|
50
|
+
try {
|
|
51
|
+
const rawRoots = Array.isArray(opts.worktreeRoots) ? opts.worktreeRoots : defaultWorktreeRoots();
|
|
52
|
+
const roots = [];
|
|
53
|
+
for (const r of rawRoots) {
|
|
54
|
+
if (!r) continue;
|
|
55
|
+
roots.push(path.resolve(r));
|
|
56
|
+
try { roots.push(fs.realpathSync(r)); } catch { /* root absent */ }
|
|
57
|
+
}
|
|
58
|
+
const modules = path.join(mainCwd, 'node_modules');
|
|
59
|
+
let scanned = 0;
|
|
60
|
+
const check = (rel) => {
|
|
61
|
+
const full = path.join(modules, rel);
|
|
62
|
+
let target;
|
|
63
|
+
try {
|
|
64
|
+
target = fs.realpathSync(full);
|
|
65
|
+
} catch (e) {
|
|
66
|
+
if (dangling.length < MAX_MISSING) dangling.push(rel);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (roots.some((r) => target.startsWith(r + path.sep)) && intoWorktree.length < MAX_MISSING) {
|
|
70
|
+
intoWorktree.push(rel);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const entries = fs.readdirSync(modules, { withFileTypes: true });
|
|
74
|
+
outer: for (const ent of entries) {
|
|
75
|
+
if (ent.name.startsWith('.')) continue;
|
|
76
|
+
if (++scanned > MAX_SCANNED) break;
|
|
77
|
+
if (ent.name.startsWith('@')) {
|
|
78
|
+
let subs = [];
|
|
79
|
+
try { subs = fs.readdirSync(path.join(modules, ent.name), { withFileTypes: true }); } catch { continue; }
|
|
80
|
+
for (const sub of subs) {
|
|
81
|
+
if (sub.name.startsWith('.')) continue;
|
|
82
|
+
if (++scanned > MAX_SCANNED) break outer;
|
|
83
|
+
if (sub.isSymbolicLink()) check(`${ent.name}/${sub.name}`);
|
|
84
|
+
}
|
|
85
|
+
} else if (ent.isSymbolicLink()) {
|
|
86
|
+
check(ent.name);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
return { intoWorktree: [], dangling: [] };
|
|
91
|
+
}
|
|
92
|
+
return { intoWorktree, dangling };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {string} mainCwd
|
|
97
|
+
* @param {{ worktreeRoots?: string[] }} [opts]
|
|
98
|
+
* @returns {{ ok: boolean, skipped?: string, reason?: string, missing?: string[] }}
|
|
99
|
+
*/
|
|
100
|
+
function checkMainDependencyHealth(mainCwd, opts = {}) {
|
|
101
|
+
try {
|
|
102
|
+
if (!fs.existsSync(path.join(mainCwd, 'pnpm-lock.yaml'))) return { ok: true, skipped: 'not-pnpm' };
|
|
103
|
+
const modules = path.join(mainCwd, 'node_modules');
|
|
104
|
+
let isDir = false;
|
|
105
|
+
try { isDir = fs.statSync(modules).isDirectory(); } catch { /* missing */ }
|
|
106
|
+
if (!isDir) return { ok: false, reason: 'node_modules-missing' };
|
|
107
|
+
if (!fs.existsSync(path.join(modules, '.modules.yaml'))) return { ok: false, reason: 'modules-yaml-missing' };
|
|
108
|
+
|
|
109
|
+
const links = findWorktreeEscapingLinks(mainCwd, { worktreeRoots: opts && opts.worktreeRoots });
|
|
110
|
+
if (links.intoWorktree.length) return { ok: false, reason: 'links-into-worktree', missing: links.intoWorktree };
|
|
111
|
+
if (links.dangling.length) return { ok: false, reason: 'dangling-links', missing: links.dangling };
|
|
112
|
+
|
|
113
|
+
let pkg;
|
|
114
|
+
try {
|
|
115
|
+
pkg = JSON.parse(fs.readFileSync(path.join(mainCwd, 'package.json'), 'utf8'));
|
|
116
|
+
} catch {
|
|
117
|
+
return { ok: true, skipped: 'no-package-json' };
|
|
118
|
+
}
|
|
119
|
+
if (!pkg || typeof pkg !== 'object') return { ok: true, skipped: 'no-package-json' };
|
|
120
|
+
const names = [
|
|
121
|
+
...Object.keys(pkg.dependencies || {}),
|
|
122
|
+
...Object.keys(pkg.devDependencies || {}),
|
|
123
|
+
].slice(0, MAX_DEPS);
|
|
124
|
+
const missing = [];
|
|
125
|
+
for (const name of names) {
|
|
126
|
+
try {
|
|
127
|
+
fs.statSync(path.join(modules, name, 'package.json'));
|
|
128
|
+
} catch {
|
|
129
|
+
if (missing.length < MAX_MISSING) missing.push(name);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return missing.length ? { ok: false, reason: 'unresolved-deps', missing } : { ok: true };
|
|
133
|
+
} catch {
|
|
134
|
+
return { ok: true, skipped: 'no-package-json' };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {string} mainCwd
|
|
140
|
+
* @returns {Promise<{ ok: boolean, code: number|null, tail: string }>}
|
|
141
|
+
*/
|
|
142
|
+
function repairMainDependencies(mainCwd) {
|
|
143
|
+
return new Promise((resolve) => {
|
|
144
|
+
try {
|
|
145
|
+
execFile(
|
|
146
|
+
process.env.SM_PNPM_BIN || 'pnpm',
|
|
147
|
+
['install', '--offline', '--frozen-lockfile', '--config.confirmModulesPurge=false'],
|
|
148
|
+
{ cwd: mainCwd, env: { ...process.env, CI: '1' }, timeout: 300_000, maxBuffer: 16 * 1024 * 1024 },
|
|
149
|
+
(err, stdout, stderr) => {
|
|
150
|
+
const tail = `${stdout || ''}${stderr || ''}`.slice(-TAIL_CHARS) || (err ? String(err.message) : '');
|
|
151
|
+
if (!err) return resolve({ ok: true, code: 0, tail });
|
|
152
|
+
resolve({ ok: false, code: typeof err.code === 'number' ? err.code : null, tail: tail || String(err.message) });
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
} catch (e) {
|
|
156
|
+
resolve({ ok: false, code: null, tail: String((e && e.message) || e) });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = { checkMainDependencyHealth, findWorktreeEscapingLinks, repairMainDependencies };
|