claude-code-session-manager 0.35.15 → 0.35.17

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 (35) hide show
  1. package/dist/assets/{TiptapBody-B7PHDZwk.js → TiptapBody-BM9Kz1Nm.js} +1 -1
  2. package/dist/assets/index-DX2w2YhJ.css +32 -0
  3. package/dist/assets/index-DuoC6oCy.js +3559 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +2 -1
  6. package/src/main/__tests__/adminServer.test.cjs +205 -0
  7. package/src/main/__tests__/chat-queue.test.cjs +27 -0
  8. package/src/main/__tests__/health-tick-liveness.test.cjs +143 -0
  9. package/src/main/__tests__/prd-group-allocator.test.cjs +119 -0
  10. package/src/main/__tests__/prdCreate.test.cjs +82 -0
  11. package/src/main/__tests__/runVerify.test.cjs +146 -0
  12. package/src/main/__tests__/scheduler-committed-in-window.test.cjs +5 -3
  13. package/src/main/__tests__/scheduler-tick-cancel-token.test.cjs +54 -0
  14. package/src/main/__tests__/scheduler-transient-failure.test.cjs +141 -0
  15. package/src/main/adminServer.cjs +87 -2
  16. package/src/main/browserCapture.cjs +21 -7
  17. package/src/main/chatRunner.cjs +5 -1
  18. package/src/main/health.cjs +114 -3
  19. package/src/main/hives.cjs +2 -1
  20. package/src/main/ipcSchemas.cjs +33 -4
  21. package/src/main/lib/kebabCase.cjs +12 -0
  22. package/src/main/lib/memorySlug.cjs +10 -0
  23. package/src/main/lib/prdCreate.cjs +73 -0
  24. package/src/main/memoryAggregate.cjs +1 -1
  25. package/src/main/memoryTool.cjs +2 -1
  26. package/src/main/pty.cjs +2 -1
  27. package/src/main/runVerify.cjs +119 -6
  28. package/src/main/scheduler/prdParser.cjs +88 -0
  29. package/src/main/scheduler.cjs +176 -9
  30. package/src/main/templates/PRD_AUTHORING.md +126 -0
  31. package/src/main/usage.cjs +4 -0
  32. package/src/main/webRemote.cjs +13 -18
  33. package/src/preload/api.d.ts +1 -0
  34. package/dist/assets/index-B39Qiq0n.css +0 -32
  35. package/dist/assets/index-DRSHENEg.js +0 -3559
@@ -500,6 +500,17 @@ async function listPrdFiles() {
500
500
  return prdParser.listPrdFiles(PRDS_DIR);
501
501
  }
502
502
 
503
+ /**
504
+ * Atomically mint a brand-new parallel-group NN for a PRD about to be
505
+ * authored under PRDS_DIR. See prdParser.allocateParallelGroup for the
506
+ * collision-proof mechanics. Callers wanting to join an EXISTING group
507
+ * (deliberate parallel siblings) skip this and just reuse the NN prefix.
508
+ */
509
+ async function allocateParallelGroup() {
510
+ ensureDirs();
511
+ return prdParser.allocateParallelGroup(PRDS_DIR);
512
+ }
513
+
503
514
  /**
504
515
  * Best-effort kill of a child claude PID that the previous app instance spawned
505
516
  * but never reaped. Used by init() to clean up the orphan tree on boot.
@@ -709,6 +720,37 @@ let cancelToken = { cancelled: false };
709
720
  // Last memory-gate observation; included in snapshot for renderer visibility.
710
721
  let lastMemGate = null;
711
722
 
723
+ /**
724
+ * Pure: applies clearPause()'s effect on the tick cancel-token.
725
+ *
726
+ * ROOT CAUSE (2026-07-14 stall, PRD 543/544): setPaused() cancels the
727
+ * in-flight tick batch by setting cancelToken.cancelled = true (e.g. when a
728
+ * job's run is rate-limited). That flag was previously only ever reset back
729
+ * to false inside runDueJobs() (used by the force-tick/run-now IPC handlers
730
+ * and the resume-timer callback). clearPause() itself — the function the
731
+ * poll-loop's auth/network auto-recovery and the manual "Resume" button
732
+ * actually call — never reset it. So once ANY pause fired and was later
733
+ * cleared through one of THOSE paths instead of runDueJobs(), cancelToken
734
+ * .cancelled stayed permanently true, and tickQueue()'s very first guard
735
+ * (`if (cancelToken.cancelled) return {fired:false, reason:'cancelled'}`)
736
+ * silently short-circuited every future tick — from spawnJob's own
737
+ * post-completion tick, from the when-available poll, and from the
738
+ * dead-process reaper — forever, even though queue.json's `paused` field
739
+ * was correctly null and every other gate (enabled, concurrency, memory)
740
+ * said go. This explains the full incident: lastRunAt froze at the last
741
+ * tick that got past the guard, new PRDs kept appearing as `pending`
742
+ * because reconcile() also runs independently from the `schedule:state` IPC
743
+ * read (unrelated to tickQueue), and only a manual force-tick (which goes
744
+ * through runDueJobs()) could ever unwedge it.
745
+ *
746
+ * Exported so this regression is unit-testable without touching the real
747
+ * scheduler's fs-backed queue.json.
748
+ */
749
+ function applyPauseCleared(wasPaused, token) {
750
+ if (wasPaused) token.cancelled = false;
751
+ return token;
752
+ }
753
+
712
754
  function attachWindow(w) { mainWindow = w; }
713
755
 
714
756
  /**
@@ -839,6 +881,8 @@ async function clearPause(source) {
839
881
  s.paused = null;
840
882
  return true;
841
883
  });
884
+ // Un-cancel the tick guard on every recovery path, not just runDueJobs().
885
+ applyPauseCleared(wasPaused, cancelToken);
842
886
  // Track manual clears for the auto-pause cooldown.
843
887
  if (source === 'manual' || source === 'run-now') {
844
888
  pauseClearedManuallyAt = Date.now();
@@ -883,6 +927,69 @@ function detectRateLimitInLog(logPath) {
883
927
  }
884
928
  }
885
929
 
930
+ /** Scan the tail of a job's log for a network-outage signal: the structured
931
+ * `terminal_reason":"api_error"` field alongside a network-class error
932
+ * string. This is NOT a real code defect — spawning an auto-fix
933
+ * investigation for it just burns a second run diagnosing an outage (PRD
934
+ * 543's ENOTFOUND incident, 2026-07-14). Deliberately narrow: only fires on
935
+ * the structured terminal_reason field, never on ad-hoc "error" text that
936
+ * legitimately appears in TDD red-phase transcripts. */
937
+ function detectNetworkErrorInLog(logPath) {
938
+ try {
939
+ const text = readTail(logPath, 16384);
940
+ if (!text) return false;
941
+ if (!/"terminal_reason":"api_error"/.test(text)) return false;
942
+ return /ENOTFOUND/.test(text)
943
+ || /ECONNREFUSED/.test(text)
944
+ || /ETIMEDOUT/.test(text)
945
+ || /EAI_AGAIN/.test(text)
946
+ || /network is unreachable/i.test(text);
947
+ } catch {
948
+ return false;
949
+ }
950
+ }
951
+
952
+ // Bounded retry cap for transient (signal-kill or network-outage) failures.
953
+ // A small constant, not a config knob: the point is that it is impossible to
954
+ // loop unboundedly (queueOps.cjs lints for unbounded loops; see the fizzpop
955
+ // poll-hang in PRD_AUTHORING.md for why an unbounded retry is a real hazard).
956
+ const TRANSIENT_RETRY_CAP = 2;
957
+
958
+ /**
959
+ * Classify a failed job's outcome as one of: retry it (transient, bounded),
960
+ * fail it without auto-fix (transient but unsafe to retry, or the retry cap
961
+ * is exhausted), or investigate it (a genuine code failure). Pure/no I/O so
962
+ * the transient-vs-terminal boundary can be unit-tested directly rather than
963
+ * only through a live spawnJob run.
964
+ *
965
+ * A 143/137 exit within the idle-watchdog window is a signal-kill transient
966
+ * (external kill, not a real failure — see the call site's long-form
967
+ * rationale). A `terminal_reason:"api_error"` + network-class error
968
+ * (ENOTFOUND etc., detected by detectNetworkErrorInLog) is an outage
969
+ * transient (PRD 543/545 incident) — same bounded-retry treatment, not a
970
+ * second parallel classifier.
971
+ */
972
+ function classifyFailureOutcome({ exitCode, networkError, durationMs, transientRetries, newlyDirtyCount }) {
973
+ const signalTransient = (exitCode === 143 || exitCode === 137) && durationMs < IDLE_OUTPUT_KILL_MS;
974
+ const networkTransient = networkError === true;
975
+ const transient = signalTransient || networkTransient;
976
+ if (!transient) return { action: 'investigate' };
977
+
978
+ const transientKind = networkTransient ? 'network' : `exit=${exitCode}`;
979
+ const retries = transientRetries ?? 0;
980
+ // A transient failure can still leave partial work on disk (the 543
981
+ // ENOTFOUND run wrote its renderer, then died before committing). Requeuing
982
+ // over that dirt would either re-implement on top of it or conflict with
983
+ // it — refuse and fail instead of silently re-running over orphaned edits.
984
+ if (newlyDirtyCount > 0) {
985
+ return { action: 'fail-dirty', transientKind, newlyDirtyCount };
986
+ }
987
+ if (retries >= TRANSIENT_RETRY_CAP) {
988
+ return { action: 'fail-cap', transientKind, retries };
989
+ }
990
+ return { action: 'retry', transientKind, retries };
991
+ }
992
+
886
993
  // ---------- execution ----------
887
994
 
888
995
  function pickRunDir() {
@@ -1119,14 +1226,15 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
1119
1226
  sl(`\n[scheduler] exit code=${effectiveCode} (raw code=${exitCode} signal=${signal}) ` +
1120
1227
  `duration=${Math.round(durationMs / 1000)}s\n`);
1121
1228
  const rateLimited = effectiveCode !== 0 && detectRateLimitInLog(logPath);
1229
+ const networkError = effectiveCode !== 0 && !rateLimited && detectNetworkErrorInLog(logPath);
1122
1230
  // Sync write: child 'exit' handler must flush meta before resolve()
1123
1231
  // so the spawnJob mutate() that follows sees the persisted exit code.
1124
1232
  config.writeJsonSync(metaPath, {
1125
- slug: job.slug, cwd, sessionId, exitCode: effectiveCode, rateLimited,
1233
+ slug: job.slug, cwd, sessionId, exitCode: effectiveCode, rateLimited, networkError,
1126
1234
  startedAt, finishedAt: Date.now(), durationMs,
1127
1235
  agentResultSubtype, mappedFromSignal: mappedToSuccess ? signal || `code=${exitCode}` : null,
1128
1236
  });
1129
- resolve({ exitCode: effectiveCode, durationMs, rateLimited, sessionId });
1237
+ resolve({ exitCode: effectiveCode, durationMs, rateLimited, networkError, sessionId });
1130
1238
  },
1131
1239
  });
1132
1240
 
@@ -1538,7 +1646,14 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
1538
1646
  let effectiveStatus;
1539
1647
  if (res.exitCode !== 0) {
1540
1648
  effectiveStatus = 'failed';
1541
- } else if (!verifyResult || verifyResult.verdict === 'clean') {
1649
+ } else if (
1650
+ !verifyResult
1651
+ || verifyResult.verdict === 'clean'
1652
+ // pass_no_commit_target_verified: -merge-main postcondition exemption
1653
+ // (runVerify.cjs) — an independently gh-confirmed clean merge target,
1654
+ // not a plain unsubstantiated PASS. Completed, same as 'clean'.
1655
+ || verifyResult.verdict === 'pass_no_commit_target_verified'
1656
+ ) {
1542
1657
  effectiveStatus = 'completed';
1543
1658
  } else if (verifyResult.downgradeTo === 'pending') {
1544
1659
  // HALT or deps_unmet: reset to pending so the job re-fires.
@@ -1641,15 +1756,58 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
1641
1756
  // 67 s.) Genuine watchdog kills (idle ≥20 min, deadman 4 h) run longer than
1642
1757
  // the threshold and still fall through to investigation.
1643
1758
  const ec = failedJobSnapshot.exitCode;
1644
- const transient = (ec === 143 || ec === 137) && res.durationMs < IDLE_OUTPUT_KILL_MS;
1645
1759
  const retries = failedJobSnapshot.transientRetries ?? 0;
1646
- if (transient && retries < 2) {
1647
- console.log(`[scheduler] transient failure (exit=${ec} dur=${res.durationMs}ms) auto-retry ${retries + 1}/2 for ${job.slug}`);
1760
+ // Only pay for the extra git status call when the failure is plausibly
1761
+ // transient a real code failure never needs the dirty-tree check.
1762
+ const maybeTransient = (ec === 143 || ec === 137) || res.networkError === true;
1763
+ let newlyDirtyCount = 0;
1764
+ let dirtySample = '';
1765
+ if (maybeTransient) {
1766
+ const afterFailure = await uncommittedChanges(guardCwd);
1767
+ const baseSet = new Set(guardBaseline || []);
1768
+ const newlyDirty = (afterFailure || []).filter((p) => !baseSet.has(p));
1769
+ newlyDirtyCount = newlyDirty.length;
1770
+ dirtySample = newlyDirty.slice(0, 3).join(', ');
1771
+ }
1772
+ const decision = classifyFailureOutcome({
1773
+ exitCode: ec,
1774
+ networkError: res.networkError,
1775
+ durationMs: res.durationMs,
1776
+ transientRetries: retries,
1777
+ newlyDirtyCount,
1778
+ });
1779
+
1780
+ if (decision.action === 'retry') {
1781
+ console.log(`[scheduler] transient failure (${decision.transientKind} dur=${res.durationMs}ms) — auto-retry ${decision.retries + 1}/${TRANSIENT_RETRY_CAP} for ${job.slug}`);
1648
1782
  await mutate((s) => {
1649
1783
  const i = s.jobs.findIndex((x) => x.slug === job.slug);
1650
1784
  if (i >= 0) {
1651
1785
  resetJobFields(s.jobs[i], null);
1652
- s.jobs[i].transientRetries = retries + 1;
1786
+ s.jobs[i].transientRetries = decision.retries + 1;
1787
+ }
1788
+ });
1789
+ await broadcast();
1790
+ } else if (decision.action === 'fail-dirty') {
1791
+ console.log(`[scheduler] transient failure (${decision.transientKind}) for ${job.slug} left ${newlyDirtyCount} uncommitted file(s) (e.g. ${dirtySample}) — not auto-requeuing`);
1792
+ await mutate((s) => {
1793
+ const i = s.jobs.findIndex((x) => x.slug === job.slug);
1794
+ if (i >= 0) {
1795
+ s.jobs[i].status = 'failed';
1796
+ s.jobs[i].error = `transient failure (${decision.transientKind}) left ${newlyDirtyCount} uncommitted file(s) in working tree (e.g. ${dirtySample}) — not auto-requeued to avoid overwriting partial work; review and commit or discard manually`;
1797
+ }
1798
+ });
1799
+ await broadcast();
1800
+ // No auto-fix investigation: this isn't a code defect, and the
1801
+ // dirty tree needs a human, not a diagnosis run.
1802
+ } else if (decision.action === 'fail-cap') {
1803
+ // Retry cap exhausted: fail terminally, but a transient classification
1804
+ // never spawns an auto-fix investigation — there is no code defect to
1805
+ // diagnose, only a recurring outage.
1806
+ console.log(`[scheduler] transient failure (${decision.transientKind}) for ${job.slug} exhausted retry cap (${decision.retries}/${TRANSIENT_RETRY_CAP}) — failing without auto-fix`);
1807
+ await mutate((s) => {
1808
+ const i = s.jobs.findIndex((x) => x.slug === job.slug);
1809
+ if (i >= 0) {
1810
+ s.jobs[i].error = `transient failure (${decision.transientKind}) exhausted retry cap (${decision.retries}/${TRANSIENT_RETRY_CAP}) — marking failed without auto-fix investigation`;
1653
1811
  }
1654
1812
  });
1655
1813
  await broadcast();
@@ -2184,7 +2342,10 @@ async function reverifyNeedsReview() {
2184
2342
  allowPreSentinelHeal: true,
2185
2343
  });
2186
2344
  } catch { leftForReview.push({ slug: job.slug, reason: 'verifyRun threw' }); continue; }
2187
- if (v && v.verdict === 'clean') {
2345
+ // pass_no_commit_target_verified: -merge-main postcondition exemption
2346
+ // (runVerify.cjs) — same "heal it" treatment as 'clean', see spawnJob's
2347
+ // effectiveStatus branch above for the primary-path equivalent.
2348
+ if (v && (v.verdict === 'clean' || v.verdict === 'pass_no_commit_target_verified')) {
2188
2349
  healed.push(job.slug);
2189
2350
  } else {
2190
2351
  leftForReview.push({ slug: job.slug, reason: v ? `${v.verdict}: ${v.reason}` : 'null verdict' });
@@ -2818,6 +2979,12 @@ const remote = {
2818
2979
  return state.jobs.map((j) => ({ slug: j.slug, title: j.title, status: j.status, cwd: j.cwd }));
2819
2980
  },
2820
2981
 
2982
+ // Exposes the module-level allocateParallelGroup (PRD 548) to callers that
2983
+ // only hold the `remote` object (adminServer.cjs's create-prd route) —
2984
+ // reuses the same allocator the file-based /develop authoring path relies
2985
+ // on implicitly, rather than re-deriving NN here.
2986
+ allocateParallelGroup,
2987
+
2821
2988
  async runNow() {
2822
2989
  await clearPause('run-now');
2823
2990
  runDueJobs().catch((e) => logs.writeLine({
@@ -2841,4 +3008,4 @@ const remote = {
2841
3008
  },
2842
3009
  };
2843
3010
 
2844
- module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, 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 };
3011
+ 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 };
@@ -314,3 +314,129 @@ bounded number of times (§1) rather than capping the foreground command.
314
314
  `timeout`-capped full-universe EDGAR ingest. Both committed correct, green, AC-complete work
315
315
  (77's cursor-hold fix; 80's 6 EDGAR rows + installed cron) yet were downgraded to `needs_review`
316
316
  on the incidental middle-of-run markers. 13a/13b keep the middle of the transcript clean.
317
+
318
+ ## §14 Queueing PRDs from external automation
319
+
320
+ **Summary:** A downstream project (Connector Atlas, `gh-issue-5`) wanted a Slack-feedback → PRD
321
+ loop but had no documented programmatic queueing path. This section is that path.
322
+
323
+ **Decision, up front:** Is the session-manager Electron app running on this machine right now?
324
+ - **Yes** → call the `scheduler_create_prd` MCP tool.
325
+ - **No** → write the PRD file by hand into the prds directory, and accept the collision risk
326
+ described below.
327
+
328
+ ### The `scheduler_create_prd` MCP tool
329
+
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
332
+ repo's `.mcp.json` as the `session-manager-scheduler` MCP server. An external project wanting to
333
+ call it from its own automation needs the equivalent MCP server registration pointing at this
334
+ repo's `scripts/scheduler-mcp-server.cjs`, or can call the admin HTTP route directly (same
335
+ request/response shape) using the token at `~/.claude/session-manager/admin-api.json`.
336
+
337
+ **Input** (validated server-side by `ipcSchemas.cjs`'s `schemas.schedulerCreatePrd`):
338
+
339
+ | field | type | required | notes |
340
+ |---|---|---|---|
341
+ | `title` | string | yes | one-line title, no newlines |
342
+ | `cwd` | string | yes | absolute path to the target project; validated via `config.cjs`'s `validatePath` (allowedRoots = home dir) |
343
+ | `estimateMinutes` | number | yes | integer wall-clock estimate |
344
+ | `goal` | string | yes | 2–4 sentences: what the executor builds and why |
345
+ | `acceptanceCriteria` | string[] | yes | 1–100 entries, each one verifiable checklist line |
346
+ | `implementationNotes` | string | yes | file paths, patterns, constraints the executor needs |
347
+ | `outOfScope` | string[] | no | what NOT to build |
348
+ | `slug` | string | no | kebab-case; derived from `title` if omitted |
349
+ | `parallelGroup` | number | no | opt into an existing `NN` group instead of allocating a new one |
350
+
351
+ **Return** (`{nn, filename, status}`, per `adminServer.cjs`):
352
+ ```json
353
+ { "nn": 550, "filename": "550-my-feature.md", "status": "queued" }
354
+ ```
355
+ On failure the tool returns `{ ok: false, error: "..." }` (e.g. `409` if `filename` already
356
+ exists, `400` if `cwd` is rejected by `validatePath` or the payload fails schema validation).
357
+
358
+ **Worked example** (MCP tool call, e.g. from Claude Code or any MCP client):
359
+ ```json
360
+ {
361
+ "tool": "scheduler_create_prd",
362
+ "arguments": {
363
+ "title": "Sync Slack #feedback channel into feedback intake",
364
+ "cwd": "~/Projects/connector-atlas",
365
+ "estimateMinutes": 20,
366
+ "goal": "Pull unread messages from the #feedback Slack channel and materialize each as a feedback file, deduped against already-tracked message ts.",
367
+ "acceptanceCriteria": [
368
+ "New feedback files land in connector-atlas's own feedback intake folder, one per undeduped message",
369
+ "Each file's `source` field is `slack-<channel>-<ts>` for future dedup",
370
+ "timeout 300 npm run typecheck passes"
371
+ ],
372
+ "implementationNotes": "Use the Slack Web API conversations.history endpoint; token lives in connector-atlas's own secrets store, not session-manager's."
373
+ }
374
+ }
375
+ ```
376
+ Server-side, this atomically allocates the `NN` prefix (`allocateParallelGroup()`, PRD 548),
377
+ appends the engineering standards block, and writes the PRD file — the same shape `/develop`
378
+ produces by hand.
379
+
380
+ ### The app-must-be-running caveat (read this first)
381
+
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
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`
386
+ architecture entry) and stops existing the moment the app quits — it is not a standalone daemon.
387
+ If the app is closed, `scheduler-mcp-server.cjs` cannot read a live port/token and every call
388
+ returns the error `session-manager app is not running (admin API unreachable) — start it first`.
389
+ This is the single most likely point of confusion for an automation author who assumes the tool
390
+ is a normal always-on API — it is not; it is a convenience surface hosted by a desktop app that
391
+ the user may or may not have open.
392
+
393
+ ### Fallback: writing the PRD file directly
394
+
395
+ When the app is not running, write `<NN>-<slug>.md` by hand into
396
+ `~/.claude/session-manager/scheduled-plans/prds/` following the frontmatter rules in §6 and the
397
+ body conventions the rest of this guide describes (`# Goal`, `# Acceptance criteria`,
398
+ `# Implementation notes`, `## Engineering standards` inlined verbatim — see `/develop`'s output
399
+ for the exact shape). **Trade-off:** this path has no atomic `NN` allocation. The tool's
400
+ `allocateParallelGroup()` (PRD 548) exists specifically to close a race where two writers pick
401
+ the same `NN` at once; a hand-written file bypasses that reservation entirely, so if another
402
+ writer (a human, `/develop`, or another automation) picks the same `NN` around the same time, one
403
+ file silently shadows or is shadowed by the other's parallel-group slot. Pick an `NN` by scanning
404
+ the existing prds directory for the current max and incrementing, and treat a collision as
405
+ possible, not merely theoretical.
406
+
407
+ ### Ownership boundary
408
+
409
+ Projects own their own source adapters — Slack, GitHub, Linear, email, whatever inbound channel
410
+ they read. Session-manager owns the queueing API (`scheduler_create_prd` / the admin route) and
411
+ nothing upstream of it. Session-manager does not host, run, or import another project's adapter
412
+ code; the adapter runs entirely inside the calling project (its own cron, its own credentials,
413
+ its own filtering/triage logic) and only reaches into session-manager at the single, narrow
414
+ `scheduler_create_prd` call.
415
+
416
+ ### Why project-supplied `automation-hooks.js` was declined
417
+
418
+ Connector Atlas's third ask was a mechanism to drop a project-supplied `automation-hooks.js` file
419
+ that session-manager would load and execute in-process on a timer. This was declined, and should
420
+ not be re-proposed:
421
+
422
+ - It would grant main-process privileges (full filesystem access, IPC, the admin server's own
423
+ token) to arbitrary code from any project directory, invoked on a schedule the *project*
424
+ controls rather than the *user*.
425
+ - It inverts this project's core invariants: `config.cjs`'s `validatePath` gate on every
426
+ filesystem path, `ipcSchemas.cjs`'s zod-validated IPC boundary, and `CLAUDE.md`'s Avoid-list
427
+ ban on `shell: true` outside the two features that legitimately need it. A loaded-and-executed
428
+ project JS file has no equivalent boundary to pass through — it *is* the process.
429
+ - The supported extension point is the MCP tool described above, called from *outside* the
430
+ session-manager process by the project's own cron/automation. That keeps the privilege boundary
431
+ where it already is (the loopback admin server, token-authed, narrow three-route surface) rather
432
+ than dissolving it.
433
+
434
+ ### Reference implementation of this exact loop
435
+
436
+ `/process-feedback`'s step 0b (source → triage → `/develop` → queue) is a working example of this
437
+ shape, landed 2026-07-14 (`feat(process-feedback): sync open GitHub issues into the feedback
438
+ intake`, commit `352b89c`). It syncs open GitHub issues into `session-manager-operations/feedback/`
439
+ (deduped on a `gh-issue-<N>` token), then processes each item through the same triage → `/develop`
440
+ → queue path the rest of this guide documents. An external project building a Slack (or any
441
+ other) adapter should follow the same source → triage → queue shape, ending at
442
+ `scheduler_create_prd` (app running) or a hand-written PRD file (app closed) as described above.
@@ -240,6 +240,10 @@ function registerBillingHandlers() {
240
240
  if (cache) return { kind: 'ok-stale', data: cache.data, staleSince: cache.fetchedAt, lastError: r.message };
241
241
  return r;
242
242
  }
243
+ if (r.kind === 'meter_rate_limited') {
244
+ if (cache) return { kind: 'meter_rate_limited', message: r.message, httpStatus: r.httpStatus, cached: cache.data, staleSince: cache.fetchedAt };
245
+ return r;
246
+ }
243
247
  return r; // config
244
248
  });
245
249
  }
@@ -321,11 +321,12 @@ function resetE2e(state = 'idle') {
321
321
 
322
322
  // ─── WebSocket lifecycle ──────────────────────────────────────────────────────
323
323
 
324
- function broadcastStatus() {
325
- if (!_window || _window.isDestroyed()) return;
326
- const cfg = loadConfigSync();
324
+ // Single source of truth for the status payload shape shared by the
325
+ // broadcast push (webRemote:status) and the get-status IPC pull — both must
326
+ // report the same connected/e2e/device view of `cfg` + `_ws`/`_e2e` module state.
327
+ function computeStatus(cfg) {
327
328
  const connected = _ws !== null && _ws.readyState === WebSocket.OPEN;
328
- sendIfAlive(_window, 'webRemote:status', {
329
+ return {
329
330
  enabled: cfg.remoteEnabled,
330
331
  remoteControlEnabled: cfg.remoteControlEnabled ?? false,
331
332
  connected,
@@ -336,7 +337,13 @@ function broadcastStatus() {
336
337
  devices: (cfg.devices || []).map(({ deviceId, deviceName, issuedAt, lastConnectedAt }) => ({
337
338
  deviceId, deviceName, issuedAt, lastConnectedAt,
338
339
  })),
339
- });
340
+ };
341
+ }
342
+
343
+ function broadcastStatus() {
344
+ if (!_window || _window.isDestroyed()) return;
345
+ const cfg = loadConfigSync();
346
+ sendIfAlive(_window, 'webRemote:status', computeStatus(cfg));
340
347
  }
341
348
 
342
349
  function stopHeartbeat() {
@@ -1214,19 +1221,7 @@ function registerRemoteHandlers() {
1214
1221
  // Returns current status without tokens — safe to expose to renderer.
1215
1222
  ipcMain.handle('webRemote:get-status', async () => {
1216
1223
  const cfg = await loadConfig();
1217
- const connected = _ws !== null && _ws.readyState === WebSocket.OPEN;
1218
- return {
1219
- enabled: cfg.remoteEnabled,
1220
- remoteControlEnabled: cfg.remoteControlEnabled ?? false,
1221
- connected,
1222
- e2eActive: connected && _e2e.sessionKey !== null,
1223
- e2eAuthenticated: connected && _e2e.state === 'authenticated',
1224
- e2eState: connected ? _e2e.state : 'idle',
1225
- pendingSas: connected && _e2e.state === 'pending_sas' ? _e2e.pendingSas : null,
1226
- devices: (cfg.devices || []).map(({ deviceId, deviceName, issuedAt, lastConnectedAt }) => ({
1227
- deviceId, deviceName, issuedAt, lastConnectedAt,
1228
- })),
1229
- };
1224
+ return computeStatus(cfg);
1230
1225
  });
1231
1226
 
1232
1227
  // User confirmed that the SAS shown on both desktop and browser match.
@@ -212,6 +212,7 @@ export type BillingFetchResult =
212
212
  | { kind: 'ok-stale'; data: BillingData; staleSince: number; lastError: string }
213
213
  | { kind: 'auth'; message: string; httpStatus: number; expiredAt?: number | null; cached?: BillingData; staleSince?: number }
214
214
  | { kind: 'transient'; message: string; httpStatus: number | null }
215
+ | { kind: 'meter_rate_limited'; message: string; httpStatus: number; cached?: BillingData; staleSince?: number }
215
216
  | { kind: 'config'; message: string };
216
217
 
217
218
  // ── MCP server live connection probe (`claude mcp list`)