claude-code-session-manager 0.37.1 → 0.38.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 (60) hide show
  1. package/bin/cli.cjs +12 -1
  2. package/dist/assets/{TiptapBody-DXQDiOUx.js → TiptapBody-GMk5LS9Y.js} +1 -1
  3. package/dist/assets/index-CD_LuJZF.css +32 -0
  4. package/dist/assets/{index--a8m5_ET.js → index-CQqSHpIq.js} +444 -436
  5. package/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/plugins/session-manager-dev/skills/develop/SKILL.md +134 -36
  8. package/plugins/session-manager-dev/skills/develop/standards.md +25 -0
  9. package/plugins/session-manager-dev/skills/issue-address/0-select/0-fetch-pool/SKILL.md +38 -0
  10. package/plugins/session-manager-dev/skills/issue-address/0-select/1-reject-fixed/SKILL.md +64 -0
  11. package/plugins/session-manager-dev/skills/issue-address/0-select/2-reject-claimed/SKILL.md +71 -0
  12. package/plugins/session-manager-dev/skills/issue-address/0-select/3-rank-impact/SKILL.md +39 -0
  13. package/plugins/session-manager-dev/skills/issue-address/0-select/SKILL.md +75 -0
  14. package/plugins/session-manager-dev/skills/issue-address/1-confirm-open/SKILL.md +39 -0
  15. package/plugins/session-manager-dev/skills/issue-address/2-claim/SKILL.md +57 -0
  16. package/plugins/session-manager-dev/skills/issue-address/3-reproduce/SKILL.md +40 -0
  17. package/plugins/session-manager-dev/skills/issue-address/4-fix/SKILL.md +33 -0
  18. package/plugins/session-manager-dev/skills/issue-address/5-verify/SKILL.md +40 -0
  19. package/plugins/session-manager-dev/skills/issue-address/SKILL.md +120 -0
  20. package/plugins/session-manager-dev/skills/pr-review-sweep/0-fetch/SKILL.md +41 -0
  21. package/plugins/session-manager-dev/skills/pr-review-sweep/1-classify/SKILL.md +45 -0
  22. package/plugins/session-manager-dev/skills/pr-review-sweep/2-check-fixed/SKILL.md +49 -0
  23. package/plugins/session-manager-dev/skills/pr-review-sweep/3-queue/SKILL.md +50 -0
  24. package/plugins/session-manager-dev/skills/pr-review-sweep/4-land-and-resolve/SKILL.md +60 -0
  25. package/plugins/session-manager-dev/skills/pr-review-sweep/SKILL.md +124 -0
  26. package/plugins/session-manager-dev/skills/pr-signal/SKILL.md +67 -0
  27. package/plugins/session-manager-dev/skills/requesting-code-review/SKILL.md +20 -17
  28. package/scripts/lib/watchdogHelpers.cjs +296 -166
  29. package/src/main/__tests__/chat-cancel-terminal.test.cjs +31 -0
  30. package/src/main/__tests__/chat-exit-close-race.test.cjs +140 -0
  31. package/src/main/__tests__/classifyTranscriptLine.test.cjs +90 -0
  32. package/src/main/__tests__/docEdit.test.cjs +164 -2
  33. package/src/main/__tests__/prdCreate.test.cjs +265 -25
  34. package/src/main/__tests__/scheduler-admin-routes.test.cjs +150 -0
  35. package/src/main/__tests__/scheduler-boot-orphans.test.cjs +172 -0
  36. package/src/main/__tests__/scheduler-committed-in-window.test.cjs +64 -0
  37. package/src/main/browserAgentServer.cjs +11 -10
  38. package/src/main/chatRunner.cjs +58 -14
  39. package/src/main/docEdit.cjs +125 -5
  40. package/src/main/health.cjs +15 -0
  41. package/src/main/index.cjs +12 -6
  42. package/src/main/ipcSchemas.cjs +16 -3
  43. package/src/main/lib/__tests__/localAdminHttp.test.cjs +120 -0
  44. package/src/main/lib/classifyTranscriptLine.cjs +89 -0
  45. package/src/main/lib/localAdminHttp.cjs +157 -0
  46. package/src/main/lib/personaImportHealth.cjs +161 -0
  47. package/src/main/lib/prdCreate.cjs +107 -17
  48. package/src/main/lib/rcaFeedbackHook.cjs +2 -2
  49. package/src/main/lib/singleInstanceGuard.cjs +20 -0
  50. package/src/main/scheduler.cjs +223 -56
  51. package/src/main/sessionsStore.cjs +4 -5
  52. package/src/main/templates/PRD_AUTHORING.md +4 -4
  53. package/src/main/transcripts.cjs +1 -85
  54. package/src/main/webRemote.cjs +3 -3
  55. package/src/preload/api.d.ts +16 -6
  56. package/src/preload/index.cjs +9 -2
  57. package/dist/assets/index-CTTjT08J.css +0 -32
  58. package/plugins/session-manager-dev/skills/security-review/SKILL.md +0 -105
  59. package/src/main/__tests__/adminServer.test.cjs +0 -380
  60. package/src/main/adminServer.cjs +0 -242
@@ -38,8 +38,8 @@ const PRDS_DIR = path.join(os.homedir(), '.claude', 'session-manager', 'schedule
38
38
  const SM_REPO_ROOT = path.join(os.homedir(), 'Projects', 'session-manager');
39
39
  const SM_REPO_FEEDBACK_DIR = path.join(SM_REPO_ROOT, 'session-manager-operations', 'feedback');
40
40
 
41
- // NEVER write here: ~/.claude/session-manager/feedback/ is the external
42
- // watchdog's auto-PRD intake (scripts/scheduler-watchdog.cjs `sweep()`) — it
41
+ // NEVER write here: ~/.claude/session-manager/feedback/ is the scheduler's
42
+ // auto-PRD intake (src/main/scheduler.cjs's periodic feedback sweep) — it
43
43
  // converts anything dropped there into a scheduled PRD automatically. Filing
44
44
  // an RCA there would create an RCA → auto-PRD → run → needs_review → RCA
45
45
  // cycle with no human in the loop. Both real destinations below are project
@@ -0,0 +1,20 @@
1
+ /**
2
+ * singleInstanceGuard.cjs — the losing side of Electron's single-instance
3
+ * lock must actually terminate the process, not just request a quit.
4
+ *
5
+ * app.quit() only requests a graceful shutdown via the normal
6
+ * window-all-closed -> quit -> exit chain; a losing instance calls it before
7
+ * app.whenReady() ever fires (no window, no IPC), so there's nothing for
8
+ * that chain to hook into and the process can sit as a zombie indefinitely.
9
+ * app.exit() terminates immediately without waiting on that chain; the
10
+ * setTimeout is a defense-in-depth backstop in case app.exit() itself is
11
+ * ever delayed by an Electron internal on a future version.
12
+ */
13
+ 'use strict';
14
+
15
+ function terminateLosingInstance(app, { fallbackDelayMs = 2000 } = {}) {
16
+ setTimeout(() => process.exit(0), fallbackDelayMs).unref?.();
17
+ app.exit(0);
18
+ }
19
+
20
+ module.exports = { terminateLosingInstance };
@@ -63,6 +63,7 @@ const prdParser = require('./scheduler/prdParser.cjs');
63
63
  const { verifyRun } = require('./runVerify.cjs');
64
64
  const logs = require('./logs.cjs');
65
65
  const { schemas, validated } = require('./ipcSchemas.cjs');
66
+ const { readBody, sendJson } = require('./lib/localAdminHttp.cjs');
66
67
  const {
67
68
  POLL_INTERVAL_MS,
68
69
  USAGE_REFRESH_INTERVAL_MS,
@@ -74,6 +75,12 @@ const { runDefinitionOfDoneOnDrain } = require('./lib/dodDrainHook.cjs');
74
75
  const { fileRcaFeedback, extractRcaBlock } = require('./lib/rcaFeedbackHook.cjs');
75
76
  const queueHistory = require('./lib/queueHistory.cjs');
76
77
  const queueOps = require('./queueOps.cjs');
78
+ // Feedback-auto-PRD sweep — formerly only run by the external scheduler-watchdog
79
+ // while the app was down (PRD 686 moved it in-app so it also runs while alive).
80
+ // Plain Node module, no Electron dependency; queuePath/prdsDir defaults already
81
+ // match ROOT/QUEUE_PATH below since both resolve the same ~/.claude/session-manager
82
+ // home-dir layout.
83
+ const { sweep: sweepFeedback } = require('../../scripts/lib/watchdogHelpers.cjs');
77
84
 
78
85
  const MAX_INVESTIGATION_DURATION_MS = 30 * 60_000;
79
86
 
@@ -201,15 +208,38 @@ function gitHead(cwd) {
201
208
  });
202
209
  }
203
210
 
211
+ // Best-effort `git fetch --all --prune` in cwd, bounded and never throwing.
212
+ // A PRD that does its work in a separate `git worktree add` checkout (per
213
+ // standards.md's own recommended pattern for shared repos) commits and pushes
214
+ // from THAT worktree, then removes it — the commit never touches job.cwd's
215
+ // own refs, so job.cwd's remote-tracking branches can be stale relative to
216
+ // what was actually pushed. Refreshing them here is what lets the git log
217
+ // --all scan below see a worktree-pushed commit. Resolves once fetch settles
218
+ // (or times out / errors) — callers don't need the result, just the refresh.
219
+ function fetchAllRefs(cwd) {
220
+ return new Promise((resolve) => {
221
+ if (!cwd) { resolve(); return; }
222
+ execFile(
223
+ 'git',
224
+ ['-C', cwd, 'fetch', '--all', '--prune'],
225
+ { timeout: 20_000, windowsHide: true },
226
+ () => resolve(),
227
+ );
228
+ });
229
+ }
230
+
204
231
  // Returns true if ≥1 commit landed on any ref (branch, remote-tracking branch,
205
232
  // or tag) in cwd between startedAt and finishedAt (with 60s slack) — not just
206
- // the currently checked-out branch. Used by the self-heal pass to derive
207
- // committedDuringRun from the recorded run window — the live commit-guard uses
208
- // gitHead() instead. Never throws; git-unavailable false (no override, job
209
- // stays as-is).
210
- function committedInWindow(cwd, startedAt, finishedAt) {
233
+ // the currently checked-out branch. Used both by the self-heal pass and by the
234
+ // live commit-guard's fallback (see computeCommittedDuringRun) to derive
235
+ // committedDuringRun from the recorded run window. Fetches remotes first (see
236
+ // fetchAllRefs) so a commit pushed from a separate worktree checkout that was
237
+ // since removed is still visible via job.cwd's remote-tracking refs. Never
238
+ // throws; git-unavailable → false (no override, job stays as-is).
239
+ async function committedInWindow(cwd, startedAt, finishedAt) {
240
+ if (!cwd || !startedAt) return false;
241
+ await fetchAllRefs(cwd);
211
242
  return new Promise((resolve) => {
212
- if (!cwd || !startedAt) { resolve(false); return; }
213
243
  const until = finishedAt
214
244
  ? new Date(Date.parse(finishedAt) + 60_000).toISOString()
215
245
  : new Date().toISOString();
@@ -222,6 +252,16 @@ function committedInWindow(cwd, startedAt, finishedAt) {
222
252
  });
223
253
  }
224
254
 
255
+ // Live commit-guard: cheap HEAD-diff fast path, falling back to the
256
+ // git-log --all scan when HEAD didn't move on the starting branch. A PRD that
257
+ // checks out other branches, commits real work on each, then checks its
258
+ // starting branch back out before exit leaves HEAD unchanged even though
259
+ // commits landed — the fallback catches that case. Never throws.
260
+ async function computeCommittedDuringRun(cwd, headBefore, headAfter, startedAt, untilIso) {
261
+ if (headBefore && headAfter && headBefore !== headAfter) return true;
262
+ return committedInWindow(cwd, startedAt, untilIso);
263
+ }
264
+
225
265
  const ROOT = path.join(os.homedir(), '.claude', 'session-manager', 'scheduled-plans');
226
266
  const PRDS_DIR = path.join(ROOT, 'prds');
227
267
  const RUNS_DIR = path.join(ROOT, 'runs');
@@ -817,6 +857,17 @@ let resumeTimer = null;
817
857
  let pollLoopTimer = null;
818
858
  let rescheduleInterval = null;
819
859
  let heartbeatInterval = null;
860
+ // Feedback sweep piggybacks on the 60s heartbeat tick but runs far less often —
861
+ // every Nth tick — since it's a readdir-per-active-project scan, not free.
862
+ // 5 ticks = 5 minutes; well inside sweep()/activeProjectCwds()'s 90-minute
863
+ // active-session window, so no active project can be missed between sweeps.
864
+ const FEEDBACK_SWEEP_TICK_INTERVAL = 5;
865
+ let feedbackSweepTickCount = 0;
866
+ /** Pure gate for the tick counter above — exported so the wiring is testable
867
+ * without waiting on real setInterval timers. */
868
+ function feedbackSweepDue(tickCount, interval = FEEDBACK_SWEEP_TICK_INTERVAL) {
869
+ return tickCount >= interval;
870
+ }
820
871
  // In-memory set of slugs currently spawned in this process. Prevents
821
872
  // double-spawn when runDueJobs() is called while jobs are in flight.
822
873
  const runningSet = new Set();
@@ -1059,6 +1110,79 @@ function resetJobFields(job, errorMsg) {
1059
1110
  delete job.verifierVerdict;
1060
1111
  }
1061
1112
 
1113
+ // Grace period between a boot orphan's SIGTERM and reading its log to
1114
+ // classify the outcome — matches killOrphanClaudePid's own internal 5s
1115
+ // SIGKILL follow-up delay, plus a small margin so classification always runs
1116
+ // after that SIGKILL has had a chance to land.
1117
+ const BOOT_ORPHAN_KILL_GRACE_MS = 6000;
1118
+
1119
+ /**
1120
+ * partitionBootOrphans(jobs, isAlive?) → { immediate: string[], deferred: string[] }
1121
+ *
1122
+ * Pure decision split for boot reconciliation. A 'running' job whose recorded
1123
+ * pid is still alive must NOT be classified from its log yet — the orphaned
1124
+ * process may still be writing to it, so reading now risks misclassifying a
1125
+ * job that is about to emit result:success as no_result and double-running it.
1126
+ * Ported from reconcileQueueOffline's cross-tick escalation (see
1127
+ * scripts/lib/watchdogHelpers.cjs) — here it's a single deferred window since
1128
+ * this process stays up to revisit it, rather than a separate short-lived
1129
+ * watchdog process needing another tick.
1130
+ */
1131
+ function partitionBootOrphans(jobs, isAlive = claudePidAlive) {
1132
+ const immediate = [];
1133
+ const deferred = [];
1134
+ for (const j of jobs) {
1135
+ if (j.status !== 'running') continue;
1136
+ const pid = j.runtime?.pid;
1137
+ if (pid && isAlive(pid)) {
1138
+ deferred.push(j.slug);
1139
+ } else {
1140
+ immediate.push(j.slug);
1141
+ }
1142
+ }
1143
+ return { immediate, deferred };
1144
+ }
1145
+
1146
+ /**
1147
+ * applyOrphanOutcome(job, outcome, killNote?) → void
1148
+ *
1149
+ * Mutates `job` in place to finalize a boot-orphaned 'running' job given its
1150
+ * classified run outcome: success/failed finalize terminally; no_result/unknown
1151
+ * re-queues to pending bounded by ORPHAN_REQUEUE_CAP. The status-mutation
1152
+ * semantics (and the cap-exhaustion boundary) match the now-deleted
1153
+ * reconcileQueueOffline (scripts/lib/watchdogHelpers.cjs) verbatim; killNote
1154
+ * plumbing differs slightly (see call sites) since this path always knows
1155
+ * pid liveness up front rather than re-checking per tick.
1156
+ */
1157
+ function applyOrphanOutcome(job, outcome, killNote = '') {
1158
+ const now = new Date().toISOString();
1159
+ if (outcome === 'success') {
1160
+ job.status = 'completed';
1161
+ job.exitCode = 0;
1162
+ job.error = null;
1163
+ job.finishedAt = now;
1164
+ delete job.runtime;
1165
+ } else if (outcome === 'failed') {
1166
+ job.status = 'failed';
1167
+ job.exitCode = job.exitCode ?? 1;
1168
+ job.error = `orphaned: app restarted while running${killNote}`;
1169
+ job.finishedAt = now;
1170
+ delete job.runtime;
1171
+ } else {
1172
+ const tries = job.orphanRetries ?? 0;
1173
+ if (tries < ORPHAN_REQUEUE_CAP) {
1174
+ resetJobFields(job, `orphaned: app restarted mid-run, re-queued (attempt ${tries + 1}/${ORPHAN_REQUEUE_CAP})${killNote}`);
1175
+ job.orphanRetries = tries + 1;
1176
+ } else {
1177
+ job.status = 'failed';
1178
+ job.exitCode = job.exitCode ?? 1;
1179
+ job.error = `orphaned: app restarted while running, exhausted ${ORPHAN_REQUEUE_CAP} re-queue attempts${killNote}`;
1180
+ job.finishedAt = now;
1181
+ delete job.runtime;
1182
+ }
1183
+ }
1184
+ }
1185
+
1062
1186
  /** Scan the tail of a job's log for the canonical rate-limit signal. We look
1063
1187
  * at the last 16 KB — final result event always lands at the end.
1064
1188
  * Uses readTail() so no raw fd lifecycle is needed here. */
@@ -1730,7 +1854,13 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
1730
1854
  // Used by the sentinel override: SCHEDULER_VERDICT: PASS + a landed
1731
1855
  // commit together override incidental transcript noise verdicts.
1732
1856
  const headAtExit = await gitHead(guardCwd);
1733
- const committedDuringRun = !!(guardHeadBefore && headAtExit && guardHeadBefore !== headAtExit);
1857
+ const committedDuringRun = await computeCommittedDuringRun(
1858
+ guardCwd,
1859
+ guardHeadBefore,
1860
+ headAtExit,
1861
+ job.startedAt,
1862
+ new Date().toISOString(),
1863
+ );
1734
1864
 
1735
1865
  const prdPath = path.join(PRDS_DIR, `${job.slug}.md`);
1736
1866
  const stateForDeps = await readQueue();
@@ -2944,65 +3074,60 @@ async function init() {
2944
3074
  // classifyRunOutcome calls readTail → fs.readFileSync (up to 64 KB per job).
2945
3075
  // Pre-compute all outcomes BEFORE entering the mutate lock so the blocking I/O
2946
3076
  // does not stall the event loop or hold the mutateTail chain during startup.
3077
+ //
3078
+ // Jobs whose recorded pid is still alive are deferred (not classified here) —
3079
+ // see partitionBootOrphans. Everything else (dead pid or no pid) is safe to
3080
+ // classify immediately below.
2947
3081
  const bootSnap = readQueueSync();
3082
+ const { immediate: immediateSlugs, deferred: deferredSlugs } = partitionBootOrphans(bootSnap.jobs);
2948
3083
  const bootOutcomes = new Map();
2949
3084
  for (const j of bootSnap.jobs) {
2950
- if (j.status !== 'running') continue;
3085
+ if (!immediateSlugs.includes(j.slug)) continue;
2951
3086
  const logPath = j.runId ? path.join(RUNS_DIR, j.runId, `${j.slug}.log`) : null;
2952
3087
  bootOutcomes.set(j.slug, logPath ? classifyRunOutcome(logPath) : 'unknown');
2953
3088
  }
2954
3089
  await mutate((state) => {
2955
3090
  for (const j of state.jobs) {
2956
- if (j.status !== 'running') continue;
2957
- const pid = j.runtime?.pid;
2958
- let killNote = '';
2959
- if (pid) {
2960
- const result = killOrphanClaudePid(pid);
2961
- killNote = ` (orphan pid=${pid}: ${result})`;
2962
- if (result === 'killed') {
2963
- console.log(`[scheduler] boot: SIGTERM'd orphan claude pid=${pid} for ${j.slug}`);
2964
- }
2965
- }
3091
+ if (j.status !== 'running' || !immediateSlugs.includes(j.slug)) continue;
2966
3092
  const outcome = bootOutcomes.get(j.slug) ?? 'unknown';
2967
- if (outcome === 'success') {
2968
- // Job finished cleanly before the crash keep the win.
2969
- j.status = 'completed';
2970
- j.exitCode = 0;
2971
- j.error = null;
2972
- j.finishedAt = new Date().toISOString();
2973
- delete j.runtime;
2974
- console.log(`[scheduler] boot reconcile: slug=${j.slug} outcome=success → completed`);
2975
- } else if (outcome === 'failed') {
2976
- // The log carries a real failure result event — a genuine failure, keep it.
2977
- j.status = 'failed';
2978
- j.exitCode = j.exitCode ?? 1;
2979
- j.error = `orphaned: app restarted while running${killNote}`;
2980
- j.finishedAt = new Date().toISOString();
2981
- delete j.runtime;
2982
- console.log(`[scheduler] boot reconcile: slug=${j.slug} outcome=failed → failed`);
2983
- } else {
2984
- // no_result / unknown: the run was interrupted (host died / app restarted)
2985
- // with NO evidence it failed on its own merits. Punishing the PRD here is
2986
- // the wrong call — it demands a manual flip and burns an Opus fix-plan on a
2987
- // job that never actually failed. Re-queue it (bounded) so an app restart
2988
- // self-recovers. Mirrors the transient-kill auto-retry on the live path.
2989
- const tries = j.orphanRetries ?? 0;
2990
- if (tries < ORPHAN_REQUEUE_CAP) {
2991
- resetJobFields(j, `orphaned: app restarted mid-run, re-queued (attempt ${tries + 1}/${ORPHAN_REQUEUE_CAP})${killNote}`);
2992
- j.orphanRetries = tries + 1;
2993
- console.log(`[scheduler] boot reconcile: slug=${j.slug} outcome=${outcome} → re-queued (${tries + 1}/${ORPHAN_REQUEUE_CAP})`);
2994
- } else {
2995
- j.status = 'failed';
2996
- j.exitCode = j.exitCode ?? 1;
2997
- j.error = `orphaned: app restarted while running, exhausted ${ORPHAN_REQUEUE_CAP} re-queue attempts${killNote}`;
2998
- j.finishedAt = new Date().toISOString();
2999
- delete j.runtime;
3000
- console.log(`[scheduler] boot reconcile: slug=${j.slug} outcome=${outcome} → failed (orphan retries exhausted)`);
3001
- }
3002
- }
3093
+ const pid = j.runtime?.pid;
3094
+ const killNote = pid ? ` (orphan pid=${pid}: dead)` : '';
3095
+ applyOrphanOutcome(j, outcome, killNote);
3096
+ console.log(`[scheduler] boot reconcile: slug=${j.slug} outcome=${outcome} → status=${j.status}`);
3003
3097
  }
3004
3098
  });
3005
3099
 
3100
+ // Still-alive orphans: SIGTERM (+ killOrphanClaudePid's own deferred SIGKILL
3101
+ // follow-up) now, but classification waits until BOOT_ORPHAN_KILL_GRACE_MS
3102
+ // later — reading the log while the orphan might still be writing to it
3103
+ // could misclassify an about-to-succeed run as no_result and double-run the
3104
+ // same PRD (2026-05-21 incident this guard exists for).
3105
+ for (const slug of deferredSlugs) {
3106
+ const j = bootSnap.jobs.find((x) => x.slug === slug);
3107
+ const pid = j?.runtime?.pid;
3108
+ const bootRunId = j?.runId ?? null; // captured now — guards against reconciling a DIFFERENT later run of the same slug
3109
+ if (!pid) continue;
3110
+ const result = killOrphanClaudePid(pid);
3111
+ const killNote = ` (orphan pid=${pid}: ${result})`;
3112
+ if (result === 'killed') {
3113
+ console.log(`[scheduler] boot: SIGTERM'd orphan claude pid=${pid} for ${slug} — deferring finalize ${BOOT_ORPHAN_KILL_GRACE_MS}ms`);
3114
+ }
3115
+ setTimeout(() => {
3116
+ const logPath = j.runId ? path.join(RUNS_DIR, j.runId, `${j.slug}.log`) : null;
3117
+ const outcome = logPath ? classifyRunOutcome(logPath) : 'unknown';
3118
+ mutate((state) => {
3119
+ const cur = state.jobs.find((x) => x.slug === slug);
3120
+ // Race guard: bail if the job already resolved, OR if it's already been
3121
+ // re-picked into a NEW run (different runId) within the grace window —
3122
+ // that new run is not the boot orphan we SIGTERM'd and must not be
3123
+ // touched by this stale classification.
3124
+ if (!cur || cur.status !== 'running' || cur.runId !== bootRunId) return;
3125
+ applyOrphanOutcome(cur, outcome, killNote);
3126
+ console.log(`[scheduler] boot reconcile (deferred): slug=${slug} outcome=${outcome} → status=${cur.status}`);
3127
+ }).catch((e) => console.error(`[scheduler] deferred boot reconcile failed for ${slug}:`, e?.message));
3128
+ }, BOOT_ORPHAN_KILL_GRACE_MS).unref?.();
3129
+ }
3130
+
3006
3131
  // If we boot up while paused with a resumeAt in the past, clear it. This
3007
3132
  // happens when the app was closed across the reset window.
3008
3133
  const boot = await readQueue();
@@ -3074,6 +3199,16 @@ async function init() {
3074
3199
  utilization: cachedUtilization,
3075
3200
  consecutiveFailures,
3076
3201
  });
3202
+
3203
+ feedbackSweepTickCount++;
3204
+ if (feedbackSweepDue(feedbackSweepTickCount, FEEDBACK_SWEEP_TICK_INTERVAL)) {
3205
+ feedbackSweepTickCount = 0;
3206
+ try {
3207
+ sweepFeedback();
3208
+ } catch (e) {
3209
+ console.warn('[scheduler] feedback sweep failed', e?.message);
3210
+ }
3211
+ }
3077
3212
  }, 60_000);
3078
3213
  if (heartbeatInterval.unref) heartbeatInterval.unref();
3079
3214
 
@@ -3188,7 +3323,7 @@ const remote = {
3188
3323
  },
3189
3324
 
3190
3325
  // Exposes the module-level allocateParallelGroup (PRD 548) to callers that
3191
- // only hold the `remote` object (adminServer.cjs's create-prd route) —
3326
+ // only hold the `remote` object (lib/prdCreate.cjs's create-prd route) —
3192
3327
  // reuses the same allocator the file-based /develop authoring path relies
3193
3328
  // on implicitly, rather than re-deriving NN here.
3194
3329
  allocateParallelGroup,
@@ -3216,4 +3351,36 @@ const remote = {
3216
3351
  },
3217
3352
  };
3218
3353
 
3219
- module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, committedInWindow, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, TRANSIENT_RETRY_CAP, buildScheduleStatePayload };
3354
+ // Registers the two job-management admin HTTP routes (PRD 689 moved
3355
+ // verbatim out of the former standalone admin HTTP server module's
3356
+ // handleRequest, no behavior change) against an injected localAdminHttp.cjs
3357
+ // transport. `remoteObj` is accepted as a parameter (defaults to this
3358
+ // module's own `remote`) so the route logic stays testable in isolation
3359
+ // without booting Electron, matching that former module's original
3360
+ // dependency-injection pattern.
3361
+ function registerAdminRoutes(adminHttp, remoteObj = remote) {
3362
+ adminHttp.registerRoute('GET', '/admin/scheduler/jobs', async (req, res) => {
3363
+ const jobs = await remoteObj.listJobs();
3364
+ sendJson(res, 200, jobs);
3365
+ });
3366
+
3367
+ adminHttp.registerRoute('POST', '/admin/scheduler/reset-job', async (req, res) => {
3368
+ const raw = await readBody(req);
3369
+ let parsed;
3370
+ try {
3371
+ parsed = raw ? JSON.parse(raw) : {};
3372
+ } catch {
3373
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
3374
+ return;
3375
+ }
3376
+ const slug = typeof parsed.slug === 'string' ? parsed.slug : null;
3377
+ if (!slug) {
3378
+ sendJson(res, 400, { ok: false, error: 'missing slug' });
3379
+ return;
3380
+ }
3381
+ const result = await remoteObj.resetJob(slug);
3382
+ sendJson(res, 200, result);
3383
+ });
3384
+ }
3385
+
3386
+ module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, committedInWindow, computeCommittedDuringRun, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, TRANSIENT_RETRY_CAP, buildScheduleStatePayload, partitionBootOrphans, applyOrphanOutcome, BOOT_ORPHAN_KILL_GRACE_MS, feedbackSweepDue, FEEDBACK_SWEEP_TICK_INTERVAL, sweepFeedback, registerAdminRoutes };
@@ -5,9 +5,9 @@
5
5
  * Storage: ~/.config/session-manager/tabs.json
6
6
  * Shape: { tabs: PersistedTab[], activeTabId: string | null, savedAt: number }
7
7
  *
8
- * Only serializable, durable fields are persisted: id, claudeSessionId,
9
- * chatSessionId, cwd, label, presetId. Runtime-only fields (pid, status,
10
- * startupCommand, exitCode) are recomputed on boot.
8
+ * Only serializable, durable fields are persisted: id, sessionId, cwd,
9
+ * label, presetId. Runtime-only fields (pid, status, startupCommand,
10
+ * exitCode) are recomputed on boot.
11
11
  */
12
12
 
13
13
  const fs = require('node:fs');
@@ -58,8 +58,7 @@ async function markFreshRestart() {
58
58
  const crypto = require('node:crypto');
59
59
  const freshTabs = tabs.map((t) => ({
60
60
  ...t,
61
- claudeSessionId: crypto.randomUUID(),
62
- chatSessionId: crypto.randomUUID(),
61
+ sessionId: crypto.randomUUID(),
63
62
  }));
64
63
  await save({ tabs: freshTabs, activeTabId, freshStart: true });
65
64
  }
@@ -328,7 +328,7 @@ loop but had no documented programmatic queueing path. This section is that path
328
328
  ### The `scheduler_create_prd` MCP tool
329
329
 
330
330
  Wraps `POST /admin/scheduler/create-prd` on the loopback admin API
331
- (`src/main/adminServer.cjs`, PRD 549) via `scripts/scheduler-mcp-server.cjs`, registered in this
331
+ (`src/main/lib/localAdminHttp.cjs` + `src/main/lib/prdCreate.cjs`, PRD 549/688) via `scripts/scheduler-mcp-server.cjs`, registered in this
332
332
  repo's `.mcp.json` as the `session-manager-scheduler` MCP server. An external project wanting to
333
333
  call it from its own automation needs the equivalent MCP server registration pointing at this
334
334
  repo's `scripts/scheduler-mcp-server.cjs`, or can call the admin HTTP route directly (same
@@ -348,7 +348,7 @@ request/response shape) using the token at `~/.claude/session-manager/admin-api.
348
348
  | `slug` | string | no | kebab-case; derived from `title` if omitted |
349
349
  | `parallelGroup` | number | no | opt into an existing `NN` group instead of allocating a new one |
350
350
 
351
- **Return** (`{nn, filename, status}`, per `adminServer.cjs`):
351
+ **Return** (`{nn, filename, status}`, per `prdCreate.cjs`'s registerAdminRoute):
352
352
  ```json
353
353
  { "nn": 550, "filename": "550-my-feature.md", "status": "queued" }
354
354
  ```
@@ -380,9 +380,9 @@ produces by hand.
380
380
  ### The app-must-be-running caveat (read this first)
381
381
 
382
382
  **`scheduler_create_prd` only works while the session-manager Electron app is running on this
383
- machine.** The admin server it depends on (`src/main/adminServer.cjs`) is hosted *inside* the
383
+ machine.** The admin server it depends on (`src/main/lib/localAdminHttp.cjs`) is hosted *inside* the
384
384
  Electron process — it binds a loopback port and writes its token to
385
- `~/.claude/session-manager/admin-api.json` on app boot (see `CLAUDE.md`'s `adminServer.cjs`
385
+ `~/.claude/session-manager/admin-api.json` on app boot (see `CLAUDE.md`'s `localAdminHttp.cjs`
386
386
  architecture entry) and stops existing the moment the app quits — it is not a standalone daemon.
387
387
  If the app is closed, `scheduler-mcp-server.cjs` cannot read a live port/token and every call
388
388
  returns the error `session-manager app is not running (admin API unreachable) — start it first`.
@@ -38,100 +38,16 @@ function attachWindow(w) {
38
38
  }
39
39
 
40
40
  const { encodeCwd } = require('./lib/encodeCwd.cjs');
41
+ const { classifyLine } = require('./lib/classifyTranscriptLine.cjs');
41
42
 
42
43
  function transcriptPath(cwd, sessionUuid) {
43
44
  return path.join(os.homedir(), '.claude', 'projects', encodeCwd(cwd), `${sessionUuid}.jsonl`);
44
45
  }
45
46
 
46
- const MAX_RAW_STR = 4096;
47
47
  // Cap bytes read per readDelta pass. Bounds memory on first attach to a very
48
48
  // large transcript (hundreds of MB) — see readDelta's seek-to-tail branch.
49
49
  const MAX_DELTA_BYTES = 8 * 1024 * 1024;
50
50
 
51
- // Block types whose text/content fields are parsed structurally by
52
- // orchestrator.ts / race.ts — truncating them produces mid-token "…" and
53
- // unparseable JSON, so they are exempt from the size cap.
54
- const EXEMPT_TYPES = new Set(['tool_result', 'tool_use']);
55
-
56
- /**
57
- * Cap string fields in a content block array so arbitrary tool output doesn't
58
- * bloat the ring buffer. Blocks whose type is in EXEMPT_TYPES are passed
59
- * through intact so that structured result payloads survive to the digest
60
- * parsers in race.ts / orchestrator.ts.
61
- */
62
- function trimContentArray(content) {
63
- if (!Array.isArray(content)) return content;
64
- return content.map((block) => {
65
- if (!block || typeof block !== 'object') return block;
66
- if (EXEMPT_TYPES.has(block.type)) return block;
67
- const b = { ...block };
68
- if (typeof b.text === 'string' && b.text.length > MAX_RAW_STR) {
69
- b.text = b.text.slice(0, MAX_RAW_STR) + '…';
70
- }
71
- if (typeof b.content === 'string' && b.content.length > MAX_RAW_STR) {
72
- b.content = b.content.slice(0, MAX_RAW_STR) + '…';
73
- }
74
- if (Array.isArray(b.content)) {
75
- b.content = trimContentArray(b.content);
76
- }
77
- return b;
78
- });
79
- }
80
-
81
- /** Build the slim raw projection used by race.ts and orchestrator.ts. */
82
- function makeRaw(obj) {
83
- const msgContent = obj?.message?.content;
84
- return { message: { content: trimContentArray(msgContent) } };
85
- }
86
-
87
- /**
88
- * Parse one JSONL line defensively. Real schema drifts, so we pass through
89
- * anything that parses and tag a coarse `kind`.
90
- */
91
- function classifyLine(obj) {
92
- if (!obj || typeof obj !== 'object') return null;
93
- // Many shapes exist — try several common fields.
94
- const type = obj.type || obj.event || obj.role;
95
- const msg = obj.message || obj;
96
- const content = msg?.content;
97
-
98
- // Usage rollups arrive as summary events.
99
- if (obj.usage || msg?.usage) {
100
- return { kind: 'usage', data: obj.usage || msg.usage, raw: makeRaw(obj) };
101
- }
102
-
103
- // Tool uses: scan content array for tool_use blocks.
104
- if (Array.isArray(content)) {
105
- for (const block of content) {
106
- if (block?.type === 'tool_use') {
107
- if (block.name === 'TodoWrite') {
108
- return { kind: 'todo_write', data: block.input?.todos || block.input || [], raw: makeRaw(obj) };
109
- }
110
- if (block.name === 'ExitPlanMode' || block.name === 'EnterPlanMode') {
111
- return { kind: 'plan', data: block.input, raw: makeRaw(obj) };
112
- }
113
- if (block.name === 'Agent' || block.name === 'Task') {
114
- // Include block.id as toolUseId so the live store can match the
115
- // corresponding tool_result and update per-agent lastActivityAt.
116
- return { kind: 'agent_spawn', data: { ...block.input, toolUseId: block.id }, raw: makeRaw(obj) };
117
- }
118
- return {
119
- kind: 'tool_use',
120
- data: { name: block.name, input: block.input, id: block.id },
121
- raw: makeRaw(obj),
122
- };
123
- }
124
- // tool_result carries the tool_use_id of the completed Task/Agent call.
125
- // The live store uses this to update the agent's lastActivityAt bookend.
126
- if (block?.type === 'tool_result' && block.tool_use_id) {
127
- return { kind: 'tool_result', data: { toolUseId: block.tool_use_id }, raw: makeRaw(obj) };
128
- }
129
- }
130
- }
131
-
132
- return { kind: type || 'message', data: obj, raw: makeRaw(obj) };
133
- }
134
-
135
51
  /**
136
52
  * Read new bytes from sub.filePath into sub.offset/pending/inode in place.
137
53
  * Resets offset+pending when the file inode changes (rename+replace rotation).
@@ -729,13 +729,13 @@ async function pushSessionList() {
729
729
  if (_e2e.state !== 'authenticated') return;
730
730
  const sessionsStore = require('./sessionsStore.cjs');
731
731
  const data = await sessionsStore.load();
732
- // Normalize persisted tabs → SessionMeta. tabId === claudeSessionId so it
732
+ // Normalize persisted tabs → SessionMeta. tabId === sessionId so it
733
733
  // matches the transcript JSONL name used by cmd:session:subscribe.
734
734
  const sessions = (data?.tabs ?? []).map((t) => ({
735
- tabId: t.claudeSessionId,
735
+ tabId: t.sessionId,
736
736
  cwd: t.cwd,
737
737
  title: t.label || t.cwd,
738
- state: _sessionWatchers.get(t.claudeSessionId)?.state ?? null,
738
+ state: _sessionWatchers.get(t.sessionId)?.state ?? null,
739
739
  }));
740
740
  const payload = { sessions, activeTabId: data?.activeTabId ?? null };
741
741
  const json = JSON.stringify(payload);
@@ -163,9 +163,7 @@ export interface SubscribeResult {
163
163
 
164
164
  export interface PersistedTab {
165
165
  id: string;
166
- claudeSessionId: string;
167
- /** Chat mode's own session id, decoupled from claudeSessionId. Optional for backwards-compat with tabs.json written before this field existed. */
168
- chatSessionId?: string;
166
+ sessionId: string;
169
167
  cwd: string;
170
168
  label: string;
171
169
  presetId: string | null;
@@ -650,6 +648,7 @@ export interface FilesRenameResult { ok: boolean; newPath?: string; error: strin
650
648
  export interface FilesDeleteResult { ok: boolean; error: string | null }
651
649
  export interface FilesDuplicateResult { ok: boolean; path?: string; error?: string | null }
652
650
  export interface DocEditResult { ok: boolean; after?: string; error?: string }
651
+ export interface DocEditSessionResult { tabId: string; requestId: string; ok: boolean; after?: string; error?: string }
653
652
 
654
653
  export interface SearchFileEntry {
655
654
  name: string;
@@ -1272,7 +1271,17 @@ export interface SessionManagerAPI {
1272
1271
  duplicate: (path: string) => Promise<FilesDuplicateResult>;
1273
1272
  };
1274
1273
  docEdit: {
1275
- run: (payload: { path: string; before: string; instruction: string }) => Promise<DocEditResult>;
1274
+ run: (payload: { path: string; before: string; instruction: string; documentText?: string }) => Promise<DocEditResult>;
1275
+ runInSession: (payload: {
1276
+ tabId: string;
1277
+ sessionId: string;
1278
+ cwd: string;
1279
+ before: string;
1280
+ instruction: string;
1281
+ documentText?: string;
1282
+ requestId: string;
1283
+ }) => Promise<{ ok: boolean }>;
1284
+ onSessionResult: (handler: (payload: DocEditSessionResult) => void) => () => void;
1276
1285
  };
1277
1286
  /** Consolidated shell open/reveal. One method, discriminated on `as`, replaces
1278
1287
  * the former app.openIn* / app.openExternal / files.openExternal / files.showInFinder.
@@ -1452,8 +1461,9 @@ export interface SessionManagerAPI {
1452
1461
  chat: {
1453
1462
  /** Spawn a headless `claude -p` run for a tab. Results arrive via the on* listeners. */
1454
1463
  run: (payload: ChatRunPayload) => Promise<{ ok: boolean }>;
1455
- /** Cancel the in-flight run for a tab (SIGTERM→SIGKILL). No-op when idle. */
1456
- cancel: (tabId: string) => void;
1464
+ /** Cancel the in-flight run for a tab (SIGTERM→SIGKILL). Resolves once the
1465
+ * run has fully settled (terminal event fired). No-op when idle. */
1466
+ cancel: (tabId: string) => Promise<void>;
1457
1467
  onQueued: (handler: (e: ChatRunQueuedEvent) => void) => () => void;
1458
1468
  onRunStarted: (handler: (e: ChatRunStartedEvent) => void) => () => void;
1459
1469
  onOutput: (handler: (e: ChatRunOutputEvent) => void) => () => void;
@@ -229,6 +229,12 @@ contextBridge.exposeInMainWorld('api', {
229
229
  },
230
230
  docEdit: {
231
231
  run: (payload) => ipcRenderer.invoke('docedit:run', payload),
232
+ runInSession: (payload) => ipcRenderer.invoke('docedit:run-in-session', payload),
233
+ onSessionResult: (handler) => {
234
+ const listener = (_e, payload) => handler(payload);
235
+ ipcRenderer.on('docedit:session-result', listener);
236
+ return () => ipcRenderer.removeListener('docedit:session-result', listener);
237
+ },
232
238
  },
233
239
  // Consolidated shell open/reveal — see shell:open in index.cjs.
234
240
  // as: 'editor' | 'fileInEditor' | 'finder' | 'terminal' | 'external' | 'openPath' | 'revealPath'
@@ -387,8 +393,9 @@ contextBridge.exposeInMainWorld('api', {
387
393
  chat: {
388
394
  /** Spawn a headless claude -p job. Results arrive via the on* listeners. */
389
395
  run: (payload) => ipcRenderer.invoke('chat:run', payload),
390
- /** Cancel an in-flight run for the given tabId. No-op if none running. */
391
- cancel: (tabId) => ipcRenderer.send('chat:cancel', { tabId }),
396
+ /** Cancel an in-flight run for the given tabId. Resolves once the run has
397
+ * fully settled (terminal event fired). No-op if none running. */
398
+ cancel: (tabId) => ipcRenderer.invoke('chat:cancel', { tabId }),
392
399
  onQueued: (handler) => {
393
400
  const listener = (_e, payload) => handler(payload);
394
401
  ipcRenderer.on('chat:run:queued', listener);