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.
Files changed (107) hide show
  1. package/dist/assets/DataModel-B0LDnnSL.js +1 -0
  2. package/dist/assets/{History-BOR_fJNr.js → History-C685Kytt.js} +2 -2
  3. package/dist/assets/{Hooks-BtD436iL.js → Hooks-CwLnp0Z_.js} +3 -3
  4. package/dist/assets/HostBilko-CwEHEKYk.js +1 -0
  5. package/dist/assets/{Library-DeKdY-tD.js → Library-BU9np05E.js} +25 -25
  6. package/dist/assets/{MarkdownEditor-CbzC-Oqf.js → MarkdownEditor-B74ogK2f.js} +1 -1
  7. package/dist/assets/McpServers-BLM3ftzP.js +2 -0
  8. package/dist/assets/{Memory-B68AJjg2.js → Memory-DAP1t8bA.js} +6 -6
  9. package/dist/assets/{Permissions-Cr0RRvNy.js → Permissions-3pGLJmxQ.js} +3 -3
  10. package/dist/assets/Plugins-Q1KoLEzn.js +2 -0
  11. package/dist/assets/ProvenanceBadge-BnjBQiV9.js +1 -0
  12. package/dist/assets/{SaveBar-87ZJfscX.js → SaveBar-Zq2NfcPf.js} +1 -1
  13. package/dist/assets/Scheduler-D488ebKm.js +16 -0
  14. package/dist/assets/{ScopeSwitcher-CKzpqjJ_.js → ScopeSwitcher-Cnojv22D.js} +1 -1
  15. package/dist/assets/Settings-D5Lhj7ga.js +3 -0
  16. package/dist/assets/{SkillReferenceGraph-D_-wg4r9.js → SkillReferenceGraph-BRDiE6Hv.js} +2 -2
  17. package/dist/assets/Skills-BQDqp-EN.js +3 -0
  18. package/dist/assets/SystemPrompt-tFr19Od6.js +1 -0
  19. package/dist/assets/TagLibrary-BjyrnaRE.js +1 -0
  20. package/dist/assets/{TiptapBody-DYXIp8aG.js → TiptapBody-DGzSg3BO.js} +1 -1
  21. package/dist/assets/{Toggle-CDXR5l3m.js → Toggle-DzoROibf.js} +1 -1
  22. package/dist/assets/index-CCl4tz-u.js +3079 -0
  23. package/dist/assets/{index-D3P_jldk.css → index-CKH5Uxik.css} +1 -1
  24. package/dist/assets/settingsSchema-BYKVe1WM.js +1 -0
  25. package/dist/index.html +2 -2
  26. package/package.json +6 -3
  27. package/scripts/README.md +3 -0
  28. package/src/main/__tests__/agentEffortResolve.test.cjs +117 -0
  29. package/src/main/__tests__/agentLibrary.test.cjs +21 -0
  30. package/src/main/__tests__/agentModelResolve.test.cjs +16 -0
  31. package/src/main/__tests__/agentOverlayWrite.test.cjs +95 -0
  32. package/src/main/__tests__/chat-cancel-terminal.test.cjs +5 -2
  33. package/src/main/__tests__/chat-exit-close-race.test.cjs +8 -2
  34. package/src/main/__tests__/chat-mcp-consent-notice.test.cjs +5 -2
  35. package/src/main/__tests__/chatRunner-session-flag-retry.test.cjs +44 -0
  36. package/src/main/__tests__/intradayRefresh.test.cjs +39 -0
  37. package/src/main/__tests__/openExternalApp-spawn-error.test.cjs +25 -0
  38. package/src/main/__tests__/opsErrorLog.test.cjs +22 -0
  39. package/src/main/__tests__/personaMerge.test.cjs +169 -0
  40. package/src/main/__tests__/prdCreatePlanId.test.cjs +132 -0
  41. package/src/main/__tests__/promptSessionsCreateEpicHandler.test.cjs +13 -0
  42. package/src/main/__tests__/pty-epic-worktree-spawn-cwd.test.cjs +21 -0
  43. package/src/main/__tests__/rateLimitPollerStreak.test.cjs +14 -0
  44. package/src/main/__tests__/runVerify-atomic-verdicts.test.cjs +26 -0
  45. package/src/main/__tests__/runVerify-landed-commit-outranks.test.cjs +11 -1
  46. package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +11 -1
  47. package/src/main/__tests__/runVerify.test.cjs +12 -1
  48. package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +47 -2
  49. package/src/main/__tests__/transcriptsUsageFor.test.cjs +112 -1
  50. package/src/main/agentLibrary.cjs +86 -40
  51. package/src/main/build-info.json +4 -4
  52. package/src/main/chatRunner.cjs +50 -10
  53. package/src/main/git.cjs +9 -2
  54. package/src/main/historyAggregator.cjs +2 -19
  55. package/src/main/index.cjs +17 -8
  56. package/src/main/ipcSchemas.cjs +25 -3
  57. package/src/main/lib/__tests__/childWithLog.test.cjs +180 -0
  58. package/src/main/lib/__tests__/delegationReadiness.test.cjs +19 -0
  59. package/src/main/lib/__tests__/effectiveModelInfo.test.cjs +10 -5
  60. package/src/main/lib/__tests__/gitCacheBound.test.cjs +69 -0
  61. package/src/main/lib/__tests__/modelCatalog.test.cjs +202 -0
  62. package/src/main/lib/agentEffortResolve.cjs +74 -0
  63. package/src/main/lib/agentModelResolve.cjs +52 -59
  64. package/src/main/lib/agentPersonaSchema.cjs +5 -0
  65. package/src/main/lib/childWithLog.cjs +92 -51
  66. package/src/main/lib/delegationReadiness.cjs +3 -1
  67. package/src/main/lib/effectiveModelInfo.cjs +38 -20
  68. package/src/main/lib/epicMint.cjs +5 -2
  69. package/src/main/lib/epicSpawnPlan.cjs +27 -8
  70. package/src/main/lib/epicTranscriptPath.cjs +5 -1
  71. package/src/main/lib/epicWorktreeMerge.cjs +7 -6
  72. package/src/main/lib/headTailBuffer.cjs +43 -0
  73. package/src/main/lib/intradayRefresh.cjs +33 -0
  74. package/src/main/lib/lruCache.cjs +39 -0
  75. package/src/main/lib/modelCatalog.cjs +243 -0
  76. package/src/main/lib/openExternalApp.cjs +27 -9
  77. package/src/main/lib/opsErrorLog.cjs +22 -0
  78. package/src/main/lib/personaMerge.cjs +166 -0
  79. package/src/main/lib/prdCreate.cjs +23 -5
  80. package/src/main/lib/prdDisposition.cjs +46 -0
  81. package/src/main/lib/prdFrontmatter.cjs +6 -2
  82. package/src/main/lib/promptSessionSchema.cjs +5 -0
  83. package/src/main/lib/promptSessionsCreateEpic.cjs +5 -3
  84. package/src/main/lib/rendererRecovery.cjs +141 -0
  85. package/src/main/lib/scheduleJobSchema.cjs +3 -0
  86. package/src/main/runVerify.cjs +3 -1
  87. package/src/main/scheduler/prdParser.cjs +2 -0
  88. package/src/main/scheduler.cjs +381 -262
  89. package/src/main/templates/PRD_AUTHORING.md +4 -0
  90. package/src/main/transcripts.cjs +54 -11
  91. package/src/preload/api.d.ts +56 -3
  92. package/src/preload/index.cjs +4 -0
  93. package/dist/assets/AgentLibrary-Ci6u03bq.js +0 -3
  94. package/dist/assets/DataModel-BIqcjsv2.js +0 -1
  95. package/dist/assets/HostBilko-CMe5cH3H.js +0 -1
  96. package/dist/assets/ListDetail-CtVjrHKB.js +0 -1
  97. package/dist/assets/McpServers-DKN75Shn.js +0 -2
  98. package/dist/assets/Panel-C2YTW-qP.js +0 -1
  99. package/dist/assets/Plugins-DbZkJs5l.js +0 -2
  100. package/dist/assets/ProvenanceBadge-kJ__BOQv.js +0 -1
  101. package/dist/assets/Scheduler-DdEaxku8.js +0 -16
  102. package/dist/assets/Settings-CjywVsNB.js +0 -3
  103. package/dist/assets/Skills-Cd_yPO_l.js +0 -3
  104. package/dist/assets/SystemPrompt-2z6eGPSF.js +0 -1
  105. package/dist/assets/TagLibrary-DcaWQSp4.js +0 -1
  106. package/dist/assets/index-D5H_H5wC.js +0 -3074
  107. 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() 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
 
@@ -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
- * Effort-level resolution is deliberately NOT here — it already exists,
17
- * unused, in the renderer's `useEffectiveSettings.ts` (a scope-chain reader
18
- * with a file watcher). This module supplies only the half that genuinely
19
- * needs main-process filesystem access: persona lookup (overlay-aware) and
20
- * concrete-model evidence (scheduler run logs, session transcripts).
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
- // Mirrors rawSessionModel.ts's RAW_MODELS — duplicated rather than imported
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
- const KNOWN_MODEL_ALIASES = new Set(['opus', 'sonnet', 'haiku', 'fable']);
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
- return Boolean(modelAlias) && !KNOWN_MODEL_ALIASES.has(modelAlias);
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 computeEffortReachable(deps) {
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
- const { fm } = splitFrontmatter(persona.text);
112
- const fromOverlay = isProjectOverlayPersonaPath(cwd, agentType, persona.path, deps);
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: fromOverlay ? 'persona-overlay' : 'persona' };
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, effortReachable }
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 effortReachable = computeEffortReachable(deps);
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
- effortReachable,
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 result = { agentType, modelAlias, modelSource, resolvedModelId: null, resolvedFrom: null, effortReachable };
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
- computeEffortReachable,
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
- 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) {
@@ -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). Callers reach it two ways: an explicit "merge to main"
13
- * action (renderer state's `mergeEpicToMain`), and `markCompleted`'s
14
- * merge-before-archive checkpoint — both funnel through this one handler so
15
- * the integration strategy is never duplicated.
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 before
24
- * re-running this same action.
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 };