claude-code-session-manager 0.119.0 → 0.120.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-CdRrehRI.js → AgentLibrary-LBg3mVL8.js} +1 -1
- package/dist/assets/{Button-BTANn6A_.js → Button-C4BN5ISR.js} +1 -1
- package/dist/assets/{DataModel-D4DhKYDV.js → DataModel-CHhjk61s.js} +1 -1
- package/dist/assets/{History-Dd1SM-AR.js → History-CL-T6Lv3.js} +1 -1
- package/dist/assets/{Hooks-BaSxmXkk.js → Hooks-ByWVgXIf.js} +1 -1
- package/dist/assets/{Library-DiRx14Qs.js → Library-QvYVAEmY.js} +1 -1
- package/dist/assets/{ListDetail-DD12P0m2.js → ListDetail-FvNe6fo5.js} +1 -1
- package/dist/assets/{McpServers-B5Zhq3uc.js → McpServers-C7EmPm8B.js} +1 -1
- package/dist/assets/{Memory-D8Uibw65.js → Memory-nyLR4Yw4.js} +1 -1
- package/dist/assets/{Panel-cUxN--SY.js → Panel-thElCbLX.js} +1 -1
- package/dist/assets/{Permissions-CgocoJzX.js → Permissions-FQ9GM6Ca.js} +1 -1
- package/dist/assets/{Plugins-BJPK6dlg.js → Plugins-D9KE7XTI.js} +2 -2
- package/dist/assets/{ProvenanceBadge-Cw0cwJ1w.js → ProvenanceBadge-lAViG8yV.js} +1 -1
- package/dist/assets/{RemoteMachines-BIaTLP0C.js → RemoteMachines-AWpARk7N.js} +1 -1
- package/dist/assets/{SaveBar-DlGYYdsI.js → SaveBar-c15EEGB_.js} +1 -1
- package/dist/assets/{Scheduler-I4qOJsyy.js → Scheduler-FvduP1FG.js} +5 -5
- package/dist/assets/{ScopeSwitcher-hTMYmy6R.js → ScopeSwitcher-BLep1rtB.js} +1 -1
- package/dist/assets/Settings-D1kTUIDB.js +4 -0
- package/dist/assets/{SkillReferenceGraph-D9a_UFm6.js → SkillReferenceGraph-_40_f0t3.js} +1 -1
- package/dist/assets/{Skills-Bzn5Wasd.js → Skills-DaTkSOuU.js} +1 -1
- package/dist/assets/{SystemPrompt-D0LpRn5u.js → SystemPrompt-DxNf-soM.js} +1 -1
- package/dist/assets/{TagLibrary-CgqAdpwI.js → TagLibrary-L6KpBV-x.js} +1 -1
- package/dist/assets/{TiptapBody-u-CUpM2Q.js → TiptapBody-C_XjtJ82.js} +1 -1
- package/dist/assets/{Toggle-Bb6BJMNB.js → Toggle-H0rERKNI.js} +1 -1
- package/dist/assets/{ViewTabs-Ck2NIjLp.js → ViewTabs-ZUWphDat.js} +1 -1
- package/dist/assets/{historyTotals-vNPWEsnZ.js → historyTotals-BxFKOztb.js} +1 -1
- package/dist/assets/{index-Cq-Ppqar.js → index-B5wj0DEX.js} +8 -8
- package/dist/assets/{index-DAMBm3UN.css → index-Dd7UQabV.css} +1 -1
- package/dist/assets/{settingsSchema-D2dkl1uu.js → settingsSchema-BRcYkXJN.js} +1 -1
- package/dist/assets/{skillFrontmatter-CW0PqdDX.js → skillFrontmatter-DFGp4DST.js} +1 -1
- package/dist/assets/{useAgentsDirWatch-C9ya0GpQ.js → useAgentsDirWatch-VvrgalfC.js} +1 -1
- package/dist/assets/{useRemotePeers-4evTc_IE.js → useRemotePeers-mvXKNEE3.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 +11 -8
- 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 +3 -3
- 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/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/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 +4 -27
- package/src/preload/api.d.ts +28 -3
- package/src/preload/index.cjs +7 -0
- package/dist/assets/Settings-C8AGOoqw.js +0 -4
|
@@ -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 };
|
|
@@ -9,12 +9,40 @@
|
|
|
9
9
|
* `info/exclude` as `/<name>` — otherwise the link shows as untracked and the
|
|
10
10
|
* job parks as `uncommitted_changes`.
|
|
11
11
|
*
|
|
12
|
+
* pnpm projects are the exception for node_modules: `pnpm install` purges and
|
|
13
|
+
* rebuilds the modules dir THROUGH a symlink, wiping the main checkout's
|
|
14
|
+
* node_modules. So a pnpm worktree gets its own node_modules, installed
|
|
15
|
+
* offline from the shared store (`installed`); the main checkout is never
|
|
16
|
+
* touched. `node_modules/` (any depth) is excluded so nested workspace
|
|
17
|
+
* node_modules stay untracked.
|
|
18
|
+
*
|
|
19
|
+
* Relative `file:`/`link:` deps that point OUTSIDE the repo (e.g.
|
|
20
|
+
* `file:../Bilko/packages/host-kit`) cannot resolve from a worktree, which lives
|
|
21
|
+
* under the session-manager state dir rather than next to the sibling repo — the
|
|
22
|
+
* offline install would ENOENT and leave no node_modules. So before installing,
|
|
23
|
+
* `linkOutOfRepoLocalDeps` creates a symlink at the equivalent location relative
|
|
24
|
+
* to the worktree (first path segment past the `..` hops, e.g.
|
|
25
|
+
* `<worktrees>/<hash>/Bilko -> ~/Projects/Bilko`). That link may therefore sit
|
|
26
|
+
* OUTSIDE the worktree; it is only created where nothing exists, never inside
|
|
27
|
+
* the main checkout, and never overwrites anything.
|
|
28
|
+
*
|
|
29
|
+
* uv projects (`uv.lock`) are the exception for .venv/venv: `uv run` and
|
|
30
|
+
* `uv sync` re-sync the project env, and for an editable project install that
|
|
31
|
+
* rewrites site-packages/<pkg>.pth to the CURRENT tree's src. Through a shared
|
|
32
|
+
* symlink that silently repoints the main checkout's imports at the worktree
|
|
33
|
+
* (social-signals-trader, 2026-10-18), and worktree tests import whichever
|
|
34
|
+
* tree synced last. So a uv worktree gets its own env, built by an offline
|
|
35
|
+
* frozen `uv sync` (hardlinks from uv's cache — cheap); the main env is never
|
|
36
|
+
* linked. If that sync fails the name is skipped and the executor's own `uv`
|
|
37
|
+
* call builds a worktree-local env — never the main one.
|
|
38
|
+
*
|
|
12
39
|
* Never throws; failures land in `skipped`.
|
|
13
40
|
*/
|
|
14
41
|
'use strict';
|
|
15
42
|
|
|
16
43
|
const fs = require('node:fs');
|
|
17
44
|
const path = require('node:path');
|
|
45
|
+
const { execFile } = require('node:child_process');
|
|
18
46
|
const { execGit } = require('./gitExec.cjs');
|
|
19
47
|
|
|
20
48
|
const DEFAULT_DEP_DIRS = ['node_modules', '.venv', 'venv'];
|
|
@@ -34,27 +62,141 @@ async function isIgnored(cwd, name) {
|
|
|
34
62
|
} catch { return false; }
|
|
35
63
|
}
|
|
36
64
|
|
|
37
|
-
|
|
65
|
+
/** True when mainCwd is a pnpm project (lockfile or pnpm modules manifest). Never throws. */
|
|
66
|
+
function isPnpmProject(mainCwd) {
|
|
67
|
+
try {
|
|
68
|
+
return fs.existsSync(path.join(mainCwd, 'pnpm-lock.yaml'))
|
|
69
|
+
|| fs.existsSync(path.join(mainCwd, 'node_modules', '.modules.yaml'));
|
|
70
|
+
} catch { return false; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** True when mainCwd is a uv project (uv.lock). Never throws. */
|
|
74
|
+
function isUvProject(mainCwd) {
|
|
75
|
+
try { return fs.existsSync(path.join(mainCwd, 'uv.lock')); } catch { return false; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Offline frozen `uv sync` into worktreeCwd/<name>. Resolves true on exit 0; never throws. */
|
|
79
|
+
function installUvEnv(worktreeCwd, name) {
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
try {
|
|
82
|
+
const env = { ...process.env, UV_PROJECT_ENVIRONMENT: path.join(worktreeCwd, name) };
|
|
83
|
+
delete env.VIRTUAL_ENV;
|
|
84
|
+
execFile(
|
|
85
|
+
'uv',
|
|
86
|
+
['sync', '--frozen', '--offline'],
|
|
87
|
+
{ cwd: worktreeCwd, env, timeout: 180_000, maxBuffer: 16 * 1024 * 1024 },
|
|
88
|
+
(err) => resolve(!err),
|
|
89
|
+
);
|
|
90
|
+
} catch { resolve(false); }
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Top-level importer dirs from pnpm-lock.yaml (`.` is the root). Simple line scan; never throws. */
|
|
95
|
+
function lockImporters(cwd) {
|
|
96
|
+
const out = ['.'];
|
|
97
|
+
try {
|
|
98
|
+
let inImporters = false;
|
|
99
|
+
for (const line of fs.readFileSync(path.join(cwd, 'pnpm-lock.yaml'), 'utf8').split('\n')) {
|
|
100
|
+
if (/^importers:\s*$/.test(line)) { inImporters = true; continue; }
|
|
101
|
+
if (!inImporters) continue;
|
|
102
|
+
if (/^\S/.test(line)) break;
|
|
103
|
+
const m = /^ {2}(\S[^:]*):\s*$/.exec(line);
|
|
104
|
+
if (m) out.push(m[1].replace(/^(['"])(.*)\1$/, '$2'));
|
|
105
|
+
}
|
|
106
|
+
} catch { /* no lockfile */ }
|
|
107
|
+
return [...new Set(out)];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Relative file:/link: specifiers of a package.json. Never throws. */
|
|
111
|
+
function localSpecs(pkgJson) {
|
|
112
|
+
const specs = [];
|
|
113
|
+
try {
|
|
114
|
+
const pkg = JSON.parse(fs.readFileSync(pkgJson, 'utf8'));
|
|
115
|
+
for (const f of ['dependencies', 'devDependencies', 'optionalDependencies']) {
|
|
116
|
+
for (const v of Object.values(pkg[f] || {})) {
|
|
117
|
+
const m = typeof v === 'string' && /^(?:file|link):(.+)$/.exec(v);
|
|
118
|
+
if (m && !path.isAbsolute(m[1])) specs.push(m[1]);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} catch { /* unreadable */ }
|
|
122
|
+
return specs;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const within = (parent, p) => p === parent || p.startsWith(parent + path.sep);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Best-effort: link out-of-repo relative file:/link: dep targets at their
|
|
129
|
+
* worktree-relative location so the offline install can resolve them. Never throws.
|
|
130
|
+
*/
|
|
131
|
+
function linkOutOfRepoLocalDeps(mainCwd, worktreeCwd) {
|
|
132
|
+
try {
|
|
133
|
+
for (const importer of lockImporters(worktreeCwd)) {
|
|
134
|
+
const mainDir = path.resolve(mainCwd, importer);
|
|
135
|
+
const wtDir = path.resolve(worktreeCwd, importer);
|
|
136
|
+
for (const spec of localSpecs(path.join(mainDir, 'package.json'))) {
|
|
137
|
+
try {
|
|
138
|
+
const target = path.resolve(mainDir, spec);
|
|
139
|
+
if (within(mainCwd, target) || !exists(target)) continue;
|
|
140
|
+
const segs = path.normalize(spec).split(path.sep).filter((x) => x && x !== '.');
|
|
141
|
+
let ups = 0;
|
|
142
|
+
while (segs[ups] === '..') ups++;
|
|
143
|
+
const rest = segs.slice(ups);
|
|
144
|
+
if (!rest.length) continue;
|
|
145
|
+
// Link the first segment past the `..` hops, to the matching target ancestor.
|
|
146
|
+
let wtBase = wtDir;
|
|
147
|
+
for (let i = 0; i < ups; i++) wtBase = path.dirname(wtBase);
|
|
148
|
+
const linkPath = path.join(wtBase, rest[0]);
|
|
149
|
+
let linkTarget = target;
|
|
150
|
+
for (let i = 1; i < rest.length; i++) linkTarget = path.dirname(linkTarget);
|
|
151
|
+
if (within(mainCwd, linkPath) || within(worktreeCwd, linkPath) || exists(linkPath)) continue;
|
|
152
|
+
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
|
|
153
|
+
fs.symlinkSync(linkTarget, linkPath, isDir(linkTarget) ? 'dir' : 'file');
|
|
154
|
+
} catch { /* best-effort per dep */ }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
} catch { /* best-effort */ }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Offline frozen install in the worktree. Resolves true on exit 0; never throws. */
|
|
161
|
+
function installPnpmModules(worktreeCwd) {
|
|
162
|
+
return new Promise((resolve) => {
|
|
163
|
+
try {
|
|
164
|
+
execFile(
|
|
165
|
+
'pnpm',
|
|
166
|
+
['install', '--offline', '--frozen-lockfile', '--config.confirmModulesPurge=false'],
|
|
167
|
+
{ cwd: worktreeCwd, env: { ...process.env, CI: '1' }, timeout: 180_000, maxBuffer: 16 * 1024 * 1024 },
|
|
168
|
+
(err) => resolve(!err),
|
|
169
|
+
);
|
|
170
|
+
} catch { resolve(false); }
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function addExcludeLines(mainCwd, lines) {
|
|
38
175
|
const out = (await execGit(mainCwd, ['rev-parse', '--git-common-dir'], { timeout: 10_000 })).trim();
|
|
39
176
|
const commonDir = path.resolve(mainCwd, out);
|
|
40
177
|
const file = path.join(commonDir, 'info', 'exclude');
|
|
41
178
|
let existing = '';
|
|
42
179
|
try { existing = fs.readFileSync(file, 'utf8'); } catch { /* absent */ }
|
|
43
180
|
const have = new Set(existing.split('\n').map((l) => l.trim()));
|
|
44
|
-
const missing =
|
|
181
|
+
const missing = lines.filter((l) => !have.has(l));
|
|
45
182
|
if (!missing.length) return;
|
|
46
183
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
47
184
|
const sep = existing && !existing.endsWith('\n') ? '\n' : '';
|
|
48
185
|
fs.appendFileSync(file, `${sep}${missing.join('\n')}\n`);
|
|
49
186
|
}
|
|
50
187
|
|
|
188
|
+
function addExcludes(mainCwd, names) {
|
|
189
|
+
return addExcludeLines(mainCwd, names.map((n) => `/${n}`));
|
|
190
|
+
}
|
|
191
|
+
|
|
51
192
|
/**
|
|
52
193
|
* @param {{ mainCwd: string, worktreeCwd: string, names?: string[] }} opts
|
|
53
|
-
* @returns {Promise<{ linked: string[], skipped: string[] }>}
|
|
194
|
+
* @returns {Promise<{ linked: string[], skipped: string[], installed: string[] }>}
|
|
54
195
|
*/
|
|
55
196
|
async function linkDependencyDirs({ mainCwd, worktreeCwd, names = DEFAULT_DEP_DIRS }) {
|
|
56
197
|
const linked = [];
|
|
57
198
|
const skipped = [];
|
|
199
|
+
const installed = [];
|
|
58
200
|
for (const name of names) {
|
|
59
201
|
try {
|
|
60
202
|
const src = path.join(mainCwd, name);
|
|
@@ -63,6 +205,17 @@ async function linkDependencyDirs({ mainCwd, worktreeCwd, names = DEFAULT_DEP_DI
|
|
|
63
205
|
skipped.push(name);
|
|
64
206
|
continue;
|
|
65
207
|
}
|
|
208
|
+
if (name === 'node_modules' && isPnpmProject(mainCwd)) {
|
|
209
|
+
await addExcludeLines(mainCwd, ['node_modules/']);
|
|
210
|
+
linkOutOfRepoLocalDeps(mainCwd, worktreeCwd);
|
|
211
|
+
(await installPnpmModules(worktreeCwd) ? installed : skipped).push(name);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if ((name === '.venv' || name === 'venv') && isUvProject(mainCwd)) {
|
|
215
|
+
await addExcludes(mainCwd, [name]);
|
|
216
|
+
(await installUvEnv(worktreeCwd, name) ? installed : skipped).push(name);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
66
219
|
// Exclude first: a link without its exclude line would dirty status.
|
|
67
220
|
await addExcludes(mainCwd, [name]);
|
|
68
221
|
fs.symlinkSync(src, dest, 'dir');
|
|
@@ -71,7 +224,7 @@ async function linkDependencyDirs({ mainCwd, worktreeCwd, names = DEFAULT_DEP_DI
|
|
|
71
224
|
skipped.push(name);
|
|
72
225
|
}
|
|
73
226
|
}
|
|
74
|
-
return { linked, skipped };
|
|
227
|
+
return { linked, skipped, installed };
|
|
75
228
|
}
|
|
76
229
|
|
|
77
|
-
module.exports = { linkDependencyDirs, DEFAULT_DEP_DIRS };
|
|
230
|
+
module.exports = { linkDependencyDirs, isPnpmProject, isUvProject, DEFAULT_DEP_DIRS };
|
|
@@ -117,6 +117,8 @@ async function buildMachineProfile(opts = {}) {
|
|
|
117
117
|
let appPath = null;
|
|
118
118
|
if (electronApp) {
|
|
119
119
|
try { appVersion = electronApp.getVersion(); } catch { /* not ready / not a real app object */ }
|
|
120
|
+
// Unpackaged launch with a script path makes getVersion() return Electron's own version.
|
|
121
|
+
if (appVersion && process.versions && appVersion === process.versions.electron) appVersion = null;
|
|
120
122
|
try { appPath = electronApp.getAppPath(); } catch { /* not ready / not a real app object */ }
|
|
121
123
|
}
|
|
122
124
|
if (!appVersion) appVersion = resolvePackageVersion();
|
|
@@ -193,7 +193,9 @@ const MCP_TOOL_CATALOG = [
|
|
|
193
193
|
purpose: "THE ONLY SUPPORTED WAY to cancel a not-yet-terminal scheduler job via the session-manager app's admin "
|
|
194
194
|
+ 'API. A running job is SIGTERM\'d; a pending job is simply retired. There is no "cancelled" job status, so a '
|
|
195
195
|
+ 'cancelled job lands as "failed" with an error naming the cause. Refuses a slug whose job is already terminal '
|
|
196
|
-
+ '(completed/failed/needs_review) — nothing to cancel.'
|
|
196
|
+
+ '(completed/failed/needs_review) — nothing to cancel. A cancelled running job\'s commits are never landed on '
|
|
197
|
+
+ 'the base branch; they stay on the job branch, reported in the result as landed:false plus '
|
|
198
|
+
+ 'jobBranch/jobBranchSha.',
|
|
197
199
|
whenToUse: 'Use to stop a running or pending job that should not continue (e.g. it was queued in error, or scope changed underneath it).',
|
|
198
200
|
whenNotToUse: 'Do not use on an already-terminal job (completed/failed/needs_review) — use scheduler_reset_job if it should run again.',
|
|
199
201
|
exampleArgs: { slug: 'add-mcp-tool-catalog' },
|