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.
- package/dist/assets/{DataModel-DDGx3qrn.js → DataModel-DZK9QZpV.js} +1 -1
- package/dist/assets/{History-CHBjYZyD.js → History-BDyJUGtR.js} +2 -2
- package/dist/assets/{Hooks-DG4c-Rzz.js → Hooks-CmK6l0uf.js} +3 -3
- package/dist/assets/HostBilko-D8TIx-Bx.js +1 -0
- package/dist/assets/{Library-CN1h5rAE.js → Library-DTxpOjx8.js} +25 -25
- package/dist/assets/{MarkdownEditor-DmBxrhdn.js → MarkdownEditor-BPJyrevq.js} +1 -1
- package/dist/assets/McpServers-B_rvIYDs.js +2 -0
- package/dist/assets/{Memory-Dk4uDCjg.js → Memory-B-9GlHqe.js} +6 -6
- package/dist/assets/{Permissions-BovobeZw.js → Permissions-DhMMp27n.js} +3 -3
- package/dist/assets/{Plugins-DvOf_B62.js → Plugins-DKfAlmMY.js} +2 -2
- package/dist/assets/{ProvenanceBadge-Dutz_4OY.js → ProvenanceBadge-ptlItdCK.js} +1 -1
- package/dist/assets/{SaveBar-CT5u6ZCJ.js → SaveBar-CSbNSl2W.js} +1 -1
- package/dist/assets/Scheduler--rn1QBYc.js +16 -0
- package/dist/assets/{ScopeSwitcher-BUYDpz2v.js → ScopeSwitcher-BJof-GWH.js} +1 -1
- package/dist/assets/{Settings-DIRmr3tL.js → Settings-Dsx6XZP5.js} +3 -3
- package/dist/assets/{SkillReferenceGraph-CdGr6d3_.js → SkillReferenceGraph-CGl4B89-.js} +2 -2
- package/dist/assets/{Skills-BQOheQv4.js → Skills-D522Zfol.js} +2 -2
- package/dist/assets/{SystemPrompt-5LsimKbX.js → SystemPrompt-DKj-q39J.js} +1 -1
- package/dist/assets/{TagLibrary-DreLsT3n.js → TagLibrary-BH7CG7-6.js} +1 -1
- package/dist/assets/{TiptapBody-C9kmlBnI.js → TiptapBody-BbcJL9az.js} +1 -1
- package/dist/assets/{Toggle-BKLCBu9A.js → Toggle-DAslvnnv.js} +1 -1
- package/dist/assets/{index-Db_SK9rW.js → index-Yf8e7VCB.js} +821 -821
- package/dist/assets/{settingsSchema-B5jBhSUP.js → settingsSchema-pzp0TgvN.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +8 -3
- package/scripts/README.md +6 -0
- package/src/main/__tests__/chat-cancel-terminal.test.cjs +5 -2
- package/src/main/__tests__/chat-exit-close-race.test.cjs +8 -2
- package/src/main/__tests__/chat-mcp-consent-notice.test.cjs +5 -2
- package/src/main/__tests__/health-tick-liveness.test.cjs +12 -0
- package/src/main/__tests__/intradayRefresh.test.cjs +39 -0
- package/src/main/__tests__/openExternalApp-spawn-error.test.cjs +25 -0
- package/src/main/__tests__/opsErrorLog.test.cjs +22 -0
- package/src/main/__tests__/pty-epic-worktree-spawn-cwd.test.cjs +21 -0
- package/src/main/__tests__/rateLimitPollerStreak.test.cjs +14 -0
- package/src/main/__tests__/runVerify-atomic-verdicts.test.cjs +26 -0
- package/src/main/__tests__/runVerify-landed-commit-outranks.test.cjs +11 -1
- package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +11 -1
- package/src/main/__tests__/runVerify.test.cjs +12 -1
- package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +15 -0
- package/src/main/__tests__/scheduler-utilization-hold.test.cjs +89 -0
- package/src/main/__tests__/transcriptsUsageFor.test.cjs +112 -1
- package/src/main/build-info.json +4 -4
- package/src/main/chatRunner.cjs +45 -10
- package/src/main/crashDiagnostics.cjs +9 -0
- package/src/main/git.cjs +9 -2
- package/src/main/health.cjs +25 -1
- package/src/main/historyAggregator.cjs +2 -19
- package/src/main/index.cjs +7 -8
- package/src/main/ipcSchemas.cjs +13 -3
- package/src/main/lib/__tests__/childWithLog.test.cjs +180 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +19 -0
- package/src/main/lib/__tests__/gitCacheBound.test.cjs +69 -0
- package/src/main/lib/__tests__/loopDelay.test.cjs +68 -0
- package/src/main/lib/__tests__/usageCircuit.test.cjs +86 -17
- package/src/main/lib/agentPersonaSchema.cjs +2 -0
- package/src/main/lib/childWithLog.cjs +92 -51
- package/src/main/lib/delegationReadiness.cjs +3 -1
- package/src/main/lib/epicSpawnPlan.cjs +27 -8
- package/src/main/lib/epicTranscriptPath.cjs +5 -1
- package/src/main/lib/headTailBuffer.cjs +43 -0
- package/src/main/lib/intradayRefresh.cjs +33 -0
- package/src/main/lib/loopDelay.cjs +44 -0
- package/src/main/lib/lruCache.cjs +39 -0
- package/src/main/lib/openExternalApp.cjs +27 -9
- package/src/main/lib/opsErrorLog.cjs +22 -0
- package/src/main/lib/promptSessionSchema.cjs +2 -0
- package/src/main/lib/rendererRecovery.cjs +141 -0
- package/src/main/lib/scheduleJobSchema.cjs +3 -0
- package/src/main/lib/usageCircuit.cjs +40 -12
- package/src/main/runVerify.cjs +3 -1
- package/src/main/scheduler.cjs +409 -260
- package/src/main/transcripts.cjs +54 -11
- package/src/main/usage.cjs +9 -1
- package/src/preload/api.d.ts +20 -3
- package/dist/assets/HostBilko-DHXcpV2c.js +0 -1
- package/dist/assets/McpServers-Beb_HD6I.js +0 -2
- 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()
|
|
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,
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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}
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
return { ok: true, opener: t };
|
|
151
|
+
return spawnDetached(t, args);
|
|
133
152
|
}
|
|
134
153
|
} else if (process.platform === 'darwin') {
|
|
135
|
-
|
|
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);
|