claude-code-session-manager 0.39.2 → 0.39.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,299 @@
1
+ /**
2
+ * queueStore.cjs — federated scheduler state (2026-07-31 domain-model
3
+ * decision: "retire the global-aware scheduler").
4
+ *
5
+ * The old system of record was one global
6
+ * `~/.claude/session-manager/scheduled-plans/queue.json`. It is retired.
7
+ * State now lives where it belongs in the TAB → EPIC → PRD hierarchy:
8
+ *
9
+ * - Per-project job rows: `<cwd>/session-manager-operations/scheduler/state/queue.json`
10
+ * ({ jobs: [...] } — only that project's jobs)
11
+ * - Per-project history: `<cwd>/session-manager-operations/scheduler/state/history.jsonl`
12
+ * (owned by queueHistory.cjs, path resolved here)
13
+ * - Machine runtime state: `~/.claude/session-manager/scheduler-machine.json`
14
+ * (config, paused/rate-limit, scheduledFor, lastRunAt — these are
15
+ * Session-Manager runtime concerns, like the sessionSlots pool, not any
16
+ * one project's data. Run logs under scheduled-plans/runs/ stay
17
+ * machine-local for the same reason: they're execution artifacts of this
18
+ * machine's runner.)
19
+ *
20
+ * scheduler.cjs's 4k lines keep operating on ONE merged in-memory state
21
+ * object (jobs across all projects + machine fields); this module is the
22
+ * read-merge / write-split shim underneath readQueue/writeQueue. Jobs are
23
+ * split by `job.cwd` (fallback: the provided defaultCwd).
24
+ *
25
+ * Plain Node (no Electron deps) so watchdog scripts can require it; atomic
26
+ * writes are tmp+rename here rather than config.cjs's writeJson because
27
+ * config.cjs requires electron/chokidar and this must load outside the app.
28
+ */
29
+ 'use strict';
30
+
31
+ const fs = require('node:fs');
32
+ const fsp = require('node:fs/promises');
33
+ const path = require('node:path');
34
+ const os = require('node:os');
35
+ const { allProjectCwds, activeProjectCwds } = require('../../../scripts/lib/activeSessions.cjs');
36
+
37
+ const MACHINE_STATE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduler-machine.json');
38
+ const LEGACY_QUEUE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'queue.json');
39
+ const STATE_SUBPATH = ['session-manager-operations', 'scheduler', 'state'];
40
+
41
+ function projectStateDir(cwd) {
42
+ if (!cwd || typeof cwd !== 'string') throw new Error('projectStateDir: cwd is required');
43
+ return path.join(cwd, ...STATE_SUBPATH);
44
+ }
45
+
46
+ function projectQueuePath(cwd) {
47
+ return path.join(projectStateDir(cwd), 'queue.json');
48
+ }
49
+
50
+ function projectHistoryPath(cwd) {
51
+ return path.join(projectStateDir(cwd), 'history.jsonl');
52
+ }
53
+
54
+ function writeJsonAtomicSync(file, value) {
55
+ fs.mkdirSync(path.dirname(file), { recursive: true });
56
+ const tmp = `${file}.tmp-${process.pid}`;
57
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2));
58
+ fs.renameSync(tmp, file);
59
+ }
60
+
61
+ async function writeJsonAtomic(file, value) {
62
+ await fsp.mkdir(path.dirname(file), { recursive: true });
63
+ const tmp = `${file}.tmp-${process.pid}`;
64
+ await fsp.writeFile(tmp, JSON.stringify(value, null, 2));
65
+ await fsp.rename(tmp, file);
66
+ }
67
+
68
+ // ---------- project-cwd enumeration (cached) ----------
69
+
70
+ // allProjectCwds scans ~/.claude/projects; on the readQueue hot path (every
71
+ // IPC status call) that's too much stat traffic, so cache the resolved cwd
72
+ // list briefly. Correctness fallback: a brand-new project appears at worst
73
+ // CACHE_MS late, and its first write goes through writeSplit which busts the
74
+ // cache.
75
+ const CACHE_MS = 30_000;
76
+ let cwdCache = { at: 0, cwds: [] };
77
+
78
+ function stateCwds(opts) {
79
+ const now = Date.now();
80
+ if (!opts && now - cwdCache.at < CACHE_MS) return cwdCache.cwds;
81
+ const seen = new Set();
82
+ const cwds = [];
83
+ const add = (cwd) => { if (cwd && !seen.has(cwd)) { seen.add(cwd); cwds.push(cwd); } };
84
+ // Projects that already have a state file are authoritative sources...
85
+ for (const cwd of allProjectCwds(opts)) {
86
+ try { if (fs.existsSync(projectQueuePath(cwd))) add(cwd); } catch { /* skip */ }
87
+ }
88
+ // ...and active projects are included even before their first write.
89
+ for (const cwd of activeProjectCwds(undefined, opts)) add(cwd);
90
+ if (!opts) cwdCache = { at: now, cwds };
91
+ return cwds;
92
+ }
93
+
94
+ function bustCwdCache() {
95
+ cwdCache = { at: 0, cwds: [] };
96
+ }
97
+
98
+ // ---------- merged read ----------
99
+
100
+ function shapeMachine(raw) {
101
+ const data = raw ? JSON.parse(raw) : {};
102
+ return {
103
+ config: data.config || {},
104
+ scheduledFor: data.scheduledFor ?? null,
105
+ lastRunAt: data.lastRunAt ?? null,
106
+ paused: data.paused ?? null,
107
+ };
108
+ }
109
+
110
+ function shapeJobs(raw) {
111
+ const data = JSON.parse(raw);
112
+ return Array.isArray(data.jobs) ? data.jobs : [];
113
+ }
114
+
115
+ /**
116
+ * readMergedSync(opts?) → { config, jobs, scheduledFor, lastRunAt, paused,
117
+ * unreadable?, unreadablePath?, sourceCwds }.
118
+ *
119
+ * `unreadable` mirrors the old single-file semantics: ANY source file that
120
+ * exists but fails to parse halts scheduling (never treat a project's queue
121
+ * as empty because it read corrupt). `sourceCwds` records every project file
122
+ * consulted so writeSplit can persist "this project now has zero jobs".
123
+ */
124
+ function readMergedSync(opts) {
125
+ const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null };
126
+ const sourceCwds = [];
127
+ try {
128
+ Object.assign(out, shapeMachine(fs.readFileSync(MACHINE_STATE_PATH, 'utf8')));
129
+ } catch (e) {
130
+ if (e?.code !== 'ENOENT') {
131
+ out.unreadable = `machine state unreadable: ${e?.message}`;
132
+ out.unreadablePath = MACHINE_STATE_PATH;
133
+ }
134
+ }
135
+ for (const cwd of stateCwds(opts)) {
136
+ const file = projectQueuePath(cwd);
137
+ try {
138
+ out.jobs.push(...shapeJobs(fs.readFileSync(file, 'utf8')));
139
+ sourceCwds.push(cwd);
140
+ } catch (e) {
141
+ if (e?.code === 'ENOENT') { sourceCwds.push(cwd); continue; }
142
+ out.unreadable = out.unreadable || `project queue unreadable (${file}): ${e?.message}`;
143
+ out.unreadablePath = out.unreadablePath || file;
144
+ }
145
+ }
146
+ defineSources(out, sourceCwds);
147
+ return out;
148
+ }
149
+
150
+ /** Async twin of readMergedSync for IPC hot paths. */
151
+ async function readMerged(opts) {
152
+ const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null };
153
+ const sourceCwds = [];
154
+ try {
155
+ Object.assign(out, shapeMachine(await fsp.readFile(MACHINE_STATE_PATH, 'utf8')));
156
+ } catch (e) {
157
+ if (e?.code !== 'ENOENT') {
158
+ out.unreadable = `machine state unreadable: ${e?.message}`;
159
+ out.unreadablePath = MACHINE_STATE_PATH;
160
+ }
161
+ }
162
+ for (const cwd of stateCwds(opts)) {
163
+ const file = projectQueuePath(cwd);
164
+ try {
165
+ out.jobs.push(...shapeJobs(await fsp.readFile(file, 'utf8')));
166
+ sourceCwds.push(cwd);
167
+ } catch (e) {
168
+ if (e?.code === 'ENOENT') { sourceCwds.push(cwd); continue; }
169
+ out.unreadable = out.unreadable || `project queue unreadable (${file}): ${e?.message}`;
170
+ out.unreadablePath = out.unreadablePath || file;
171
+ }
172
+ }
173
+ defineSources(out, sourceCwds);
174
+ return out;
175
+ }
176
+
177
+ // Non-enumerable so broadcast/JSON payloads of the state never carry it.
178
+ function defineSources(state, sourceCwds) {
179
+ Object.defineProperty(state, 'sourceCwds', {
180
+ value: sourceCwds, enumerable: false, configurable: true, writable: true,
181
+ });
182
+ }
183
+
184
+ // ---------- split write ----------
185
+
186
+ /**
187
+ * writeSplit(state, defaultCwd) — persist a merged state back to its shards:
188
+ * machine fields → MACHINE_STATE_PATH; jobs grouped by job.cwd (fallback
189
+ * defaultCwd) → each project's state/queue.json. Every cwd the read consulted
190
+ * (state.sourceCwds) is written even when it now holds zero jobs, so
191
+ * deletions stick.
192
+ */
193
+ async function writeSplit(state, defaultCwd) {
194
+ await writeJsonAtomic(MACHINE_STATE_PATH, {
195
+ config: state.config,
196
+ scheduledFor: state.scheduledFor ?? null,
197
+ lastRunAt: state.lastRunAt ?? null,
198
+ paused: state.paused ?? null,
199
+ });
200
+
201
+ const byCwd = new Map();
202
+ for (const cwd of state.sourceCwds ?? []) byCwd.set(cwd, []);
203
+ for (const job of state.jobs ?? []) {
204
+ const cwd = job.cwd || defaultCwd;
205
+ if (!cwd) continue; // nowhere to put it; job is dropped from persistence rather than crashing
206
+ if (!byCwd.has(cwd)) byCwd.set(cwd, []);
207
+ byCwd.get(cwd).push(job);
208
+ }
209
+ for (const [cwd, jobs] of byCwd) {
210
+ try {
211
+ await writeJsonAtomic(projectQueuePath(cwd), { jobs });
212
+ } catch (e) {
213
+ // A single unwritable project (deleted repo dir, permissions) must not
214
+ // lose every other project's write.
215
+ console.error(`[queueStore] failed to write ${projectQueuePath(cwd)}: ${e?.message}`);
216
+ }
217
+ }
218
+ bustCwdCache();
219
+ }
220
+
221
+ // ---------- legacy migration ----------
222
+
223
+ /**
224
+ * migrateLegacyGlobalQueue(defaultCwd) — one-time boot split of the retired
225
+ * global queue.json into per-project shards. Shard rows win over legacy rows
226
+ * with the same slug (the shard is newer by construction). The legacy file is
227
+ * renamed to `queue.json.retired-<epoch>` so a rollback can recover it but no
228
+ * reader ever consults it again. Machine fields (config/paused/...) migrate
229
+ * only when no machine file exists yet. Idempotent: no legacy file → no-op.
230
+ */
231
+ async function migrateLegacyGlobalQueue(defaultCwd) {
232
+ let raw;
233
+ try {
234
+ raw = await fsp.readFile(LEGACY_QUEUE_PATH, 'utf8');
235
+ } catch {
236
+ return { migrated: false };
237
+ }
238
+ let legacy;
239
+ try {
240
+ legacy = JSON.parse(raw);
241
+ } catch (e) {
242
+ console.error(`[queueStore] legacy queue.json unparseable — leaving in place: ${e?.message}`);
243
+ return { migrated: false, error: e?.message };
244
+ }
245
+
246
+ if (!fs.existsSync(MACHINE_STATE_PATH)) {
247
+ await writeJsonAtomic(MACHINE_STATE_PATH, {
248
+ config: legacy.config || {},
249
+ scheduledFor: legacy.scheduledFor ?? null,
250
+ lastRunAt: legacy.lastRunAt ?? null,
251
+ paused: legacy.paused ?? null,
252
+ });
253
+ }
254
+
255
+ const legacyJobs = Array.isArray(legacy.jobs) ? legacy.jobs : [];
256
+ const byCwd = new Map();
257
+ for (const job of legacyJobs) {
258
+ const cwd = job.cwd || defaultCwd;
259
+ if (!cwd) continue;
260
+ if (!byCwd.has(cwd)) byCwd.set(cwd, []);
261
+ byCwd.get(cwd).push(job);
262
+ }
263
+ let moved = 0;
264
+ for (const [cwd, jobs] of byCwd) {
265
+ const file = projectQueuePath(cwd);
266
+ let existing = [];
267
+ try { existing = shapeJobs(await fsp.readFile(file, 'utf8')); } catch { /* fresh shard */ }
268
+ const have = new Set(existing.map((j) => j.slug));
269
+ const merged = [...existing, ...jobs.filter((j) => !have.has(j.slug))];
270
+ try {
271
+ await writeJsonAtomic(file, { jobs: merged });
272
+ moved += merged.length - existing.length;
273
+ } catch (e) {
274
+ console.error(`[queueStore] legacy split: failed to write ${file}: ${e?.message}`);
275
+ return { migrated: false, error: e?.message };
276
+ }
277
+ }
278
+
279
+ await fsp.rename(LEGACY_QUEUE_PATH, `${LEGACY_QUEUE_PATH}.retired-${Date.now()}`);
280
+ bustCwdCache();
281
+ return { migrated: true, moved, projects: byCwd.size };
282
+ }
283
+
284
+ module.exports = {
285
+ MACHINE_STATE_PATH,
286
+ LEGACY_QUEUE_PATH,
287
+ STATE_SUBPATH,
288
+ projectStateDir,
289
+ projectQueuePath,
290
+ projectHistoryPath,
291
+ stateCwds,
292
+ bustCwdCache,
293
+ readMerged,
294
+ readMergedSync,
295
+ writeSplit,
296
+ migrateLegacyGlobalQueue,
297
+ writeJsonAtomic,
298
+ writeJsonAtomicSync,
299
+ };
@@ -0,0 +1,85 @@
1
+ /**
2
+ * sessionSlots.cjs — the Session-Manager-owned machine-wide `claude -p`
3
+ * concurrency pool (2026-07-31 domain-model decision).
4
+ *
5
+ * Caps and limits belong to Session-Manager, not to any one consumer: the
6
+ * scheduler and chatRunner previously each enforced a private cap (3 and 2),
7
+ * which combined could exceed the machine's real budget — the exact shape of
8
+ * the 2026-06-10 five-parallel-`claude -p` OOM. Now every subsystem that
9
+ * wants to launch a `claude -p` process REQUESTS a slot here first and
10
+ * releases it when the process settles. There is one pool, sized to the
11
+ * machine (default 3 — CLAUDE.md "Avoid" cap; SM_SESSION_SLOTS overrides,
12
+ * clamped to [1, 3]).
13
+ *
14
+ * Consumers keep their own scheduling policy (FIFO lanes, batch picking,
15
+ * memory gates); this module only answers "may one more process start right
16
+ * now?". Plain Node, no Electron deps, process-local state — all consumers
17
+ * live in the one Electron main process, which is exactly why it can be the
18
+ * arbiter.
19
+ */
20
+ 'use strict';
21
+
22
+ const crypto = require('node:crypto');
23
+
24
+ function totalSlots() {
25
+ const parsed = parseInt(process.env.SM_SESSION_SLOTS || '3', 10);
26
+ return Math.min(3, Math.max(1, Number.isFinite(parsed) ? parsed : 3));
27
+ }
28
+
29
+ // token → { owner, at }
30
+ const holders = new Map();
31
+
32
+ function inUse() {
33
+ return holders.size;
34
+ }
35
+
36
+ function available() {
37
+ return Math.max(0, totalSlots() - holders.size);
38
+ }
39
+
40
+ /**
41
+ * acquire(owner) → token string, or null when the pool is exhausted.
42
+ * `owner` is a diagnostic label ("scheduler:<slug>", "chat:<tabId>") shown in
43
+ * snapshot() so a stuck holder is attributable.
44
+ */
45
+ function acquire(owner) {
46
+ if (holders.size >= totalSlots()) return null;
47
+ const token = crypto.randomUUID();
48
+ holders.set(token, { owner: String(owner || 'unknown'), at: new Date().toISOString() });
49
+ return token;
50
+ }
51
+
52
+ // Release listeners: each consumer registers its own "a slot freed — try to
53
+ // start work" pump so a scheduler release wakes the chat lane and vice versa.
54
+ const listeners = new Set();
55
+ function subscribe(fn) {
56
+ listeners.add(fn);
57
+ return () => listeners.delete(fn);
58
+ }
59
+
60
+ /** release(token) — idempotent; releasing an unknown/already-released token is a no-op. */
61
+ function release(token) {
62
+ const had = holders.delete(token);
63
+ if (had) {
64
+ for (const fn of listeners) {
65
+ try { fn(); } catch { /* a consumer's pump error is its own problem */ }
66
+ }
67
+ }
68
+ return had;
69
+ }
70
+
71
+ /** Diagnostic view for status surfaces and tests. */
72
+ function snapshot() {
73
+ return {
74
+ total: totalSlots(),
75
+ inUse: holders.size,
76
+ holders: [...holders.values()],
77
+ };
78
+ }
79
+
80
+ /** Test hook: drop all held slots. */
81
+ function __resetForTests() {
82
+ holders.clear();
83
+ }
84
+
85
+ module.exports = { totalSlots, inUse, available, acquire, release, subscribe, snapshot, __resetForTests };
@@ -307,6 +307,16 @@ async function archiveOne(slug, archiveDir) {
307
307
  }
308
308
  }
309
309
 
310
+ /**
311
+ * Injected by index.cjs at registration time (see registerQueueOpsHandlers)
312
+ * so a manual archive can retire any still-runnable queue job for the same
313
+ * slug without queueOps.cjs importing scheduler.cjs (circular — scheduler.cjs
314
+ * already requires queueOps.cjs). No-op until set; the auto-archive path
315
+ * never needs it (selectAutoArchivable only ever selects already-completed
316
+ * jobs).
317
+ */
318
+ let retireCompletedSlugsFn = async () => {};
319
+
310
320
  async function archiveMany(slugs) {
311
321
  if (!Array.isArray(slugs) || slugs.length === 0) {
312
322
  return { ok: true, archived: 0, archivedTo: null, results: [] };
@@ -324,6 +334,12 @@ async function archiveMany(slugs) {
324
334
  results.push(await archiveOne(slug, archiveDir));
325
335
  }
326
336
  const archived = results.filter((r) => r.ok).length;
337
+ const archivedSlugs = results.filter((r) => r.ok).map((r) => r.slug);
338
+ if (archivedSlugs.length > 0) {
339
+ await retireCompletedSlugsFn(archivedSlugs).catch((e) => {
340
+ logs.writeLine({ level: 'warn', scope: 'queueOps', message: 'archiveMany: retireCompletedSlugs failed', meta: { error: e?.message } });
341
+ });
342
+ }
327
343
  return { ok: true, archived, archivedTo: archiveDir, results };
328
344
  }
329
345
 
@@ -547,7 +563,10 @@ async function retagMany(items) {
547
563
 
548
564
  // ────────────────────────────────────────────── IPC registration
549
565
 
550
- function registerQueueOpsHandlers() {
566
+ function registerQueueOpsHandlers({ retireCompletedSlugs } = {}) {
567
+ if (typeof retireCompletedSlugs === 'function') {
568
+ retireCompletedSlugsFn = retireCompletedSlugs;
569
+ }
551
570
  ipcMain.handle('schedule:lint-queue', async () => {
552
571
  return lintAll();
553
572
  });