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.
- package/dist/assets/{TiptapBody-B7PHDZwk.js → TiptapBody-BM9Kz1Nm.js} +1 -1
- package/dist/assets/index-DX2w2YhJ.css +32 -0
- package/dist/assets/index-DuoC6oCy.js +3559 -0
- package/dist/index.html +2 -2
- package/package.json +2 -1
- package/src/main/__tests__/adminServer.test.cjs +205 -0
- package/src/main/__tests__/chat-queue.test.cjs +27 -0
- package/src/main/__tests__/health-tick-liveness.test.cjs +143 -0
- package/src/main/__tests__/prd-group-allocator.test.cjs +119 -0
- package/src/main/__tests__/prdCreate.test.cjs +82 -0
- package/src/main/__tests__/runVerify.test.cjs +146 -0
- package/src/main/__tests__/scheduler-committed-in-window.test.cjs +5 -3
- package/src/main/__tests__/scheduler-tick-cancel-token.test.cjs +54 -0
- package/src/main/__tests__/scheduler-transient-failure.test.cjs +141 -0
- package/src/main/adminServer.cjs +87 -2
- package/src/main/browserCapture.cjs +21 -7
- package/src/main/chatRunner.cjs +5 -1
- package/src/main/health.cjs +114 -3
- package/src/main/hives.cjs +2 -1
- package/src/main/ipcSchemas.cjs +33 -4
- package/src/main/lib/kebabCase.cjs +12 -0
- package/src/main/lib/memorySlug.cjs +10 -0
- package/src/main/lib/prdCreate.cjs +73 -0
- package/src/main/memoryAggregate.cjs +1 -1
- package/src/main/memoryTool.cjs +2 -1
- package/src/main/pty.cjs +2 -1
- package/src/main/runVerify.cjs +119 -6
- package/src/main/scheduler/prdParser.cjs +88 -0
- package/src/main/scheduler.cjs +176 -9
- package/src/main/templates/PRD_AUTHORING.md +126 -0
- package/src/main/usage.cjs +4 -0
- package/src/main/webRemote.cjs +13 -18
- package/src/preload/api.d.ts +1 -0
- package/dist/assets/index-B39Qiq0n.css +0 -32
- package/dist/assets/index-DRSHENEg.js +0 -3559
package/src/main/scheduler.cjs
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
1647
|
-
|
|
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
|
-
|
|
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.
|
package/src/main/usage.cjs
CHANGED
|
@@ -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
|
}
|
package/src/main/webRemote.cjs
CHANGED
|
@@ -321,11 +321,12 @@ function resetE2e(state = 'idle') {
|
|
|
321
321
|
|
|
322
322
|
// ─── WebSocket lifecycle ──────────────────────────────────────────────────────
|
|
323
323
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/src/preload/api.d.ts
CHANGED
|
@@ -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`)
|