claude-code-session-manager 0.41.1 → 0.43.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.
@@ -49,6 +49,15 @@ const TOKEN_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'admin-
49
49
  * harmless tie-break, since real launches only ever set one or the other.
50
50
  */
51
51
  function resolveTokenPath() {
52
+ // Explicit override, highest priority. Exists because the unit tests call
53
+ // createAdminHttp().start() directly with neither SM_DEV nor SM_E2E set,
54
+ // which resolved to the PRODUCTION admin-api.json and overwrote the
55
+ // running app's port+token with a throwaway test server's — silently
56
+ // orphaning scheduler-mcp-server.cjs's access to the live app until the
57
+ // next app restart (confirmed live 2026-08-01: `npm run test:unit` made
58
+ // every scheduler_* MCP tool report "app is not running"). Tests point
59
+ // this at a tmp file; production never sets it.
60
+ if (process.env.SM_ADMIN_TOKEN_PATH) return process.env.SM_ADMIN_TOKEN_PATH;
52
61
  if (process.env.SM_DEV === '1') {
53
62
  return path.join(os.homedir(), '.claude', 'session-manager', 'admin-api.dev.json');
54
63
  }
@@ -24,6 +24,7 @@ const config = require('../config.cjs');
24
24
  const { expandHome } = require('./expandHome.cjs');
25
25
  const { readBody, sendJson } = require('./localAdminHttp.cjs');
26
26
  const { readActiveIndex } = require('./epicMint.cjs');
27
+ const { appendAuditEvent } = require('./auditLog.cjs');
27
28
 
28
29
  const STANDARDS_PATH = path.join(
29
30
  __dirname, '..', '..', '..',
@@ -192,6 +193,17 @@ async function createPrd(input, remote) {
192
193
  return { ok: false, status: 500, error: writeResult?.error ?? 'write failed' };
193
194
  }
194
195
 
196
+ // Trace-back point for "who/what created this PRD" (auditLog.cjs) — every
197
+ // PRD write, whether from the admin/MCP route or the renderer's chat:create-prd
198
+ // IPC handler, lands one record here regardless of outcome path above.
199
+ appendAuditEvent('prd_create', {
200
+ cwd: input.cwd,
201
+ slug: filenameSlug,
202
+ title: input.title,
203
+ sourcePromptId: input.sourcePromptId ?? null,
204
+ sourceTabId: input.sourceTabId ?? null,
205
+ });
206
+
195
207
  return { ok: true, nn, filename: `${filenameSlug}.md` };
196
208
  }
197
209
 
@@ -49,10 +49,32 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
49
49
  // unchanged (lowest-number-first waves), so an in-flight mixed queue keeps
50
50
  // its order without migration.
51
51
  const rowBySlug = new Map(projectJobs.map((j) => [j.slug, j]));
52
- const blockingDep = (j) => (j.dependsOn ?? []).find((slug) => {
53
- const dep = rowBySlug.get(slug);
54
- return dep && dep.status !== 'completed';
55
- });
52
+ // A PRD's filename slug carries the auto-allocated `NN-` prefix
53
+ // (prdCreate.cjs builds `${nn}-${slug}`), but a human/agent authoring
54
+ // `dependsOn:` writes the BARE name it chose — it cannot know the number
55
+ // the allocator will hand out. Without this, every such dep resolved to
56
+ // undefined and was silently treated as "already done", turning the whole
57
+ // dependency gate into a no-op (observed live 2026-08-01: 22 PRDs listing
58
+ // `leftnav-two-face-framework` while the real row was
59
+ // `873-leftnav-two-face-framework`, all of them eligible immediately even
60
+ // though the framework PRD they build on had not landed). Resolve a dep by
61
+ // exact slug first, then fall back to matching rows whose slug is that dep
62
+ // with a leading `NN-` stripped.
63
+ const rowsByBareSlug = new Map();
64
+ for (const j of projectJobs) {
65
+ const bare = String(j.slug ?? '').replace(/^\d+-/, '');
66
+ if (!rowsByBareSlug.has(bare)) rowsByBareSlug.set(bare, []);
67
+ rowsByBareSlug.get(bare).push(j);
68
+ }
69
+ /** Every queue row a dep string refers to (exact match, else bare-name match). */
70
+ const rowsForDep = (slug) => {
71
+ const exact = rowBySlug.get(slug);
72
+ if (exact) return [exact];
73
+ return rowsByBareSlug.get(String(slug ?? '').replace(/^\d+-/, '')) ?? [];
74
+ };
75
+ const blockingDep = (j) => (j.dependsOn ?? []).find((slug) => (
76
+ rowsForDep(slug).some((dep) => dep.status !== 'completed')
77
+ ));
56
78
 
57
79
  const allPending = projectJobs.filter(
58
80
  (j) => j.status === 'pending' && !runningSlugsInProject.has(j.slug),
@@ -64,7 +86,7 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
64
86
  for (const j of allPending) {
65
87
  const dep = blockingDep(j);
66
88
  if (!dep) { pending.push(j); continue; }
67
- if (rowBySlug.get(dep)?.status === 'failed') heldByFailedDep.push({ job: j, dep });
89
+ if (rowsForDep(dep).some((d) => d.status === 'failed')) heldByFailedDep.push({ job: j, dep });
68
90
  // running/pending/needs_review dep — simply not eligible this tick.
69
91
  }
70
92
  if (pending.length === 0) {
package/src/main/pty.cjs CHANGED
@@ -147,6 +147,14 @@ class PtyManager {
147
147
  FORCE_COLOR: '1',
148
148
  // Tag the session so hook server / transcript tail can correlate.
149
149
  SESSION_MANAGER_TAB_ID: tabId,
150
+ // Tab ID = claudeSessionId by design (see CLAUDE.md domain model), and
151
+ // for an Epic, Terminal/Chat are two views over the SAME session — so
152
+ // this is exactly the id chatRunner.cjs sets for headless chat runs.
153
+ // Without it, a PRD queued via scheduler_create_prd from an
154
+ // interactive Epic terminal (not headless chat) had no way to resolve
155
+ // back to its originating Epic and minted an unrelated sibling one
156
+ // instead (prdCreate.cjs's resolveSourcePromptIdFromClaudeSession).
157
+ SM_CHAT_SESSION_ID: tabId,
150
158
  });
151
159
 
152
160
  const shell = process.env.SHELL || '/bin/bash';
@@ -377,16 +377,43 @@ function parsePrdBodyDepFragments(body) {
377
377
  */
378
378
  function checkDeps(queueEntry, allJobs, prdBody) {
379
379
  const bySlug = new Map(allJobs.map((j) => [j.slug, j]));
380
+ // A PRD's filename carries the allocator's `NN-` prefix, but `dependsOn:` is
381
+ // authored with the bare name (the author cannot know the number that will
382
+ // be issued). Resolve exact slug first, then bare-name — identical rule to
383
+ // schedulerBatch.pickForProject's rowsForDep, which MUST agree with this
384
+ // function (see the missing-row note below).
385
+ const rowsByBareSlug = new Map();
386
+ for (const j of allJobs) {
387
+ const bare = String(j.slug ?? '').replace(/^\d+-/, '');
388
+ if (!rowsByBareSlug.has(bare)) rowsByBareSlug.set(bare, []);
389
+ rowsByBareSlug.get(bare).push(j);
390
+ }
391
+ const rowsForDep = (slug) => {
392
+ const exact = bySlug.get(slug);
393
+ if (exact) return [exact];
394
+ return rowsByBareSlug.get(String(slug ?? '').replace(/^\d+-/, '')) ?? [];
395
+ };
380
396
 
381
397
  // 1. Queue-level dependsOn (explicit).
382
398
  const queueDeps = Array.isArray(queueEntry.dependsOn) ? queueEntry.dependsOn : [];
383
399
  for (const depSlug of queueDeps) {
384
- const dep = bySlug.get(depSlug);
385
- if (!dep) {
386
- return { ok: false, reason: `dependsOn: "${depSlug}" not found in queue` };
387
- }
388
- if (dep.status !== 'completed') {
389
- return { ok: false, reason: `dependsOn: "${depSlug}" is ${dep.status} (need completed)` };
400
+ const deps = rowsForDep(depSlug);
401
+ // A dep with NO row is SATISFIED, not unmet. Completed rows are retired to
402
+ // history shards and their PRD is archived, so absence is the normal
403
+ // end-state of a finished dependency — exactly how pickForProject reads it.
404
+ //
405
+ // This used to return deps_unmet, which put the two halves of the
406
+ // scheduler in direct contradiction: the picker treated a missing dep as
407
+ // done and fired the job, then this check treated the same missing dep as
408
+ // unmet and downgraded the finished run back to pending — an infinite
409
+ // re-run, one full billed claude -p per cycle. It also silently dropped
410
+ // real work: PRDs 854 and 857 each ran to exit 0, were downgraded on a
411
+ // bare dep name that never matched any row, and ended up on disk with no
412
+ // queue row at all.
413
+ if (deps.length === 0) continue;
414
+ const unfinished = deps.find((d) => d.status !== 'completed');
415
+ if (unfinished) {
416
+ return { ok: false, reason: `dependsOn: "${depSlug}" is ${unfinished.status} (need completed)` };
390
417
  }
391
418
  }
392
419
 
@@ -1071,6 +1071,11 @@ function reconcileSourcePromptId(job, parsedSourcePromptId) {
1071
1071
  * Status is preserved: pending stays pending, completed stays completed.
1072
1072
  * Newly-discovered PRDs land as `pending`.
1073
1073
  */
1074
+ // Epic ids already warned about (PRD-bearing but absent from
1075
+ // active-index.json). Module-scoped so the warning is one line per Epic per
1076
+ // process rather than one per 60s reconcile tick.
1077
+ const warnedUnregisteredEpics = new Set();
1078
+
1074
1079
  async function reconcile(state) {
1075
1080
  // Defence in depth — tickQueue already gates on this, but reconcile is the
1076
1081
  // function that would do the damage (every unmatched PRD .md becomes a
@@ -1163,8 +1168,54 @@ async function reconcile(state) {
1163
1168
  // for them (that row already exists, durably, in history.jsonl).
1164
1169
  const historyArchiveCandidates = [];
1165
1170
 
1171
+ // Cache active-index.json reads per cwd across this reconcile pass — many
1172
+ // unmatched PRDs typically share a project, and readActiveIndex is a
1173
+ // synchronous fs.readFileSync.
1174
+ const epicStatusCache = new Map(); // cwd -> { sessions }
1175
+ function epicStatus(cwd, epicId) {
1176
+ if (!cwd || !epicId) return null;
1177
+ let idx = epicStatusCache.get(cwd);
1178
+ if (!idx) {
1179
+ idx = readActiveIndex(cwd);
1180
+ epicStatusCache.set(cwd, idx);
1181
+ }
1182
+ return idx.sessions[epicId]?.status ?? null;
1183
+ }
1184
+
1166
1185
  for (const [slug, p] of onDisk) {
1167
1186
  if (seen.has(slug)) continue;
1187
+ // Security gate: a PRD's file location IS its Epic membership
1188
+ // (prdLocations.deriveEpicIdFromPrdPath / p.epicId). Before this check,
1189
+ // the scheduler queued ANY .md file it found under an Epic's prds/ dir
1190
+ // regardless of that Epic's status or even whether it was ever properly
1191
+ // minted — so a PRD filed under a 'proposed' Epic (agent-proposed,
1192
+ // awaiting human Approve & start via EpicApprovalBar), or one dropped
1193
+ // straight onto disk under a directory that was never registered via
1194
+ // ensureEpic()/active-index.json at all (bypassing the app entirely),
1195
+ // would still execute as a claude -p job with --dangerously-skip-permissions.
1196
+ // Block ONLY an explicit 'proposed' — the actual human gate. The human's
1197
+ // Approve action flips status to 'active' (state/promptSessions.ts) and
1198
+ // the very next reconcile pass picks the PRD up with no other change.
1199
+ //
1200
+ // This deliberately does NOT require a positive 'active' registration.
1201
+ // That stricter form shipped briefly and caused a 6-hour silent outage
1202
+ // (2026-08-01): 23 real PRDs whose Epic dir existed on disk but had no
1203
+ // active-index.json entry were skipped forever with no log line, so the
1204
+ // scheduler looked simply dead. Absence of an entry is not evidence of a
1205
+ // rogue writer — an Epic archived on completion also leaves active-index,
1206
+ // and other paths can create the dir without registering it. Fail-closed
1207
+ // is right for the approval gate; it is wrong as a blanket requirement,
1208
+ // because the failure mode is invisible and unrecoverable by the user.
1209
+ const ownerStatus = epicStatus(p.cwd, p.epicId);
1210
+ if (p.epicId && ownerStatus === 'proposed') {
1211
+ continue;
1212
+ }
1213
+ // Unregistered Epic dir: allowed, but never silently — this is the only
1214
+ // signal that a PRD's Epic is missing from the index it should be in.
1215
+ if (p.epicId && ownerStatus === null && !warnedUnregisteredEpics.has(p.epicId)) {
1216
+ warnedUnregisteredEpics.add(p.epicId);
1217
+ console.warn(`[scheduler] epic ${p.epicId} (${p.cwd}) has PRDs on disk but no active-index.json entry — queuing anyway; register or archive it to silence this`);
1218
+ }
1168
1219
  const hist = historyBySlug.get(slug);
1169
1220
  if (hist) {
1170
1221
  if (hist.status === 'completed') {