claude-code-session-manager 0.119.1 → 0.121.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/assets/{AgentLibrary-BpW0pGgk.js → AgentLibrary-FO2ei2wB.js} +1 -1
  2. package/dist/assets/{Button-DZX5guWb.js → Button-CeIOXUPU.js} +1 -1
  3. package/dist/assets/{DataModel-WG4pG_4z.js → DataModel-D0p__Hil.js} +1 -1
  4. package/dist/assets/{History-D58whvn_.js → History-DVqQ5azN.js} +1 -1
  5. package/dist/assets/{Hooks-DCI9ezg4.js → Hooks-DkTnRHyT.js} +1 -1
  6. package/dist/assets/{Library-YqMi-vsA.js → Library-l7KS6pkE.js} +1 -1
  7. package/dist/assets/{ListDetail-ap4W5z_Z.js → ListDetail-DDw8fuRT.js} +1 -1
  8. package/dist/assets/{McpServers-CT0pBTF8.js → McpServers-UUNIxk_3.js} +1 -1
  9. package/dist/assets/{Memory-BODDUBy4.js → Memory-CNYUoQ8C.js} +1 -1
  10. package/dist/assets/{Panel-CeOfUS9B.js → Panel-Bb54soc2.js} +1 -1
  11. package/dist/assets/{Permissions-DyOqpLQx.js → Permissions-D-dNdV3f.js} +1 -1
  12. package/dist/assets/{Plugins-DBXzKjL2.js → Plugins-DhcIXRhQ.js} +2 -2
  13. package/dist/assets/{ProvenanceBadge-C47IX1Q6.js → ProvenanceBadge-DEAkDNjA.js} +1 -1
  14. package/dist/assets/{RemoteMachines-CtkAJvkZ.js → RemoteMachines-xCEgvTGU.js} +1 -1
  15. package/dist/assets/{SaveBar-YMtzcCzP.js → SaveBar-BSEs7ecA.js} +1 -1
  16. package/dist/assets/{Scheduler-BNiYg0YD.js → Scheduler-BIKJIU-C.js} +5 -5
  17. package/dist/assets/{ScopeSwitcher-Dgi8CiDi.js → ScopeSwitcher-DcaFyAV-.js} +1 -1
  18. package/dist/assets/Settings-vcdWy_Jx.js +4 -0
  19. package/dist/assets/{SkillReferenceGraph-CaFsccNI.js → SkillReferenceGraph-CLrJWTPd.js} +1 -1
  20. package/dist/assets/{Skills-BeCMBQ3l.js → Skills-CYM4rBEC.js} +1 -1
  21. package/dist/assets/{SystemPrompt-C3WmcPEF.js → SystemPrompt-BWNIu-iY.js} +1 -1
  22. package/dist/assets/{TagLibrary-DP4GH7Dr.js → TagLibrary-CYdwHK6m.js} +1 -1
  23. package/dist/assets/{TiptapBody-CXCMvRr_.js → TiptapBody-Kt90lHrD.js} +1 -1
  24. package/dist/assets/{Toggle-TY-Wa8hv.js → Toggle-D1OXWYOz.js} +1 -1
  25. package/dist/assets/{ViewTabs-CaacRnp6.js → ViewTabs-BeQR3CKh.js} +1 -1
  26. package/dist/assets/{historyTotals-C28IlVVQ.js → historyTotals-B0qafrRo.js} +1 -1
  27. package/dist/assets/{index-DdEMkmbi.js → index-Bq9uH08R.js} +201 -201
  28. package/dist/assets/{index-DAMBm3UN.css → index-Dd7UQabV.css} +1 -1
  29. package/dist/assets/{settingsSchema-7Yg6dNNB.js → settingsSchema-0ZksSAPB.js} +1 -1
  30. package/dist/assets/{skillFrontmatter-Dcuys1km.js → skillFrontmatter-C0nh8yfa.js} +1 -1
  31. package/dist/assets/{useAgentsDirWatch-B0H4M-TT.js → useAgentsDirWatch-DPkoBybS.js} +1 -1
  32. package/dist/assets/{useRemotePeers-Zp9jchCS.js → useRemotePeers-BDJMUihy.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 +54 -14
  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 +18 -4
  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/persona-macro/macroLibrary.cjs +75 -0
  55. package/src/main/lib/prd/prdCreate.cjs +25 -0
  56. package/src/main/lib/prd/prdWorktreeReach.cjs +2 -2
  57. package/src/main/lib/prd/workTypeLibrary.cjs +7 -3
  58. package/src/main/lib/process/procGroupRss.cjs +61 -0
  59. package/src/main/lib/remoteTranscriptTail.cjs +69 -2
  60. package/src/main/lib/scheduler/queueHistory.cjs +68 -9
  61. package/src/main/lib/scheduler/runLogRetention.cjs +129 -43
  62. package/src/main/lib/scheduler/schedulerBatch.cjs +7 -3
  63. package/src/main/lib/scheduler/staleTmpSweep.cjs +117 -0
  64. package/src/main/lib/telemetry/auditLog.cjs +51 -6
  65. package/src/main/lib/telemetry/crashTelemetry.cjs +49 -1
  66. package/src/main/lib/telemetry/opsErrorLog.cjs +30 -10
  67. package/src/main/lib/telemetry/telemetryClient.cjs +5 -2
  68. package/src/main/scheduler/prdParser.cjs +50 -0
  69. package/src/main/scheduler/prdPaths.cjs +61 -16
  70. package/src/main/scheduler/reviewLadder.cjs +24 -1
  71. package/src/main/scheduler.cjs +214 -37
  72. package/src/main/templates/PRD_AUTHORING.md +2 -2
  73. package/src/main/webRemote.cjs +82 -91
  74. package/src/preload/api.d.ts +41 -3
  75. package/src/preload/index.cjs +9 -0
  76. package/dist/assets/Settings-B4LuoRnd.js +0 -4
@@ -497,7 +497,7 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
497
497
  // run settled without one.
498
498
  let terminalSent = false;
499
499
  let lineOverflowLogged = false;
500
- const emitTerminal = (channel, payload) => {
500
+ const emitTerminal = (channel, payload, diag = {}) => {
501
501
  if (terminalSent) return;
502
502
  terminalSent = true;
503
503
  // Every terminal error for this tab, logged once, regardless of the
@@ -507,6 +507,8 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
507
507
  opsErrorLog.appendError({
508
508
  cwd,
509
509
  scope: 'chatRunner',
510
+ level: diag.level || 'error',
511
+ code: diag.code || payload?.code,
510
512
  tabId,
511
513
  tags: silent ? ['silent-probe'] : [],
512
514
  message: payload?.message ?? 'chat run failed',
@@ -677,7 +679,7 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
677
679
  `Killed after ${Math.round(elapsedMs / 60_000)}m (ceiling ${KILL_CEILING_MIN}m). ` +
678
680
  `Last actions: ${lastActionsText}. External side effects may have completed — verify ` +
679
681
  `before retrying.`,
680
- });
682
+ }, { level: 'warn', code: 'ceiling-kill' });
681
683
  cancelFn();
682
684
  }, KILL_CEILING_MS);
683
685
  if (killTimer.unref) killTimer.unref();
@@ -707,7 +709,7 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
707
709
  tabId,
708
710
  sessionId,
709
711
  message: `spawn failed: ${err?.message ?? String(err)}`,
710
- });
712
+ }, { code: 'spawn-failed' });
711
713
  settle();
712
714
  return;
713
715
  }
@@ -803,7 +805,7 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
803
805
  tabId,
804
806
  sessionId,
805
807
  message: `run ended with result subtype: ${event.subtype ?? 'unknown'}`,
806
- });
808
+ }, { code: 'result-error' });
807
809
  }
808
810
  }
809
811
  };
@@ -823,6 +825,7 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
823
825
  opsErrorLog.appendError({
824
826
  cwd,
825
827
  scope: 'chatRunner',
828
+ code: 'stdout-line-overflow',
826
829
  tabId,
827
830
  tags: silent ? ['silent-probe'] : [],
828
831
  message: `stdout line exceeded ${LINE_BUFFER_MAX} bytes without a newline; buffer dropped`,
@@ -843,7 +846,7 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
843
846
  tabId,
844
847
  sessionId,
845
848
  message: err?.message ?? String(err),
846
- });
849
+ }, { code: 'child-error' });
847
850
  settle();
848
851
  });
849
852
 
@@ -893,7 +896,7 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
893
896
  tabId,
894
897
  sessionId,
895
898
  message: 'run cancelled',
896
- });
899
+ }, { level: 'info', code: 'cancelled' });
897
900
  } else if (SESSION_IN_USE_RE.test(stderrText()) || NO_CONVERSATION_RE.test(stderrText())) {
898
901
  // A session-flag rejection that survived the one-shot swap (or was
899
902
  // not eligible for it). The raw stderr dump names neither the flag,
@@ -911,7 +914,7 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
911
914
  + `Spawn cwd: ${execCwd}. Transcript: ${plan.transcriptPath ?? 'no transcript found on disk'}. `
912
915
  + `App version: ${appVersion}. If this version is behind the published latest, update the app first — `
913
916
  + 'the session-flag resolution logic lives in main, and a stale build resolves it with older rules.',
914
- });
917
+ }, { code: 'session-flag-rejected' });
915
918
  } else {
916
919
  const preview = stderrBuffer.preview();
917
920
  const errDetail = preview ? `: ${preview}` : '';
@@ -919,7 +922,7 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
919
922
  tabId,
920
923
  sessionId,
921
924
  message: `process exited without a result event (code=${code} signal=${signal})${errDetail}`,
922
- });
925
+ }, { code: 'exit-no-result' });
923
926
  }
924
927
  }
925
928
  settle();
@@ -966,16 +969,48 @@ function cancel(tabId) {
966
969
  // directly, reusing send()'s existing queued-vs-immediate branching rather
967
970
  // than duplicating it here.
968
971
 
972
+ const EXTERNAL_SEND_ACK_TIMEOUT_MS = 8000;
973
+ const pendingExternalSends = new Map(); // requestId -> { resolve, timer }
974
+
975
+ function hasLiveWindow() {
976
+ try {
977
+ return !!(mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents && !mainWindow.webContents.isDestroyed());
978
+ } catch { return false; }
979
+ }
980
+
981
+ /**
982
+ * Resolves { disposition, reason? } once the renderer reports what it did, or
983
+ * { disposition: 'unconfirmed', reason: 'timeout' | 'no-window' }. Never rejects.
984
+ */
969
985
  function enqueueExternalPrompt(tabId, prompt, extra = {}) {
970
986
  const { sessionId, cwd } = extra ?? {};
971
- broadcast('chat:external-send', {
972
- tabId,
973
- prompt,
974
- ...(sessionId !== undefined ? { sessionId } : {}),
975
- ...(cwd !== undefined ? { cwd } : {}),
987
+ if (!hasLiveWindow()) return Promise.resolve({ disposition: 'unconfirmed', reason: 'no-window' });
988
+ const requestId = require('node:crypto').randomUUID();
989
+ return new Promise((resolve) => {
990
+ const timer = setTimeout(() => {
991
+ pendingExternalSends.delete(requestId);
992
+ resolve({ disposition: 'unconfirmed', reason: 'timeout' });
993
+ }, EXTERNAL_SEND_ACK_TIMEOUT_MS);
994
+ timer.unref?.();
995
+ pendingExternalSends.set(requestId, { resolve, timer });
996
+ broadcast('chat:external-send', {
997
+ requestId,
998
+ tabId,
999
+ prompt,
1000
+ ...(sessionId !== undefined ? { sessionId } : {}),
1001
+ ...(cwd !== undefined ? { cwd } : {}),
1002
+ });
976
1003
  });
977
1004
  }
978
1005
 
1006
+ function settleExternalSend({ requestId, disposition, reason }) {
1007
+ const entry = pendingExternalSends.get(requestId);
1008
+ if (!entry) return;
1009
+ clearTimeout(entry.timer);
1010
+ pendingExternalSends.delete(requestId);
1011
+ entry.resolve({ disposition, ...(reason !== undefined ? { reason } : {}) });
1012
+ }
1013
+
979
1014
  // Asks the renderer to start a new Session (same launchSession a macro press
980
1015
  // uses) on behalf of the paired phone. Payload is pre-validated by the caller.
981
1016
  function requestExternalNewSession(payload) {
@@ -1007,7 +1042,7 @@ function registerAdminRoute(adminHttp) {
1007
1042
  sendJson(res, 400, { ok: false, error: e?.message ?? 'invalid body' });
1008
1043
  return;
1009
1044
  }
1010
- enqueueExternalPrompt(body.tabId, body.prompt);
1045
+ void enqueueExternalPrompt(body.tabId, body.prompt);
1011
1046
  sendJson(res, 200, { ok: true });
1012
1047
  });
1013
1048
  }
@@ -1049,6 +1084,10 @@ function registerChatHandlers() {
1049
1084
  return classifyPromptTicket(text);
1050
1085
  }));
1051
1086
 
1087
+ ipcMain.handle('chat:external-send-result', validated(schemas.chatExternalSendResult, async (payload) => {
1088
+ settleExternalSend(payload);
1089
+ }));
1090
+
1052
1091
  ipcMain.handle('chat:cancel', async (_e, payload) => {
1053
1092
  let tabId;
1054
1093
  try { tabId = schemas.chatCancel.parse(payload).tabId; } catch { return; }
@@ -1079,6 +1118,7 @@ module.exports = {
1079
1118
  WARN_CEILING_MS,
1080
1119
  __setExecutor,
1081
1120
  enqueueExternalPrompt,
1121
+ EXTERNAL_SEND_ACK_TIMEOUT_MS,
1082
1122
  requestExternalNewSession,
1083
1123
  registerAdminRoute,
1084
1124
  };
@@ -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
@@ -13,7 +13,7 @@ const { PromptSessionSchema, EpicTagSchema, EpicSourceSchema, EpicIntakeSectionS
13
13
  const { PrdWorkTypeSchema } = require('./lib/prd/workTypeLibrary.cjs');
14
14
  const { PERSONA_NAME_RE } = require('./agentLibrary.cjs');
15
15
  const { AgentPersonaSaveSchema } = require('./lib/persona-macro/agentPersonaSchema.cjs');
16
- const { MacroSaveSchema, MacroDeleteSchema, MacroSetProjectSchema } = require('./lib/persona-macro/macroLibrary.cjs');
16
+ const { MacroSaveSchema, MacroDeleteSchema, MacroSetProjectSchema, MacroInputSchema } = require('./lib/persona-macro/macroLibrary.cjs');
17
17
 
18
18
  // ──────────────────────────────────────────── PTY
19
19
  const ptySpawn = z.object({
@@ -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.
@@ -960,6 +960,14 @@ const chatExternalSend = z.object({
960
960
  ),
961
961
  });
962
962
 
963
+ // Renderer's report of what it did with a `chat:external-send` (mirrors
964
+ // ChatExternalSendResult in preload/api.d.ts).
965
+ const chatExternalSendResult = z.object({
966
+ requestId: z.string().min(1).max(64),
967
+ disposition: z.enum(['sent', 'queued', 'refused']),
968
+ reason: z.enum(['terminal-attached', 'completed', 'unknown-target', 'empty-prompt']).optional(),
969
+ });
970
+
963
971
  // Phone drill-down reads. cwd is re-checked against the open tabs in
964
972
  // webRemote.cjs; sessionKey becomes a transcript filename lookup so it is
965
973
  // restricted to a session-id charset (no '/', so no traversal).
@@ -968,6 +976,7 @@ const remoteSessionTranscript = z.object({
968
976
  cwd: z.string().min(1).max(4096),
969
977
  sessionKey: z.string().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9_.-]*$/),
970
978
  limit: z.number().int().min(1).max(100).optional(),
979
+ before: z.number().int().min(0).max(10000).optional(),
971
980
  });
972
981
  // One Epic's PRD list (slug/title/status projection only, never bodies).
973
982
  const remoteSessionPrds = z.object({
@@ -986,6 +995,10 @@ const remoteSessionCreate = z.object({
986
995
  (s) => Buffer.byteLength(s, 'utf8') <= CHAT_PROMPT_MAX_BYTES,
987
996
  `prompt must be ≤ ${CHAT_PROMPT_MAX_BYTES} bytes`,
988
997
  ),
998
+ macroId: MacroDeleteSchema.shape.id.optional(),
999
+ inputs: z.record(MacroInputSchema.shape.name, z.string().max(2000))
1000
+ .refine((o) => Object.keys(o).length <= 20, 'too many inputs')
1001
+ .optional(),
989
1002
  });
990
1003
 
991
1004
  // ──────────────────────────────────────────── Web Remote
@@ -1352,6 +1365,7 @@ module.exports = {
1352
1365
  chatCancel,
1353
1366
  chatClassifyTicket,
1354
1367
  chatExternalSend,
1368
+ chatExternalSendResult,
1355
1369
  remoteSessionCreate,
1356
1370
  remoteProjectSessions,
1357
1371
  remoteSessionTranscript,