claude-code-session-manager 0.50.0 → 0.52.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.
@@ -85,7 +85,7 @@ const queueOps = require('./queueOps.cjs');
85
85
  // Plain Node module, no Electron dependency; queuePath/prdsDir defaults already
86
86
  // match ROOT/QUEUE_PATH below since both resolve the same ~/.claude/session-manager
87
87
  // home-dir layout.
88
- const { resolvePrdsDirs, resolvePrdWriteDir, listEpicPrdDirs, listArchivedPrdDirs } = require('./lib/prdLocations.cjs');
88
+ const { resolvePrdsDirs, resolveArchivedPrdsDirs, resolvePrdWriteDir, listEpicPrdDirs, listArchivedPrdDirs } = require('./lib/prdLocations.cjs');
89
89
  const { ensureEpic, appendPrdCreatedEvent, readActiveIndex } = require('./lib/epicMint.cjs');
90
90
 
91
91
  // ---------- origin session resolution (PRD 832) ----------
@@ -486,6 +486,35 @@ function candidatePrdsDirs() {
486
486
  return [PRDS_DIR, ...resolvePrdsDirs()];
487
487
  }
488
488
 
489
+ /**
490
+ * Every `prds-archived/` dir across every project (the sibling-of-source
491
+ * layout archiveCompletedPrd writes into — see that function's comment).
492
+ * Used only by list-prds so the PRDs/Runs tabs can show an Epic's REAL
493
+ * historical PRD count, not just its currently-pending one. Deliberately
494
+ * excludes the flat legacy PRDS_ARCHIVE_DIR (manual `schedule:archive-prd`
495
+ * timestamped-subdir layout) — that path is for a different, unused-by-
496
+ * any-renderer manual archive flow, not Epic-scoped completion archiving.
497
+ */
498
+ function candidateArchivedPrdsDirs() {
499
+ return resolveArchivedPrdsDirs();
500
+ }
501
+
502
+ /**
503
+ * Resolve an archived PRD's real terminal outcome for `schedule:list-prds`:
504
+ * its live queue.json row (if the job hasn't aged out yet), else its
505
+ * history.jsonl row, else 'completed' — archiveCompletedPrd only ever
506
+ * archives a job whose effective status is 'completed' (a 'failed' job's
507
+ * PRD source stays in the live prds/ dir), so 'completed' is a safe
508
+ * default, not a guess; the live/history lookups are defensive in case
509
+ * that archiving invariant ever changes. Exported as a pure function (no
510
+ * IPC/fs) so this fallback chain is unit-testable without an Electron
511
+ * harness.
512
+ */
513
+ function resolveArchivedPrdStatus(slug, liveStatusBySlug, histBySlug) {
514
+ const resolved = liveStatusBySlug.get(slug) ?? histBySlug.get(slug)?.status ?? 'completed';
515
+ return resolved === 'failed' ? 'failed' : 'completed';
516
+ }
517
+
489
518
  /** The PRD-source directory for a given job cwd (falls back to DEFAULT_PROJECT_CWD). */
490
519
  function prdDirForCwd(cwd) {
491
520
  return resolvePrdWriteDir(cwd || DEFAULT_PROJECT_CWD);
@@ -2458,6 +2487,16 @@ async function spawnInvestigation(failedJob, runDir) {
2458
2487
  return { deferred: false };
2459
2488
  }
2460
2489
 
2490
+ // Mark the job 'investigating' so the Queue UI shows an active status for
2491
+ // the whole probe duration — previously this left the job's persisted
2492
+ // status frozen at 'failed'/'needs_review' the entire time, which read as
2493
+ // "nothing is happening" even though an Opus process was actively running.
2494
+ await mutate((s) => {
2495
+ const j = s.jobs.find((x) => x.slug === failedJob.slug);
2496
+ if (j) j.status = 'investigating';
2497
+ });
2498
+ await broadcast({ flush: true });
2499
+
2461
2500
  const claudeBin = resolveClaudeBin();
2462
2501
  const childEnv = cleanChildEnv({ PATH: pathWithUserBins() }); // Homebrew/user bins for macOS
2463
2502
 
@@ -2495,6 +2534,14 @@ async function spawnInvestigation(failedJob, runDir) {
2495
2534
  watchdogs: [deadmanWatchdog],
2496
2535
  onExit({ exitCode, error, spawnFailed, safeLog: sl }) {
2497
2536
  releaseSlot();
2537
+ // Restore the pre-investigation status now that the probe has exited —
2538
+ // 'investigating' must never be the job's resting state.
2539
+ mutate((s) => {
2540
+ const j = s.jobs.find((x) => x.slug === failedJob.slug);
2541
+ if (j && j.status === 'investigating') j.status = failedJob.status || 'failed';
2542
+ })
2543
+ .then(() => broadcast({ flush: true }))
2544
+ .catch(() => {});
2498
2545
  if (error) {
2499
2546
  const errMsg = spawnFailed
2500
2547
  ? `investigation spawn failed: ${error?.message ?? String(error)}`
@@ -2541,8 +2588,15 @@ async function spawnInvestigation(failedJob, runDir) {
2541
2588
  return { deferred: false };
2542
2589
  } catch (e) {
2543
2590
  // A synchronous throw before onExit is wired (e.g. resolveClaudeBin not found,
2544
- // openLog failure, spawn setup) must not strand the reserved slot.
2591
+ // openLog failure, spawn setup) must not strand the reserved slot, and must
2592
+ // not strand the job on the transient 'investigating' status either.
2545
2593
  releaseSlot();
2594
+ mutate((s) => {
2595
+ const j = s.jobs.find((x) => x.slug === failedJob.slug);
2596
+ if (j && j.status === 'investigating') j.status = failedJob.status || 'failed';
2597
+ })
2598
+ .then(() => broadcast({ flush: true }))
2599
+ .catch(() => {});
2546
2600
  throw e;
2547
2601
  }
2548
2602
  }
@@ -3167,7 +3221,11 @@ async function maybeLaunchWhenAvailable(state) {
3167
3221
  */
3168
3222
  async function reapDeadRunningJobs() {
3169
3223
  try {
3170
- if (runningSet.size === 0) return; // fast path: no in-flight jobs
3224
+ // Do NOT gate on runningSet: spawnJob()'s finally block unconditionally
3225
+ // deletes a job's slug from runningSet even when the preceding completion
3226
+ // mutate() threw and was swallowed, leaving queue.json stuck at
3227
+ // status:"running" with no slug left in runningSet to trigger reconciliation.
3228
+ // queue.json is the source of truth for which jobs are actually running.
3171
3229
  const state = await readQueue();
3172
3230
  const dead = [];
3173
3231
  for (const j of state.jobs) {
@@ -3925,7 +3983,9 @@ function registerScheduleHandlers() {
3925
3983
  ipcMain.handle('schedule:list-prds', async () => {
3926
3984
  ensureDirs();
3927
3985
  const out = [];
3928
- for (const dir of candidatePrdsDirs()) {
3986
+ const seenSlugs = new Set();
3987
+
3988
+ async function readDirInto(dir, { archived }) {
3929
3989
  let entries;
3930
3990
  try {
3931
3991
  entries = await fsp.readdir(dir);
@@ -3933,15 +3993,21 @@ function registerScheduleHandlers() {
3933
3993
  if (e?.code !== 'ENOENT') {
3934
3994
  logs.writeLine({ level: 'warn', scope: 'scheduler', message: 'list-prds: readdir failed', meta: { dir, error: e?.message } });
3935
3995
  }
3936
- continue;
3996
+ return;
3937
3997
  }
3938
3998
  for (const name of entries) {
3939
3999
  if (!name.endsWith('.md') || name.startsWith('.')) continue;
3940
4000
  const filePath = path.join(dir, name);
3941
4001
  try {
3942
4002
  const parsed = await parsePrd(filePath);
4003
+ // A slug can't be both live and archived at once, but a duplicate
4004
+ // slug found in two archive dirs (shouldn't happen — archiving is
4005
+ // a single rename — but is cheap to guard) is skipped rather than
4006
+ // double-counted.
4007
+ if (seenSlugs.has(parsed.slug)) continue;
4008
+ seenSlugs.add(parsed.slug);
3943
4009
  const stat = await fsp.stat(filePath);
3944
- out.push({
4010
+ const entry = {
3945
4011
  slug: parsed.slug,
3946
4012
  parallelGroup: parsed.parallelGroup,
3947
4013
  title: parsed.title,
@@ -3950,12 +4016,46 @@ function registerScheduleHandlers() {
3950
4016
  sourcePromptId: parsed.sourcePromptId,
3951
4017
  epicId: parsed.epicId ?? null,
3952
4018
  mtimeMs: stat.mtimeMs,
3953
- });
4019
+ archived,
4020
+ };
4021
+ out.push(entry);
3954
4022
  } catch (e) {
3955
4023
  logs.writeLine({ level: 'warn', scope: 'scheduler', message: 'list-prds: skipping unparseable file', meta: { name, error: e?.message } });
3956
4024
  }
3957
4025
  }
3958
4026
  }
4027
+
4028
+ // Live PRDs first, so an archived duplicate (shouldn't exist, but a
4029
+ // stale rename copy is possible) never shadows the still-runnable live
4030
+ // entry.
4031
+ for (const dir of candidatePrdsDirs()) {
4032
+ await readDirInto(dir, { archived: false });
4033
+ }
4034
+
4035
+ const archivedStart = out.length;
4036
+ for (const dir of candidateArchivedPrdsDirs()) {
4037
+ await readDirInto(dir, { archived: true });
4038
+ }
4039
+
4040
+ // Archived PRDs need a status: archiveCompletedPrd (scheduler.cjs) only
4041
+ // ever archives a job whose effective status is 'completed' — a 'failed'
4042
+ // job's PRD source stays in the live prds/ dir (still visible/countable
4043
+ // there already). Still resolve the real job status defensively (live
4044
+ // queue row, falling back to history.jsonl) rather than hard-coding
4045
+ // 'completed', so this stays correct if that archiving invariant ever
4046
+ // changes.
4047
+ if (out.length > archivedStart) {
4048
+ const [state, histBySlug] = await Promise.all([
4049
+ readQueue(),
4050
+ queueHistory.historyTerminalBySlug().catch(() => new Map()),
4051
+ ]);
4052
+ const liveStatusBySlug = new Map(state.jobs.map((j) => [j.slug, j.status]));
4053
+ for (let i = archivedStart; i < out.length; i++) {
4054
+ const entry = out[i];
4055
+ entry.archivedStatus = resolveArchivedPrdStatus(entry.slug, liveStatusBySlug, histBySlug);
4056
+ }
4057
+ }
4058
+
3959
4059
  out.sort((a, b) => a.slug.localeCompare(b.slug, undefined, { numeric: true }));
3960
4060
  return out;
3961
4061
  });
@@ -4380,4 +4480,4 @@ function registerAdminRoutes(adminHttp, remoteObj = remote) {
4380
4480
  });
4381
4481
  }
4382
4482
 
4383
- module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, writeQueue, reconcile, reconcileSourcePromptId, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, committedInWindow, computeCommittedDuringRun, classifySigtermWithCommit, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, commitGuardVerdict, TRANSIENT_RETRY_CAP, buildScheduleStatePayload, partitionBootOrphans, applyOrphanOutcome, BOOT_ORPHAN_KILL_GRACE_MS, registerAdminRoutes, notifyOriginatingTab, isNotifiableTerminalStatus, extractResultTextFromLog, candidatePrdsDirs, prdDirForCwd, prdPathForJob, archivedPrdPathForJob, archivedTwinExists, findPrdDir, runPrdMigration, shouldSkipInvestigationForCleanRun, archiveCompletedPrd, retireCompletedSlugs, SCHEDULER_BOOTED_AT, SCHEDULER_CODE_SHA, resetJobFields };
4483
+ module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, writeQueue, reconcile, reconcileSourcePromptId, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, committedInWindow, computeCommittedDuringRun, classifySigtermWithCommit, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, commitGuardVerdict, TRANSIENT_RETRY_CAP, buildScheduleStatePayload, partitionBootOrphans, applyOrphanOutcome, BOOT_ORPHAN_KILL_GRACE_MS, registerAdminRoutes, notifyOriginatingTab, isNotifiableTerminalStatus, extractResultTextFromLog, candidatePrdsDirs, candidateArchivedPrdsDirs, resolveArchivedPrdStatus, prdDirForCwd, prdPathForJob, archivedPrdPathForJob, archivedTwinExists, findPrdDir, runPrdMigration, shouldSkipInvestigationForCleanRun, archiveCompletedPrd, retireCompletedSlugs, SCHEDULER_BOOTED_AT, SCHEDULER_CODE_SHA, resetJobFields };
@@ -385,7 +385,7 @@ export interface ScheduleConfig {
385
385
  schemaVersion: 1;
386
386
  }
387
387
 
388
- export type ScheduleJobStatus = 'pending' | 'running' | 'completed' | 'failed' | 'needs_review';
388
+ export type ScheduleJobStatus = 'pending' | 'running' | 'investigating' | 'completed' | 'failed' | 'needs_review';
389
389
 
390
390
  export interface ScheduleJobRuntime {
391
391
  pid: number;
@@ -490,6 +490,15 @@ export interface PrdListItem {
490
490
  sourcePromptId?: string | null;
491
491
  /** Owning Epic id, derived from the PRD's directory. See ScheduleJob.epicId. */
492
492
  epicId?: string | null;
493
+ /** True when this PRD's source .md was found in a `prds-archived/` dir
494
+ * (its scheduler job already ran to completion) rather than the live
495
+ * `prds/` dir. Archived PRDs have no matching queue.json job row (the
496
+ * row may have aged out into history.jsonl), so consumers must read
497
+ * `archivedStatus` instead of joining on a job to know the outcome. */
498
+ archived?: boolean;
499
+ /** Only present when `archived` is true — the resolved terminal outcome
500
+ * ('completed' vs 'failed') of the job that produced this archived PRD. */
501
+ archivedStatus?: 'completed' | 'failed';
493
502
  }
494
503
 
495
504
  export interface SupervisorConfig {
@@ -959,6 +968,19 @@ export type ProjectBriefUpdateResult =
959
968
  | { ok: true; brief: ProjectBrief }
960
969
  | { ok: false; error: string };
961
970
 
971
+ // ────────────────────────────────────────────── Project Pages (PRD 929-932)
972
+ export interface ProjectPagesOutput {
973
+ marketing: string;
974
+ feature: string;
975
+ architecture: string;
976
+ generatedAt: string;
977
+ }
978
+
979
+ export interface ProjectPagesGetResult {
980
+ /** null when no manifest/output files exist yet — the empty-state signal. */
981
+ output: ProjectPagesOutput | null;
982
+ }
983
+
962
984
  // ────────────────────────────────────────────── Per-subagent memory
963
985
  // Stored at ~/.claude/session-manager/agent-memory/<agentId>.json. Keyed by
964
986
  // agent name (the .md filename in ~/.claude/agents/), not by workspace cwd.
@@ -1109,7 +1131,7 @@ export interface ChatCreatePrdPayload {
1109
1131
  /** Originating tab id — used at job completion to route a status prompt back into the tab. */
1110
1132
  sourceTabId?: string;
1111
1133
  /** User-selected Feature/Bug tag (PRD 774) carried from the originating PromptTicket. */
1112
- tag?: 'feature' | 'bug' | 'discussion' | 'build';
1134
+ tag?: 'feature' | 'bug' | 'discussion' | 'build' | 'project-home-builder';
1113
1135
  }
1114
1136
 
1115
1137
  export type ChatCreatePrdResult =
@@ -1520,6 +1542,10 @@ export interface SessionManagerAPI {
1520
1542
  /** Hand-edit brief.json in place — no LLM cost. Edited pinnable blocks are auto-pinned so the next refresh preserves them. */
1521
1543
  update: (cwd: string, patch: ProjectBriefPatch) => Promise<ProjectBriefUpdateResult>;
1522
1544
  };
1545
+ projectPages: {
1546
+ /** Read output/*.html + manifest.json (or `{output: null}` if none exist yet). Never fires an LLM call. */
1547
+ get: (cwd: string) => Promise<ProjectPagesGetResult>;
1548
+ };
1523
1549
  promptSessionTranscript: {
1524
1550
  /** Append one full-text turn to an Epic's durable JSONL transcript. Best-effort — resolves `{ok:false}` rather than throwing on failure. */
1525
1551
  append: (
@@ -330,6 +330,9 @@ contextBridge.exposeInMainWorld('api', {
330
330
  setPin: (cwd, block, pinned) => ipcRenderer.invoke('project-brief:set-pin', { cwd, block, pinned }),
331
331
  update: (cwd, patch) => ipcRenderer.invoke('project-brief:update', { cwd, patch }),
332
332
  },
333
+ projectPages: {
334
+ get: (cwd) => ipcRenderer.invoke('project-pages:get', { cwd }),
335
+ },
333
336
  promptSessionTranscript: {
334
337
  append: (cwd, epicId, turn) =>
335
338
  ipcRenderer.invoke('promptSessionTranscript:append', { cwd, epicId, ...turn }),