claude-code-session-manager 0.93.0 → 0.95.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 (78) hide show
  1. package/dist/assets/{DataModel-DDGx3qrn.js → DataModel-DZK9QZpV.js} +1 -1
  2. package/dist/assets/{History-CHBjYZyD.js → History-BDyJUGtR.js} +2 -2
  3. package/dist/assets/{Hooks-DG4c-Rzz.js → Hooks-CmK6l0uf.js} +3 -3
  4. package/dist/assets/HostBilko-D8TIx-Bx.js +1 -0
  5. package/dist/assets/{Library-CN1h5rAE.js → Library-DTxpOjx8.js} +25 -25
  6. package/dist/assets/{MarkdownEditor-DmBxrhdn.js → MarkdownEditor-BPJyrevq.js} +1 -1
  7. package/dist/assets/McpServers-B_rvIYDs.js +2 -0
  8. package/dist/assets/{Memory-Dk4uDCjg.js → Memory-B-9GlHqe.js} +6 -6
  9. package/dist/assets/{Permissions-BovobeZw.js → Permissions-DhMMp27n.js} +3 -3
  10. package/dist/assets/{Plugins-DvOf_B62.js → Plugins-DKfAlmMY.js} +2 -2
  11. package/dist/assets/{ProvenanceBadge-Dutz_4OY.js → ProvenanceBadge-ptlItdCK.js} +1 -1
  12. package/dist/assets/{SaveBar-CT5u6ZCJ.js → SaveBar-CSbNSl2W.js} +1 -1
  13. package/dist/assets/Scheduler--rn1QBYc.js +16 -0
  14. package/dist/assets/{ScopeSwitcher-BUYDpz2v.js → ScopeSwitcher-BJof-GWH.js} +1 -1
  15. package/dist/assets/{Settings-DIRmr3tL.js → Settings-Dsx6XZP5.js} +3 -3
  16. package/dist/assets/{SkillReferenceGraph-CdGr6d3_.js → SkillReferenceGraph-CGl4B89-.js} +2 -2
  17. package/dist/assets/{Skills-BQOheQv4.js → Skills-D522Zfol.js} +2 -2
  18. package/dist/assets/{SystemPrompt-5LsimKbX.js → SystemPrompt-DKj-q39J.js} +1 -1
  19. package/dist/assets/{TagLibrary-DreLsT3n.js → TagLibrary-BH7CG7-6.js} +1 -1
  20. package/dist/assets/{TiptapBody-C9kmlBnI.js → TiptapBody-BbcJL9az.js} +1 -1
  21. package/dist/assets/{Toggle-BKLCBu9A.js → Toggle-DAslvnnv.js} +1 -1
  22. package/dist/assets/{index-Db_SK9rW.js → index-Yf8e7VCB.js} +821 -821
  23. package/dist/assets/{settingsSchema-B5jBhSUP.js → settingsSchema-pzp0TgvN.js} +1 -1
  24. package/dist/index.html +1 -1
  25. package/package.json +8 -3
  26. package/scripts/README.md +6 -0
  27. package/src/main/__tests__/chat-cancel-terminal.test.cjs +5 -2
  28. package/src/main/__tests__/chat-exit-close-race.test.cjs +8 -2
  29. package/src/main/__tests__/chat-mcp-consent-notice.test.cjs +5 -2
  30. package/src/main/__tests__/health-tick-liveness.test.cjs +12 -0
  31. package/src/main/__tests__/intradayRefresh.test.cjs +39 -0
  32. package/src/main/__tests__/openExternalApp-spawn-error.test.cjs +25 -0
  33. package/src/main/__tests__/opsErrorLog.test.cjs +22 -0
  34. package/src/main/__tests__/pty-epic-worktree-spawn-cwd.test.cjs +21 -0
  35. package/src/main/__tests__/rateLimitPollerStreak.test.cjs +14 -0
  36. package/src/main/__tests__/runVerify-atomic-verdicts.test.cjs +26 -0
  37. package/src/main/__tests__/runVerify-landed-commit-outranks.test.cjs +11 -1
  38. package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +11 -1
  39. package/src/main/__tests__/runVerify.test.cjs +12 -1
  40. package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +15 -0
  41. package/src/main/__tests__/scheduler-utilization-hold.test.cjs +89 -0
  42. package/src/main/__tests__/transcriptsUsageFor.test.cjs +112 -1
  43. package/src/main/build-info.json +4 -4
  44. package/src/main/chatRunner.cjs +45 -10
  45. package/src/main/crashDiagnostics.cjs +9 -0
  46. package/src/main/git.cjs +9 -2
  47. package/src/main/health.cjs +25 -1
  48. package/src/main/historyAggregator.cjs +2 -19
  49. package/src/main/index.cjs +7 -8
  50. package/src/main/ipcSchemas.cjs +13 -3
  51. package/src/main/lib/__tests__/childWithLog.test.cjs +180 -0
  52. package/src/main/lib/__tests__/delegationReadiness.test.cjs +19 -0
  53. package/src/main/lib/__tests__/gitCacheBound.test.cjs +69 -0
  54. package/src/main/lib/__tests__/loopDelay.test.cjs +68 -0
  55. package/src/main/lib/__tests__/usageCircuit.test.cjs +86 -17
  56. package/src/main/lib/agentPersonaSchema.cjs +2 -0
  57. package/src/main/lib/childWithLog.cjs +92 -51
  58. package/src/main/lib/delegationReadiness.cjs +3 -1
  59. package/src/main/lib/epicSpawnPlan.cjs +27 -8
  60. package/src/main/lib/epicTranscriptPath.cjs +5 -1
  61. package/src/main/lib/headTailBuffer.cjs +43 -0
  62. package/src/main/lib/intradayRefresh.cjs +33 -0
  63. package/src/main/lib/loopDelay.cjs +44 -0
  64. package/src/main/lib/lruCache.cjs +39 -0
  65. package/src/main/lib/openExternalApp.cjs +27 -9
  66. package/src/main/lib/opsErrorLog.cjs +22 -0
  67. package/src/main/lib/promptSessionSchema.cjs +2 -0
  68. package/src/main/lib/rendererRecovery.cjs +141 -0
  69. package/src/main/lib/scheduleJobSchema.cjs +3 -0
  70. package/src/main/lib/usageCircuit.cjs +40 -12
  71. package/src/main/runVerify.cjs +3 -1
  72. package/src/main/scheduler.cjs +409 -260
  73. package/src/main/transcripts.cjs +54 -11
  74. package/src/main/usage.cjs +9 -1
  75. package/src/preload/api.d.ts +20 -3
  76. package/dist/assets/HostBilko-DHXcpV2c.js +0 -1
  77. package/dist/assets/McpServers-Beb_HD6I.js +0 -2
  78. package/dist/assets/Scheduler-B7GSnZj5.js +0 -16
@@ -21,7 +21,7 @@
21
21
  * shouldFire(ctx) returns true, then calls action(ctx).
22
22
  * - action() may call ctx.addTimer(t) to register secondary timers
23
23
  * (e.g. SIGTERM → SIGKILL cascades) so they are also cleared on exit.
24
- * Calls onExit() synchronously inside the child 'exit'/'error' handler,
24
+ * Calls onExit() inside the child 'exit'/'error' handler,
25
25
  * before calling closeFd(). Caller may call ctx.safeLog() inside onExit.
26
26
  *
27
27
  * WatchdogContext:
@@ -51,7 +51,10 @@
51
51
  */
52
52
 
53
53
  const fs = require('node:fs');
54
- const { spawn, execFileSync } = require('node:child_process');
54
+ const { spawn, execFile } = require('node:child_process');
55
+
56
+ // Injectable for tests (enumeration-failure coverage).
57
+ const deps = { execFile };
55
58
 
56
59
  // Grace period between the post-exit SIGTERM sweep and the follow-up SIGKILL
57
60
  // sweep of a detached child's process group. See sweepChildProcessGroup below.
@@ -144,44 +147,58 @@ function openLog(logPath) {
144
147
  // Read-only — never throws, never blocks the caller's exit path. Used to
145
148
  // report what sweepChildProcessGroup is about to kill, since the kill itself
146
149
  // is silent (ESRCH on the normal empty-group case).
150
+ // Async: resolves to the survivors list, rejects on ps failure/timeout. O(P) in
151
+ // the process table, off the main event loop.
147
152
  function enumerateProcessGroupSurvivors(pgid) {
148
- if (process.platform !== 'linux') return [];
149
- if (!pgid || pgid <= 1) return [];
150
- try {
151
- const out = execFileSync('ps', ['-eo', 'pid,pgrp,pcpu,etimes,comm'], { encoding: 'utf8', timeout: 2000 });
152
- const survivors = [];
153
- for (const line of out.split('\n').slice(1)) {
154
- const m = line.trim().match(/^(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(.+)$/);
155
- if (!m) continue;
156
- const [, pidStr, pgrpStr, pcpuStr, etimesStr, comm] = m;
157
- if (Number(pgrpStr) !== pgid) continue;
158
- survivors.push({ pid: Number(pidStr), pcpu: Number(pcpuStr), etimes: Number(etimesStr), comm });
159
- }
160
- return survivors;
161
- } catch {
162
- return [];
163
- }
153
+ if (process.platform !== 'linux') return Promise.resolve([]);
154
+ if (!pgid || pgid <= 1) return Promise.resolve([]);
155
+ return new Promise((resolve, reject) => {
156
+ try {
157
+ deps.execFile('ps', ['-eo', 'pid,pgrp,pcpu,etimes,comm'], { encoding: 'utf8', timeout: 2000 }, (err, out) => {
158
+ if (err) { reject(err); return; }
159
+ const survivors = [];
160
+ for (const line of String(out).split('\n').slice(1)) {
161
+ const m = line.trim().match(/^(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(.+)$/);
162
+ if (!m) continue;
163
+ const [, pidStr, pgrpStr, pcpuStr, etimesStr, comm] = m;
164
+ if (Number(pgrpStr) !== pgid) continue;
165
+ survivors.push({ pid: Number(pidStr), pcpu: Number(pcpuStr), etimes: Number(etimesStr), comm });
166
+ }
167
+ resolve(survivors);
168
+ });
169
+ } catch (e) { reject(e); }
170
+ });
164
171
  }
165
172
 
166
- // Returns the leaked descendants found (see enumerateProcessGroupSurvivors),
167
- // having enumerated them BEFORE issuing SIGTERM so the report reflects what
168
- // was actually swept, not what happened to still be running afterward.
169
- function sweepChildProcessGroup(child, detached) {
170
- if (!detached) return [];
171
- if (!child || !child.pid || child.pid <= 1) return [];
173
+ // Returns null when there is nothing to enumerate/sweep (non-detached, missing or
174
+ // invalid pid, our own group) so the caller can finish synchronously. Otherwise
175
+ // returns a promise for the leaked descendants: enumeration runs FIRST so the
176
+ // report reflects what the sweep is about to reap, and SIGTERM (+ the delayed
177
+ // SIGKILL) is issued only once it has SETTLED — resolved or rejected. The 2 s ps
178
+ // timeout bounds that wait. The promise never rejects: a failed enumeration
179
+ // resolves [] after a safeLog line, and the group is still killed.
180
+ function sweepChildProcessGroup(child, detached, safeLog) {
181
+ if (!detached) return null;
182
+ if (!child || !child.pid || child.pid <= 1) return null;
172
183
  // Defensive: never target the Session Manager process's own group.
173
- if (child.pid === process.pid) return [];
174
-
175
- const leaked = enumerateProcessGroupSurvivors(child.pid).filter((p) => p.pid !== child.pid);
184
+ if (child.pid === process.pid) return null;
176
185
 
177
- try { process.kill(-child.pid, 'SIGTERM'); } catch { /* ESRCH: empty group, the normal case */ }
178
-
179
- const t = setTimeout(() => {
180
- try { process.kill(-child.pid, 'SIGKILL'); } catch { /* ESRCH: empty group, the normal case */ }
181
- }, POST_EXIT_GROUP_SWEEP_GRACE_MS);
182
- if (t.unref) t.unref();
186
+ const pid = child.pid;
187
+ const kill = () => {
188
+ try { process.kill(-pid, 'SIGTERM'); } catch { /* ESRCH: empty group, the normal case */ }
189
+ const t = setTimeout(() => {
190
+ try { process.kill(-pid, 'SIGKILL'); } catch { /* ESRCH: empty group, the normal case */ }
191
+ }, POST_EXIT_GROUP_SWEEP_GRACE_MS);
192
+ if (t.unref) t.unref();
193
+ };
183
194
 
184
- return leaked;
195
+ return enumerateProcessGroupSurvivors(pid)
196
+ .then((all) => all.filter((p) => p.pid !== pid))
197
+ .catch((e) => {
198
+ safeLog(`[childWithLog] process-group enumeration failed; leak report omitted: ${e && e.message}\n`);
199
+ return [];
200
+ })
201
+ .then((leaked) => { kill(); return leaked; });
185
202
  }
186
203
 
187
204
  function withChildAndLog({ fd, logPath, safeLog, closeFd, spawn: spawnSpec, watchdogs = [], onExit }) {
@@ -220,9 +237,15 @@ function withChildAndLog({ fd, logPath, safeLog, closeFd, spawn: spawnSpec, watc
220
237
  // O(|watchdogs|) setup, O(1) per poll tick.
221
238
  const clearFns = watchdogs.map((wd) => {
222
239
  const t = setInterval(() => {
223
- if (wd.shouldFire(ctx)) {
224
- clearInterval(t);
225
- wd.action(ctx);
240
+ try {
241
+ if (wd.shouldFire(ctx)) {
242
+ clearInterval(t);
243
+ wd.action(ctx);
244
+ }
245
+ } catch (e) {
246
+ // A throwing shouldFire is retried next tick; a throwing action has
247
+ // already cleared its interval. Never let it escape the timer.
248
+ safeLog(`[childWithLog] watchdog "${wd.label}" threw: ${e && e.message}\n`);
226
249
  }
227
250
  }, wd.intervalMs);
228
251
  if (t.unref) t.unref();
@@ -238,19 +261,37 @@ function withChildAndLog({ fd, logPath, safeLog, closeFd, spawn: spawnSpec, watc
238
261
  // calls onExit (caller may still safeLog inside), then closes the fd.
239
262
  const handleDone = (exitCode, signal, error, spawnFailed) => {
240
263
  clearAllTimers();
241
- const leakedDescendants = sweepChildProcessGroup(ctx.child, !!spawnSpec.options?.detached);
242
- if (onExit) {
243
- onExit({
244
- exitCode,
245
- signal: signal ?? null,
246
- killedByWatchdog: ctx.killedByWatchdog,
247
- error,
248
- spawnFailed: spawnFailed ?? false,
249
- leakedDescendants,
250
- safeLog,
251
- });
252
- }
253
- closeFd();
264
+ const finish = (leakedDescendants) => {
265
+ try {
266
+ if (onExit) {
267
+ onExit({
268
+ exitCode,
269
+ signal: signal ?? null,
270
+ killedByWatchdog: ctx.killedByWatchdog,
271
+ error,
272
+ spawnFailed: spawnFailed ?? false,
273
+ leakedDescendants,
274
+ safeLog,
275
+ });
276
+ }
277
+ } catch (e) {
278
+ // Log while the fd is still open (closeFd below makes safeLog a no-op),
279
+ // then rethrow: sync callers see the throw as before PRD 1353; the
280
+ // deferred path contains it below.
281
+ const stack = e && e.stack ? String(e.stack).split('\n').slice(0, 3).join('\n') : '';
282
+ safeLog(`[childWithLog] onExit threw: ${e && e.message}${stack ? `\n${stack}` : ''}\n`);
283
+ throw e;
284
+ } finally {
285
+ closeFd();
286
+ }
287
+ };
288
+ const pending = sweepChildProcessGroup(ctx.child, !!spawnSpec.options?.detached, safeLog);
289
+ // Nothing to enumerate: stay synchronous. Otherwise onExit + closeFd wait
290
+ // (bounded by the 2 s ps timeout) for the leak report; a throwing onExit
291
+ // must not become an unhandled rejection.
292
+ if (!pending) { finish([]); return; }
293
+ // finish() already logged the throw and closed the fd; just contain it.
294
+ pending.then(finish).catch(() => {});
254
295
  };
255
296
 
256
297
  // Attempt synchronous spawn. On failure, call handleDone immediately so the
@@ -282,4 +323,4 @@ function withChildAndLog({ fd, logPath, safeLog, closeFd, spawn: spawnSpec, watc
282
323
  return { child, cancel };
283
324
  }
284
325
 
285
- module.exports = { openLog, withChildAndLog, POST_EXIT_GROUP_SWEEP_GRACE_MS };
326
+ module.exports = { openLog, withChildAndLog, POST_EXIT_GROUP_SWEEP_GRACE_MS, _deps: deps };
@@ -662,6 +662,8 @@ async function checkDelegationReadiness({ cwd, homeDir = os.homedir() }) {
662
662
  * failure must not prevent the guard install it's reporting on.
663
663
  */
664
664
  function logAutoInstallEvent({ cwd, guard, action, error }) {
665
+ // A no-op outcome is not an event worth an error-log row (24 rows/3 days of boot noise).
666
+ if (action === 'already-installed') return;
665
667
  try {
666
668
  const opsErrorLog = require('./opsErrorLog.cjs');
667
669
  opsErrorLog.appendError({
@@ -743,7 +745,7 @@ async function ensureGuardsInstalled(cwd, { homeDir = os.homedir() } = {}) {
743
745
  return { ok: false, root: null, guards: {}, error: err?.message ?? String(err) };
744
746
  }
745
747
 
746
- const cacheKey = `${root}${homeDir}`;
748
+ const cacheKey = `${root}\0${homeDir}`;
747
749
  const cached = guardsInstalledCache.get(cacheKey);
748
750
  if (cached) return cached;
749
751
 
@@ -28,6 +28,7 @@ const fs = require('node:fs');
28
28
  const os = require('node:os');
29
29
  const path = require('node:path');
30
30
  const { encodeCwd } = require('./encodeCwd.cjs');
31
+ const { projectRootOf } = require('./activeSessions.cjs');
31
32
  const { readActiveIndex } = require('./epicMint.cjs');
32
33
  const { resolveEpicSpawnCwd } = require('./epicSpawnCwd.cjs');
33
34
  const { resolveEpicTranscriptPath } = require('./epicTranscriptPath.cjs');
@@ -71,7 +72,7 @@ function findEpic(cwd, claudeSessionId, deps) {
71
72
  /**
72
73
  * @param {{ cwd: string, claudeSessionId: string, fallbackResume?: boolean, deps?: object }} opts
73
74
  * @returns {{ ok: true, execCwd: string, useResume: boolean, transcriptPath: string|null }
74
- * | { ok: false, code: 'epic_closed'|'session_unreachable', message: string }}
75
+ * | { ok: false, code: 'epic_closed'|'session_unreachable'|'spawn_cwd_missing', message: string }}
75
76
  */
76
77
  function planEpicSpawn({ cwd, claudeSessionId, fallbackResume = false, deps = {} } = {}) {
77
78
  const statSync = deps.statSync || fs.statSync;
@@ -107,14 +108,24 @@ function planEpicSpawn({ cwd, claudeSessionId, fallbackResume = false, deps = {}
107
108
  const home = path.join(projectsDir, encodeCwd(dir));
108
109
  return resolved.existingPaths.find((p) => path.dirname(p) === home) || null;
109
110
  };
110
- let hit = findReachable(execCwd);
111
- if (!hit && execCwd !== cwd) {
112
- // The transcript may already live under the project encoding; spawning there writes back to
113
- // the directory it occupies, so nothing forks.
114
- hit = findReachable(cwd);
115
- if (hit) execCwd = cwd;
111
+ // The transcript may already live under the project encoding; spawning there writes back to
112
+ // the directory it occupies, so nothing forks. `projectRootOf(cwd)` only contributes for a LIVE
113
+ // worktree caller cwd (it returns a vanished worktree path unchanged, so it adds nothing there);
114
+ // `epic.cwd`/`worktree.baseCwd` are not probed — the Epic is only found via the caller cwd, which
115
+ // is already a candidate, so they could never change an outcome.
116
+ let projectRoot = null;
117
+ try { projectRoot = projectRootOf(cwd); } catch { /* unresolvable → skip */ }
118
+ const candidates = [...new Set([execCwd, cwd, projectRoot].filter((d) => typeof d === 'string' && d))];
119
+ let reachableDir = null;
120
+ for (const dir of candidates) {
121
+ if (findReachable(dir)) { reachableDir = dir; break; }
116
122
  }
117
- if (!hit) {
123
+ if (reachableDir) {
124
+ execCwd = reachableDir;
125
+ unreachable.delete(claudeSessionId);
126
+ } else {
127
+ // Name a directory that would actually work: an existing candidate is only worth naming if
128
+ // its encoding owns a transcript, which none did — so name the recorded worktree checkout.
118
129
  const missing = brokenDir || execCwd;
119
130
  const wt = epic?.worktree;
120
131
  const recovery = wt?.status === 'merged'
@@ -128,6 +139,14 @@ function planEpicSpawn({ cwd, claudeSessionId, fallbackResume = false, deps = {}
128
139
  }
129
140
  }
130
141
 
142
+ if (!isDir(execCwd, statSync)) {
143
+ return {
144
+ ok: false,
145
+ code: 'spawn_cwd_missing',
146
+ message: `Cannot spawn session ${claudeSessionId}: directory ${execCwd} does not exist. Pass the Epic's project cwd (the project root, not a removed worktree path) as the caller cwd.`,
147
+ };
148
+ }
149
+
131
150
  return { ok: true, execCwd, useResume, transcriptPath: resolved?.path ?? null };
132
151
  }
133
152
 
@@ -140,12 +140,16 @@ function resolveEpicTranscriptPath({ cwd, claudeSessionId, deps = {} } = {}) {
140
140
  const file = (dir) => path.join(projectsDir, encodeCwd(dir), `${claudeSessionId}.jsonl`);
141
141
 
142
142
  const worktreeDirs = [];
143
+ // Project cwd recorded on the Epic — the caller's `cwd` may itself be the vanished worktree
144
+ // path, whose encoding is not where a merged/swept Epic's transcript lives.
145
+ const recordedCwds = [];
143
146
  try {
144
147
  const sessions = cachedSessions(cwd, deps, statSync);
145
148
  let inIndex = false;
146
149
  for (const s of Object.values(sessions)) {
147
150
  if (s && s.claudeSessionId === claudeSessionId) {
148
151
  inIndex = true;
152
+ if (typeof s.cwd === 'string' && s.cwd) recordedCwds.push(s.cwd);
149
153
  if (typeof s.worktree?.dir === 'string' && s.worktree.dir) worktreeDirs.push(s.worktree.dir);
150
154
  }
151
155
  }
@@ -161,7 +165,7 @@ function resolveEpicTranscriptPath({ cwd, claudeSessionId, deps = {} } = {}) {
161
165
  spawnCwd = cwd;
162
166
  }
163
167
 
164
- const candidates = [...new Set([file(spawnCwd), ...worktreeDirs.map(file), file(cwd)])];
168
+ const candidates = [...new Set([file(spawnCwd), ...worktreeDirs.map(file), file(cwd), ...recordedCwds.map(file)])];
165
169
 
166
170
  const existing = [];
167
171
  for (const p of candidates) {
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+
3
+ // Bounded text accumulator: keeps the first HEAD chars (for error-detail
4
+ // previews) plus a ring of the last `tailMax` chars (for regex matching).
5
+ // Same trim-oldest idiom as pty.cjs `#appendReplay`. Memory is O(head + tailMax)
6
+ // regardless of input size; append is O(tailMax) per chunk.
7
+
8
+ const HEAD_PREVIEW_CHARS = 300;
9
+ // One extra char lets preview() tell "exactly 300" from "longer than 300".
10
+ const HEAD_KEEP = HEAD_PREVIEW_CHARS + 1;
11
+
12
+ function createHeadTailBuffer(tailMax) {
13
+ let head = '';
14
+ let tail = '';
15
+ let truncated = false;
16
+ return {
17
+ append(str) {
18
+ if (!str) return;
19
+ if (head.length < HEAD_KEEP) {
20
+ // Skip leading whitespace so the head equals `text.trim().slice(0, …)`.
21
+ const s = head.length === 0 ? str.replace(/^\s+/, '') : str;
22
+ head += s.slice(0, HEAD_KEEP - head.length);
23
+ }
24
+ tail += str;
25
+ if (tail.length > tailMax) {
26
+ tail = tail.slice(tail.length - tailMax);
27
+ truncated = true;
28
+ }
29
+ },
30
+ /** Text to run regexes over: the tail, plus the head once the middle was dropped. */
31
+ matchText() {
32
+ return truncated ? `${head}\n${tail}` : tail;
33
+ },
34
+ /** Equivalent to `fullText.trim().slice(0, 300)`. */
35
+ preview() {
36
+ return head.length > HEAD_PREVIEW_CHARS ? head.slice(0, HEAD_PREVIEW_CHARS) : head.trimEnd();
37
+ },
38
+ /** Retained char count (head + tail). */
39
+ get size() { return head.length + tail.length; },
40
+ };
41
+ }
42
+
43
+ module.exports = { createHeadTailBuffer, HEAD_PREVIEW_CHARS };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * intradayRefresh.cjs — lock-guarded runner for the in-app intraday rollup tick.
3
+ *
4
+ * Extracted from index.cjs so the failure paths are unit-testable. Guarantees:
5
+ * a lock-acquire throw is logged (never escapes the interval callback), and a
6
+ * synchronous OR asynchronous throw from `refresh` always releases the lock.
7
+ */
8
+ 'use strict';
9
+
10
+ function createIntradayRefresh({ tryAcquireLock, releaseLock, lockPath, refresh, logs }) {
11
+ const logError = (message, e) =>
12
+ logs.writeLine({ scope: 'history-rollup', level: 'error', message, meta: { error: e?.message } });
13
+
14
+ return function runIntradayRefresh() {
15
+ let acquired;
16
+ try {
17
+ acquired = tryAcquireLock(lockPath);
18
+ } catch (e) {
19
+ logError('intraday lock acquire failed', e);
20
+ return;
21
+ }
22
+ if (!acquired) return;
23
+ // Promise.resolve().then would defer the call; use an async IIFE so a sync
24
+ // throw from refresh() lands in the same catch as a rejection.
25
+ (async () => { await refresh(); })()
26
+ .catch((e) => logError('refreshIntradayToday failed', e))
27
+ .finally(() => {
28
+ try { releaseLock(lockPath); } catch (e) { logError('intraday lock release failed', e); }
29
+ });
30
+ };
31
+ }
32
+
33
+ module.exports = { createIntradayRefresh };
@@ -0,0 +1,44 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Event-loop delay probe for the main-process memory heartbeat. Wraps a
5
+ * perf_hooks IntervalHistogram (injectable for tests): enable() at init,
6
+ * snapshot() once per heartbeat returns the interval just ended in ms and
7
+ * reset()s, disable() rides the heartbeat teardown. No timer of its own.
8
+ */
9
+
10
+ const { monitorEventLoopDelay } = require('node:perf_hooks');
11
+
12
+ const LOOP_DELAY_RESOLUTION_MS = 20;
13
+ // A single beat whose worst loop delay exceeds this is a visible main-thread stall.
14
+ const LOOP_STALL_WARN_MS = 250;
15
+
16
+ const NS_PER_MS = 1e6;
17
+ const toMs = (ns) => Math.round(ns / NS_PER_MS * 10) / 10;
18
+
19
+ function createLoopDelayMonitor(createHistogram = monitorEventLoopDelay) {
20
+ const h = createHistogram({ resolution: LOOP_DELAY_RESOLUTION_MS });
21
+ return {
22
+ enable() { h.enable(); },
23
+ disable() { h.disable(); },
24
+ /** Percentiles for the interval since the last snapshot; resets the histogram. */
25
+ snapshot() {
26
+ const out = {
27
+ loopDelayP50Ms: toMs(h.percentile(50)),
28
+ loopDelayP99Ms: toMs(h.percentile(99)),
29
+ loopDelayMaxMs: toMs(h.max),
30
+ };
31
+ h.reset();
32
+ return out;
33
+ },
34
+ };
35
+ }
36
+
37
+ /** Log level + message for a heartbeat given its loop-delay snapshot. */
38
+ function stallVerdict(snap) {
39
+ return snap.loopDelayMaxMs > LOOP_STALL_WARN_MS
40
+ ? { level: 'warn', message: 'main loop stall' }
41
+ : null;
42
+ }
43
+
44
+ module.exports = { createLoopDelayMonitor, stallVerdict, LOOP_STALL_WARN_MS, LOOP_DELAY_RESOLUTION_MS };
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Bounded LRU cache backed by an insertion-order Map: delete+re-insert on
5
+ * access = O(1) get/set/evict. Single implementation shared by
6
+ * historyAggregator.cjs and transcripts.cjs.
7
+ */
8
+ class LRUCache {
9
+ constructor(max) {
10
+ this._max = max;
11
+ this._m = new Map();
12
+ }
13
+ get size() {
14
+ return this._m.size;
15
+ }
16
+ has(k) {
17
+ return this._m.has(k);
18
+ }
19
+ delete(k) {
20
+ return this._m.delete(k);
21
+ }
22
+ clear() {
23
+ this._m.clear();
24
+ }
25
+ get(k) {
26
+ if (!this._m.has(k)) return undefined;
27
+ const v = this._m.get(k);
28
+ this._m.delete(k);
29
+ this._m.set(k, v);
30
+ return v;
31
+ }
32
+ set(k, v) {
33
+ this._m.delete(k);
34
+ this._m.set(k, v);
35
+ if (this._m.size > this._max) this._m.delete(this._m.keys().next().value);
36
+ }
37
+ }
38
+
39
+ module.exports = { LRUCache };
@@ -41,6 +41,28 @@ function findCommand(name) {
41
41
  return null;
42
42
  }
43
43
 
44
+ /**
45
+ * Spawn a detached opener and resolve once the OS reports the outcome.
46
+ * A missing/unexecutable binary emits an async 'error' (ENOENT/EACCES); without
47
+ * a listener that is an uncaught exception and the caller would see ok:true.
48
+ * Races 'spawn' against 'error' — never uses shell:true.
49
+ *
50
+ * @returns {Promise<{ ok: true, opener: string } | { ok: false, error: string }>}
51
+ */
52
+ function spawnDetached(cmd, args, opener = cmd) {
53
+ return new Promise((resolve) => {
54
+ let child;
55
+ try {
56
+ child = spawn(cmd, args, { detached: true, stdio: 'ignore', env: cleanChildEnv() });
57
+ } catch (e) {
58
+ resolve({ ok: false, error: `failed to launch ${opener}: ${e?.message ?? e}` });
59
+ return;
60
+ }
61
+ child.once('error', (e) => resolve({ ok: false, error: `failed to launch ${opener}: ${e?.message ?? e}` }));
62
+ child.once('spawn', () => { child.unref(); resolve({ ok: true, opener }); });
63
+ });
64
+ }
65
+
44
66
  /**
45
67
  * Open a project root directory in the user's editor.
46
68
  *
@@ -51,8 +73,7 @@ async function openInEditor({ cwd, editor }) {
51
73
  const candidates = (editor && editor !== 'auto') ? [editor] : editorCandidates();
52
74
  for (const cmd of candidates) {
53
75
  if (!findCommand(cmd)) continue;
54
- spawn(cmd, [cwd], { detached: true, stdio: 'ignore', env: cleanChildEnv() }).unref();
55
- return { ok: true, opener: cmd };
76
+ return spawnDetached(cmd, [cwd]);
56
77
  }
57
78
  return { ok: false, error: 'no editor found' };
58
79
  }
@@ -89,8 +110,7 @@ async function openFileInEditor({ path: abs, line, col, editor }) {
89
110
  const supportsGoto = /^(code|cursor|subl)$/.test(cmd);
90
111
  const target = (supportsGoto && line) ? `${abs}:${line}${col ? `:${col}` : ''}` : abs;
91
112
  const args = supportsGoto ? ['-g', target] : [abs];
92
- spawn(cmd, args, { detached: true, stdio: 'ignore', env: cleanChildEnv() }).unref();
93
- return { ok: true, opener: cmd };
113
+ return spawnDetached(cmd, args);
94
114
  }
95
115
  return { ok: false, error: 'no editor found' };
96
116
  }
@@ -128,14 +148,12 @@ async function openInTerminal({ cwd }) {
128
148
  const args = t === 'gnome-terminal'
129
149
  ? ['--working-directory=' + cwd]
130
150
  : ['-e', `bash -c "cd '${cwd.replace(/'/g, "'\\''")}' && exec bash"`];
131
- spawn(t, args, { detached: true, stdio: 'ignore', env: cleanChildEnv() }).unref();
132
- return { ok: true, opener: t };
151
+ return spawnDetached(t, args);
133
152
  }
134
153
  } else if (process.platform === 'darwin') {
135
- spawn('open', ['-a', 'Terminal', cwd], { detached: true, stdio: 'ignore', env: cleanChildEnv() }).unref();
136
- return { ok: true, opener: 'Terminal.app' };
154
+ return spawnDetached('open', ['-a', 'Terminal', cwd], 'Terminal.app');
137
155
  }
138
156
  return { ok: false, error: 'no terminal found' };
139
157
  }
140
158
 
141
- module.exports = { findCommand, openInEditor, openFileInEditor, openInFinder, openInTerminal };
159
+ module.exports = { spawnDetached, findCommand, openInEditor, openFileInEditor, openInFinder, openInTerminal };
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  const fs = require('node:fs');
15
+ const os = require('node:os');
15
16
  const path = require('node:path');
16
17
  const { assertOpsWrite } = require('./opsOwnership.cjs');
17
18
  const { isEphemeralCwd } = require('./ephemeralCwd.cjs');
@@ -125,6 +126,23 @@ function reportToTelemetry({ cwd, scope, level, tabId, epicId, tags, message })
125
126
  } catch { /* telemetry must never break the caller — this is the sole error funnel */ }
126
127
  }
127
128
 
129
+ function realOrResolved(p) {
130
+ const abs = path.resolve(p);
131
+ try { return fs.realpathSync(abs); } catch { return abs; }
132
+ }
133
+
134
+ function isInside(child, parent) {
135
+ const rel = path.relative(parent, child);
136
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
137
+ }
138
+
139
+ /** True when `cwd` resolves inside os.tmpdir() or SM_SANDBOX_DIR. O(roots). */
140
+ function isInsideTestScratch(cwd) {
141
+ const target = realOrResolved(cwd);
142
+ const roots = [os.tmpdir(), process.env.SM_SANDBOX_DIR].filter(Boolean);
143
+ return roots.some((r) => isInside(target, realOrResolved(r)));
144
+ }
145
+
128
146
  /**
129
147
  * Append one structured error line, tagged for tracing/analysis.
130
148
  *
@@ -142,6 +160,10 @@ function reportToTelemetry({ cwd, scope, level, tabId, epicId, tags, message })
142
160
  function appendError({ cwd, scope, level = 'error', tabId, epicId, tags = [], message, meta }) {
143
161
  if (!cwd || typeof cwd !== 'string') return; // no project to attribute this line to — skip
144
162
 
163
+ // Test-process guard: a vitest fixture passing a real project root must never
164
+ // write to that project's logs/ NOR ship a fake event to production telemetry.
165
+ if (process.env.VITEST && !isInsideTestScratch(cwd)) return;
166
+
145
167
  if (isEphemeralCwd(cwd)) {
146
168
  // A worktree is torn down when its Epic/job ends and os.tmpdir() is
147
169
  // scratch space either way — never materialize logs there. See
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  /**
2
3
  * Canonical runtime schema for a PromptSession (Epic) record, mirroring the
3
4
  * TS `PromptSession` interface at src/renderer/state/promptSessions.ts:29-76
@@ -96,6 +97,7 @@ const PromptSessionSchema = z.object({
96
97
  * doesn't match PromptSessionSchema. Callers that want fail-closed
97
98
  * enforcement before persisting a session object call this immediately
98
99
  * before their write.
100
+ * @param {unknown} session
99
101
  */
100
102
  function assertValidPromptSession(session) {
101
103
  const result = PromptSessionSchema.safeParse(session);