claude-code-session-manager 0.43.0 → 0.45.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.
- package/dist/assets/{TiptapBody-rWzd7CPW.js → TiptapBody-Pv4sVeQ8.js} +1 -1
- package/dist/assets/{index-6w1gIeyB.js → index-CSUJZAO6.js} +466 -460
- package/dist/assets/{index-CxGY-V5G.css → index-l5y1xr1v.css} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +32 -10
- package/scripts/lib/watchdogHelpers.cjs +1 -0
- package/scripts/mint-epic.cjs +26 -13
- package/scripts/propose-epic.cjs +1 -0
- package/src/main/__tests__/chat-dead-channels.test.cjs +63 -0
- package/src/main/__tests__/epicMint.test.cjs +132 -1
- package/src/main/__tests__/rcaFeedbackHook.test.cjs +81 -0
- package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +41 -14
- package/src/main/ipcSchemas.cjs +15 -7
- package/src/main/lib/__tests__/sessionSlots.test.cjs +59 -15
- package/src/main/lib/epicMint.cjs +139 -12
- package/src/main/lib/rcaFeedbackHook.cjs +23 -2
- package/src/main/lib/sessionSlots.cjs +74 -9
- package/src/main/scheduler.cjs +33 -88
- package/src/main/templates/PRD_AUTHORING.md +5 -1
- package/src/main/webRemote.cjs +0 -32
- package/src/preload/api.d.ts +10 -2
- package/src/preload/index.cjs +1 -0
|
@@ -14,7 +14,7 @@ const fs = require('node:fs');
|
|
|
14
14
|
const fsp = require('node:fs/promises');
|
|
15
15
|
const os = require('node:os');
|
|
16
16
|
const path = require('node:path');
|
|
17
|
-
const { ensureEpic, removeEpic, readActiveIndex } = require('../lib/epicMint.cjs');
|
|
17
|
+
const { ensureEpic, removeEpic, readActiveIndex, findJoinableEpic } = require('../lib/epicMint.cjs');
|
|
18
18
|
|
|
19
19
|
const tmpDirs = [];
|
|
20
20
|
afterEach(async () => {
|
|
@@ -83,3 +83,134 @@ test('removeEpic is a no-op (returns false) for an unknown epicId', async () =>
|
|
|
83
83
|
expect(removed).toBe(false);
|
|
84
84
|
expect(Object.keys(readActiveIndex(cwd).sessions)).toHaveLength(1);
|
|
85
85
|
});
|
|
86
|
+
|
|
87
|
+
// A plain object's bracket lookup with a prototype-chain key like
|
|
88
|
+
// "__proto__"/"constructor" returns a truthy Object.prototype member even
|
|
89
|
+
// when no such Epic was ever written — without an own-property check, this
|
|
90
|
+
// would make mintIfMissing:false's "join an existing Epic" gate falsely
|
|
91
|
+
// report a match, defeating the restriction that a PRD write may only join
|
|
92
|
+
// a real, already-approved Epic.
|
|
93
|
+
for (const pollutedKey of ['__proto__', 'constructor', 'toString', 'hasOwnProperty']) {
|
|
94
|
+
test(`ensureEpic refuses to "join" the prototype-chain key "${pollutedKey}" (mintIfMissing:false)`, async () => {
|
|
95
|
+
const cwd = await mkCwd();
|
|
96
|
+
|
|
97
|
+
await expect(
|
|
98
|
+
ensureEpic(cwd, { epicId: pollutedKey, mintIfMissing: false }),
|
|
99
|
+
).rejects.toThrow(/no existing Epic found/);
|
|
100
|
+
|
|
101
|
+
const index = readActiveIndex(cwd);
|
|
102
|
+
expect(Object.keys(index.sessions)).toHaveLength(0);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
test('removeEpic refuses to report success for the prototype-chain key "__proto__"', async () => {
|
|
107
|
+
const cwd = await mkCwd();
|
|
108
|
+
const removed = removeEpic(cwd, '__proto__');
|
|
109
|
+
expect(removed).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('ensureEpic persists the passed source onto the written active-index.json session record', async () => {
|
|
113
|
+
const cwd = await mkCwd();
|
|
114
|
+
const source = { producer: 'rca-hook', prdSlug: 'x', runId: 'y' };
|
|
115
|
+
|
|
116
|
+
const minted = await ensureEpic(cwd, { goalText: 'auto-filed epic', source });
|
|
117
|
+
|
|
118
|
+
const index = readActiveIndex(cwd);
|
|
119
|
+
expect(index.sessions[minted.epicId].source).toEqual(source);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('ensureEpic omits source from the written record when not passed', async () => {
|
|
123
|
+
const cwd = await mkCwd();
|
|
124
|
+
|
|
125
|
+
const minted = await ensureEpic(cwd, { goalText: 'human-filed epic' });
|
|
126
|
+
|
|
127
|
+
const index = readActiveIndex(cwd);
|
|
128
|
+
expect('source' in index.sessions[minted.epicId]).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('ensureEpic joins an existing open Epic when goalText is a near-identical paraphrase (no preferEpicId)', async () => {
|
|
132
|
+
const cwd = await mkCwd();
|
|
133
|
+
|
|
134
|
+
const first = await ensureEpic(cwd, { goalText: 'Fix the login button color' });
|
|
135
|
+
expect(first.created).toBe(true);
|
|
136
|
+
|
|
137
|
+
const second = await ensureEpic(cwd, { goalText: 'Fix login button colour issue' });
|
|
138
|
+
|
|
139
|
+
expect(second.created).toBe(false);
|
|
140
|
+
expect(second.epicId).toBe(first.epicId);
|
|
141
|
+
|
|
142
|
+
const index = readActiveIndex(cwd);
|
|
143
|
+
expect(Object.keys(index.sessions)).toHaveLength(1);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('ensureEpic mints a distinct Epic when goalText is genuinely unrelated', async () => {
|
|
147
|
+
const cwd = await mkCwd();
|
|
148
|
+
|
|
149
|
+
const first = await ensureEpic(cwd, { goalText: 'Fix the login button color' });
|
|
150
|
+
expect(first.created).toBe(true);
|
|
151
|
+
|
|
152
|
+
const second = await ensureEpic(cwd, { goalText: 'Add CSV export to History tab' });
|
|
153
|
+
|
|
154
|
+
expect(second.created).toBe(true);
|
|
155
|
+
expect(second.epicId).not.toBe(first.epicId);
|
|
156
|
+
|
|
157
|
+
const index = readActiveIndex(cwd);
|
|
158
|
+
expect(Object.keys(index.sessions)).toHaveLength(2);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('findJoinableEpic joins the preferEpicId Epic even when goalText similarity is near-zero', async () => {
|
|
162
|
+
const cwd = await mkCwd();
|
|
163
|
+
|
|
164
|
+
const first = await ensureEpic(cwd, { goalText: 'Fix the login button color' });
|
|
165
|
+
expect(first.created).toBe(true);
|
|
166
|
+
|
|
167
|
+
const joined = findJoinableEpic(cwd, {
|
|
168
|
+
goalText: 'Completely unrelated topic about database migrations',
|
|
169
|
+
preferEpicId: first.epicId,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(joined).toEqual({ epicId: first.epicId, matchedBy: 'preferEpicId' });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('findJoinableEpic falls through to the similarity check when preferEpicId points at a completed Epic', async () => {
|
|
176
|
+
const cwd = await mkCwd();
|
|
177
|
+
|
|
178
|
+
const first = await ensureEpic(cwd, { goalText: 'Fix the login button color' });
|
|
179
|
+
const index = readActiveIndex(cwd);
|
|
180
|
+
index.sessions[first.epicId].status = 'completed';
|
|
181
|
+
fs.writeFileSync(
|
|
182
|
+
path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json'),
|
|
183
|
+
JSON.stringify(index, null, 2),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// No other open Epic shares tokens with the completed one, so the
|
|
187
|
+
// fall-through similarity check finds nothing rather than joining the
|
|
188
|
+
// dead (completed) preferEpicId Epic.
|
|
189
|
+
const joined = findJoinableEpic(cwd, { goalText: 'Fix login button colour issue', preferEpicId: first.epicId });
|
|
190
|
+
|
|
191
|
+
expect(joined).toBeNull();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('findJoinableEpic falls through to the similarity check when preferEpicId points at a nonexistent Epic, and can still find a match there', async () => {
|
|
195
|
+
const cwd = await mkCwd();
|
|
196
|
+
const sibling = await ensureEpic(cwd, { goalText: 'Fix the login button color' });
|
|
197
|
+
|
|
198
|
+
const joined = findJoinableEpic(cwd, { goalText: 'Fix login button colour issue', preferEpicId: 'nonexistent-epic-id' });
|
|
199
|
+
|
|
200
|
+
expect(joined).toEqual({ epicId: sibling.epicId, matchedBy: 'similarity', score: expect.any(Number) });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('ensureEpic with forceNewEpic:true mints even when a near-identical open Epic exists', async () => {
|
|
204
|
+
const cwd = await mkCwd();
|
|
205
|
+
|
|
206
|
+
const first = await ensureEpic(cwd, { goalText: 'Fix the login button color' });
|
|
207
|
+
expect(first.created).toBe(true);
|
|
208
|
+
|
|
209
|
+
const second = await ensureEpic(cwd, { goalText: 'Fix login button colour issue', forceNewEpic: true });
|
|
210
|
+
|
|
211
|
+
expect(second.created).toBe(true);
|
|
212
|
+
expect(second.epicId).not.toBe(first.epicId);
|
|
213
|
+
|
|
214
|
+
const index = readActiveIndex(cwd);
|
|
215
|
+
expect(Object.keys(index.sessions)).toHaveLength(2);
|
|
216
|
+
});
|
|
@@ -147,6 +147,87 @@ test('fileRcaFeedback: files into the job\'s own project, no feedback dir requir
|
|
|
147
147
|
expect(readSessions(cwd)[res.epicId].status).toBe('proposed');
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
+
// ─── epic targeting (job.epicId → preferred join target) ───────────────────
|
|
151
|
+
|
|
152
|
+
function writeActiveIndex(cwd, sessions) {
|
|
153
|
+
const dir = path.join(cwd, 'session-manager-operations', 'prompt-sessions');
|
|
154
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
155
|
+
const events = {};
|
|
156
|
+
for (const id of Object.keys(sessions)) {
|
|
157
|
+
events[id] = [{ id: `${id}-e0`, promptSessionId: id, kind: 'prompt', causedByEventId: null, at: new Date(0).toISOString(), text: sessions[id].goalText }];
|
|
158
|
+
}
|
|
159
|
+
fs.writeFileSync(path.join(dir, 'active-index.json'), JSON.stringify({ sessions, events }));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
test('fileRcaFeedback: joins the job\'s known origin Epic when it is still open, and tags source', async () => {
|
|
163
|
+
const cwd = makeProjectWithInbox();
|
|
164
|
+
const runDir = path.join(tmpHome, 'run1');
|
|
165
|
+
writeRun(runDir, 'testslug');
|
|
166
|
+
writePrd(cwd, 'testslug');
|
|
167
|
+
|
|
168
|
+
writeActiveIndex(cwd, {
|
|
169
|
+
'origin-epic-1': { id: 'origin-epic-1', cwd, goalText: 'Unrelated title text', status: 'proposed', createdAt: new Date(0).toISOString(), completedAt: null },
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const res = await rcaFeedbackHook.fileRcaFeedback({
|
|
173
|
+
job: baseJob({ cwd, epicId: 'origin-epic-1' }), runDir, verdict: 'transcript_errors',
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(res.filed).toBe(true);
|
|
177
|
+
expect(res.epicId).toBe('origin-epic-1');
|
|
178
|
+
expect(res.created).toBe(false);
|
|
179
|
+
expect(Object.keys(readSessions(cwd)).length).toBe(1);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('fileRcaFeedback: does not join a completed origin Epic, falls through to mint', async () => {
|
|
183
|
+
const cwd = makeProjectWithInbox();
|
|
184
|
+
const runDir = path.join(tmpHome, 'run1');
|
|
185
|
+
writeRun(runDir, 'testslug');
|
|
186
|
+
writePrd(cwd, 'testslug');
|
|
187
|
+
|
|
188
|
+
writeActiveIndex(cwd, {
|
|
189
|
+
'origin-epic-1': { id: 'origin-epic-1', cwd, goalText: 'Unrelated title text', status: 'completed', createdAt: new Date(0).toISOString(), completedAt: new Date(0).toISOString() },
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const res = await rcaFeedbackHook.fileRcaFeedback({
|
|
193
|
+
job: baseJob({ cwd, epicId: 'origin-epic-1' }), runDir, verdict: 'transcript_errors',
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
expect(res.filed).toBe(true);
|
|
197
|
+
expect(res.epicId).not.toBe('origin-epic-1');
|
|
198
|
+
expect(res.created).toBe(true);
|
|
199
|
+
const sessions = readSessions(cwd);
|
|
200
|
+
expect(sessions[res.epicId].source).toEqual({ producer: 'rca-hook', prdSlug: 'testslug', runId: baseJob().runId });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('fileRcaFeedback: a nonexistent job.epicId falls through to mint without erroring', async () => {
|
|
204
|
+
const cwd = makeProjectWithInbox();
|
|
205
|
+
const runDir = path.join(tmpHome, 'run1');
|
|
206
|
+
writeRun(runDir, 'testslug');
|
|
207
|
+
writePrd(cwd, 'testslug');
|
|
208
|
+
|
|
209
|
+
const res = await rcaFeedbackHook.fileRcaFeedback({
|
|
210
|
+
job: baseJob({ cwd, epicId: 'does-not-exist' }), runDir, verdict: 'transcript_errors',
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expect(res.filed).toBe(true);
|
|
214
|
+
expect(res.epicId).not.toBe('does-not-exist');
|
|
215
|
+
expect(res.created).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('fileRcaFeedback: no job.epicId preserves current mint-or-reuseByGoal behavior', async () => {
|
|
219
|
+
const cwd = makeProjectWithInbox();
|
|
220
|
+
const runDir = path.join(tmpHome, 'run1');
|
|
221
|
+
writeRun(runDir, 'testslug');
|
|
222
|
+
writePrd(cwd, 'testslug');
|
|
223
|
+
|
|
224
|
+
const res = await rcaFeedbackHook.fileRcaFeedback({ job: baseJob({ cwd }), runDir, verdict: 'transcript_errors' });
|
|
225
|
+
expect(res.filed).toBe(true);
|
|
226
|
+
expect(res.created).toBe(true);
|
|
227
|
+
const sessions = readSessions(cwd);
|
|
228
|
+
expect(sessions[res.epicId].source).toEqual({ producer: 'rca-hook', prdSlug: 'testslug', runId: baseJob().runId });
|
|
229
|
+
});
|
|
230
|
+
|
|
150
231
|
test('fileRcaFeedback: rejects a slug containing path-traversal segments', async () => {
|
|
151
232
|
const cwd = makeProjectWithInbox();
|
|
152
233
|
const runDir = path.join(tmpHome, 'run1');
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* scheduler-writeprd-epic-rollback.test.cjs —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* scheduler-writeprd-epic-rollback.test.cjs — remote.writePrd may only JOIN
|
|
3
|
+
* an existing, already-human-approved Epic (via `sourcePromptId`); it must
|
|
4
|
+
* never mint a new one (mintIfMissing:false, see epicMint.cjs's ensureEpic).
|
|
5
|
+
* A write with no resolvable Epic fails outright with zero residual
|
|
6
|
+
* active-index.json entries — no orphaned Epic can ever be left behind by
|
|
7
|
+
* this path because none is ever created by it.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* and then fails must leave that Epic's history untouched.
|
|
9
|
+
* A PRD write that joins a pre-existing Epic and then fails (e.g. an invalid
|
|
10
|
+
* slug) must leave that Epic's history untouched.
|
|
12
11
|
*
|
|
13
12
|
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs
|
|
14
13
|
*/
|
|
@@ -21,6 +20,7 @@ const os = require('node:os');
|
|
|
21
20
|
const path = require('node:path');
|
|
22
21
|
const { remote } = require('../scheduler.cjs');
|
|
23
22
|
const { ensureEpic, readActiveIndex } = require('../lib/epicMint.cjs');
|
|
23
|
+
const config = require('../config.cjs');
|
|
24
24
|
|
|
25
25
|
const tmpDirs = [];
|
|
26
26
|
|
|
@@ -38,18 +38,20 @@ afterEach(() => {
|
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
// A slug that escapes safeSlugPathIn's containment check, tripping the
|
|
41
|
-
// "invalid slug" branch of writePrd (scheduler.cjs
|
|
42
|
-
//
|
|
41
|
+
// "invalid slug" branch of writePrd (scheduler.cjs's writePrd). Only the
|
|
42
|
+
// second test below (joining a pre-existing Epic) actually reaches this
|
|
43
|
+
// check — the first test fails earlier, at Epic resolution, before the slug
|
|
44
|
+
// is ever considered.
|
|
43
45
|
const ESCAPING_SLUG = '../escape-attempt';
|
|
44
46
|
|
|
45
|
-
test('writePrd:
|
|
47
|
+
test('writePrd: no sourcePromptId refuses to write (no Epic to join) and mints nothing', async () => {
|
|
46
48
|
const cwd = makeFixtureCwd();
|
|
47
|
-
const body = '---\ntitle:
|
|
49
|
+
const body = '---\ntitle: No epic given\n---\n\nbody text\n';
|
|
48
50
|
|
|
49
51
|
const result = await remote.writePrd(ESCAPING_SLUG, body, cwd);
|
|
50
52
|
|
|
51
53
|
expect(result.ok).toBe(false);
|
|
52
|
-
expect(result.error).
|
|
54
|
+
expect(result.error).toMatch(/no existing Epic to join/);
|
|
53
55
|
|
|
54
56
|
const index = readActiveIndex(cwd);
|
|
55
57
|
expect(Object.keys(index.sessions)).toHaveLength(0);
|
|
@@ -76,3 +78,28 @@ test('writePrd: invalid-slug failure when joining a pre-existing Epic leaves tha
|
|
|
76
78
|
expect(afterIndex.sessions[preexisting.epicId]).toEqual(beforeIndex.sessions[preexisting.epicId]);
|
|
77
79
|
expect(afterIndex.events[preexisting.epicId]).toEqual(beforeIndex.events[preexisting.epicId]);
|
|
78
80
|
});
|
|
81
|
+
|
|
82
|
+
// Provenance (PRD 905): writePrd's ensureEpic call passes
|
|
83
|
+
// source: { producer: 'scheduler-dispatch', prdSlug }, but this call is
|
|
84
|
+
// join-only (mintIfMissing:false, explicit epicId) — ensureEpic's
|
|
85
|
+
// explicitEpicId branch returns before ever reading `source`, so a
|
|
86
|
+
// successful join must NOT write/alter the joined Epic's `source` field.
|
|
87
|
+
test('writePrd: successful join does not write a source field onto the pre-existing Epic (join-only no-op)', async () => {
|
|
88
|
+
// Must live under the home dir — config.cjs's writeTextAtomic validates
|
|
89
|
+
// paths against allowedRoots=home dir, unlike the failure-path tests above
|
|
90
|
+
// (which never reach the write) os.tmpdir() is not sufficient here.
|
|
91
|
+
const cwd = fs.mkdtempSync(path.join(os.homedir(), '.sm-writeprd-epic-source-'));
|
|
92
|
+
tmpDirs.push(cwd);
|
|
93
|
+
config.addAllowedRoot(cwd);
|
|
94
|
+
|
|
95
|
+
const preexisting = await ensureEpic(cwd, { goalText: 'pre-existing epic goal for join' });
|
|
96
|
+
expect(preexisting.created).toBe(true);
|
|
97
|
+
|
|
98
|
+
const body = `---\ntitle: Joins the existing epic\nsourcePromptId: ${preexisting.epicId}\n---\n\nbody text\n`;
|
|
99
|
+
const result = await remote.writePrd('valid-slug', body, cwd);
|
|
100
|
+
|
|
101
|
+
expect(result.ok).toBe(true);
|
|
102
|
+
|
|
103
|
+
const index = readActiveIndex(cwd);
|
|
104
|
+
expect('source' in index.sessions[preexisting.epicId]).toBe(false);
|
|
105
|
+
});
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -377,6 +377,14 @@ const setConfigSchema = z.object({
|
|
|
377
377
|
}).optional(),
|
|
378
378
|
}).strict();
|
|
379
379
|
|
|
380
|
+
// Machine-wide claude -p slot pool cap (sessionSlots.cjs) — deliberately its own
|
|
381
|
+
// schema/channel rather than folded into setConfigSchema: it governs the shared
|
|
382
|
+
// process pool, not any one project's scheduler config, and its range (0-10, 0
|
|
383
|
+
// pauses new launches) differs from concurrencyCap's (1-20).
|
|
384
|
+
const setSessionSlotsSchema = z.object({
|
|
385
|
+
cap: z.number().int().min(0).max(10),
|
|
386
|
+
}).strict();
|
|
387
|
+
|
|
380
388
|
// ──────────────────────────────────────────── Memory tool (Bundle C, cycle 3)
|
|
381
389
|
// Workspace-scoped markdown store at ~/.claude/projects/<ws>/memory/.
|
|
382
390
|
// Slug regex is shared with memoryTool.cjs/memoryAggregate.cjs via lib/memorySlug.cjs;
|
|
@@ -754,9 +762,6 @@ const READ_COMMANDS = new Set([
|
|
|
754
762
|
// Sensitive reads — return user data; require SAS confirmation same as MUTATE.
|
|
755
763
|
const SAS_GATED_READS = new Set([
|
|
756
764
|
'cmd:sessions:load',
|
|
757
|
-
'cmd:schedule:state',
|
|
758
|
-
'cmd:schedule:read-prd',
|
|
759
|
-
'cmd:schedule:read-log',
|
|
760
765
|
'cmd:history:aggregate',
|
|
761
766
|
// subscribe initiates a live stream of session state/summary — sensitive.
|
|
762
767
|
'cmd:session:subscribe',
|
|
@@ -764,6 +769,12 @@ const SAS_GATED_READS = new Set([
|
|
|
764
769
|
// no dispatch handler for it, so an allowlist entry would only fail closed with
|
|
765
770
|
// an opaque reject. Re-add here together with the handler when remote exchanges
|
|
766
771
|
// are wired, so the allowlist always mirrors an actual capability.
|
|
772
|
+
//
|
|
773
|
+
// Scheduler/Epics commands (cmd:schedule:*) were removed here deliberately
|
|
774
|
+
// (core scheduler/Epics redesign) — Remote no longer reaches into scheduler
|
|
775
|
+
// internals at all; a future Remote rebuild will talk to Scheduler/Epics
|
|
776
|
+
// through whatever higher-level surface that redesign lands on, not this
|
|
777
|
+
// per-command allowlist.
|
|
767
778
|
]);
|
|
768
779
|
|
|
769
780
|
const MUTATE_COMMANDS = new Set([
|
|
@@ -776,10 +787,6 @@ const MUTATE_COMMANDS = new Set([
|
|
|
776
787
|
// mobile mirror has no business killing or resizing the desktop's session.
|
|
777
788
|
'cmd:pty:kill',
|
|
778
789
|
'cmd:pty:resize',
|
|
779
|
-
'cmd:schedule:write-prd',
|
|
780
|
-
'cmd:schedule:reset-job',
|
|
781
|
-
'cmd:schedule:run-now',
|
|
782
|
-
'cmd:schedule:set-config',
|
|
783
790
|
// Pushes a prompt into an open tab's chat queue — a real mutation of that
|
|
784
791
|
// tab's session, same tier as pty:write.
|
|
785
792
|
'cmd:chat:send',
|
|
@@ -839,6 +846,7 @@ module.exports = {
|
|
|
839
846
|
scheduleArchivePrd,
|
|
840
847
|
scheduleRetagPrd,
|
|
841
848
|
setConfigSchema,
|
|
849
|
+
setSessionSlotsSchema,
|
|
842
850
|
shellOpen,
|
|
843
851
|
archiveProject,
|
|
844
852
|
historyAggregate,
|
|
@@ -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
|
-
|
|
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
|
|
13
|
-
assert.equal(slots.totalSlots(),
|
|
14
|
-
const
|
|
15
|
-
|
|
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('
|
|
20
|
-
slots.release(
|
|
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('
|
|
33
|
+
assert.ok(slots.acquire('f'));
|
|
23
34
|
});
|
|
24
35
|
|
|
25
|
-
test('SM_SESSION_SLOTS overrides, clamped to [
|
|
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 = '
|
|
29
|
-
assert.equal(slots.totalSlots(),
|
|
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(),
|
|
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(),
|
|
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', () => {
|