claude-code-session-manager 0.35.3 → 0.35.4

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.
@@ -1151,6 +1151,19 @@ function isPromotableOriginal(status) {
1151
1151
  return status === 'failed' || status === 'needs_review';
1152
1152
  }
1153
1153
 
1154
+ /**
1155
+ * Pure helper: given a completed fix-plan slug (e.g. '451-fix-foo'), find the
1156
+ * SPECIFIC promotable original job it heals. Matches on the full
1157
+ * numeric-prefixed slug ('451-foo'), not just the base ('foo') — two
1158
+ * needs_review jobs can share a base slug with different NN prefixes
1159
+ * (e.g. '451-foo' and '453-foo'), and a fix plan must only heal the
1160
+ * lineage whose NN it was authored against. Exported for tests.
1161
+ */
1162
+ function healTargetForFix(fixSlug, jobs) {
1163
+ const originalSlug = fixSlug.replace(/^(\d+)-fix-/, '$1-');
1164
+ return jobs.find((x) => x.slug === originalSlug && isPromotableOriginal(x.status)) || null;
1165
+ }
1166
+
1154
1167
  /**
1155
1168
  * Spawn an Opus investigation session for a failed job. The investigator's job
1156
1169
  * is to read the failure log + original PRD, identify the root cause, and write
@@ -1329,6 +1342,13 @@ DO NOT attempt the fix. ONLY write the file. When the file exists, exit immediat
1329
1342
  console.log(`[scheduler] investigation produced fix plan: ${fixSlug}`);
1330
1343
  } else {
1331
1344
  console.log(`[scheduler] investigation finished WITHOUT producing fix plan (slug=${failedJob.slug}, code=${exitCode})`);
1345
+ // Record the no-plan outcome so selectAutoFixTargets can offer one
1346
+ // bounded retry instead of permanently dead-ending behind the
1347
+ // 1-attempt cap (autoFixAttempted stays true).
1348
+ mutate((s) => {
1349
+ const j = s.jobs.find((x) => x.slug === failedJob.slug);
1350
+ if (j) j.autoFixOutcome = 'no-plan';
1351
+ }).catch(() => {});
1332
1352
  }
1333
1353
  // Trigger a tick so the new fix plan is reconciled into the queue and fired.
1334
1354
  tickQueue().catch(() => {});
@@ -1525,17 +1545,16 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
1525
1545
  // this, the queue stalls indefinitely behind a stale failure even
1526
1546
  // though the auto-recovery did its job.
1527
1547
  if (effectiveStatus === 'completed' && isFixPlanSlug(job.slug)) {
1528
- const originalSlug = job.slug.replace(/^(\d+)-fix-/, '$1-');
1529
- const orig = s.jobs.findIndex((x) => x.slug === originalSlug && isPromotableOriginal(x.status));
1530
- if (orig >= 0) {
1531
- const priorStatus = s.jobs[orig].status;
1532
- console.log(`[scheduler] auto-promote: ${originalSlug} (${priorStatus}) → completed because ${job.slug} succeeded`);
1533
- s.jobs[orig].status = 'completed';
1534
- s.jobs[orig].exitCode = 0;
1535
- s.jobs[orig].error = null;
1536
- s.jobs[orig].completedBy = job.slug;
1548
+ const orig = healTargetForFix(job.slug, s.jobs);
1549
+ if (orig) {
1550
+ const priorStatus = orig.status;
1551
+ console.log(`[scheduler] auto-promote: ${orig.slug} (${priorStatus}) completed because ${job.slug} succeeded`);
1552
+ orig.status = 'completed';
1553
+ orig.exitCode = 0;
1554
+ orig.error = null;
1555
+ orig.completedBy = job.slug;
1537
1556
  if (priorStatus === 'needs_review') {
1538
- delete s.jobs[orig].verifierVerdict;
1557
+ delete orig.verifierVerdict;
1539
1558
  }
1540
1559
  }
1541
1560
  }
@@ -1881,16 +1900,57 @@ function selectHistoryJobs(jobs, limit) {
1881
1900
  // heal a genuinely-unfinished job.
1882
1901
  const RESCANNABLE_VERDICTS = new Set(['transcript_errors', 'verify_unavailable']);
1883
1902
 
1903
+ /**
1904
+ * Backfill a job's missing runId by scanning RUNS_DIR for a run directory
1905
+ * whose contents reference this job's slug (a '<slug>.log' file inside it).
1906
+ * A needs_review job can lose its runId (e.g. an old queue-schema gap) and
1907
+ * become invisible to every auto-resolution path that requires job.runId
1908
+ * truthy. O(number of run dirs) — a single readdir + per-dir existsSync
1909
+ * check, no nested loop over user-scaled data. Dir names are ISO timestamps,
1910
+ * so lexical-descending sort picks the newest match. Exported for tests.
1911
+ */
1912
+ function resolveRunId(job, { runsDir = RUNS_DIR } = {}) {
1913
+ if (!job || job.runId) return job?.runId || null;
1914
+ if (!job.slug) return null;
1915
+ let dirs;
1916
+ try {
1917
+ dirs = fs.readdirSync(runsDir);
1918
+ } catch {
1919
+ return null;
1920
+ }
1921
+ const matches = dirs.filter((d) => {
1922
+ try {
1923
+ return fs.existsSync(path.join(runsDir, d, `${job.slug}.log`));
1924
+ } catch {
1925
+ return false;
1926
+ }
1927
+ });
1928
+ if (!matches.length) return null;
1929
+ matches.sort().reverse();
1930
+ return matches[0];
1931
+ }
1932
+
1933
+ /**
1934
+ * Pure predicate: a needs_review job with no runId and no backfillable run
1935
+ * directory can never self-heal or get an auto-fix investigation — it must
1936
+ * be surfaced for a human rather than silently dead-ended. Exported for
1937
+ * tests.
1938
+ */
1939
+ function isUnresolvableNeedsReview(job, { hasRunDir }) {
1940
+ return !!job && job.status === 'needs_review' && !job.runId && !hasRunDir;
1941
+ }
1942
+
1884
1943
  /**
1885
1944
  * Pure predicate: is this job eligible for the boot re-verify self-heal? Only
1886
- * needs_review jobs with a run log AND a transcript-scan verdict. Crucially
1887
- * EXCLUDES 'uncommitted_changes' (git commit-guard) — verifyRun can't see git,
1888
- * so re-scanning it would falsely heal an unfinished job. Exported for tests.
1945
+ * needs_review jobs with a run log (own or backfilled via resolveRunId) AND a
1946
+ * transcript-scan verdict. Crucially EXCLUDES 'uncommitted_changes' (git
1947
+ * commit-guard) — verifyRun can't see git, so re-scanning it would falsely
1948
+ * heal an unfinished job. Exported for tests.
1889
1949
  */
1890
1950
  function isRescanCandidate(job) {
1891
1951
  return !!job
1892
1952
  && job.status === 'needs_review'
1893
- && !!job.runId
1953
+ && !!(job.runId || resolveRunId(job))
1894
1954
  && RESCANNABLE_VERDICTS.has(job.verifierVerdict);
1895
1955
  }
1896
1956
 
@@ -1906,23 +1966,32 @@ function isRescanCandidate(job) {
1906
1966
  * @returns {Promise<{rescanned:number, healed:string[]}>}
1907
1967
  */
1908
1968
  /**
1909
- * Pure helper — no I/O. Returns the subset of jobs eligible for automatic
1910
- * fix-plan authoring after a reverify pass leaves them still in needs_review.
1969
+ * Pure helper — no I/O by default (resolveJobRunId is injectable; production
1970
+ * caller passes resolveRunId, which does touch disk). Returns the subset of
1971
+ * jobs eligible for automatic fix-plan authoring after a reverify pass
1972
+ * leaves them still in needs_review.
1911
1973
  *
1912
1974
  * Exclusion rules (all must pass):
1913
1975
  * - status === 'needs_review'
1914
- * - truthy runId (need a run log to investigate)
1915
- * - autoFixAttempted !== true (1-attempt cap)
1976
+ * - a runId, own or backfilled via resolveJobRunId (need a run log to investigate)
1916
1977
  * - not itself a fix-plan slug (avoids infinite recursion)
1978
+ * - autoFixAttempted cap: a job with no prior attempt is eligible; a job
1979
+ * whose prior attempt outcome was 'no-plan' gets ONE bounded retry
1980
+ * (autoFixRetries < 1); any other attempted job (or an exhausted
1981
+ * no-plan retry) is excluded
1917
1982
  * - no fix sibling on disk (fixSlugExists) or already in the queue
1918
1983
  */
1919
- function selectAutoFixTargets(jobs, { fixSlugExists }) {
1984
+ function selectAutoFixTargets(jobs, { fixSlugExists, resolveJobRunId = resolveRunId }) {
1920
1985
  const slugsInQueue = new Set(jobs.map((j) => j.slug));
1921
1986
  return jobs.filter((job) => {
1922
1987
  if (job.status !== 'needs_review') return false;
1923
- if (!job.runId) return false;
1924
- if (job.autoFixAttempted) return false;
1988
+ const runId = job.runId || resolveJobRunId(job);
1989
+ if (!runId) return false;
1925
1990
  if (isFixPlanSlug(job.slug)) return false;
1991
+ if (job.autoFixAttempted) {
1992
+ if (job.autoFixOutcome !== 'no-plan') return false;
1993
+ if ((job.autoFixRetries ?? 0) >= 1) return false;
1994
+ }
1926
1995
  const fixSlug = `${String(job.parallelGroup ?? 99).padStart(2, '0')}-fix-${job.slug.replace(/^\d+-/, '')}`;
1927
1996
  if (fixSlugExists(fixSlug)) return false;
1928
1997
  if (slugsInQueue.has(fixSlug)) return false;
@@ -1936,7 +2005,7 @@ async function reverifyNeedsReview() {
1936
2005
  const healed = [];
1937
2006
  const leftForReview = [];
1938
2007
  for (const job of candidates) {
1939
- const runDir = path.join(RUNS_DIR, job.runId);
2008
+ const runDir = path.join(RUNS_DIR, job.runId || resolveRunId(job));
1940
2009
  const prdPath = path.join(PRDS_DIR, `${job.slug}.md`);
1941
2010
  // Derive committedDuringRun from the recorded run window. The live
1942
2011
  // commit-guard uses gitHead() (before/after HEAD diff); here the run is
@@ -1978,24 +2047,72 @@ async function reverifyNeedsReview() {
1978
2047
  console.log(`[scheduler] boot reverify: left for review: ${detail}`);
1979
2048
  }
1980
2049
 
2050
+ // Surface needs_review jobs that can never self-heal or get an auto-fix
2051
+ // investigation (no runId, no backfillable run dir) instead of leaving
2052
+ // them silently stranded. Also surface no-plan auto-fix jobs whose one
2053
+ // bounded retry is already exhausted. Both are annotated, never looped on.
2054
+ const afterHealForAnnotate = await readQueue();
2055
+ const unresolvable = afterHealForAnnotate.jobs.filter(
2056
+ (j) => isUnresolvableNeedsReview(j, { hasRunDir: !!resolveRunId(j) }) && j.verifierVerdict !== 'no_run_artifacts',
2057
+ );
2058
+ const exhaustedNoPlan = afterHealForAnnotate.jobs.filter(
2059
+ (j) => j.status === 'needs_review'
2060
+ && j.autoFixAttempted && j.autoFixOutcome === 'no-plan' && (j.autoFixRetries ?? 0) >= 1
2061
+ && j.verifierVerdict !== 'autofix_no_plan',
2062
+ );
2063
+ if (unresolvable.length || exhaustedNoPlan.length) {
2064
+ const unresolvableSet = new Set(unresolvable.map((j) => j.slug));
2065
+ const exhaustedSet = new Set(exhaustedNoPlan.map((j) => j.slug));
2066
+ await mutate((s) => {
2067
+ for (const j of s.jobs) {
2068
+ if (unresolvableSet.has(j.slug)) {
2069
+ j.verifierVerdict = 'no_run_artifacts';
2070
+ j.error = 'no run artifacts — manual review';
2071
+ } else if (exhaustedSet.has(j.slug)) {
2072
+ j.verifierVerdict = 'autofix_no_plan';
2073
+ j.error = 'auto-fix investigation produced no fix plan after retry — manual review';
2074
+ }
2075
+ }
2076
+ });
2077
+ if (unresolvable.length) {
2078
+ console.log(`[scheduler] boot reverify: no run artifacts for ${unresolvable.map((j) => j.slug).join(', ')} — flagged for manual review`);
2079
+ }
2080
+ if (exhaustedNoPlan.length) {
2081
+ console.log(`[scheduler] boot reverify: auto-fix retry exhausted for ${exhaustedNoPlan.map((j) => j.slug).join(', ')} — flagged for manual review`);
2082
+ }
2083
+ await broadcast();
2084
+ }
2085
+
1981
2086
  // Auto-fix: spawn a fix-plan investigation for each job still in
1982
2087
  // needs_review after the heal pass (kill-switch: SM_AUTOFIX_DISABLE=1).
2088
+ // spawnInvestigation early-returns once investigationsInFlight reaches
2089
+ // MAX_CONCURRENT_INVESTIGATIONS (queues the rest for retry), so this loop
2090
+ // cannot fan out past the cap regardless of how many targets are selected.
1983
2091
  if (process.env.SM_AUTOFIX_DISABLE !== '1') {
1984
2092
  const afterHeal = await readQueue();
1985
2093
  const targets = selectAutoFixTargets(afterHeal.jobs, {
1986
2094
  fixSlugExists: (s) => fs.existsSync(path.join(PRDS_DIR, `${s}.md`)),
1987
2095
  });
1988
2096
  for (const job of targets) {
1989
- const runDir = path.join(RUNS_DIR, job.runId);
2097
+ const runId = job.runId || resolveRunId(job);
2098
+ const runDir = path.join(RUNS_DIR, runId);
2099
+ const isNoPlanRetry = job.autoFixAttempted && job.autoFixOutcome === 'no-plan';
1990
2100
  // Persist the attempt BEFORE spawning — a crash mid-investigation still
1991
2101
  // counts it (mirrors orphanRetries). Safe even when the slot is busy: the
1992
2102
  // investigation is queued and drained as slots free, so it is genuinely
1993
2103
  // attempted rather than silently dropped.
1994
2104
  await mutate((s) => {
1995
2105
  const j = s.jobs.find((x) => x.slug === job.slug);
1996
- if (j) j.autoFixAttempted = true;
2106
+ if (j) {
2107
+ j.autoFixAttempted = true;
2108
+ if (!j.runId && runId) j.runId = runId;
2109
+ if (isNoPlanRetry) {
2110
+ j.autoFixRetries = (j.autoFixRetries ?? 0) + 1;
2111
+ delete j.autoFixOutcome;
2112
+ }
2113
+ }
1997
2114
  });
1998
- console.log(`[scheduler] auto-fix: needs_review ${job.slug} → authoring fix-plan (1/1)`);
2115
+ console.log(`[scheduler] auto-fix: needs_review ${job.slug} → authoring fix-plan (${isNoPlanRetry ? 'retry' : '1/1'})`);
1999
2116
  spawnInvestigation(job, runDir).catch((e) => {
2000
2117
  console.error('[scheduler] auto-fix spawnInvestigation error', job.slug, e);
2001
2118
  });
@@ -2335,10 +2452,16 @@ async function init() {
2335
2452
  // Periodic self-heal: re-run the verifier over stale needs_review jobs so a
2336
2453
  // job whose work actually landed (committed in-window, no FAIL sentinel)
2337
2454
  // auto-clears WITHOUT waiting for the next app restart. Cheap-guarded — the
2338
- // log scan only runs when something is actually flagged.
2339
- const s = readQueueSync();
2340
- if (s.jobs.some((j) => j.status === 'needs_review')) {
2341
- reverifyNeedsReview().catch(() => {});
2455
+ // log scan only runs when something is actually flagged. Kill-switch:
2456
+ // SM_REVERIFY_PERIODIC_DISABLE=1 (boot reverify above stays always-on).
2457
+ // reverifyNeedsReview's auto-fix loop is capped downstream by
2458
+ // MAX_CONCURRENT_INVESTIGATIONS (spawnInvestigation queues/early-returns
2459
+ // past it), so this interval firing cannot fan out investigations.
2460
+ if (process.env.SM_REVERIFY_PERIODIC_DISABLE !== '1') {
2461
+ const s = readQueueSync();
2462
+ if (s.jobs.some((j) => j.status === 'needs_review')) {
2463
+ reverifyNeedsReview().catch(() => {});
2464
+ }
2342
2465
  }
2343
2466
  }, 10 * 60_000);
2344
2467
 
@@ -2510,4 +2633,4 @@ const remote = {
2510
2633
  },
2511
2634
  };
2512
2635
 
2513
- module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets };
2636
+ module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, resolveRunId, isUnresolvableNeedsReview, healTargetForFix };
@@ -198,6 +198,21 @@ export type BillingFetchResult =
198
198
  | { kind: 'transient'; message: string; httpStatus: number | null }
199
199
  | { kind: 'config'; message: string };
200
200
 
201
+ // ── MCP server live connection probe (`claude mcp list`)
202
+ export interface McpServerStatus {
203
+ name: string;
204
+ target: string;
205
+ transport: 'stdio' | 'http' | 'sse' | 'ws' | 'unknown';
206
+ status: 'connected' | 'failed' | 'needs-auth' | 'pending' | 'unknown';
207
+ }
208
+
209
+ export interface McpStatusResult {
210
+ ok: boolean;
211
+ servers: McpServerStatus[];
212
+ error?: string;
213
+ checkedAt: number;
214
+ }
215
+
201
216
  // ── Usage matrix (AgOps dashboard, main-side aggregator)
202
217
  export type UsageMatrixState = 'idle' | 'executing' | 'plan' | 'background';
203
218
  export type UsageMatrixIntensity = 'idle' | 'low' | 'medium' | 'critical';
@@ -978,6 +993,12 @@ export interface ChatRunErrorEvent {
978
993
  message: string;
979
994
  }
980
995
 
996
+ export interface ChatRunNoticeEvent {
997
+ tabId: string;
998
+ sessionId: string;
999
+ message: string;
1000
+ }
1001
+
981
1002
  export interface SessionManagerAPI {
982
1003
  app: {
983
1004
  version: () => Promise<string>;
@@ -1076,6 +1097,9 @@ export interface SessionManagerAPI {
1076
1097
  billing: {
1077
1098
  fetch: () => Promise<BillingFetchResult>;
1078
1099
  };
1100
+ mcp: {
1101
+ status: () => Promise<McpStatusResult>;
1102
+ };
1079
1103
  usageMatrix: {
1080
1104
  snapshot: () => Promise<UsageMatrixSnapshot>;
1081
1105
  onTick: (handler: (snap: UsageMatrixSnapshot) => void) => () => void;
@@ -1311,6 +1335,7 @@ export interface SessionManagerAPI {
1311
1335
  onNeedsInput: (handler: (e: ChatRunNeedsInputEvent) => void) => () => void;
1312
1336
  onComplete: (handler: (e: ChatRunCompleteEvent) => void) => () => void;
1313
1337
  onError: (handler: (e: ChatRunErrorEvent) => void) => () => void;
1338
+ onNotice: (handler: (e: ChatRunNoticeEvent) => void) => () => void;
1314
1339
  };
1315
1340
  exchanges: {
1316
1341
  /** Durable per-exchange log entries for a project, newest-first (max 100 by default). */
@@ -129,6 +129,9 @@ contextBridge.exposeInMainWorld('api', {
129
129
  billing: {
130
130
  fetch: () => ipcRenderer.invoke('billing:fetch'),
131
131
  },
132
+ mcp: {
133
+ status: () => ipcRenderer.invoke('mcp:status'),
134
+ },
132
135
  usageMatrix: {
133
136
  snapshot: () => ipcRenderer.invoke('usage:matrix:snapshot'),
134
137
  onTick: (handler) => {
@@ -406,6 +409,11 @@ contextBridge.exposeInMainWorld('api', {
406
409
  ipcRenderer.on('chat:run:error', listener);
407
410
  return () => ipcRenderer.removeListener('chat:run:error', listener);
408
411
  },
412
+ onNotice: (handler) => {
413
+ const listener = (_e, payload) => handler(payload);
414
+ ipcRenderer.on('chat:run:notice', listener);
415
+ return () => ipcRenderer.removeListener('chat:run:notice', listener);
416
+ },
409
417
  },
410
418
  exchanges: {
411
419
  /** List exchanges for a project (durable chat-run log), newest-first.