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.
Files changed (73) hide show
  1. package/dist/assets/{AgentLibrary-CdRrehRI.js → AgentLibrary-LBg3mVL8.js} +1 -1
  2. package/dist/assets/{Button-BTANn6A_.js → Button-C4BN5ISR.js} +1 -1
  3. package/dist/assets/{DataModel-D4DhKYDV.js → DataModel-CHhjk61s.js} +1 -1
  4. package/dist/assets/{History-Dd1SM-AR.js → History-CL-T6Lv3.js} +1 -1
  5. package/dist/assets/{Hooks-BaSxmXkk.js → Hooks-ByWVgXIf.js} +1 -1
  6. package/dist/assets/{Library-DiRx14Qs.js → Library-QvYVAEmY.js} +1 -1
  7. package/dist/assets/{ListDetail-DD12P0m2.js → ListDetail-FvNe6fo5.js} +1 -1
  8. package/dist/assets/{McpServers-B5Zhq3uc.js → McpServers-C7EmPm8B.js} +1 -1
  9. package/dist/assets/{Memory-D8Uibw65.js → Memory-nyLR4Yw4.js} +1 -1
  10. package/dist/assets/{Panel-cUxN--SY.js → Panel-thElCbLX.js} +1 -1
  11. package/dist/assets/{Permissions-CgocoJzX.js → Permissions-FQ9GM6Ca.js} +1 -1
  12. package/dist/assets/{Plugins-BJPK6dlg.js → Plugins-D9KE7XTI.js} +2 -2
  13. package/dist/assets/{ProvenanceBadge-Cw0cwJ1w.js → ProvenanceBadge-lAViG8yV.js} +1 -1
  14. package/dist/assets/{RemoteMachines-BIaTLP0C.js → RemoteMachines-AWpARk7N.js} +1 -1
  15. package/dist/assets/{SaveBar-DlGYYdsI.js → SaveBar-c15EEGB_.js} +1 -1
  16. package/dist/assets/{Scheduler-I4qOJsyy.js → Scheduler-FvduP1FG.js} +5 -5
  17. package/dist/assets/{ScopeSwitcher-hTMYmy6R.js → ScopeSwitcher-BLep1rtB.js} +1 -1
  18. package/dist/assets/Settings-D1kTUIDB.js +4 -0
  19. package/dist/assets/{SkillReferenceGraph-D9a_UFm6.js → SkillReferenceGraph-_40_f0t3.js} +1 -1
  20. package/dist/assets/{Skills-Bzn5Wasd.js → Skills-DaTkSOuU.js} +1 -1
  21. package/dist/assets/{SystemPrompt-D0LpRn5u.js → SystemPrompt-DxNf-soM.js} +1 -1
  22. package/dist/assets/{TagLibrary-CgqAdpwI.js → TagLibrary-L6KpBV-x.js} +1 -1
  23. package/dist/assets/{TiptapBody-u-CUpM2Q.js → TiptapBody-C_XjtJ82.js} +1 -1
  24. package/dist/assets/{Toggle-Bb6BJMNB.js → Toggle-H0rERKNI.js} +1 -1
  25. package/dist/assets/{ViewTabs-Ck2NIjLp.js → ViewTabs-ZUWphDat.js} +1 -1
  26. package/dist/assets/{historyTotals-vNPWEsnZ.js → historyTotals-BxFKOztb.js} +1 -1
  27. package/dist/assets/{index-Cq-Ppqar.js → index-B5wj0DEX.js} +8 -8
  28. package/dist/assets/{index-DAMBm3UN.css → index-Dd7UQabV.css} +1 -1
  29. package/dist/assets/{settingsSchema-D2dkl1uu.js → settingsSchema-BRcYkXJN.js} +1 -1
  30. package/dist/assets/{skillFrontmatter-CW0PqdDX.js → skillFrontmatter-DFGp4DST.js} +1 -1
  31. package/dist/assets/{useAgentsDirWatch-C9ya0GpQ.js → useAgentsDirWatch-VvrgalfC.js} +1 -1
  32. package/dist/assets/{useRemotePeers-4evTc_IE.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/main/webRemote.cjs +4 -27
  71. package/src/preload/api.d.ts +28 -3
  72. package/src/preload/index.cjs +7 -0
  73. package/dist/assets/Settings-C8AGOoqw.js +0 -4
@@ -26,7 +26,7 @@
26
26
  const { app, powerSaveBlocker, powerMonitor, crashReporter } = require('electron');
27
27
  const fs = require('node:fs');
28
28
  const path = require('node:path');
29
- const { reportCrash } = require('./lib/telemetry/crashTelemetry.cjs');
29
+ const { reportCrash, classifyUncleanShutdown, reportChildProcessGone } = require('./lib/telemetry/crashTelemetry.cjs');
30
30
  const { createLoopDelayMonitor, stallVerdict } = require('./lib/util/loopDelay.cjs');
31
31
 
32
32
  let logs = null;
@@ -37,6 +37,7 @@ let loopMonitor = null;
37
37
  let sentinelPath = null;
38
38
  let startedAtIso = null;
39
39
  let lastSample = null;
40
+ let lastLoop = null;
40
41
 
41
42
  // Warn threshold for total working set across all Electron processes. The OOM
42
43
  // journal showed a single renderer hitting ~900 MB anon-rss before the kernel
@@ -140,6 +141,7 @@ function persistSentinel(clean) {
140
141
  startedAt: startedAtIso,
141
142
  closedAt: clean ? new Date().toISOString() : undefined,
142
143
  lastSample,
144
+ loop: lastLoop ? { loopDelayMaxMs: lastLoop.loopDelayMaxMs, loopDelayP99Ms: lastLoop.loopDelayP99Ms } : null,
143
145
  };
144
146
  try {
145
147
  fs.mkdirSync(path.dirname(sentinelPath), { recursive: true });
@@ -152,11 +154,12 @@ function persistSentinel(clean) {
152
154
 
153
155
  function heartbeat() {
154
156
  lastSample = sampleMemory();
157
+ const loop = loopMonitor ? loopMonitor.snapshot() : null;
158
+ lastLoop = loop;
155
159
  // Persist every tick so the final pre-crash sample survives a SIGKILL.
156
160
  persistSentinel(false);
157
161
  const top = [...lastSample.procs].sort((a, b) => b.mb - a.mb).slice(0, 4);
158
162
  const level = lastSample.totalMb >= MEM_WARN_MB ? 'warn' : 'debug';
159
- const loop = loopMonitor ? loopMonitor.snapshot() : null;
160
163
  logs?.writeLine({
161
164
  scope: 'crash-diag',
162
165
  level,
@@ -183,10 +186,14 @@ function checkPreviousRun() {
183
186
  if (!prev || prev.open !== true) return;
184
187
  const s = prev.lastSample;
185
188
  const lastTop = s && Array.isArray(s.procs) ? [...s.procs].sort((a, b) => b.mb - a.mb).slice(0, 4).map((p) => `${p.type}:${p.mb}MB`) : undefined;
189
+ const lastLoopDelayMaxMs = prev.loop?.loopDelayMaxMs;
190
+ const sampleMs = s?.ts ? Date.parse(s.ts) : NaN;
191
+ const silentGapSec = Number.isFinite(sampleMs) ? Math.round((Date.now() - sampleMs) / 1000) : undefined;
192
+ const likelyCause = classifyUncleanShutdown({ lastLoopDelayMaxMs, lastTotalMb: s?.totalMb, silentGapSec });
186
193
  logs?.writeLine({
187
194
  scope: 'crash-diag',
188
195
  level: 'error',
189
- message: 'previous session ended UNCLEANLY (likely OOM-kill / native crash — no graceful quit)',
196
+ message: `previous session ended UNCLEANLY (likelyCause=${likelyCause}; no graceful quit)`,
190
197
  meta: {
191
198
  prevPid: prev.pid,
192
199
  startedAt: prev.startedAt,
@@ -195,7 +202,10 @@ function checkPreviousRun() {
195
202
  lastMainRssMb: s?.main?.rssMb,
196
203
  lastTop,
197
204
  powerBlockerHeldAtDeath: s?.powerBlockerHeld,
198
- hint: 'Check `journalctl -k | grep -i oom` around lastSampleTs to confirm the kernel OOM-killer.',
205
+ lastLoopDelayMaxMs,
206
+ silentGapSec,
207
+ likelyCause,
208
+ hint: 'Check `journalctl -k | grep -i oom` around lastSampleTs to confirm the kernel OOM-killer, and the "main-loop stall" heartbeats before lastSampleTs to confirm a wedged main thread.',
199
209
  },
200
210
  });
201
211
  // The unclean-shutdown postmortem is the report most likely to be the ONLY
@@ -209,6 +219,9 @@ function checkPreviousRun() {
209
219
  lastMainRssMb: s?.main?.rssMb,
210
220
  lastTop,
211
221
  powerBlockerHeldAtDeath: s?.powerBlockerHeld,
222
+ lastLoopDelayMaxMs,
223
+ silentGapSec,
224
+ likelyCause,
212
225
  },
213
226
  });
214
227
  }
@@ -244,15 +257,7 @@ function registerCrashHooks() {
244
257
  lastTotalMb: lastSample?.totalMb,
245
258
  },
246
259
  });
247
- reportCrash({
248
- reason: details?.reason || 'unknown',
249
- meta: {
250
- type: details?.type,
251
- name: details?.name || details?.serviceName,
252
- exitCode: details?.exitCode,
253
- lastTotalMb: lastSample?.totalMb,
254
- },
255
- });
260
+ reportChildProcessGone({ details, lastTotalMb: lastSample?.totalMb });
256
261
  });
257
262
 
258
263
  // powerMonitor ties the suspend story to the crash timeline. If we ever log
@@ -10,7 +10,7 @@ const path = require('node:path');
10
10
  const os = require('node:os');
11
11
  const { execFileSync } = require('node:child_process');
12
12
  const { POLL_INTERVAL_MS, loadGateThreshold } = require('./lib/scheduler/schedulerConfig.cjs');
13
- const { opsPath } = require('./lib/opsOwnership.cjs');
13
+ const { opsPath, OPS_ROOT_DIR } = require('./lib/opsOwnership.cjs');
14
14
  const { effectivePaused } = require('./lib/scheduler/upgradeDrain.cjs');
15
15
  const { checkPersonaImports } = require('./lib/persona-macro/personaImportHealth.cjs');
16
16
  const { checkDelegationReadiness } = require('./lib/epic/delegationReadiness.cjs');
@@ -24,6 +24,7 @@ const { classifyQueueStarvation, launchBlockedSlugs, STARVE_ESCALATION_MS } = re
24
24
  const { REVERIFY_INTERVAL_MS } = require('./scheduler/reviewLadder.cjs');
25
25
  const { findStarvedProjects, findUnresolvableDepRoots, DEFAULT_PROJECT_CWD, DEP_HISTORY_FAIL_OPEN } = require('./lib/scheduler/schedulerBatch.cjs');
26
26
  const { auditLogPath, readTail } = require('./lib/telemetry/auditLog.cjs');
27
+ const { checkMainDependencyHealth } = require('./lib/git/depHealth.cjs');
27
28
  const { resolveBuildIdentity } = require('./lib/buildIdentity.cjs');
28
29
  const { computeReport, isRetentionEnabled, liveKeysFromJobs } = require('./lib/scheduler/runLogRetention.cjs');
29
30
  const { allProjectCwds } = require('./lib/activeSessions.cjs');
@@ -399,6 +400,41 @@ function evaluateEpicIndexHealth(cwds) {
399
400
  return { component: { ok: !anyOrphanRows, byProject }, issues };
400
401
  }
401
402
 
403
+ // Read-only scan for stray `session-manager-operations` dirs nested under a
404
+ // project root (symptom of a writer joining an agent cwd without
405
+ // projectRootOf/opsPath). The top-level one is legitimate; node_modules, .git
406
+ // and .claude/worktrees are skipped. Bounded depth, sync readdir, never writes.
407
+ const OPS_DIR_NAME = OPS_ROOT_DIR;
408
+ function findNestedOpsRoots(projectRoot, { maxDepth = 3 } = {}) {
409
+ const found = [];
410
+ const walk = (dir, depth) => {
411
+ let entries;
412
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
413
+ for (const e of entries) {
414
+ if (!e.isDirectory()) continue; // symlinks are not followed
415
+ if (e.name === 'node_modules' || e.name === '.git') continue;
416
+ const full = path.join(dir, e.name);
417
+ if (e.name === '.claude') {
418
+ // only .claude/worktrees is skipped; other .claude children still scan
419
+ if (depth + 1 < maxDepth) {
420
+ try {
421
+ for (const c of fs.readdirSync(full, { withFileTypes: true })) {
422
+ if (c.isDirectory() && c.name !== 'worktrees') walk(path.join(full, c.name), depth + 2);
423
+ }
424
+ } catch { /* unreadable */ }
425
+ }
426
+ continue;
427
+ }
428
+ if (e.name === OPS_DIR_NAME) {
429
+ if (depth > 0) { found.push(full); continue; } // stray; don't descend
430
+ }
431
+ if (depth + 1 < maxDepth) walk(full, depth + 1);
432
+ }
433
+ };
434
+ walk(projectRoot, 0);
435
+ return found;
436
+ }
437
+
402
438
  // Pure evaluator over ~/.claude/session-manager/scheduler-state.json's parsed
403
439
  // content (the sidecar scheduler.cjs's pollLoop maintains for the billing
404
440
  // usage-meter / rate-limit-window poller — see rateLimitPollerStreak tests).
@@ -619,14 +655,56 @@ function latestStarveEscalationReasons(auditLogPath) {
619
655
  * repo HEAD differs from the installed build. Both are reported, neither is
620
656
  * critical (never flips status.ok).
621
657
  */
622
- function evaluateBuildFreshness({ running = null, installedOnDisk = null, repoHead = null } = {}) {
658
+ const WORKTREE_SAFETY_PATHS = [
659
+ 'src/main/lib/git/worktreeDeps.cjs',
660
+ 'src/main/lib/git/depHealth.cjs',
661
+ 'src/main/lib/git/gitWorktree.cjs',
662
+ 'src/main/lib/git/jobWorktree.cjs',
663
+ ];
664
+
665
+ function evaluateBuildFreshness({ running = null, installedOnDisk = null, repoHead = null, safetyCommitsMissing = [] } = {}) {
623
666
  const differs = (a, b) => !!a && !!b && !(a.startsWith(b) || b.startsWith(a));
624
667
  const restartNeeded = differs(running, installedOnDisk);
625
668
  const publishNeeded = differs(repoHead, installedOnDisk);
626
669
  const notes = [];
627
670
  if (restartNeeded) notes.push(`restart needed: running ${running}, installed ${installedOnDisk}`);
628
671
  if (publishNeeded) notes.push(`publish needed: repo HEAD ${repoHead}, installed ${installedOnDisk}`);
629
- return { ok: true, running, installedOnDisk, repoHead, restartNeeded, publishNeeded, ...(notes.length ? { note: notes.join('; ') } : {}) };
672
+ let safetyIssue;
673
+ if (safetyCommitsMissing.length) {
674
+ const listed = safetyCommitsMissing.slice(0, 3).map((c) => `${c.sha} ${c.subject}`).join('; ');
675
+ const more = safetyCommitsMissing.length > 3 ? `; +${safetyCommitsMissing.length - 3} more` : '';
676
+ safetyIssue = `running build ${running} predates ${safetyCommitsMissing.length} worktree-safety commit(s): ${listed}${more} — publish a new release and restart Session Manager`;
677
+ }
678
+ return {
679
+ ok: true, running, installedOnDisk, repoHead, restartNeeded, publishNeeded,
680
+ safetyCommitsMissing, ...(safetyIssue ? { safetyIssue } : {}),
681
+ ...(notes.length ? { note: notes.join('; ') } : {}),
682
+ };
683
+ }
684
+
685
+ /**
686
+ * evaluateMainDepLinks(results) → { component, issues }
687
+ *
688
+ * Pure over `Array<{ cwd, health }>` (health = checkMainDependencyHealth's
689
+ * return). One issue per project whose node_modules links point into a job
690
+ * worktree or dangle. Non-critical: component.ok is always true.
691
+ */
692
+ function evaluateMainDepLinks(results = []) {
693
+ const list = Array.isArray(results) ? results : [];
694
+ const broken = [];
695
+ const issues = [];
696
+ for (const { cwd, health } of list) {
697
+ if (!health || health.ok !== false) continue;
698
+ if (health.reason !== 'links-into-worktree' && health.reason !== 'dangling-links') continue;
699
+ const missing = Array.isArray(health.missing) ? health.missing : [];
700
+ broken.push({ cwd, reason: health.reason, missing });
701
+ const shown = missing.slice(0, 5).join(', ');
702
+ const more = missing.length > 5 ? `, +${missing.length - 5} more` : '';
703
+ issues.push(
704
+ `${cwd}: node_modules ${health.reason}${shown ? ` (${shown}${more})` : ''} — fix: cd ${cwd} && CI=1 pnpm install --offline --frozen-lockfile`,
705
+ );
706
+ }
707
+ return { component: { ok: true, checked: list.length, broken }, issues };
630
708
  }
631
709
 
632
710
  /**
@@ -1234,6 +1312,18 @@ async function check(opts = {}) {
1234
1312
  status.components.epic_transcripts = { ok: true, error: e.message };
1235
1313
  }
1236
1314
 
1315
+ // 6.665. Nested ops roots (NON-fatal WARN): stray session-manager-operations
1316
+ // dirs under this project. Read-only; never deletes.
1317
+ try {
1318
+ const nested = findNestedOpsRoots(PROJECT_ROOT, { maxDepth: 3 });
1319
+ status.components.nested_ops_roots = { ok: true, count: nested.length, paths: nested };
1320
+ if (nested.length > 0) {
1321
+ status.issues.push(`Warning: ${nested.length} nested session-manager-operations dir(s): ${nested.join(', ')}`);
1322
+ }
1323
+ } catch (e) {
1324
+ status.components.nested_ops_roots = { ok: true, error: e.message };
1325
+ }
1326
+
1237
1327
  // 6.7. Build freshness (informational): running heartbeat vs installed
1238
1328
  // build-info vs repo HEAD.
1239
1329
  try {
@@ -1243,15 +1333,47 @@ async function check(opts = {}) {
1243
1333
  timeout: 5000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
1244
1334
  }).trim() || null;
1245
1335
  } catch { /* not a git checkout */ }
1336
+ const running = readFreshHeartbeat(schedulerPaths.heartbeatPath())?.build?.codeSha ?? null;
1337
+ let safetyCommitsMissing = [];
1338
+ if (typeof running === 'string' && running && typeof repoHead === 'string' && repoHead) {
1339
+ try {
1340
+ safetyCommitsMissing = execFileSync(
1341
+ 'git',
1342
+ ['-C', PROJECT_ROOT, 'log', '--format=%h%x09%s', `${running}..HEAD`, '--', ...WORKTREE_SAFETY_PATHS],
1343
+ { timeout: 5000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
1344
+ ).split('\n').filter(Boolean).map((line) => {
1345
+ const i = line.indexOf('\t');
1346
+ return { sha: line.slice(0, i), subject: line.slice(i + 1) };
1347
+ });
1348
+ } catch { safetyCommitsMissing = []; }
1349
+ }
1246
1350
  status.components.build = evaluateBuildFreshness({
1247
- running: readFreshHeartbeat(schedulerPaths.heartbeatPath())?.build?.codeSha ?? null,
1351
+ running,
1248
1352
  installedOnDisk: resolveBuildIdentity().codeSha ?? null,
1249
1353
  repoHead,
1354
+ safetyCommitsMissing,
1250
1355
  });
1356
+ if (status.components.build.safetyIssue) status.issues.push(status.components.build.safetyIssue);
1251
1357
  } catch (e) {
1252
1358
  status.components.build = { ok: true, error: e.message };
1253
1359
  }
1254
1360
 
1361
+ // 6.75. Main-checkout dependency links: node_modules entries pointing into a
1362
+ // job worktree or dangling, for every cwd the queue knows. Non-critical.
1363
+ if (queueState) {
1364
+ try {
1365
+ const cwds = [...new Set(Object.values(queueState.jobs || {})
1366
+ .map((j) => j && j.cwd).filter((c) => typeof c === 'string' && c))].slice(0, 50);
1367
+ const results = cwds.filter((cwd) => fs.existsSync(cwd))
1368
+ .map((cwd) => ({ cwd, health: checkMainDependencyHealth(cwd) }));
1369
+ const { component, issues } = evaluateMainDepLinks(results);
1370
+ status.components.main_dep_links = component;
1371
+ status.issues.push(...issues);
1372
+ } catch (e) {
1373
+ status.components.main_dep_links = { ok: true, error: e.message };
1374
+ }
1375
+ }
1376
+
1255
1377
  // 6.8. Credential health (informational-but-loud): is the Claude OAuth token
1256
1378
  // usable? An expired/absent token silently voids every headless `claude -p`
1257
1379
  // job — the queue "runs" but nothing authenticates. refreshIfNeeded() reads
@@ -1297,6 +1419,7 @@ module.exports = {
1297
1419
  evaluateCredentialHealth,
1298
1420
  computeEpicIndexDrift,
1299
1421
  evaluateEpicIndexHealth,
1422
+ findNestedOpsRoots,
1300
1423
  computeProjectProblemCounts,
1301
1424
  evaluateWorktreeCapBlocked,
1302
1425
  evaluatePerProjectStall,
@@ -1309,6 +1432,8 @@ module.exports = {
1309
1432
  evaluateBlockingParkHealth,
1310
1433
  evaluateStarveEscalationHealth,
1311
1434
  evaluateBuildFreshness,
1435
+ evaluateMainDepLinks,
1436
+ WORKTREE_SAFETY_PATHS,
1312
1437
  latestStarveEscalationReasons,
1313
1438
  DISPATCH_STALL_THRESHOLD_MS,
1314
1439
  DISPATCH_WARN_THRESHOLD_MS,
@@ -1007,6 +1007,7 @@ pluginInstall.registerPluginInstallHandlers();
1007
1007
  memoryTool.registerMemoryHandlers();
1008
1008
  activeIndexMerge.registerActiveIndexMergeHandlers();
1009
1009
  promptSessionsCreateEpic.registerPromptSessionsCreateEpicHandlers();
1010
+ require('./lib/epic/archivedTranscript.cjs').registerIpc(ipcMain);
1010
1011
  customerFeedbackIpc.registerCustomerFeedbackHandlers();
1011
1012
  epicWorktreeMint.registerEpicWorktreeMintHandlers();
1012
1013
  epicWorktreeMerge.registerEpicWorktreeMergeHandlers();
@@ -1044,6 +1045,11 @@ try {
1044
1045
  } catch (e) {
1045
1046
  logs.writeLine({ scope: 'main', level: 'error', message: 'updater IPC registration failed', meta: { error: e?.message } });
1046
1047
  }
1048
+ try {
1049
+ require('./lib/opsRetentionSettings.cjs').registerIpc(ipcMain);
1050
+ } catch (e) {
1051
+ logs.writeLine({ scope: 'main', level: 'error', message: 'ops retention IPC registration failed', meta: { error: e?.message } });
1052
+ }
1047
1053
  chatRunner.registerChatHandlers();
1048
1054
 
1049
1055
  // Direct in-process PRD authoring for a queued PromptTicket classified
@@ -1110,9 +1116,11 @@ const SMFILE_MIME = {
1110
1116
  // E2E tests also skip so playwright.electron.launch can run multiple specs
1111
1117
  // back-to-back without the prior app's lock causing the next launch to quit.
1112
1118
  const isDev = process.env.SM_DEV === '1' || process.env.SM_E2E === '1';
1119
+ let lostInstanceLock = false;
1113
1120
  if (!isDev) {
1114
1121
  const gotLock = app.requestSingleInstanceLock();
1115
1122
  if (!gotLock) {
1123
+ lostInstanceLock = true;
1116
1124
  terminateLosingInstance(app);
1117
1125
  } else {
1118
1126
  app.on('second-instance', () => {
@@ -1125,6 +1133,31 @@ if (!isDev) {
1125
1133
  }
1126
1134
  }
1127
1135
 
1136
+ function runRunLogRetentionPass() {
1137
+ if (lostInstanceLock) return;
1138
+ try {
1139
+ const schedulerPaths = require('./lib/scheduler/schedulerPaths.cjs');
1140
+ const staleTmpSweep = require('./lib/scheduler/staleTmpSweep.cjs');
1141
+ const result = runLogRetention.runBootSweep();
1142
+ const plansRoot = schedulerPaths.scheduledPlansRoot();
1143
+ const tmp = staleTmpSweep.sweepStaleTmp(plansRoot);
1144
+ const archive = staleTmpSweep.pruneRunsArchive(path.join(plansRoot, 'runs-archive'));
1145
+ logs.writeLine({
1146
+ scope: 'run-log-retention',
1147
+ level: 'info',
1148
+ message: result.deleted ? 'sweep applied' : 'sweep dry-run',
1149
+ meta: {
1150
+ deleted: result.deleted,
1151
+ removedFiles: (result.removedFiles || 0) + tmp.removedFiles + archive.removedFiles,
1152
+ freedBytes: (result.freedBytes || 0) + tmp.freedBytes + archive.freedBytes,
1153
+ errorCount: (result.errors ? result.errors.length : 0) + tmp.errors.length + archive.errors.length,
1154
+ },
1155
+ });
1156
+ } catch (e) {
1157
+ logs.writeLine({ scope: 'run-log-retention', level: 'error', message: 'sweep failed', meta: { error: e?.message } });
1158
+ }
1159
+ }
1160
+
1128
1161
  // Global webContents hardening — applies to DevTools, future webContents, etc.
1129
1162
  // Must be registered before app.whenReady so it captures the first window too.
1130
1163
  app.on('web-contents-created', (_e, wc) => {
@@ -1454,25 +1487,31 @@ app.whenReady().then(async () => {
1454
1487
  logs.writeLine({ scope: 'history-rollup', level: 'error', message: 'finalizeClosedDays failed', meta: { error: e?.message } });
1455
1488
  });
1456
1489
  runIntradayRefresh();
1457
- // Run-log retention sweep: same deferred-30s, once-per-boot shape as the
1458
- // history rollup above. Read-only unless config.schedulerRunLogRetention
1490
+ // Run-log retention sweep: runs once 30s after boot, then every 24h (a
1491
+ // long-lived app would otherwise never trim). Skipped when this process
1492
+ // lost the single-instance lock (lostInstanceLock, set beside
1493
+ // terminateLosingInstance above). Read-only unless config.schedulerRunLogRetention
1459
1494
  // opts in (see runLogRetention.cjs's safety model) — non-fatal on error.
1460
- try {
1461
- const result = runLogRetention.runBootSweep();
1462
- logs.writeLine({
1463
- scope: 'run-log-retention',
1464
- level: 'info',
1465
- message: result.deleted ? 'boot sweep applied' : 'boot sweep dry-run',
1466
- meta: {
1467
- deleted: result.deleted,
1468
- removedFiles: result.removedFiles,
1469
- freedBytes: result.freedBytes,
1470
- errorCount: result.errors ? result.errors.length : 0,
1471
- },
1472
- });
1473
- } catch (e) {
1474
- logs.writeLine({ scope: 'run-log-retention', level: 'error', message: 'boot sweep failed', meta: { error: e?.message } });
1475
- }
1495
+ runRunLogRetentionPass();
1496
+ const retentionTimer = setInterval(runRunLogRetentionPass, 24 * 60 * 60 * 1000);
1497
+ if (retentionTimer.unref) retentionTimer.unref();
1498
+ // Archived-transcript migration: move embedded `transcript` strings into gzip
1499
+ // sidecars, <=50 files per project per boot, yielding between files. Uses the
1500
+ // cached allProjectCwds() list (never a fresh ~/.claude/projects scan).
1501
+ (async () => {
1502
+ try {
1503
+ const { migrateProject } = require('./lib/epic/archivedTranscriptMigration.cjs');
1504
+ for (const projectCwd of allProjectCwds()) {
1505
+ const r = await migrateProject(projectCwd, { limit: 50, yieldFn: () => new Promise((res) => setImmediate(res)) });
1506
+ if (r.migrated || r.errors.length) {
1507
+ logs.writeLine({ scope: 'main', level: r.errors.length ? 'warn' : 'info', message: 'archived transcript migration', meta: { projectCwd, migrated: r.migrated, freedBytes: r.freedBytes, errors: r.errors.length } });
1508
+ }
1509
+ await new Promise((res) => setImmediate(res));
1510
+ }
1511
+ } catch (e) {
1512
+ logs.writeLine({ scope: 'main', level: 'error', message: 'archived transcript migration failed', meta: { error: e?.message } });
1513
+ }
1514
+ })();
1476
1515
  }, 30_000);
1477
1516
  // Keep TODAY's rollup line current so the History dashboard never needs to
1478
1517
  // fall back to a live transcript scan. Shares the same O_EXCL lock file as
@@ -415,9 +415,9 @@ const schedulerCreatePrd = z.object({
415
415
  // User-selected work-type tag (PRD 774; independence from the parent
416
416
  // Epic's own tag confirmed PRD 1041) — deterministic, never LLM-classified.
417
417
  // Epic tag and PRD tag draw on the same WorkType vocabulary
418
- // (lib/prd/workTypeLibrary.cjs) but are independent values; 'discussion' is
418
+ // (lib/prd/workTypeLibrary.cjs) but are independent values; 'discussion' and 'portfolio' are
419
419
  // excluded here for the same reason adminPrdFrontmatterPatch excludes it
420
- // below — a discussion-tagged Epic never reaches PRD authoring.
420
+ // below — a discussion- or portfolio-tagged Epic never reaches PRD authoring.
421
421
  tag: PrdWorkTypeSchema.optional(),
422
422
  // WHO executes this PRD (persona name, `~/.claude/agents/<name>.md`) —
423
423
  // independent of `tag` above. Optional: prdCreate.cjs's buildPrdBody fills
@@ -516,7 +516,7 @@ const adminPrdFrontmatterPatch = z.object({
516
516
  parallelGroup: z.number().int().min(0).max(999999).optional(),
517
517
  sourcePromptId: z.string().min(1).max(128).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
518
518
  sourceTabId: z.string().min(1).max(128).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
519
- // 'discussion' deliberately excluded: matches prdFrontmatter.cjs's
519
+ // 'discussion' and 'portfolio' deliberately excluded: matches prdFrontmatter.cjs's
520
520
  // parsePrdFile, which never parses that value for a PRD's tag key (a
521
521
  // discussion-tagged Epic never reaches PRD authoring) — see
522
522
  // workTypeLibrary.cjs's PrdWorkTypeSchema.
@@ -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
- let newestPath = null;
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 (!newestPath) return null;
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 tlines = readTailLines(newestPath, TAIL_BYTES);
167
- let rawCwd = null;
168
- for (let i = tlines.length - 1; i >= 0; i--) {
169
- let row;
170
- try { row = JSON.parse(tlines[i]); } catch { continue; }
171
- if (row.cwd) { rawCwd = row.cwd; break; }
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 rawCwd ? { rawCwd, newestMtimeMs } : null;
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
- let newestPath = null;
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 (!newestPath) return null;
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 tlines = await readTailLinesAsync(newestPath, TAIL_BYTES);
280
- let rawCwd = null;
281
- for (let i = tlines.length - 1; i >= 0; i--) {
282
- let row;
283
- try { row = JSON.parse(tlines[i]); } catch { continue; }
284
- if (row.cwd) { rawCwd = row.cwd; break; }
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 rawCwd ? { rawCwd, newestMtimeMs } : null;
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
- return { model, systemPrompt, personaPath: persona.path };
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
+ };