claude-code-session-manager 0.37.1 → 0.37.2

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 (37) hide show
  1. package/bin/cli.cjs +12 -1
  2. package/dist/assets/{TiptapBody-DXQDiOUx.js → TiptapBody-BtrSXTRp.js} +1 -1
  3. package/dist/assets/index-CD_LuJZF.css +32 -0
  4. package/dist/assets/{index--a8m5_ET.js → index-uVGdpAGF.js} +498 -490
  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 +24 -0
  9. package/plugins/session-manager-dev/skills/requesting-code-review/SKILL.md +20 -17
  10. package/scripts/lib/watchdogHelpers.cjs +296 -166
  11. package/src/main/__tests__/classifyTranscriptLine.test.cjs +90 -0
  12. package/src/main/__tests__/docEdit.test.cjs +164 -2
  13. package/src/main/__tests__/prdCreate.test.cjs +265 -25
  14. package/src/main/__tests__/scheduler-admin-routes.test.cjs +150 -0
  15. package/src/main/__tests__/scheduler-boot-orphans.test.cjs +172 -0
  16. package/src/main/browserAgentServer.cjs +11 -10
  17. package/src/main/chatRunner.cjs +21 -2
  18. package/src/main/docEdit.cjs +125 -5
  19. package/src/main/health.cjs +15 -0
  20. package/src/main/index.cjs +12 -6
  21. package/src/main/ipcSchemas.cjs +14 -0
  22. package/src/main/lib/__tests__/localAdminHttp.test.cjs +120 -0
  23. package/src/main/lib/classifyTranscriptLine.cjs +89 -0
  24. package/src/main/lib/localAdminHttp.cjs +157 -0
  25. package/src/main/lib/personaImportHealth.cjs +161 -0
  26. package/src/main/lib/prdCreate.cjs +107 -17
  27. package/src/main/lib/rcaFeedbackHook.cjs +2 -2
  28. package/src/main/lib/singleInstanceGuard.cjs +20 -0
  29. package/src/main/scheduler.cjs +198 -54
  30. package/src/main/templates/PRD_AUTHORING.md +4 -4
  31. package/src/main/transcripts.cjs +1 -85
  32. package/src/preload/api.d.ts +12 -1
  33. package/src/preload/index.cjs +6 -0
  34. package/dist/assets/index-CTTjT08J.css +0 -32
  35. package/plugins/session-manager-dev/skills/security-review/SKILL.md +0 -105
  36. package/src/main/__tests__/adminServer.test.cjs +0 -380
  37. 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
 
@@ -203,10 +210,10 @@ function gitHead(cwd) {
203
210
 
204
211
  // Returns true if ≥1 commit landed on any ref (branch, remote-tracking branch,
205
212
  // 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).
213
+ // the currently checked-out branch. Used both by the self-heal pass and by the
214
+ // live commit-guard's fallback (see computeCommittedDuringRun) to derive
215
+ // committedDuringRun from the recorded run window. Never throws;
216
+ // git-unavailable → false (no override, job stays as-is).
210
217
  function committedInWindow(cwd, startedAt, finishedAt) {
211
218
  return new Promise((resolve) => {
212
219
  if (!cwd || !startedAt) { resolve(false); return; }
@@ -222,6 +229,16 @@ function committedInWindow(cwd, startedAt, finishedAt) {
222
229
  });
223
230
  }
224
231
 
232
+ // Live commit-guard: cheap HEAD-diff fast path, falling back to the
233
+ // git-log --all scan when HEAD didn't move on the starting branch. A PRD that
234
+ // checks out other branches, commits real work on each, then checks its
235
+ // starting branch back out before exit leaves HEAD unchanged even though
236
+ // commits landed — the fallback catches that case. Never throws.
237
+ async function computeCommittedDuringRun(cwd, headBefore, headAfter, startedAt, untilIso) {
238
+ if (headBefore && headAfter && headBefore !== headAfter) return true;
239
+ return committedInWindow(cwd, startedAt, untilIso);
240
+ }
241
+
225
242
  const ROOT = path.join(os.homedir(), '.claude', 'session-manager', 'scheduled-plans');
226
243
  const PRDS_DIR = path.join(ROOT, 'prds');
227
244
  const RUNS_DIR = path.join(ROOT, 'runs');
@@ -817,6 +834,17 @@ let resumeTimer = null;
817
834
  let pollLoopTimer = null;
818
835
  let rescheduleInterval = null;
819
836
  let heartbeatInterval = null;
837
+ // Feedback sweep piggybacks on the 60s heartbeat tick but runs far less often —
838
+ // every Nth tick — since it's a readdir-per-active-project scan, not free.
839
+ // 5 ticks = 5 minutes; well inside sweep()/activeProjectCwds()'s 90-minute
840
+ // active-session window, so no active project can be missed between sweeps.
841
+ const FEEDBACK_SWEEP_TICK_INTERVAL = 5;
842
+ let feedbackSweepTickCount = 0;
843
+ /** Pure gate for the tick counter above — exported so the wiring is testable
844
+ * without waiting on real setInterval timers. */
845
+ function feedbackSweepDue(tickCount, interval = FEEDBACK_SWEEP_TICK_INTERVAL) {
846
+ return tickCount >= interval;
847
+ }
820
848
  // In-memory set of slugs currently spawned in this process. Prevents
821
849
  // double-spawn when runDueJobs() is called while jobs are in flight.
822
850
  const runningSet = new Set();
@@ -1059,6 +1087,79 @@ function resetJobFields(job, errorMsg) {
1059
1087
  delete job.verifierVerdict;
1060
1088
  }
1061
1089
 
1090
+ // Grace period between a boot orphan's SIGTERM and reading its log to
1091
+ // classify the outcome — matches killOrphanClaudePid's own internal 5s
1092
+ // SIGKILL follow-up delay, plus a small margin so classification always runs
1093
+ // after that SIGKILL has had a chance to land.
1094
+ const BOOT_ORPHAN_KILL_GRACE_MS = 6000;
1095
+
1096
+ /**
1097
+ * partitionBootOrphans(jobs, isAlive?) → { immediate: string[], deferred: string[] }
1098
+ *
1099
+ * Pure decision split for boot reconciliation. A 'running' job whose recorded
1100
+ * pid is still alive must NOT be classified from its log yet — the orphaned
1101
+ * process may still be writing to it, so reading now risks misclassifying a
1102
+ * job that is about to emit result:success as no_result and double-running it.
1103
+ * Ported from reconcileQueueOffline's cross-tick escalation (see
1104
+ * scripts/lib/watchdogHelpers.cjs) — here it's a single deferred window since
1105
+ * this process stays up to revisit it, rather than a separate short-lived
1106
+ * watchdog process needing another tick.
1107
+ */
1108
+ function partitionBootOrphans(jobs, isAlive = claudePidAlive) {
1109
+ const immediate = [];
1110
+ const deferred = [];
1111
+ for (const j of jobs) {
1112
+ if (j.status !== 'running') continue;
1113
+ const pid = j.runtime?.pid;
1114
+ if (pid && isAlive(pid)) {
1115
+ deferred.push(j.slug);
1116
+ } else {
1117
+ immediate.push(j.slug);
1118
+ }
1119
+ }
1120
+ return { immediate, deferred };
1121
+ }
1122
+
1123
+ /**
1124
+ * applyOrphanOutcome(job, outcome, killNote?) → void
1125
+ *
1126
+ * Mutates `job` in place to finalize a boot-orphaned 'running' job given its
1127
+ * classified run outcome: success/failed finalize terminally; no_result/unknown
1128
+ * re-queues to pending bounded by ORPHAN_REQUEUE_CAP. The status-mutation
1129
+ * semantics (and the cap-exhaustion boundary) match the now-deleted
1130
+ * reconcileQueueOffline (scripts/lib/watchdogHelpers.cjs) verbatim; killNote
1131
+ * plumbing differs slightly (see call sites) since this path always knows
1132
+ * pid liveness up front rather than re-checking per tick.
1133
+ */
1134
+ function applyOrphanOutcome(job, outcome, killNote = '') {
1135
+ const now = new Date().toISOString();
1136
+ if (outcome === 'success') {
1137
+ job.status = 'completed';
1138
+ job.exitCode = 0;
1139
+ job.error = null;
1140
+ job.finishedAt = now;
1141
+ delete job.runtime;
1142
+ } else if (outcome === 'failed') {
1143
+ job.status = 'failed';
1144
+ job.exitCode = job.exitCode ?? 1;
1145
+ job.error = `orphaned: app restarted while running${killNote}`;
1146
+ job.finishedAt = now;
1147
+ delete job.runtime;
1148
+ } else {
1149
+ const tries = job.orphanRetries ?? 0;
1150
+ if (tries < ORPHAN_REQUEUE_CAP) {
1151
+ resetJobFields(job, `orphaned: app restarted mid-run, re-queued (attempt ${tries + 1}/${ORPHAN_REQUEUE_CAP})${killNote}`);
1152
+ job.orphanRetries = tries + 1;
1153
+ } else {
1154
+ job.status = 'failed';
1155
+ job.exitCode = job.exitCode ?? 1;
1156
+ job.error = `orphaned: app restarted while running, exhausted ${ORPHAN_REQUEUE_CAP} re-queue attempts${killNote}`;
1157
+ job.finishedAt = now;
1158
+ delete job.runtime;
1159
+ }
1160
+ }
1161
+ }
1162
+
1062
1163
  /** Scan the tail of a job's log for the canonical rate-limit signal. We look
1063
1164
  * at the last 16 KB — final result event always lands at the end.
1064
1165
  * Uses readTail() so no raw fd lifecycle is needed here. */
@@ -1730,7 +1831,13 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
1730
1831
  // Used by the sentinel override: SCHEDULER_VERDICT: PASS + a landed
1731
1832
  // commit together override incidental transcript noise verdicts.
1732
1833
  const headAtExit = await gitHead(guardCwd);
1733
- const committedDuringRun = !!(guardHeadBefore && headAtExit && guardHeadBefore !== headAtExit);
1834
+ const committedDuringRun = await computeCommittedDuringRun(
1835
+ guardCwd,
1836
+ guardHeadBefore,
1837
+ headAtExit,
1838
+ job.startedAt,
1839
+ new Date().toISOString(),
1840
+ );
1734
1841
 
1735
1842
  const prdPath = path.join(PRDS_DIR, `${job.slug}.md`);
1736
1843
  const stateForDeps = await readQueue();
@@ -2944,65 +3051,60 @@ async function init() {
2944
3051
  // classifyRunOutcome calls readTail → fs.readFileSync (up to 64 KB per job).
2945
3052
  // Pre-compute all outcomes BEFORE entering the mutate lock so the blocking I/O
2946
3053
  // does not stall the event loop or hold the mutateTail chain during startup.
3054
+ //
3055
+ // Jobs whose recorded pid is still alive are deferred (not classified here) —
3056
+ // see partitionBootOrphans. Everything else (dead pid or no pid) is safe to
3057
+ // classify immediately below.
2947
3058
  const bootSnap = readQueueSync();
3059
+ const { immediate: immediateSlugs, deferred: deferredSlugs } = partitionBootOrphans(bootSnap.jobs);
2948
3060
  const bootOutcomes = new Map();
2949
3061
  for (const j of bootSnap.jobs) {
2950
- if (j.status !== 'running') continue;
3062
+ if (!immediateSlugs.includes(j.slug)) continue;
2951
3063
  const logPath = j.runId ? path.join(RUNS_DIR, j.runId, `${j.slug}.log`) : null;
2952
3064
  bootOutcomes.set(j.slug, logPath ? classifyRunOutcome(logPath) : 'unknown');
2953
3065
  }
2954
3066
  await mutate((state) => {
2955
3067
  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
- }
3068
+ if (j.status !== 'running' || !immediateSlugs.includes(j.slug)) continue;
2966
3069
  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
- }
3070
+ const pid = j.runtime?.pid;
3071
+ const killNote = pid ? ` (orphan pid=${pid}: dead)` : '';
3072
+ applyOrphanOutcome(j, outcome, killNote);
3073
+ console.log(`[scheduler] boot reconcile: slug=${j.slug} outcome=${outcome} → status=${j.status}`);
3003
3074
  }
3004
3075
  });
3005
3076
 
3077
+ // Still-alive orphans: SIGTERM (+ killOrphanClaudePid's own deferred SIGKILL
3078
+ // follow-up) now, but classification waits until BOOT_ORPHAN_KILL_GRACE_MS
3079
+ // later — reading the log while the orphan might still be writing to it
3080
+ // could misclassify an about-to-succeed run as no_result and double-run the
3081
+ // same PRD (2026-05-21 incident this guard exists for).
3082
+ for (const slug of deferredSlugs) {
3083
+ const j = bootSnap.jobs.find((x) => x.slug === slug);
3084
+ const pid = j?.runtime?.pid;
3085
+ const bootRunId = j?.runId ?? null; // captured now — guards against reconciling a DIFFERENT later run of the same slug
3086
+ if (!pid) continue;
3087
+ const result = killOrphanClaudePid(pid);
3088
+ const killNote = ` (orphan pid=${pid}: ${result})`;
3089
+ if (result === 'killed') {
3090
+ console.log(`[scheduler] boot: SIGTERM'd orphan claude pid=${pid} for ${slug} — deferring finalize ${BOOT_ORPHAN_KILL_GRACE_MS}ms`);
3091
+ }
3092
+ setTimeout(() => {
3093
+ const logPath = j.runId ? path.join(RUNS_DIR, j.runId, `${j.slug}.log`) : null;
3094
+ const outcome = logPath ? classifyRunOutcome(logPath) : 'unknown';
3095
+ mutate((state) => {
3096
+ const cur = state.jobs.find((x) => x.slug === slug);
3097
+ // Race guard: bail if the job already resolved, OR if it's already been
3098
+ // re-picked into a NEW run (different runId) within the grace window —
3099
+ // that new run is not the boot orphan we SIGTERM'd and must not be
3100
+ // touched by this stale classification.
3101
+ if (!cur || cur.status !== 'running' || cur.runId !== bootRunId) return;
3102
+ applyOrphanOutcome(cur, outcome, killNote);
3103
+ console.log(`[scheduler] boot reconcile (deferred): slug=${slug} outcome=${outcome} → status=${cur.status}`);
3104
+ }).catch((e) => console.error(`[scheduler] deferred boot reconcile failed for ${slug}:`, e?.message));
3105
+ }, BOOT_ORPHAN_KILL_GRACE_MS).unref?.();
3106
+ }
3107
+
3006
3108
  // If we boot up while paused with a resumeAt in the past, clear it. This
3007
3109
  // happens when the app was closed across the reset window.
3008
3110
  const boot = await readQueue();
@@ -3074,6 +3176,16 @@ async function init() {
3074
3176
  utilization: cachedUtilization,
3075
3177
  consecutiveFailures,
3076
3178
  });
3179
+
3180
+ feedbackSweepTickCount++;
3181
+ if (feedbackSweepDue(feedbackSweepTickCount, FEEDBACK_SWEEP_TICK_INTERVAL)) {
3182
+ feedbackSweepTickCount = 0;
3183
+ try {
3184
+ sweepFeedback();
3185
+ } catch (e) {
3186
+ console.warn('[scheduler] feedback sweep failed', e?.message);
3187
+ }
3188
+ }
3077
3189
  }, 60_000);
3078
3190
  if (heartbeatInterval.unref) heartbeatInterval.unref();
3079
3191
 
@@ -3188,7 +3300,7 @@ const remote = {
3188
3300
  },
3189
3301
 
3190
3302
  // Exposes the module-level allocateParallelGroup (PRD 548) to callers that
3191
- // only hold the `remote` object (adminServer.cjs's create-prd route) —
3303
+ // only hold the `remote` object (lib/prdCreate.cjs's create-prd route) —
3192
3304
  // reuses the same allocator the file-based /develop authoring path relies
3193
3305
  // on implicitly, rather than re-deriving NN here.
3194
3306
  allocateParallelGroup,
@@ -3216,4 +3328,36 @@ const remote = {
3216
3328
  },
3217
3329
  };
3218
3330
 
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 };
3331
+ // Registers the two job-management admin HTTP routes (PRD 689 moved
3332
+ // verbatim out of the former standalone admin HTTP server module's
3333
+ // handleRequest, no behavior change) against an injected localAdminHttp.cjs
3334
+ // transport. `remoteObj` is accepted as a parameter (defaults to this
3335
+ // module's own `remote`) so the route logic stays testable in isolation
3336
+ // without booting Electron, matching that former module's original
3337
+ // dependency-injection pattern.
3338
+ function registerAdminRoutes(adminHttp, remoteObj = remote) {
3339
+ adminHttp.registerRoute('GET', '/admin/scheduler/jobs', async (req, res) => {
3340
+ const jobs = await remoteObj.listJobs();
3341
+ sendJson(res, 200, jobs);
3342
+ });
3343
+
3344
+ adminHttp.registerRoute('POST', '/admin/scheduler/reset-job', async (req, res) => {
3345
+ const raw = await readBody(req);
3346
+ let parsed;
3347
+ try {
3348
+ parsed = raw ? JSON.parse(raw) : {};
3349
+ } catch {
3350
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
3351
+ return;
3352
+ }
3353
+ const slug = typeof parsed.slug === 'string' ? parsed.slug : null;
3354
+ if (!slug) {
3355
+ sendJson(res, 400, { ok: false, error: 'missing slug' });
3356
+ return;
3357
+ }
3358
+ const result = await remoteObj.resetJob(slug);
3359
+ sendJson(res, 200, result);
3360
+ });
3361
+ }
3362
+
3363
+ 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 };
@@ -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).
@@ -650,6 +650,7 @@ export interface FilesRenameResult { ok: boolean; newPath?: string; error: strin
650
650
  export interface FilesDeleteResult { ok: boolean; error: string | null }
651
651
  export interface FilesDuplicateResult { ok: boolean; path?: string; error?: string | null }
652
652
  export interface DocEditResult { ok: boolean; after?: string; error?: string }
653
+ export interface DocEditSessionResult { tabId: string; requestId: string; ok: boolean; after?: string; error?: string }
653
654
 
654
655
  export interface SearchFileEntry {
655
656
  name: string;
@@ -1272,7 +1273,17 @@ export interface SessionManagerAPI {
1272
1273
  duplicate: (path: string) => Promise<FilesDuplicateResult>;
1273
1274
  };
1274
1275
  docEdit: {
1275
- run: (payload: { path: string; before: string; instruction: string }) => Promise<DocEditResult>;
1276
+ run: (payload: { path: string; before: string; instruction: string; documentText?: string }) => Promise<DocEditResult>;
1277
+ runInSession: (payload: {
1278
+ tabId: string;
1279
+ sessionId: string;
1280
+ cwd: string;
1281
+ before: string;
1282
+ instruction: string;
1283
+ documentText?: string;
1284
+ requestId: string;
1285
+ }) => Promise<{ ok: boolean }>;
1286
+ onSessionResult: (handler: (payload: DocEditSessionResult) => void) => () => void;
1276
1287
  };
1277
1288
  /** Consolidated shell open/reveal. One method, discriminated on `as`, replaces
1278
1289
  * the former app.openIn* / app.openExternal / files.openExternal / files.showInFinder.
@@ -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'