claude-code-session-manager 0.119.1 → 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.
Files changed (72) hide show
  1. package/dist/assets/{AgentLibrary-BpW0pGgk.js → AgentLibrary-LBg3mVL8.js} +1 -1
  2. package/dist/assets/{Button-DZX5guWb.js → Button-C4BN5ISR.js} +1 -1
  3. package/dist/assets/{DataModel-WG4pG_4z.js → DataModel-CHhjk61s.js} +1 -1
  4. package/dist/assets/{History-D58whvn_.js → History-CL-T6Lv3.js} +1 -1
  5. package/dist/assets/{Hooks-DCI9ezg4.js → Hooks-ByWVgXIf.js} +1 -1
  6. package/dist/assets/{Library-YqMi-vsA.js → Library-QvYVAEmY.js} +1 -1
  7. package/dist/assets/{ListDetail-ap4W5z_Z.js → ListDetail-FvNe6fo5.js} +1 -1
  8. package/dist/assets/{McpServers-CT0pBTF8.js → McpServers-C7EmPm8B.js} +1 -1
  9. package/dist/assets/{Memory-BODDUBy4.js → Memory-nyLR4Yw4.js} +1 -1
  10. package/dist/assets/{Panel-CeOfUS9B.js → Panel-thElCbLX.js} +1 -1
  11. package/dist/assets/{Permissions-DyOqpLQx.js → Permissions-FQ9GM6Ca.js} +1 -1
  12. package/dist/assets/{Plugins-DBXzKjL2.js → Plugins-D9KE7XTI.js} +2 -2
  13. package/dist/assets/{ProvenanceBadge-C47IX1Q6.js → ProvenanceBadge-lAViG8yV.js} +1 -1
  14. package/dist/assets/{RemoteMachines-CtkAJvkZ.js → RemoteMachines-AWpARk7N.js} +1 -1
  15. package/dist/assets/{SaveBar-YMtzcCzP.js → SaveBar-c15EEGB_.js} +1 -1
  16. package/dist/assets/{Scheduler-BNiYg0YD.js → Scheduler-FvduP1FG.js} +5 -5
  17. package/dist/assets/{ScopeSwitcher-Dgi8CiDi.js → ScopeSwitcher-BLep1rtB.js} +1 -1
  18. package/dist/assets/Settings-D1kTUIDB.js +4 -0
  19. package/dist/assets/{SkillReferenceGraph-CaFsccNI.js → SkillReferenceGraph-_40_f0t3.js} +1 -1
  20. package/dist/assets/{Skills-BeCMBQ3l.js → Skills-DaTkSOuU.js} +1 -1
  21. package/dist/assets/{SystemPrompt-C3WmcPEF.js → SystemPrompt-DxNf-soM.js} +1 -1
  22. package/dist/assets/{TagLibrary-DP4GH7Dr.js → TagLibrary-L6KpBV-x.js} +1 -1
  23. package/dist/assets/{TiptapBody-CXCMvRr_.js → TiptapBody-C_XjtJ82.js} +1 -1
  24. package/dist/assets/{Toggle-TY-Wa8hv.js → Toggle-H0rERKNI.js} +1 -1
  25. package/dist/assets/{ViewTabs-CaacRnp6.js → ViewTabs-ZUWphDat.js} +1 -1
  26. package/dist/assets/{historyTotals-C28IlVVQ.js → historyTotals-BxFKOztb.js} +1 -1
  27. package/dist/assets/{index-DdEMkmbi.js → index-B5wj0DEX.js} +8 -8
  28. package/dist/assets/{index-DAMBm3UN.css → index-Dd7UQabV.css} +1 -1
  29. package/dist/assets/{settingsSchema-7Yg6dNNB.js → settingsSchema-BRcYkXJN.js} +1 -1
  30. package/dist/assets/{skillFrontmatter-Dcuys1km.js → skillFrontmatter-DFGp4DST.js} +1 -1
  31. package/dist/assets/{useAgentsDirWatch-B0H4M-TT.js → useAgentsDirWatch-VvrgalfC.js} +1 -1
  32. package/dist/assets/{useRemotePeers-Zp9jchCS.js → useRemotePeers-mvXKNEE3.js} +1 -1
  33. package/dist/index.html +2 -2
  34. package/package.json +1 -1
  35. package/src/main/build-info.json +4 -4
  36. package/src/main/chatRunner.cjs +11 -8
  37. package/src/main/crashDiagnostics.cjs +18 -13
  38. package/src/main/health.cjs +129 -4
  39. package/src/main/index.cjs +57 -18
  40. package/src/main/ipcSchemas.cjs +3 -3
  41. package/src/main/lib/activeSessions.cjs +51 -34
  42. package/src/main/lib/claude/agentModelResolve.cjs +5 -3
  43. package/src/main/lib/claude/personaToolScope.cjs +68 -0
  44. package/src/main/lib/epic/archivedTranscript.cjs +91 -0
  45. package/src/main/lib/epic/archivedTranscriptMigration.cjs +84 -0
  46. package/src/main/lib/epic/delegationReadiness.cjs +2 -0
  47. package/src/main/lib/epic/epicSettle.cjs +21 -5
  48. package/src/main/lib/epic/epicValidationHook.cjs +7 -0
  49. package/src/main/lib/git/depHealth.cjs +161 -0
  50. package/src/main/lib/git/worktreeDeps.cjs +158 -5
  51. package/src/main/lib/machineProfile.cjs +2 -0
  52. package/src/main/lib/mcpToolCatalog.cjs +3 -1
  53. package/src/main/lib/opsRetentionSettings.cjs +183 -0
  54. package/src/main/lib/prd/prdWorktreeReach.cjs +2 -2
  55. package/src/main/lib/prd/workTypeLibrary.cjs +7 -3
  56. package/src/main/lib/process/procGroupRss.cjs +61 -0
  57. package/src/main/lib/scheduler/queueHistory.cjs +68 -9
  58. package/src/main/lib/scheduler/runLogRetention.cjs +129 -43
  59. package/src/main/lib/scheduler/schedulerBatch.cjs +7 -3
  60. package/src/main/lib/scheduler/staleTmpSweep.cjs +117 -0
  61. package/src/main/lib/telemetry/auditLog.cjs +51 -6
  62. package/src/main/lib/telemetry/crashTelemetry.cjs +49 -1
  63. package/src/main/lib/telemetry/opsErrorLog.cjs +30 -10
  64. package/src/main/lib/telemetry/telemetryClient.cjs +5 -2
  65. package/src/main/scheduler/prdParser.cjs +50 -0
  66. package/src/main/scheduler/prdPaths.cjs +61 -16
  67. package/src/main/scheduler/reviewLadder.cjs +24 -1
  68. package/src/main/scheduler.cjs +214 -37
  69. package/src/main/templates/PRD_AUTHORING.md +2 -2
  70. package/src/preload/api.d.ts +28 -3
  71. package/src/preload/index.cjs +7 -0
  72. package/dist/assets/Settings-B4LuoRnd.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
- async function addExcludes(mainCwd, names) {
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 = names.map((n) => `/${n}`).filter((l) => !have.has(l));
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' },