claude-code-session-manager 0.43.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,36 +1,80 @@
1
1
  // sessionSlots.cjs — the Session-Manager-owned machine-wide claude -p pool.
2
2
  // vitest globals (test/beforeEach/afterEach) — same convention as the other .cjs tests.
3
3
  const assert = require('node:assert');
4
+ const fs = require('node:fs');
4
5
  const slots = require('../sessionSlots.cjs');
5
6
 
6
- beforeEach(() => slots.__resetForTests());
7
+ // Same real-homedir convention as queueStore.cjs's own tests — no path override exists
8
+ // for this module, so keep the persisted file removed except where a test deliberately
9
+ // wants it, and restore it to unset afterward.
10
+ const CONFIG_PATH = require('node:path').join(require('node:os').homedir(), '.claude', 'session-manager', 'session-slots-config.json');
11
+ function removeConfig() {
12
+ try { fs.unlinkSync(CONFIG_PATH); } catch { /* already absent */ }
13
+ }
14
+
15
+ beforeEach(() => {
16
+ slots.__resetForTests();
17
+ removeConfig();
18
+ });
7
19
  afterEach(() => {
8
20
  slots.__resetForTests();
9
21
  delete process.env.SM_SESSION_SLOTS;
22
+ removeConfig();
10
23
  });
11
24
 
12
- test('pool defaults to 3 slots and exhausts', () => {
13
- assert.equal(slots.totalSlots(), 3);
14
- const t1 = slots.acquire('a');
15
- const t2 = slots.acquire('b');
16
- const t3 = slots.acquire('c');
17
- assert.ok(t1 && t2 && t3);
25
+ test('pool defaults to 5 slots and exhausts', () => {
26
+ assert.equal(slots.totalSlots(), 5);
27
+ const tokens = ['a', 'b', 'c', 'd', 'e'].map((o) => slots.acquire(o));
28
+ assert.ok(tokens.every(Boolean));
18
29
  assert.equal(slots.available(), 0);
19
- assert.equal(slots.acquire('d'), null, 'fourth acquire must be refused');
20
- slots.release(t2);
30
+ assert.equal(slots.acquire('f'), null, 'sixth acquire must be refused');
31
+ slots.release(tokens[1]);
21
32
  assert.equal(slots.available(), 1);
22
- assert.ok(slots.acquire('d'));
33
+ assert.ok(slots.acquire('f'));
23
34
  });
24
35
 
25
- test('SM_SESSION_SLOTS overrides, clamped to [1,3]', () => {
36
+ test('SM_SESSION_SLOTS overrides, clamped to [0, 10]', () => {
26
37
  process.env.SM_SESSION_SLOTS = '1';
27
38
  assert.equal(slots.totalSlots(), 1);
28
- process.env.SM_SESSION_SLOTS = '9';
29
- assert.equal(slots.totalSlots(), 3, 'clamped high');
39
+ process.env.SM_SESSION_SLOTS = '20';
40
+ assert.equal(slots.totalSlots(), 10, 'clamped high');
30
41
  process.env.SM_SESSION_SLOTS = '0';
31
- assert.equal(slots.totalSlots(), 1, 'clamped low');
42
+ assert.equal(slots.totalSlots(), 0, '0 is a legal explicit value (pauses new launches)');
43
+ process.env.SM_SESSION_SLOTS = '-5';
44
+ assert.equal(slots.totalSlots(), 0, 'clamped low');
32
45
  process.env.SM_SESSION_SLOTS = 'junk';
33
- assert.equal(slots.totalSlots(), 3, 'non-numeric falls back to default');
46
+ assert.equal(slots.totalSlots(), 5, 'non-numeric falls back to default');
47
+ });
48
+
49
+ test('cap of 0 pauses all new acquisitions without touching existing holders', () => {
50
+ const t = slots.acquire('a');
51
+ assert.ok(t);
52
+ process.env.SM_SESSION_SLOTS = '0';
53
+ assert.equal(slots.acquire('b'), null);
54
+ assert.equal(slots.inUse(), 1, 'existing holder is untouched by the pause');
55
+ slots.release(t);
56
+ assert.equal(slots.inUse(), 0);
57
+ });
58
+
59
+ test('setCap persists a value that totalSlots() picks up without an env override', () => {
60
+ slots.setCap(8);
61
+ assert.equal(slots.totalSlots(), 8);
62
+ slots.setCap(0);
63
+ assert.equal(slots.totalSlots(), 0);
64
+ });
65
+
66
+ test('setCap rejects out-of-range or non-integer values', () => {
67
+ assert.throws(() => slots.setCap(11));
68
+ assert.throws(() => slots.setCap(-1));
69
+ assert.throws(() => slots.setCap(2.5));
70
+ });
71
+
72
+ test('setCap notifies subscribers so a raised cap can wake waiting consumers', () => {
73
+ let notified = 0;
74
+ const unsub = slots.subscribe(() => { notified += 1; });
75
+ slots.setCap(7);
76
+ assert.equal(notified, 1);
77
+ unsub();
34
78
  });
35
79
 
36
80
  test('release is idempotent and notifies subscribers exactly on real releases', () => {
@@ -51,6 +51,17 @@ function writeActiveIndex(cwd, index) {
51
51
  fs.renameSync(tmp, file);
52
52
  }
53
53
 
54
+ // index.sessions/index.events are plain objects parsed from JSON — bracket
55
+ // lookup with an attacker/agent-supplied key like "__proto__" or
56
+ // "constructor" resolves through the prototype chain to a truthy
57
+ // Object.prototype member even though no such Epic was ever written,
58
+ // bypassing every "does this Epic exist" gate below (including the
59
+ // mintIfMissing:false join-only check that PRD-authoring paths rely on).
60
+ // Always use this instead of `obj[key]`/`!obj[key]` for existence checks.
61
+ function hasOwn(obj, key) {
62
+ return Object.prototype.hasOwnProperty.call(obj, key);
63
+ }
64
+
54
65
  function slugify(text) {
55
66
  return String(text || 'epic')
56
67
  .toLowerCase()
@@ -59,6 +70,85 @@ function slugify(text) {
59
70
  .slice(0, 48) || 'epic';
60
71
  }
61
72
 
73
+ const STOPWORDS = new Set([
74
+ 'the', 'a', 'an', 'is', 'of', 'to', 'for', 'and', 'in', 'on', 'at', 'this', 'that',
75
+ ]);
76
+
77
+ // Lowercase, strip punctuation, split on whitespace, drop stopwords — shared
78
+ // by findJoinableEpic's similarity check. Kept standalone so its behavior is
79
+ // independently testable rather than inlined into the Jaccard computation.
80
+ function tokenize(text) {
81
+ return String(text || '')
82
+ .toLowerCase()
83
+ .replace(/[^a-z0-9\s]+/g, ' ')
84
+ .split(/\s+/)
85
+ .filter((token) => token && !STOPWORDS.has(token));
86
+ }
87
+
88
+ function jaccardSimilarity(tokensA, tokensB) {
89
+ const setA = new Set(tokensA);
90
+ const setB = new Set(tokensB);
91
+ if (setA.size === 0 && setB.size === 0) return 0;
92
+ let intersection = 0;
93
+ for (const token of setA) {
94
+ if (setB.has(token)) intersection += 1;
95
+ }
96
+ const union = setA.size + setB.size - intersection;
97
+ return union === 0 ? 0 : intersection / union;
98
+ }
99
+
100
+ const JOIN_SIMILARITY_THRESHOLD = 0.35;
101
+
102
+ /**
103
+ * findJoinableEpicInIndex(index, { goalText, preferEpicId, status }) → { epicId, matchedBy, score? } | null
104
+ *
105
+ * Same contract as findJoinableEpic() but takes an already-loaded index —
106
+ * lets ensureEpic() consult this without a second readActiveIndex() call
107
+ * inside its own withPathLock critical section. Two strategies, in order:
108
+ * 1. `preferEpicId` — an explicitly-known origin Epic the caller already has
109
+ * in hand. Joined immediately (no similarity check) as long as it exists
110
+ * and is still open ('proposed' or 'active') — a 'completed' or unknown
111
+ * preferEpicId falls through to strategy 2 rather than joining a dead Epic.
112
+ * 2. Keyword-similarity — Jaccard token-set overlap between `goalText` and
113
+ * every other OPEN Epic's goalText in the same cwd (open = 'proposed' or
114
+ * 'active', regardless of the specific requested `status` — a proposed
115
+ * RCA report and an active one about the same topic are still the same
116
+ * underlying issue). Highest score wins if it clears
117
+ * JOIN_SIMILARITY_THRESHOLD; otherwise no join. `status` is accepted for
118
+ * signature symmetry with ensureEpic()/reuseByGoal but only participates
119
+ * in strategy 1's preferEpicId open-check, not the similarity filter.
120
+ */
121
+ function findJoinableEpicInIndex(index, { goalText, preferEpicId = null, status: _status = 'proposed' } = {}) {
122
+ if (preferEpicId && hasOwn(index.sessions, preferEpicId)) {
123
+ const preferred = index.sessions[preferEpicId];
124
+ if (preferred && (preferred.status === 'proposed' || preferred.status === 'active')) {
125
+ return { epicId: preferEpicId, matchedBy: 'preferEpicId' };
126
+ }
127
+ }
128
+
129
+ const candidateTokens = tokenize(goalText);
130
+ let best = null;
131
+ for (const s of Object.values(index.sessions)) {
132
+ if (!s || (s.status !== 'proposed' && s.status !== 'active')) continue;
133
+ const score = jaccardSimilarity(candidateTokens, tokenize(s.goalText));
134
+ if (score >= JOIN_SIMILARITY_THRESHOLD && (!best || score > best.score)) {
135
+ best = { epicId: s.id, matchedBy: 'similarity', score };
136
+ }
137
+ }
138
+ return best;
139
+ }
140
+
141
+ /**
142
+ * findJoinableEpic(cwd, { goalText, preferEpicId, status }) → { epicId, matchedBy, score? } | null
143
+ *
144
+ * Public entry point for callers outside ensureEpic()'s own critical section
145
+ * (tests, future PRD 899/900 wiring) — loads the index itself. See
146
+ * findJoinableEpicInIndex() for the matching logic.
147
+ */
148
+ function findJoinableEpic(cwd, opts = {}) {
149
+ return findJoinableEpicInIndex(readActiveIndex(cwd), opts);
150
+ }
151
+
62
152
  // Serializes read-modify-write cycles per active-index.json path, mirroring
63
153
  // promptSessionEvents.cjs's own pendingWritesByPath/withPathLock. The current
64
154
  // read/write pair below is synchronous (fs.readFileSync/writeFileSync), so
@@ -83,26 +173,43 @@ function withPathLock(lockPath, task) {
83
173
  }
84
174
 
85
175
  /**
86
- * ensureEpic(cwd, { goalText, tag?, reuseByGoal?, status? }) → Promise<{ epicId, prdDir, created }>
176
+ * ensureEpic(cwd, { goalText, tag?, reuseByGoal?, status?, openingPrompt?, mintIfMissing?, source?, forceNewEpic? }) → Promise<{ epicId, prdDir, created }>
177
+ *
178
+ * Before minting brand-new, the mint branch consults findJoinableEpic() —
179
+ * minting is the LAST resort, not the default, for automated callers. Pass
180
+ * `forceNewEpic: true` to skip that check and always mint (the one
181
+ * legitimate case being explicit human-authored creation).
87
182
  *
88
- * `status` defaults to 'active'. Pass 'proposed' to file an Epic that waits
89
- * for human approval before anything runs.
183
+ * `status` defaults to 'proposed' — a fail-safe default so any caller that
184
+ * forgets to pass it files an Epic that waits for human approval rather than
185
+ * one that starts running immediately. Pass 'active' explicitly only for the
186
+ * one legitimate immediate-start path (the New Epic UI's own proposed→active
187
+ * transition, `promptSessions.ts`'s `approveProposed`).
90
188
  *
91
- * Mints a new Epic — or, with `reuseByGoal`, joins the existing ACTIVE Epic
92
- * whose goalText matches (used by the recurring feedback sweep so successive
93
- * sweeps chain into one Epic instead of minting one per tick).
189
+ * `mintIfMissing` defaults to true for the small set of callers that are
190
+ * themselves the human-intent gate (propose-epic, the RCA hook, the feedback
191
+ * sweep — all of which pass `status: 'proposed'`, so "minting" here still
192
+ * never starts anything without a human's Approve & start). Callers that are
193
+ * NOT themselves a human-intent gate (an automated PRD-write path acting on
194
+ * a session's behalf) must pass `mintIfMissing: false` — that path may only
195
+ * JOIN an Epic that already exists; it throws instead of silently creating a
196
+ * new one, so no PRD-authoring surface can conjure Epics on its own.
197
+ *
198
+ * Mints a new Epic — or, with `reuseByGoal`, joins the existing Epic (of the
199
+ * same `status`) whose goalText matches (used by the recurring feedback sweep
200
+ * so successive sweeps chain into one Epic instead of minting one per tick).
94
201
  *
95
202
  * The Epic's id doubles as its directory name under scheduler/epics/, so the
96
203
  * PromptSession ↔ on-disk Epic mapping is 1:1 with no lookup table.
97
204
  */
98
- function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitEpicId, status = 'active', openingPrompt = null } = {}) {
205
+ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitEpicId, status = 'proposed', openingPrompt = null, mintIfMissing = true, source = null, forceNewEpic = false } = {}) {
99
206
  if (!cwd || typeof cwd !== 'string') throw new Error('ensureEpic: cwd is required');
100
207
  return withPathLock(activeIndexPath(cwd), () => {
101
208
  const index = readActiveIndex(cwd);
102
209
 
103
210
  // A dispatch that already knows its Epic (sourcePromptId frontmatter from
104
211
  // an Epic-conversation dispatch) joins it rather than minting a sibling.
105
- if (explicitEpicId && index.sessions[explicitEpicId]) {
212
+ if (explicitEpicId && hasOwn(index.sessions, explicitEpicId)) {
106
213
  const prdDir = resolveEpicPrdWriteDir(cwd, explicitEpicId);
107
214
  fs.mkdirSync(prdDir, { recursive: true });
108
215
  return { epicId: explicitEpicId, prdDir, created: false };
@@ -133,6 +240,23 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
133
240
  }
134
241
  }
135
242
 
243
+ if (!mintIfMissing) {
244
+ throw new Error(
245
+ `ensureEpic: no existing Epic found (epicId=${explicitEpicId ?? 'none'}) and mintIfMissing is false — `
246
+ + 'a new Epic can only be created by explicit human intent (New Epic UI, or /propose-epic + Approve & start), '
247
+ + 'never implicitly by a PRD-authoring path',
248
+ );
249
+ }
250
+
251
+ if (!forceNewEpic) {
252
+ const joinable = findJoinableEpicInIndex(index, { goalText, preferEpicId: explicitEpicId, status });
253
+ if (joinable) {
254
+ const prdDir = resolveEpicPrdWriteDir(cwd, joinable.epicId);
255
+ fs.mkdirSync(prdDir, { recursive: true });
256
+ return { epicId: joinable.epicId, prdDir, created: false };
257
+ }
258
+ }
259
+
136
260
  const epicId = `${slugify(goalText)}-${crypto.randomUUID().slice(0, 8)}`;
137
261
  const now = new Date().toISOString();
138
262
  const session = {
@@ -152,6 +276,9 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
152
276
  // Full body for a proposal whose goalText is only a one-line title;
153
277
  // sent verbatim as the first prompt when a human approves it.
154
278
  ...(openingPrompt ? { openingPrompt: String(openingPrompt) } : {}),
279
+ // Structured trace of which automated producer minted this Epic — see
280
+ // EpicSource in state/promptSessions.ts.
281
+ ...(source ? { source } : {}),
155
282
  };
156
283
  const firstEvent = {
157
284
  id: crypto.randomUUID(),
@@ -168,7 +295,7 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
168
295
  // Every mint is logged, whether the Epic starts 'proposed' (human gate
169
296
  // ahead) or 'active' (started immediately) — this is the trace-back point
170
297
  // for "who/what created this Epic" (see auditLog.cjs).
171
- appendAuditEvent('epic_mint', { cwd, epicId, status, tag: tag ?? null, goalText: session.goalText });
298
+ appendAuditEvent('epic_mint', { cwd, epicId, status, tag: tag ?? null, goalText: session.goalText, source: source ?? null });
172
299
 
173
300
  const prdDir = resolveEpicPrdWriteDir(cwd, epicId);
174
301
  fs.mkdirSync(prdDir, { recursive: true });
@@ -185,7 +312,7 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
185
312
  function appendPrdCreatedEvent(cwd, epicId, prdSlug, text) {
186
313
  return withPathLock(activeIndexPath(cwd), () => {
187
314
  const index = readActiveIndex(cwd);
188
- if (!index.sessions[epicId]) return false;
315
+ if (!hasOwn(index.sessions, epicId)) return false;
189
316
  const chain = Array.isArray(index.events[epicId]) ? index.events[epicId] : [];
190
317
  const tail = chain.length ? chain[chain.length - 1] : null;
191
318
  chain.push({
@@ -214,11 +341,11 @@ function appendPrdCreatedEvent(cwd, epicId, prdSlug, text) {
214
341
  function removeEpic(cwd, epicId) {
215
342
  if (!cwd || !epicId) return false;
216
343
  const index = readActiveIndex(cwd);
217
- if (!index.sessions[epicId]) return false;
344
+ if (!hasOwn(index.sessions, epicId)) return false;
218
345
  delete index.sessions[epicId];
219
346
  delete index.events[epicId];
220
347
  writeActiveIndex(cwd, index);
221
348
  return true;
222
349
  }
223
350
 
224
- module.exports = { ensureEpic, appendPrdCreatedEvent, removeEpic, activeIndexPath, readActiveIndex };
351
+ module.exports = { ensureEpic, appendPrdCreatedEvent, removeEpic, activeIndexPath, readActiveIndex, findJoinableEpic, tokenize };
@@ -28,7 +28,7 @@ const fs = require('node:fs');
28
28
  const os = require('node:os');
29
29
  const path = require('node:path');
30
30
  const config = require('../config.cjs');
31
- const { ensureEpic } = require('./epicMint.cjs');
31
+ const { ensureEpic, findJoinableEpic } = require('./epicMint.cjs');
32
32
  const { splitFrontmatter } = require('./prdFrontmatter.cjs');
33
33
  const { readTail } = require('./fileTail.cjs');
34
34
  const { resolvePrdWriteDir } = require('./prdLocations.cjs');
@@ -294,7 +294,7 @@ function buildRcaMarkdown({ job, verdict, meta, logTail, acText, failureClass, i
294
294
  * duplicate. A different runId (new run of the same slug) files a new RCA.
295
295
  *
296
296
  * @param {object} opts
297
- * @param {{slug: string, cwd?: string, runId: string, exitCode?: number, error?: string}} opts.job
297
+ * @param {{slug: string, cwd?: string, runId: string, exitCode?: number, error?: string, epicId?: string}} opts.job
298
298
  * @param {string} [opts.runDir] Run directory containing <slug>.log / <slug>.meta.json.
299
299
  * @param {string} opts.verdict Verifier verdict string (e.g. 'uncommitted_changes').
300
300
  * @param {Array} [opts.annotations] Non-blocking verifier annotations (currently unused
@@ -351,12 +351,33 @@ async function fileRcaFeedback({ job, runDir, verdict, annotations, investigatio
351
351
  // the old live/processed existence checks were for.
352
352
  config.addAllowedRoot(dest.allowlistRoot);
353
353
  const title = rcaProposalTitle(job, verdict);
354
+
355
+ // job.epicId (present on jobs authored after Epic-gating landed; absent
356
+ // on legacy PRDs) is the known origin Epic this failing job belonged to.
357
+ // Passing it straight through as ensureEpic's `epicId` would hit its
358
+ // explicitEpicId join-only branch, which joins unconditionally — even a
359
+ // 'completed' Epic. findJoinableEpic's preferEpicId strategy is the one
360
+ // that only joins when the Epic is still open ('proposed'/'active'), so
361
+ // resolve it here and only forward it when that strategy actually fires
362
+ // — otherwise fall through to the existing reuseByGoal/mint behavior.
363
+ // Once forwarded, ensureEpic joins it unconditionally by id (no second
364
+ // status check) — the status gate lives entirely in this resolve step.
365
+ let preferredEpicId;
366
+ if (typeof job.epicId === 'string' && job.epicId) {
367
+ const joinable = findJoinableEpic(dest.cwd, { goalText: title, preferEpicId: job.epicId, status: 'proposed' });
368
+ if (joinable && joinable.matchedBy === 'preferEpicId') {
369
+ preferredEpicId = joinable.epicId;
370
+ }
371
+ }
372
+
354
373
  const { epicId, created } = await ensureEpic(dest.cwd, {
355
374
  goalText: title,
356
375
  tag: 'bug',
357
376
  status: 'proposed',
358
377
  reuseByGoal: true,
359
378
  openingPrompt: markdown,
379
+ epicId: preferredEpicId,
380
+ source: { producer: 'rca-hook', prdSlug: job.slug, runId: job.runId },
360
381
  });
361
382
 
362
383
  console.log(`[rca] ${created ? 'proposed' : 'joined'} epic ${epicId} for ${job.slug}`);
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * sessionSlots.cjs — the Session-Manager-owned machine-wide `claude -p`
3
- * concurrency pool (2026-07-31 domain-model decision).
3
+ * concurrency pool (2026-07-31 domain-model decision; made user-configurable
4
+ * 2026-08-01).
4
5
  *
5
6
  * Caps and limits belong to Session-Manager, not to any one consumer: the
6
7
  * scheduler and chatRunner previously each enforced a private cap (3 and 2),
@@ -8,22 +9,68 @@
8
9
  * the 2026-06-10 five-parallel-`claude -p` OOM. Now every subsystem that
9
10
  * wants to launch a `claude -p` process REQUESTS a slot here first and
10
11
  * 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]).
12
+ * machine (default 5, user-adjustable [0, 10] from the Home tab; 0 pauses new
13
+ * launches without touching already-running processes — SM_SESSION_SLOTS
14
+ * still overrides everything for scripted/CI use, clamped to the same
15
+ * [0, 10] range).
13
16
  *
14
17
  * Consumers keep their own scheduling policy (FIFO lanes, batch picking,
15
18
  * 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
+ * now?". Plain Node, no Electron deps, process-local state (the persisted cap
20
+ * is the one exception — a tiny standalone JSON file, not routed through any
21
+ * other module's config store) — all consumers live in the one Electron main
22
+ * process, which is exactly why it can be the arbiter.
19
23
  */
20
24
  'use strict';
21
25
 
22
26
  const crypto = require('node:crypto');
27
+ const fs = require('node:fs');
28
+ const os = require('node:os');
29
+ const path = require('node:path');
30
+
31
+ const MIN_SLOTS = 0;
32
+ const MAX_SLOTS = 10;
33
+ const DEFAULT_SLOTS = 5;
34
+
35
+ const CONFIG_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'session-slots-config.json');
36
+
37
+ function clamp(n) {
38
+ return Math.min(MAX_SLOTS, Math.max(MIN_SLOTS, n));
39
+ }
40
+
41
+ function readPersistedCap() {
42
+ try {
43
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
44
+ const parsed = JSON.parse(raw);
45
+ const cap = Number(parsed.cap);
46
+ return Number.isFinite(cap) ? clamp(Math.trunc(cap)) : DEFAULT_SLOTS;
47
+ } catch {
48
+ return DEFAULT_SLOTS;
49
+ }
50
+ }
51
+
52
+ /** Persist a new cap to disk (tmp + rename). Throws on an out-of-range value. */
53
+ function setCap(cap) {
54
+ const n = Number(cap);
55
+ if (!Number.isFinite(n) || Math.trunc(n) !== n || n < MIN_SLOTS || n > MAX_SLOTS) {
56
+ throw new Error(`sessionSlots.setCap: cap must be an integer in [${MIN_SLOTS}, ${MAX_SLOTS}]`);
57
+ }
58
+ fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
59
+ const tmp = `${CONFIG_PATH}.${process.pid}.${Date.now()}.tmp`;
60
+ fs.writeFileSync(tmp, JSON.stringify({ cap: n }, null, 2));
61
+ fs.renameSync(tmp, CONFIG_PATH);
62
+ for (const fn of listeners) {
63
+ try { fn(); } catch { /* a consumer's pump error is its own problem */ }
64
+ }
65
+ return n;
66
+ }
23
67
 
24
68
  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));
69
+ if (process.env.SM_SESSION_SLOTS !== undefined) {
70
+ const parsed = parseInt(process.env.SM_SESSION_SLOTS, 10);
71
+ return Number.isFinite(parsed) ? clamp(parsed) : DEFAULT_SLOTS;
72
+ }
73
+ return readPersistedCap();
27
74
  }
28
75
 
29
76
  // token → { owner, at }
@@ -51,6 +98,7 @@ function acquire(owner) {
51
98
 
52
99
  // Release listeners: each consumer registers its own "a slot freed — try to
53
100
  // start work" pump so a scheduler release wakes the chat lane and vice versa.
101
+ // Also fired when the cap itself increases (setCap), for the same reason.
54
102
  const listeners = new Set();
55
103
  function subscribe(fn) {
56
104
  listeners.add(fn);
@@ -74,6 +122,10 @@ function snapshot() {
74
122
  total: totalSlots(),
75
123
  inUse: holders.size,
76
124
  holders: [...holders.values()],
125
+ min: MIN_SLOTS,
126
+ max: MAX_SLOTS,
127
+ default: DEFAULT_SLOTS,
128
+ envOverride: process.env.SM_SESSION_SLOTS !== undefined,
77
129
  };
78
130
  }
79
131
 
@@ -82,4 +134,17 @@ function __resetForTests() {
82
134
  holders.clear();
83
135
  }
84
136
 
85
- module.exports = { totalSlots, inUse, available, acquire, release, subscribe, snapshot, __resetForTests };
137
+ module.exports = {
138
+ MIN_SLOTS,
139
+ MAX_SLOTS,
140
+ DEFAULT_SLOTS,
141
+ totalSlots,
142
+ setCap,
143
+ inUse,
144
+ available,
145
+ acquire,
146
+ release,
147
+ subscribe,
148
+ snapshot,
149
+ __resetForTests,
150
+ };