claude-code-session-manager 0.92.1 → 0.94.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-B0LDnnSL.js +1 -0
- package/dist/assets/{History-BOR_fJNr.js → History-C685Kytt.js} +2 -2
- package/dist/assets/{Hooks-BtD436iL.js → Hooks-CwLnp0Z_.js} +3 -3
- package/dist/assets/HostBilko-CwEHEKYk.js +1 -0
- package/dist/assets/{Library-DeKdY-tD.js → Library-BU9np05E.js} +25 -25
- package/dist/assets/{MarkdownEditor-CbzC-Oqf.js → MarkdownEditor-B74ogK2f.js} +1 -1
- package/dist/assets/McpServers-BLM3ftzP.js +2 -0
- package/dist/assets/{Memory-B68AJjg2.js → Memory-DAP1t8bA.js} +6 -6
- package/dist/assets/{Permissions-Cr0RRvNy.js → Permissions-3pGLJmxQ.js} +3 -3
- package/dist/assets/Plugins-Q1KoLEzn.js +2 -0
- package/dist/assets/ProvenanceBadge-BnjBQiV9.js +1 -0
- package/dist/assets/{SaveBar-87ZJfscX.js → SaveBar-Zq2NfcPf.js} +1 -1
- package/dist/assets/Scheduler-D488ebKm.js +16 -0
- package/dist/assets/{ScopeSwitcher-CKzpqjJ_.js → ScopeSwitcher-Cnojv22D.js} +1 -1
- package/dist/assets/Settings-D5Lhj7ga.js +3 -0
- package/dist/assets/{SkillReferenceGraph-D_-wg4r9.js → SkillReferenceGraph-BRDiE6Hv.js} +2 -2
- package/dist/assets/Skills-BQDqp-EN.js +3 -0
- package/dist/assets/SystemPrompt-tFr19Od6.js +1 -0
- package/dist/assets/TagLibrary-BjyrnaRE.js +1 -0
- package/dist/assets/{TiptapBody-DYXIp8aG.js → TiptapBody-DGzSg3BO.js} +1 -1
- package/dist/assets/{Toggle-CDXR5l3m.js → Toggle-DzoROibf.js} +1 -1
- package/dist/assets/index-CCl4tz-u.js +3079 -0
- package/dist/assets/{index-D3P_jldk.css → index-CKH5Uxik.css} +1 -1
- package/dist/assets/settingsSchema-BYKVe1WM.js +1 -0
- package/dist/index.html +2 -2
- package/package.json +6 -3
- package/scripts/README.md +3 -0
- package/src/main/__tests__/agentEffortResolve.test.cjs +117 -0
- package/src/main/__tests__/agentLibrary.test.cjs +21 -0
- package/src/main/__tests__/agentModelResolve.test.cjs +16 -0
- package/src/main/__tests__/agentOverlayWrite.test.cjs +95 -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__/chatRunner-session-flag-retry.test.cjs +44 -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__/personaMerge.test.cjs +169 -0
- package/src/main/__tests__/prdCreatePlanId.test.cjs +132 -0
- package/src/main/__tests__/promptSessionsCreateEpicHandler.test.cjs +13 -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-prd-persona-spawn.test.cjs +47 -2
- package/src/main/__tests__/transcriptsUsageFor.test.cjs +112 -1
- package/src/main/agentLibrary.cjs +86 -40
- package/src/main/build-info.json +4 -4
- package/src/main/chatRunner.cjs +50 -10
- package/src/main/git.cjs +9 -2
- package/src/main/historyAggregator.cjs +2 -19
- package/src/main/index.cjs +17 -8
- package/src/main/ipcSchemas.cjs +25 -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__/effectiveModelInfo.test.cjs +10 -5
- package/src/main/lib/__tests__/gitCacheBound.test.cjs +69 -0
- package/src/main/lib/__tests__/modelCatalog.test.cjs +202 -0
- package/src/main/lib/agentEffortResolve.cjs +74 -0
- package/src/main/lib/agentModelResolve.cjs +52 -59
- package/src/main/lib/agentPersonaSchema.cjs +5 -0
- package/src/main/lib/childWithLog.cjs +92 -51
- package/src/main/lib/delegationReadiness.cjs +3 -1
- package/src/main/lib/effectiveModelInfo.cjs +38 -20
- package/src/main/lib/epicMint.cjs +5 -2
- package/src/main/lib/epicSpawnPlan.cjs +27 -8
- package/src/main/lib/epicTranscriptPath.cjs +5 -1
- package/src/main/lib/epicWorktreeMerge.cjs +7 -6
- package/src/main/lib/headTailBuffer.cjs +43 -0
- package/src/main/lib/intradayRefresh.cjs +33 -0
- package/src/main/lib/lruCache.cjs +39 -0
- package/src/main/lib/modelCatalog.cjs +243 -0
- package/src/main/lib/openExternalApp.cjs +27 -9
- package/src/main/lib/opsErrorLog.cjs +22 -0
- package/src/main/lib/personaMerge.cjs +166 -0
- package/src/main/lib/prdCreate.cjs +23 -5
- package/src/main/lib/prdDisposition.cjs +46 -0
- package/src/main/lib/prdFrontmatter.cjs +6 -2
- package/src/main/lib/promptSessionSchema.cjs +5 -0
- package/src/main/lib/promptSessionsCreateEpic.cjs +5 -3
- package/src/main/lib/rendererRecovery.cjs +141 -0
- package/src/main/lib/scheduleJobSchema.cjs +3 -0
- package/src/main/runVerify.cjs +3 -1
- package/src/main/scheduler/prdParser.cjs +2 -0
- package/src/main/scheduler.cjs +381 -262
- package/src/main/templates/PRD_AUTHORING.md +4 -0
- package/src/main/transcripts.cjs +54 -11
- package/src/preload/api.d.ts +56 -3
- package/src/preload/index.cjs +4 -0
- package/dist/assets/AgentLibrary-Ci6u03bq.js +0 -3
- package/dist/assets/DataModel-BIqcjsv2.js +0 -1
- package/dist/assets/HostBilko-CMe5cH3H.js +0 -1
- package/dist/assets/ListDetail-CtVjrHKB.js +0 -1
- package/dist/assets/McpServers-DKN75Shn.js +0 -2
- package/dist/assets/Panel-C2YTW-qP.js +0 -1
- package/dist/assets/Plugins-DbZkJs5l.js +0 -2
- package/dist/assets/ProvenanceBadge-kJ__BOQv.js +0 -1
- package/dist/assets/Scheduler-DdEaxku8.js +0 -16
- package/dist/assets/Settings-CjywVsNB.js +0 -3
- package/dist/assets/Skills-Cd_yPO_l.js +0 -3
- package/dist/assets/SystemPrompt-2z6eGPSF.js +0 -1
- package/dist/assets/TagLibrary-DcaWQSp4.js +0 -1
- package/dist/assets/index-D5H_H5wC.js +0 -3074
- package/dist/assets/settingsSchema-JRDfjgHb.js +0 -3
|
@@ -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
|
|
|
@@ -13,11 +13,11 @@
|
|
|
13
13
|
* "we don't know; the CLI resolves this alias at launch." The two must
|
|
14
14
|
* never collapse into one optimistic string.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
* PERSONA effort IS here now (agentEffortResolve.cjs): it needs the same
|
|
17
|
+
* main-process, overlay-aware persona read as the persona model, and a launch
|
|
18
|
+
* now actually passes it as `--effort`. The settings.json effortLevel
|
|
19
|
+
* scope-chain read stays in the renderer (`useEffectiveSettings.ts`) as the
|
|
20
|
+
* FALLBACK when the persona sets no level — this module never reads settings.
|
|
21
21
|
*
|
|
22
22
|
* Never throws — every lookup failure degrades a field to null, same
|
|
23
23
|
* contract as agentModelResolve.cjs's resolveEpicModel.
|
|
@@ -29,12 +29,17 @@ const os = require('node:os');
|
|
|
29
29
|
const { splitFrontmatter } = require('./prdFrontmatter.cjs');
|
|
30
30
|
const { resolveEpicTranscriptPath } = require('./epicTranscriptPath.cjs');
|
|
31
31
|
|
|
32
|
-
//
|
|
32
|
+
// Deliberately stays a FIXED alias set (reviewed against the catalog-driven raw picker): it answers
|
|
33
|
+
// "alias vs concrete id", not "which options to offer", so live catalog ids must not enter it.
|
|
34
|
+
// Mirrors rawSessionModel.ts's RAW_MODELS fallback — duplicated rather than imported
|
|
33
35
|
// because that file is a renderer ES module and this is a main-process CJS
|
|
34
36
|
// module (CLAUDE.md: "No CommonJS in renderer, no ES modules in main").
|
|
35
37
|
// Anything NOT in this set is treated as an already-concrete pinned model id
|
|
36
38
|
// (e.g. 'claude-opus-5'), never as an alias needing evidence.
|
|
37
|
-
|
|
39
|
+
// Also the non-family CLI aliases the Agent Library picker can now write
|
|
40
|
+
// (best / opusplan / default); a trailing `[1m]`-style suffix is stripped
|
|
41
|
+
// before the lookup (see isConcreteModelId), so 'opus[1m]' is an alias too.
|
|
42
|
+
const KNOWN_MODEL_ALIASES = new Set(['opus', 'sonnet', 'haiku', 'fable', 'best', 'opusplan', 'default']);
|
|
38
43
|
|
|
39
44
|
// Run-log evidence (scheduler.cjs's executeJob, ~line 4473) is written
|
|
40
45
|
// essentially at dispatch time, before any of the run's own stdout streams
|
|
@@ -54,10 +59,14 @@ function runsDir(homeDir) {
|
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
function isConcreteModelId(modelAlias) {
|
|
57
|
-
|
|
62
|
+
if (!modelAlias) return false;
|
|
63
|
+
// 'opus[1m]' is an alias with a context suffix; 'claude-opus-4-7[1m]' keeps its id.
|
|
64
|
+
return !KNOWN_MODEL_ALIASES.has(modelAlias.replace(/\[[^\]]*\]$/, ''));
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
/**
|
|
68
|
+
* ENV-VAR question only (`effortEnvReachable`): persona effort now travels on argv as `--effort`,
|
|
69
|
+
* which cleanChildEnv cannot strip — so this says nothing about whether effort reaches the CLI.
|
|
61
70
|
* True when this app's spawn path (cleanEnv.cjs's cleanChildEnv) would strip
|
|
62
71
|
* an effort env var before a child ever saw it — i.e. whether the settings
|
|
63
72
|
* schema's env-var effort configuration path is actually reachable for an
|
|
@@ -65,7 +74,7 @@ function isConcreteModelId(modelAlias) {
|
|
|
65
74
|
* hardcoding a boolean, so this self-corrects if that deletion list ever
|
|
66
75
|
* changes instead of silently drifting stale.
|
|
67
76
|
*/
|
|
68
|
-
function
|
|
77
|
+
function computeEffortEnvReachable(deps) {
|
|
69
78
|
try {
|
|
70
79
|
const { cleanChildEnv } = deps.cleanEnv || require('./cleanEnv.cjs');
|
|
71
80
|
const probe = cleanChildEnv({ CLAUDE_EFFORT: 'high', CLAUDE_CODE_EFFORT_LEVEL: 'high' });
|
|
@@ -106,15 +115,18 @@ async function resolvePersonaModelAlias(cwd, agentType, deps) {
|
|
|
106
115
|
} catch {
|
|
107
116
|
persona = null;
|
|
108
117
|
}
|
|
109
|
-
if (!persona) return { modelAlias: null, modelSource: 'fallback' };
|
|
118
|
+
if (!persona) return { modelAlias: null, modelSource: 'fallback', provenance: {} };
|
|
110
119
|
|
|
111
|
-
|
|
112
|
-
|
|
120
|
+
// getPersonaBody returns the MERGED persona with per-field provenance; a bare
|
|
121
|
+
// `{ path, text }` (older injected reader) falls back to whole-file provenance.
|
|
122
|
+
const fm = persona.fm || splitFrontmatter(persona.text).fm;
|
|
123
|
+
const provenance = persona.provenance
|
|
124
|
+
|| Object.fromEntries(Object.keys(fm).map((k) => [k, isProjectOverlayPersonaPath(cwd, agentType, persona.path, deps) ? 'overlay' : 'global']));
|
|
113
125
|
|
|
114
126
|
if (fm.model && fm.model !== 'inherit') {
|
|
115
|
-
return { modelAlias: fm.model, modelSource:
|
|
127
|
+
return { modelAlias: fm.model, modelSource: provenance.model === 'overlay' ? 'persona-overlay' : 'persona', provenance };
|
|
116
128
|
}
|
|
117
|
-
return { modelAlias: null, modelSource: 'inherit' };
|
|
129
|
+
return { modelAlias: null, modelSource: 'inherit', provenance };
|
|
118
130
|
}
|
|
119
131
|
|
|
120
132
|
/** Best-effort read of `<cwd>/session-manager-operations/scheduler/state/queue.json`'s hot jobs[]. */
|
|
@@ -268,21 +280,25 @@ function findLatestTranscriptModel(cwd, agentType, deps) {
|
|
|
268
280
|
|
|
269
281
|
/**
|
|
270
282
|
* resolveEffectiveModelInfo({ cwd, agentType, deps? }) →
|
|
271
|
-
* { agentType, modelAlias, modelSource, resolvedModelId, resolvedFrom,
|
|
283
|
+
* { agentType, modelAlias, modelSource, resolvedModelId, resolvedFrom, effortEnvReachable,
|
|
284
|
+
* personaEffort, personaEffortSource }
|
|
272
285
|
*
|
|
273
286
|
* Never throws. `cwd` is normalized through the same worktree/ops-internal
|
|
274
287
|
* -aware resolver every ops-root reader goes through, so a job worktree cwd
|
|
275
288
|
* resolves to its main project tree before anything is looked up.
|
|
276
289
|
*/
|
|
277
290
|
async function resolveEffectiveModelInfo({ cwd, agentType, deps = {} } = {}) {
|
|
278
|
-
const
|
|
291
|
+
const effortEnvReachable = computeEffortEnvReachable(deps);
|
|
279
292
|
const miss = {
|
|
280
293
|
agentType: agentType || null,
|
|
281
294
|
modelAlias: null,
|
|
282
295
|
modelSource: 'fallback',
|
|
296
|
+
personaProvenance: {},
|
|
283
297
|
resolvedModelId: null,
|
|
284
298
|
resolvedFrom: null,
|
|
285
|
-
|
|
299
|
+
effortEnvReachable,
|
|
300
|
+
personaEffort: null,
|
|
301
|
+
personaEffortSource: null,
|
|
286
302
|
};
|
|
287
303
|
if (!cwd || !agentType) return miss;
|
|
288
304
|
|
|
@@ -291,8 +307,10 @@ async function resolveEffectiveModelInfo({ cwd, agentType, deps = {} } = {}) {
|
|
|
291
307
|
const normalizedCwd = projectRootOf(path.resolve(cwd));
|
|
292
308
|
if (!normalizedCwd) return miss;
|
|
293
309
|
|
|
294
|
-
const { modelAlias, modelSource } = await resolvePersonaModelAlias(normalizedCwd, agentType, deps);
|
|
295
|
-
const
|
|
310
|
+
const { modelAlias, modelSource, provenance: personaProvenance } = await resolvePersonaModelAlias(normalizedCwd, agentType, deps);
|
|
311
|
+
const { effort: personaEffort, source: personaEffortSource } = (deps.agentEffortResolve || require('./agentEffortResolve.cjs'))
|
|
312
|
+
.resolveEpicEffort({ cwd: normalizedCwd, agentType, deps });
|
|
313
|
+
const result = { agentType, modelAlias, modelSource, personaProvenance, resolvedModelId: null, resolvedFrom: null, effortEnvReachable, personaEffort, personaEffortSource };
|
|
296
314
|
|
|
297
315
|
if (isConcreteModelId(modelAlias)) {
|
|
298
316
|
// Already a concrete pinned id — echo it back, fabricate no evidence.
|
|
@@ -327,6 +345,6 @@ async function resolveEffectiveModelInfo({ cwd, agentType, deps = {} } = {}) {
|
|
|
327
345
|
module.exports = {
|
|
328
346
|
resolveEffectiveModelInfo,
|
|
329
347
|
isConcreteModelId,
|
|
330
|
-
|
|
348
|
+
computeEffortEnvReachable,
|
|
331
349
|
KNOWN_MODEL_ALIASES,
|
|
332
350
|
};
|
|
@@ -208,7 +208,7 @@ function withPathLock(lockPath, task) {
|
|
|
208
208
|
}
|
|
209
209
|
|
|
210
210
|
/**
|
|
211
|
-
* ensureEpic(cwd, { epicId?, goalText, tag?, status?, openingPrompt?, source?, agentType?, mintAuthority? }) → Promise<{ epicId, prdDir, created }>
|
|
211
|
+
* ensureEpic(cwd, { epicId?, goalText, tag?, status?, openingPrompt?, source?, agentType?, model?, effort?, mintAuthority? }) → Promise<{ epicId, prdDir, created }>
|
|
212
212
|
*
|
|
213
213
|
* Two behaviors, and only two:
|
|
214
214
|
* - JOIN (the default, for every automated caller): `epicId` names an Epic
|
|
@@ -227,7 +227,7 @@ function withPathLock(lockPath, task) {
|
|
|
227
227
|
* The Epic's id doubles as its directory name under scheduler/epics/, so the
|
|
228
228
|
* PromptSession ↔ on-disk Epic mapping is 1:1 with no lookup table.
|
|
229
229
|
*/
|
|
230
|
-
function ensureEpic(cwd, { goalText, tag, epicId: explicitEpicId, status = 'proposed', openingPrompt = null, sections = null, source = null, agentType = null, mintAuthority = null } = {}, deps = {}) {
|
|
230
|
+
function ensureEpic(cwd, { goalText, tag, epicId: explicitEpicId, status = 'proposed', openingPrompt = null, sections = null, source = null, agentType = null, model = null, effort = null, mintAuthority = null } = {}, deps = {}) {
|
|
231
231
|
if (!cwd || typeof cwd !== 'string') throw new Error('ensureEpic: cwd is required');
|
|
232
232
|
// A relative cwd (e.g. a caller passing '.') would otherwise get stored
|
|
233
233
|
// verbatim on the minted Epic's `cwd` field — the renderer's EpicsWorkspace
|
|
@@ -337,6 +337,9 @@ function ensureEpic(cwd, { goalText, tag, epicId: explicitEpicId, status = 'prop
|
|
|
337
337
|
// ensureEpic IPC path (state/promptSessions.ts). Never affects which
|
|
338
338
|
// claude CLI spawns.
|
|
339
339
|
...(agentType ? { agentType } : {}),
|
|
340
|
+
// Per-Epic runtime overrides — written only when set, so an Epic minted without one keeps its on-disk shape.
|
|
341
|
+
...(typeof model === 'string' && model ? { model } : {}),
|
|
342
|
+
...(typeof effort === 'string' && effort ? { effort } : {}),
|
|
340
343
|
};
|
|
341
344
|
|
|
342
345
|
// Validate the constructed record against the canonical PromptSession
|
|
@@ -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) {
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
* This is the ONLY point where that per-Epic isolation resolves back into
|
|
10
10
|
* the shared tree — never mid-session (see gitWorktree.cjs's own header
|
|
11
11
|
* comment for the ff-only -> merge-commit -> abort-and-flag algorithm this
|
|
12
|
-
* reuses verbatim).
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* reuses verbatim). The single caller is `markCompleted`'s merge-before-archive
|
|
13
|
+
* checkpoint (via `attemptMergeToMainInternal` in
|
|
14
|
+
* `src/renderer/state/promptSessions.ts`); there is no explicit "merge to
|
|
15
|
+
* main" action.
|
|
16
16
|
*
|
|
17
17
|
* On success the worktree checkout is torn down (cleanupEpicWorktree) since
|
|
18
18
|
* its content now safely lives on the main tree. On a real conflict, the
|
|
@@ -20,8 +20,9 @@
|
|
|
20
20
|
* — so a human can still open a Terminal into `worktree.dir` (the spawn
|
|
21
21
|
* resolution in epicSpawnCwd.cjs is status-aware: `needs_merge_resolution`
|
|
22
22
|
* with an existing dir still lands there, while `merged`/`disabled` skip the
|
|
23
|
-
* re-attach and fall back to the project cwd) and resolve it manually
|
|
24
|
-
*
|
|
23
|
+
* re-attach and fall back to the project cwd) and resolve it manually — the
|
|
24
|
+
* branch and worktree dir are deliberately left intact so the human can merge
|
|
25
|
+
* them from the command line.
|
|
25
26
|
*/
|
|
26
27
|
|
|
27
28
|
const { integrateEpicBranch, cleanupEpicWorktree } = require('./gitWorktree.cjs');
|
|
@@ -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,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 };
|