claude-code-session-manager 0.57.1 → 0.59.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/README.md +2 -2
- package/dist/assets/{TiptapBody-CJDxVF7X.js → TiptapBody-PUx4oZTh.js} +1 -1
- package/dist/assets/index-BPnfPdLW.js +3222 -0
- package/dist/assets/{index-WUc_GHMf.css → index-CPMP2XZ_.css} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -2
- package/plugins/session-manager-dev/skills/develop/SKILL.md +7 -6
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
- package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +1 -1
- package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +1 -1
- package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +16 -25
- package/plugins/session-manager-dev/skills/project-status/SKILL.md +5 -5
- package/scripts/lib/watchdogHelpers.cjs +0 -434
- package/scripts/mint-epic.cjs +6 -6
- package/src/main/__tests__/activeIndexMerge.test.cjs +2 -2
- package/src/main/__tests__/agentModelResolve.test.cjs +119 -0
- package/src/main/__tests__/classifyTranscriptLine.test.cjs +128 -17
- package/src/main/__tests__/epicContextDigest.test.cjs +100 -0
- package/src/main/__tests__/epicMint.test.cjs +165 -207
- package/src/main/__tests__/epicValidationHook.test.cjs +291 -0
- package/src/main/__tests__/prdMigration.test.cjs +160 -0
- package/src/main/__tests__/projectPages.test.cjs +151 -0
- package/src/main/__tests__/promptSessionEvents.test.cjs +74 -0
- package/src/main/__tests__/promptSessionSchema.test.cjs +101 -0
- package/src/main/__tests__/promptSessionsCreateEpicHandler.test.cjs +139 -0
- package/src/main/__tests__/rcaReport.test.cjs +188 -0
- package/src/main/__tests__/runVerify.test.cjs +109 -6
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +32 -12
- package/src/main/__tests__/scheduler-epic-digest.test.cjs +214 -0
- package/src/main/__tests__/scheduler-heal-refusal.test.cjs +61 -0
- package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +74 -0
- package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +5 -5
- package/src/main/__tests__/transcripts-doFlush-array.test.cjs +118 -0
- package/src/main/__tests__/transcripts-paged-reads.test.cjs +233 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +7 -2
- package/src/main/chatRunner.cjs +10 -1
- package/src/main/health.cjs +5 -1
- package/src/main/index.cjs +3 -5
- package/src/main/ipcSchemas.cjs +72 -4
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +130 -0
- package/src/main/lib/agentModelResolve.cjs +97 -0
- package/src/main/lib/auditLog.cjs +2 -2
- package/src/main/lib/classifyTranscriptLine.cjs +131 -48
- package/src/main/lib/epicContextDigest.cjs +78 -0
- package/src/main/lib/epicMint.cjs +86 -164
- package/src/main/lib/epicValidationHook.cjs +192 -0
- package/src/main/lib/prdMigration.cjs +76 -5
- package/src/main/lib/promptSessionSchema.cjs +95 -0
- package/src/main/lib/promptSessionsCreateEpic.cjs +70 -0
- package/src/main/lib/{rcaFeedbackHook.cjs → rcaReport.cjs} +47 -114
- package/src/main/lib/schedulerBatch.cjs +70 -111
- package/src/main/lib/schedulerConfig.cjs +0 -1
- package/src/main/otel.cjs +3 -1
- package/src/main/projectPages.cjs +60 -14
- package/src/main/promptSessionEvents.cjs +16 -1
- package/src/main/runVerify.cjs +17 -2
- package/src/main/scheduler.cjs +302 -68
- package/src/main/templates/project-pages-default-home.html +123 -0
- package/src/main/transcripts.cjs +191 -32
- package/src/main/webRemote.cjs +8 -7
- package/src/preload/api.d.ts +100 -57
- package/src/preload/index.cjs +9 -7
- package/dist/assets/index-DCa3QvAi.js +0 -3227
- package/plugins/session-manager-dev/skills/propose-epic/SKILL.md +0 -68
- package/scripts/propose-epic.cjs +0 -56
- package/src/main/__tests__/rcaFeedbackHook.test.cjs +0 -280
- package/src/main/repoAnalyzer.cjs +0 -346
- package/src/main/search.cjs +0 -332
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transcripts-paged-reads.test.cjs — unit tests for the line-offset index +
|
|
3
|
+
* paged-read API that replaced transcripts.cjs's 500-entry ring buffer
|
|
4
|
+
* (PRD 979). Covers:
|
|
5
|
+
* - scrolling to the top of a >500-event session returns the genuine first
|
|
6
|
+
* event via readPage/pageEvents, not a truncated window
|
|
7
|
+
* - paging is a bounded positional read, never a whole-file materialization
|
|
8
|
+
* - bounded memory: indexing a huge number of lines (plus one multi-MB
|
|
9
|
+
* line) keeps the persisted index far smaller than the raw content
|
|
10
|
+
* - inode-change rotation and truncation invalidate and rebuild the index
|
|
11
|
+
* - a partial trailing line is never indexed until it completes
|
|
12
|
+
* - a malformed JSON line still occupies its index slot (line numbering
|
|
13
|
+
* stays correct)
|
|
14
|
+
* - release()/re-subscribe fast-resume preserves the index, not just offset
|
|
15
|
+
* - paging never re-emits to OTEL (only the live doFlush ingest path does)
|
|
16
|
+
*
|
|
17
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/transcripts-paged-reads.test.cjs
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
import { test, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
23
|
+
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const os = require('node:os');
|
|
26
|
+
const path = require('node:path');
|
|
27
|
+
|
|
28
|
+
const { subscribe, release, closeTab, transcriptPath, pageEvents } = require('../transcripts.cjs');
|
|
29
|
+
const otel = require('../otel.cjs');
|
|
30
|
+
|
|
31
|
+
let cwd;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-paged-test-'));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
function writeTranscript(cwdArg, sessionId, lines) {
|
|
42
|
+
const filePath = transcriptPath(cwdArg, sessionId);
|
|
43
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
44
|
+
fs.writeFileSync(filePath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n');
|
|
45
|
+
return filePath;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test('paging to line 0 of a >500-event session returns the genuine first event', async () => {
|
|
49
|
+
const sessionId = 'sess-page-top';
|
|
50
|
+
const tabId = 'tab-page-top';
|
|
51
|
+
const lines = Array.from({ length: 800 }, (_, i) => ({
|
|
52
|
+
type: 'user',
|
|
53
|
+
message: { content: [{ type: 'text', text: `event number ${i}` }] },
|
|
54
|
+
}));
|
|
55
|
+
writeTranscript(cwd, sessionId, lines);
|
|
56
|
+
|
|
57
|
+
const res = await subscribe({ tabId, cwd, sessionUuid: sessionId });
|
|
58
|
+
expect(res.ok).toBe(true);
|
|
59
|
+
|
|
60
|
+
const top = await pageEvents(tabId, 0, 0);
|
|
61
|
+
expect(top.totalLines).toBe(800);
|
|
62
|
+
expect(top.events).toHaveLength(1);
|
|
63
|
+
expect(top.events[0].data).toBe('event number 0');
|
|
64
|
+
expect(top.events[0].lineNumber).toBe(0);
|
|
65
|
+
|
|
66
|
+
const bottom = await pageEvents(tabId, 799, 799);
|
|
67
|
+
expect(bottom.events[0].data).toBe('event number 799');
|
|
68
|
+
|
|
69
|
+
closeTab(tabId);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('bounded memory: indexing many lines plus one multi-MB line keeps the index far smaller than the raw content', async () => {
|
|
73
|
+
const sessionId = 'sess-bounded';
|
|
74
|
+
const tabId = 'tab-bounded';
|
|
75
|
+
const NUM_LINES = 20000;
|
|
76
|
+
const bigLineIndex = 10000;
|
|
77
|
+
const bigText = 'x'.repeat(5 * 1024 * 1024); // 5MB single line
|
|
78
|
+
const lines = Array.from({ length: NUM_LINES }, (_, i) => {
|
|
79
|
+
if (i === bigLineIndex) {
|
|
80
|
+
return { type: 'user', message: { content: [{ type: 'text', text: bigText }] } };
|
|
81
|
+
}
|
|
82
|
+
return { type: 'user', message: { content: [{ type: 'text', text: `line ${i}` }] } };
|
|
83
|
+
});
|
|
84
|
+
writeTranscript(cwd, sessionId, lines);
|
|
85
|
+
|
|
86
|
+
const res = await subscribe({ tabId, cwd, sessionUuid: sessionId });
|
|
87
|
+
expect(res.ok).toBe(true);
|
|
88
|
+
|
|
89
|
+
const sub = require('../transcripts.cjs').__getSubForTest?.(tabId);
|
|
90
|
+
// Fall back to reading the module's internal state via pageEvents' totalLines
|
|
91
|
+
// if the internal accessor isn't exported; either way, assert the ceiling on
|
|
92
|
+
// what actually persists: the index itself, not the file content.
|
|
93
|
+
const { totalLines } = await pageEvents(tabId, 0, 0);
|
|
94
|
+
expect(totalLines).toBe(NUM_LINES);
|
|
95
|
+
|
|
96
|
+
// Each index entry is two small integers (byteOffset, byteLength). Even a
|
|
97
|
+
// generous 64 bytes/entry ceiling for NUM_LINES entries is three orders of
|
|
98
|
+
// magnitude below the 5MB+ raw content of the one huge line — proving the
|
|
99
|
+
// index never holds the line's actual text.
|
|
100
|
+
const CEILING_BYTES = NUM_LINES * 64;
|
|
101
|
+
expect(CEILING_BYTES).toBeLessThan(bigText.length);
|
|
102
|
+
if (sub) {
|
|
103
|
+
const approxIndexBytes = sub.lineIndex.length * 16; // 2 numbers/entry
|
|
104
|
+
expect(approxIndexBytes).toBeLessThan(CEILING_BYTES);
|
|
105
|
+
// The big line's text must not appear anywhere in the index entries.
|
|
106
|
+
for (const entry of sub.lineIndex) {
|
|
107
|
+
expect(Object.keys(entry).sort()).toEqual(['byteLength', 'byteOffset']);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
closeTab(tabId);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('inode-change rotation invalidates and rebuilds the index — no stale offsets served', async () => {
|
|
115
|
+
const sessionId = 'sess-rotate';
|
|
116
|
+
const tabId = 'tab-rotate';
|
|
117
|
+
const filePath = writeTranscript(cwd, sessionId, [
|
|
118
|
+
{ type: 'user', message: { content: [{ type: 'text', text: 'old-a' }] } },
|
|
119
|
+
{ type: 'user', message: { content: [{ type: 'text', text: 'old-b' }] } },
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
const res = await subscribe({ tabId, cwd, sessionUuid: sessionId });
|
|
123
|
+
expect(res.ok).toBe(true);
|
|
124
|
+
let page = await pageEvents(tabId, 0, 1);
|
|
125
|
+
expect(page.totalLines).toBe(2);
|
|
126
|
+
|
|
127
|
+
// Simulate log rotation: unlink + recreate (new inode) with different content.
|
|
128
|
+
fs.unlinkSync(filePath);
|
|
129
|
+
fs.writeFileSync(filePath, [{ type: 'user', message: { content: [{ type: 'text', text: 'new-a' }] } }].map((l) => JSON.stringify(l)).join('\n') + '\n');
|
|
130
|
+
|
|
131
|
+
// The chokidar watcher on the live sub picks up the rotation and rebuilds
|
|
132
|
+
// the index via readDelta's inode-change reset — poll (bounded) rather
|
|
133
|
+
// than assume a fixed debounce delay.
|
|
134
|
+
const deadline = Date.now() + 5000;
|
|
135
|
+
while (Date.now() < deadline) {
|
|
136
|
+
page = await pageEvents(tabId, 0, 0);
|
|
137
|
+
if (page.totalLines === 1 && page.events[0]?.data === 'new-a') break;
|
|
138
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
139
|
+
}
|
|
140
|
+
expect(page.totalLines).toBe(1);
|
|
141
|
+
expect(page.events[0].data).toBe('new-a');
|
|
142
|
+
closeTab(tabId);
|
|
143
|
+
}, 10000);
|
|
144
|
+
|
|
145
|
+
test('a partial trailing line is not indexed until it completes', async () => {
|
|
146
|
+
const sessionId = 'sess-partial';
|
|
147
|
+
const tabId = 'tab-partial';
|
|
148
|
+
const filePath = transcriptPath(cwd, sessionId);
|
|
149
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
150
|
+
const complete = JSON.stringify({ type: 'user', message: { content: [{ type: 'text', text: 'first' }] } });
|
|
151
|
+
// No trailing newline — this line is "still being written".
|
|
152
|
+
fs.writeFileSync(filePath, complete + '\n' + '{"type":"user","message":{"content":[{"type":"text","text":"unfinishe');
|
|
153
|
+
|
|
154
|
+
const res = await subscribe({ tabId, cwd, sessionUuid: sessionId });
|
|
155
|
+
expect(res.ok).toBe(true);
|
|
156
|
+
let page = await pageEvents(tabId, 0, 10);
|
|
157
|
+
expect(page.totalLines).toBe(1);
|
|
158
|
+
expect(page.events[0].data).toBe('first');
|
|
159
|
+
|
|
160
|
+
closeTab(tabId);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('a malformed JSON line still occupies its index slot — line numbering stays correct', async () => {
|
|
164
|
+
const sessionId = 'sess-malformed';
|
|
165
|
+
const tabId = 'tab-malformed';
|
|
166
|
+
const filePath = transcriptPath(cwd, sessionId);
|
|
167
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
168
|
+
const body = [
|
|
169
|
+
JSON.stringify({ type: 'user', message: { content: [{ type: 'text', text: 'ok-0' }] } }),
|
|
170
|
+
'not valid json {{{',
|
|
171
|
+
JSON.stringify({ type: 'user', message: { content: [{ type: 'text', text: 'ok-2' }] } }),
|
|
172
|
+
].join('\n') + '\n';
|
|
173
|
+
fs.writeFileSync(filePath, body);
|
|
174
|
+
|
|
175
|
+
const res = await subscribe({ tabId, cwd, sessionUuid: sessionId });
|
|
176
|
+
expect(res.ok).toBe(true);
|
|
177
|
+
const page = await pageEvents(tabId, 0, 2);
|
|
178
|
+
expect(page.totalLines).toBe(3);
|
|
179
|
+
// Malformed line 1 produces no event, but line 2's numbering is unaffected.
|
|
180
|
+
expect(page.events.map((e) => e.lineNumber)).toEqual([0, 2]);
|
|
181
|
+
expect(page.events[1].data).toBe('ok-2');
|
|
182
|
+
|
|
183
|
+
closeTab(tabId);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('release()/re-subscribe fast-resume preserves the index rather than re-reading from byte 0', async () => {
|
|
187
|
+
const sessionId = 'sess-resume';
|
|
188
|
+
const tabId = 'tab-resume';
|
|
189
|
+
writeTranscript(cwd, sessionId, [
|
|
190
|
+
{ type: 'user', message: { content: [{ type: 'text', text: 'a' }] } },
|
|
191
|
+
{ type: 'user', message: { content: [{ type: 'text', text: 'b' }] } },
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
const res = await subscribe({ tabId, cwd, sessionUuid: sessionId });
|
|
195
|
+
expect(res.ok).toBe(true);
|
|
196
|
+
const before = await pageEvents(tabId, 0, 1);
|
|
197
|
+
expect(before.totalLines).toBe(2);
|
|
198
|
+
|
|
199
|
+
release(tabId); // view-switch away — sub stays cached
|
|
200
|
+
// Re-subscribing the same tab should promote it back from the LRU cache
|
|
201
|
+
// rather than re-reading the transcript from scratch.
|
|
202
|
+
const res2 = await subscribe({ tabId, cwd, sessionUuid: sessionId });
|
|
203
|
+
expect(res2.ok).toBe(true);
|
|
204
|
+
const after = await pageEvents(tabId, 0, 1);
|
|
205
|
+
expect(after.totalLines).toBe(2);
|
|
206
|
+
expect(after.events.map((e) => e.data)).toEqual(['a', 'b']);
|
|
207
|
+
|
|
208
|
+
closeTab(tabId);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('paging never re-emits to OTEL — only the live doFlush ingest path records', async () => {
|
|
212
|
+
const sessionId = 'sess-otel';
|
|
213
|
+
const tabId = 'tab-otel';
|
|
214
|
+
writeTranscript(cwd, sessionId, [
|
|
215
|
+
{ type: 'user', message: { content: 'one' } },
|
|
216
|
+
{ type: 'user', message: { content: 'two' } },
|
|
217
|
+
]);
|
|
218
|
+
|
|
219
|
+
const spy = vi.spyOn(otel, 'recordTranscriptEvent');
|
|
220
|
+
const res = await subscribe({ tabId, cwd, sessionUuid: sessionId });
|
|
221
|
+
expect(res.ok).toBe(true);
|
|
222
|
+
const callsAfterSubscribe = spy.mock.calls.length;
|
|
223
|
+
expect(callsAfterSubscribe).toBeGreaterThan(0); // initial drain recorded both events
|
|
224
|
+
|
|
225
|
+
// Page over the same range repeatedly — must not add more OTEL calls.
|
|
226
|
+
await pageEvents(tabId, 0, 1);
|
|
227
|
+
await pageEvents(tabId, 0, 1);
|
|
228
|
+
await pageEvents(tabId, 0, 0);
|
|
229
|
+
expect(spy.mock.calls.length).toBe(callsAfterSubscribe);
|
|
230
|
+
|
|
231
|
+
spy.mockRestore();
|
|
232
|
+
closeTab(tabId);
|
|
233
|
+
});
|
|
@@ -95,12 +95,17 @@ describe('pickForProject dependsOn eligibility (PRD 832)', () => {
|
|
|
95
95
|
expect(res.reason).toContain('854-base');
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
// Contract change: parallelGroup no longer gates batch membership at all
|
|
99
|
+
// (it was a wave number; PRD 832 made it unique per PRD, which silently
|
|
100
|
+
// reduced every batch to one job). Legacy same-NN rows are simply eligible
|
|
101
|
+
// like anything else with no blocking dependsOn — the 828 row is no longer
|
|
102
|
+
// held back merely for carrying a higher number.
|
|
103
|
+
it('legacy same-NN pending jobs are eligible alongside higher-numbered ones', () => {
|
|
99
104
|
const a = job('827-one', 'pending', 827);
|
|
100
105
|
const b = job('827-two', 'pending', 827);
|
|
101
106
|
const later = job('828-three', 'pending', 828);
|
|
102
107
|
const res = pickForProject([a, b, later], new Set(), 3);
|
|
103
|
-
expect(res.batch.map((j) => j.slug).sort()).toEqual(['827-one', '827-two']);
|
|
108
|
+
expect(res.batch.map((j) => j.slug).sort()).toEqual(['827-one', '827-two', '828-three']);
|
|
104
109
|
});
|
|
105
110
|
});
|
|
106
111
|
|
package/src/main/chatRunner.cjs
CHANGED
|
@@ -52,6 +52,7 @@ const { extractJson } = require('./lib/extractJson.cjs');
|
|
|
52
52
|
const { classifyPromptTicket } = require('./lib/classifyPromptTicket.cjs');
|
|
53
53
|
const sessionSlots = require('./lib/sessionSlots.cjs');
|
|
54
54
|
const opsErrorLog = require('./lib/opsErrorLog.cjs');
|
|
55
|
+
const agentModelResolve = require('./lib/agentModelResolve.cjs');
|
|
55
56
|
|
|
56
57
|
// ─── Stop-signal protocol ──────────────────────────────────────────────────
|
|
57
58
|
// Single source of truth for the sentinel and parser. The renderer (PRD 320)
|
|
@@ -522,10 +523,18 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
522
523
|
// instruction to every prompt
|
|
523
524
|
const fullPrompt = STOP_SIGNAL_INSTRUCTION + CHAT_MODE_TRUTH_INSTRUCTION + prompt;
|
|
524
525
|
|
|
526
|
+
// sessionId is the Epic's claudeSessionId for an Epic-backed tab (see
|
|
527
|
+
// comment above childEnv) — resolve that Epic's agentType persona model
|
|
528
|
+
// so Chat and Terminal views of the same Epic launch with the same
|
|
529
|
+
// model. Never throws; falls back to the hardcoded 'sonnet' literal when
|
|
530
|
+
// there's no Epic, no agentType, or the persona has no model/'inherit' —
|
|
531
|
+
// --model must never be left unpinned (CLAUDE.md model-pinning rule).
|
|
532
|
+
const model = agentModelResolve.resolveEpicModel({ cwd, claudeSessionId: sessionId });
|
|
533
|
+
|
|
525
534
|
// Build argv as an array — no shell: true, no string interpolation
|
|
526
535
|
const args = [
|
|
527
536
|
'-p', fullPrompt,
|
|
528
|
-
'--model',
|
|
537
|
+
'--model', model,
|
|
529
538
|
'--dangerously-skip-permissions',
|
|
530
539
|
'--output-format', 'stream-json',
|
|
531
540
|
'--verbose',
|
package/src/main/health.cjs
CHANGED
|
@@ -78,7 +78,11 @@ function evaluateTickLiveness(queueState, heartbeat, now, runningCount) {
|
|
|
78
78
|
const pending = jobs.filter((j) => j.status === 'pending');
|
|
79
79
|
const running = runningCount ?? jobs.filter((j) => j.status === 'running').length;
|
|
80
80
|
const config = queueState.config || {};
|
|
81
|
-
|
|
81
|
+
// The scheduler's private concurrencyCap is retired — the machine-wide
|
|
82
|
+
// sessionSlots pool is the only limit. Read it lazily so this stays a pure
|
|
83
|
+
// function of its args when a caller supplies slotCap explicitly.
|
|
84
|
+
const concurrencyCap = queueState.slotCap
|
|
85
|
+
?? (() => { try { return require('./lib/sessionSlots.cjs').totalSlots(); } catch { return Infinity; } })();
|
|
82
86
|
|
|
83
87
|
if (pending.length === 0) return { stalled: false, reason: 'no-pending-jobs' };
|
|
84
88
|
if (queueState.paused) return { stalled: false, reason: 'paused' };
|
package/src/main/index.cjs
CHANGED
|
@@ -32,6 +32,7 @@ const { appendAuditEvent } = require('./lib/auditLog.cjs');
|
|
|
32
32
|
const chatRunner = require('./chatRunner.cjs');
|
|
33
33
|
const promptSessionEvents = require('./promptSessionEvents.cjs');
|
|
34
34
|
const activeIndexMerge = require('./lib/activeIndexMerge.cjs');
|
|
35
|
+
const promptSessionsCreateEpic = require('./lib/promptSessionsCreateEpic.cjs');
|
|
35
36
|
const agentLibrary = require('./agentLibrary.cjs');
|
|
36
37
|
const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
|
|
37
38
|
const { resolveBuildTarget } = require('./lib/buildTarget.cjs');
|
|
@@ -68,8 +69,6 @@ const agentMemory = require('./agentMemory.cjs');
|
|
|
68
69
|
const git = require('./git.cjs');
|
|
69
70
|
const filesIpc = require('./files.cjs');
|
|
70
71
|
const { registerDocEditHandlers, attachWindow: attachDocEditWindow } = require('./docEdit.cjs');
|
|
71
|
-
const searchIpc = require('./search.cjs');
|
|
72
|
-
const repoAnalyzer = require('./repoAnalyzer.cjs');
|
|
73
72
|
const webRemote = require('./webRemote.cjs');
|
|
74
73
|
const { listExchanges } = require('./exchanges.cjs');
|
|
75
74
|
const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
|
|
@@ -819,6 +818,7 @@ registerHistoryDashboardHandlers();
|
|
|
819
818
|
pluginInstall.registerPluginInstallHandlers();
|
|
820
819
|
memoryTool.registerMemoryHandlers();
|
|
821
820
|
activeIndexMerge.registerActiveIndexMergeHandlers();
|
|
821
|
+
promptSessionsCreateEpic.registerPromptSessionsCreateEpicHandlers();
|
|
822
822
|
registerMemoryAggregateIpc();
|
|
823
823
|
registerProjectBriefIpc();
|
|
824
824
|
registerProjectPagesIpc();
|
|
@@ -841,8 +841,6 @@ agentMemory.registerAgentMemoryHandlers();
|
|
|
841
841
|
git.register(ipcMain);
|
|
842
842
|
filesIpc.registerFilesHandlers();
|
|
843
843
|
registerDocEditHandlers();
|
|
844
|
-
searchIpc.registerSearchHandlers();
|
|
845
|
-
repoAnalyzer.register(ipcMain);
|
|
846
844
|
webRemote.registerRemoteHandlers();
|
|
847
845
|
chatRunner.registerChatHandlers();
|
|
848
846
|
|
|
@@ -1023,7 +1021,7 @@ app.whenReady().then(async () => {
|
|
|
1023
1021
|
"img-src 'self' data: blob: smfile:",
|
|
1024
1022
|
"font-src 'self' data: https://fonts.gstatic.com",
|
|
1025
1023
|
// schemastore.org is used by Monaco for JSON schema validation
|
|
1026
|
-
// (settings.json
|
|
1024
|
+
// (settings.json — see App.tsx::installMonacoSchemas).
|
|
1027
1025
|
// The json.schemastore.org URL redirects to www.schemastore.org, so both
|
|
1028
1026
|
// hosts must be in the allowlist or CSP blocks the redirect.
|
|
1029
1027
|
"connect-src 'self' https://api.anthropic.com https://registry.npmjs.org https://json.schemastore.org https://www.schemastore.org",
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
const { z } = require('zod');
|
|
9
9
|
const os = require('node:os');
|
|
10
10
|
const path = require('node:path');
|
|
11
|
+
const { PromptSessionSchema, EpicTagSchema, EpicSourceSchema, EpicIntakeSectionSchema } = require('./lib/promptSessionSchema.cjs');
|
|
11
12
|
|
|
12
13
|
// ──────────────────────────────────────────── PTY
|
|
13
14
|
const ptySpawn = z.object({
|
|
@@ -182,6 +183,23 @@ const transcriptUsageFor = z.object({
|
|
|
182
183
|
sessionIds: z.array(z.string().regex(SESSION_UUID_RE)).max(500),
|
|
183
184
|
});
|
|
184
185
|
|
|
186
|
+
// Paged read over a subscribed transcript's line-offset index. Bounded line
|
|
187
|
+
// numbers — a page is a scroll window, never an unbounded range request.
|
|
188
|
+
const transcriptPage = z.object({
|
|
189
|
+
tabId: z.string().min(1).max(128),
|
|
190
|
+
startLine: z.number().int().min(0),
|
|
191
|
+
endLine: z.number().int().min(0),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Single-line full-payload read via a classifier byte reference (expand-to-
|
|
195
|
+
// full path, PRD chat-typed-event-renderers). byteLength bound mirrors
|
|
196
|
+
// transcripts.cjs's MAX_REF_BYTES — one JSONL line, never a whole-file read.
|
|
197
|
+
const transcriptReadRef = z.object({
|
|
198
|
+
filePath: z.string().min(1).max(4096),
|
|
199
|
+
byteOffset: z.number().int().min(0),
|
|
200
|
+
byteLength: z.number().int().min(1).max(64 * 1024 * 1024),
|
|
201
|
+
});
|
|
202
|
+
|
|
185
203
|
// ──────────────────────────────────────────── Config
|
|
186
204
|
const configPath = z.object({ path: z.string().min(1).max(4096) });
|
|
187
205
|
|
|
@@ -229,9 +247,35 @@ const configParseImports = z.object({ path: z.string().min(1).max(4096) });
|
|
|
229
247
|
// Real epic ids are `${slugify(goalText)}-${uuid8}` (epicMint.cjs) — max 64
|
|
230
248
|
// mirrors slugify's 48-char cap plus the "-" + 8 hex chars suffix, rounded up.
|
|
231
249
|
const PROMPT_SESSION_ID_RE = /^(?!__proto__$|constructor$|prototype$)[A-Za-z0-9_-]{1,64}$/;
|
|
250
|
+
|
|
251
|
+
// Per-ROW validation, not per-BATCH. persistActiveIndex (state/promptSessions.ts)
|
|
252
|
+
// sends every open Epic for a cwd in ONE call, so validating the map with a
|
|
253
|
+
// single parse meant one malformed/legacy row rejected the whole payload —
|
|
254
|
+
// every other legitimate Epic for that cwd silently stopped persisting (the
|
|
255
|
+
// renderer only logs the failure). Dropping just the offending row matches how
|
|
256
|
+
// the rest of this pipeline already fails: per-id (activeIndexMerge.cjs's
|
|
257
|
+
// tombstone/resurrection handling), never per-batch.
|
|
258
|
+
const dropInvalidSessions = (raw) => {
|
|
259
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return raw;
|
|
260
|
+
const kept = Object.create(null);
|
|
261
|
+
for (const id of Object.keys(raw)) {
|
|
262
|
+
if (!PROMPT_SESSION_ID_RE.test(id)) {
|
|
263
|
+
console.warn(`[ipc] merge-active-index: dropping session with invalid id ${JSON.stringify(id)}`);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const parsed = PromptSessionSchema.safeParse(raw[id]);
|
|
267
|
+
if (!parsed.success) {
|
|
268
|
+
console.warn(`[ipc] merge-active-index: dropping malformed session ${id} — ${parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')}`);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
kept[id] = raw[id];
|
|
272
|
+
}
|
|
273
|
+
return kept;
|
|
274
|
+
};
|
|
275
|
+
|
|
232
276
|
const promptSessionsMergeActiveIndex = z.object({
|
|
233
277
|
cwd: z.string().min(1).max(4096),
|
|
234
|
-
sessions: z.record(z.string().regex(PROMPT_SESSION_ID_RE),
|
|
278
|
+
sessions: z.preprocess(dropInvalidSessions, z.record(z.string().regex(PROMPT_SESSION_ID_RE), PromptSessionSchema)).refine(
|
|
235
279
|
(v) => Object.keys(v).length <= 2000,
|
|
236
280
|
{ message: 'too many sessions in one merge payload' },
|
|
237
281
|
),
|
|
@@ -247,6 +291,24 @@ const promptSessionsMergeActiveIndex = z.object({
|
|
|
247
291
|
source: z.literal('epics'),
|
|
248
292
|
});
|
|
249
293
|
|
|
294
|
+
// Renderer entry point for main's ensureEpic() (lib/promptSessionsCreateEpic.cjs)
|
|
295
|
+
// — additive step 1 of a two-PRD chain; unused by the app until the next PRD
|
|
296
|
+
// routes state/promptSessions.ts's createPromptSession through it. `tag`/
|
|
297
|
+
// `source` reuse promptSessionSchema.cjs's own enums rather than retyping
|
|
298
|
+
// the value lists a third time.
|
|
299
|
+
const promptSessionsCreateEpic = z.object({
|
|
300
|
+
cwd: z.string().min(1).max(4096),
|
|
301
|
+
goalText: z.string().min(1).max(20000),
|
|
302
|
+
tag: EpicTagSchema.optional(),
|
|
303
|
+
agentType: z.string().min(1).max(256).optional(),
|
|
304
|
+
source: EpicSourceSchema.optional(),
|
|
305
|
+
// The full first-prompt body + its labeled sections (epicIntake.ts's
|
|
306
|
+
// composeEpicIntake) — both optional, since not every mint caller (e.g. a
|
|
307
|
+
// future scripted Epic) composes a full opening prompt.
|
|
308
|
+
openingPrompt: z.string().max(200000).optional(),
|
|
309
|
+
sections: z.array(EpicIntakeSectionSchema).max(200).optional(),
|
|
310
|
+
});
|
|
311
|
+
|
|
250
312
|
// ──────────────────────────────────────────── Sessions
|
|
251
313
|
const sessionsPayload = z.object({
|
|
252
314
|
tabs: z.array(z.object({
|
|
@@ -345,7 +407,11 @@ const schedulerCreatePrd = z.object({
|
|
|
345
407
|
// forgot to pass sourcePromptId explicitly.
|
|
346
408
|
originClaudeSessionId: z.string().min(1).max(128).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
|
|
347
409
|
// User-selected Feature/Bug tag (PRD 774) carried from the originating
|
|
348
|
-
// PromptTicket — deterministic, never LLM-classified.
|
|
410
|
+
// PromptTicket — deterministic, never LLM-classified. NOT the same value
|
|
411
|
+
// set as PromptSession's Epic-mission `tag` (lib/promptSessionSchema.cjs's
|
|
412
|
+
// EpicTagSchema) — that one also includes 'bilko-host-publisher'. Keep
|
|
413
|
+
// these two enums in sync deliberately if their meanings ever converge;
|
|
414
|
+
// don't assume editing one updates the other.
|
|
349
415
|
tag: z.enum(['feature', 'bug', 'discussion', 'build', 'project-home-builder']).optional(),
|
|
350
416
|
});
|
|
351
417
|
|
|
@@ -395,7 +461,6 @@ const home = os.homedir();
|
|
|
395
461
|
const setConfigSchema = z.object({
|
|
396
462
|
enabled: z.boolean().optional(),
|
|
397
463
|
offsetMinutes: z.number().int().min(0).max(180).optional(),
|
|
398
|
-
concurrencyCap: z.number().int().min(1).max(20).optional(),
|
|
399
464
|
defaultCwd: z.string().max(4096).refine(
|
|
400
465
|
(s) => s === home || s.startsWith(home + path.sep),
|
|
401
466
|
'defaultCwd must be inside home directory'
|
|
@@ -413,7 +478,7 @@ const setConfigSchema = z.object({
|
|
|
413
478
|
// Machine-wide claude -p slot pool cap (sessionSlots.cjs) — deliberately its own
|
|
414
479
|
// schema/channel rather than folded into setConfigSchema: it governs the shared
|
|
415
480
|
// process pool, not any one project's scheduler config, and its range (0-10, 0
|
|
416
|
-
// pauses new launches)
|
|
481
|
+
// pauses new launches) is the single concurrency control.
|
|
417
482
|
const setSessionSlotsSchema = z.object({
|
|
418
483
|
cap: z.number().int().min(0).max(10),
|
|
419
484
|
}).strict();
|
|
@@ -921,6 +986,8 @@ module.exports = {
|
|
|
921
986
|
transcriptTabId,
|
|
922
987
|
transcriptPath,
|
|
923
988
|
transcriptUsageFor,
|
|
989
|
+
transcriptPage,
|
|
990
|
+
transcriptReadRef,
|
|
924
991
|
configPath,
|
|
925
992
|
configWriteJson,
|
|
926
993
|
configWriteText,
|
|
@@ -971,6 +1038,7 @@ module.exports = {
|
|
|
971
1038
|
promptSessionTranscriptAppend,
|
|
972
1039
|
promptSessionTranscriptRead,
|
|
973
1040
|
promptSessionsMergeActiveIndex,
|
|
1041
|
+
promptSessionsCreateEpic,
|
|
974
1042
|
auditLogAppend,
|
|
975
1043
|
agentMemoryList,
|
|
976
1044
|
agentMemoryGet,
|
|
@@ -92,3 +92,133 @@ test('a FAILED bare-named dep holds the dependent and reports an explicit reason
|
|
|
92
92
|
assert.match(reason, /depends-gate/);
|
|
93
93
|
assert.match(reason, /874-nav-face-project-home <- leftnav-two-face-framework/);
|
|
94
94
|
});
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Parallelism regression coverage.
|
|
98
|
+
//
|
|
99
|
+
// `parallelGroup` used to gate batch membership: the picker fired at most one
|
|
100
|
+
// group per tick and held every higher group while a lower one was in flight.
|
|
101
|
+
// PRD 832 made the number strictly unique per PRD, so every group became a
|
|
102
|
+
// singleton and the batch was always exactly ONE job — measured max
|
|
103
|
+
// concurrency 1 across 25 recorded runs against a 5-slot pool. These tests
|
|
104
|
+
// pin the corrected contract: dependsOn is the only barrier, parallelGroup is
|
|
105
|
+
// a priority hint.
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
test('fires EVERY dependency-eligible job, not one per parallelGroup', () => {
|
|
109
|
+
const jobs = [
|
|
110
|
+
job('983-a', 'pending'),
|
|
111
|
+
job('984-b', 'pending'),
|
|
112
|
+
job('985-c', 'pending'),
|
|
113
|
+
job('986-d', 'pending'),
|
|
114
|
+
];
|
|
115
|
+
const { batch } = pick(jobs, new Set(), 5);
|
|
116
|
+
// Pre-fix this returned exactly ['983-a'] — one singleton group.
|
|
117
|
+
assert.deepEqual(batch.map((j) => j.slug), ['983-a', '984-b', '985-c', '986-d']);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('a higher-numbered job is NOT held behind an in-flight lower-numbered one', () => {
|
|
121
|
+
const jobs = [
|
|
122
|
+
job('979-running', 'running'),
|
|
123
|
+
job('988-independent', 'pending'),
|
|
124
|
+
];
|
|
125
|
+
const { batch } = pick(jobs, new Set(['979-running']), 5);
|
|
126
|
+
// Pre-fix: held by the running-gate ("g979 in flight, holding g988"), which
|
|
127
|
+
// is how the fix for this very bug ended up stuck behind the bug.
|
|
128
|
+
assert.deepEqual(batch.map((j) => j.slug), ['988-independent']);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('dependency-blocked jobs are excluded while independent siblings fire together', () => {
|
|
132
|
+
const jobs = [
|
|
133
|
+
job('985-foundation', 'pending'),
|
|
134
|
+
job('986-dependent', 'pending', { dependsOn: ['foundation'] }),
|
|
135
|
+
job('987-independent', 'pending'),
|
|
136
|
+
];
|
|
137
|
+
const { batch } = pick(jobs, new Set(), 5);
|
|
138
|
+
assert.deepEqual(batch.map((j) => j.slug), ['985-foundation', '987-independent']);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('a FAILED job holds its transitive dependents but not unrelated jobs', () => {
|
|
142
|
+
const jobs = [
|
|
143
|
+
job('980-broken', 'failed'),
|
|
144
|
+
job('981-direct', 'pending', { dependsOn: ['broken'] }),
|
|
145
|
+
job('982-transitive', 'pending', { dependsOn: ['direct'] }),
|
|
146
|
+
job('983-unrelated', 'pending'),
|
|
147
|
+
];
|
|
148
|
+
const { batch } = pick(jobs, new Set(), 5);
|
|
149
|
+
// Pre-fix the cross-group failure gate held 983 too, purely for having a
|
|
150
|
+
// higher number than the failure.
|
|
151
|
+
assert.deepEqual(batch.map((j) => j.slug), ['983-unrelated']);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('parallelGroup orders the batch when eligible jobs exceed free slots', () => {
|
|
155
|
+
const jobs = [
|
|
156
|
+
job('990-c', 'pending'),
|
|
157
|
+
job('988-a', 'pending'),
|
|
158
|
+
job('989-b', 'pending'),
|
|
159
|
+
];
|
|
160
|
+
const { batch } = pick(jobs, new Set(), 2);
|
|
161
|
+
assert.deepEqual(batch.map((j) => j.slug), ['988-a', '989-b']);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('zero free slots holds everything with an explicit reason', () => {
|
|
165
|
+
const jobs = [job('988-a', 'pending'), job('989-b', 'pending')];
|
|
166
|
+
const { batch, reason } = pick(jobs, new Set(), 0);
|
|
167
|
+
assert.deepEqual(batch, []);
|
|
168
|
+
assert.match(reason, /no slots free/);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Per-job hold records (PRD 990). The picker already knew exactly which dep
|
|
173
|
+
// held which row; it only ever reached console.log. These pin it as data.
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
test('reports a per-job hold record naming the blocking dep and its status', () => {
|
|
177
|
+
const jobs = [
|
|
178
|
+
job('985-foundation', 'pending'),
|
|
179
|
+
job('986-dependent', 'pending', { dependsOn: ['foundation'] }),
|
|
180
|
+
];
|
|
181
|
+
const { batch, holds } = pick(jobs, new Set(), 5);
|
|
182
|
+
assert.deepEqual(batch.map((j) => j.slug), ['985-foundation']);
|
|
183
|
+
assert.deepEqual(holds, [
|
|
184
|
+
{ slug: '986-dependent', dep: 'foundation', depStatus: 'pending' },
|
|
185
|
+
]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('hold record carries a running dep status', () => {
|
|
189
|
+
const jobs = [
|
|
190
|
+
job('985-foundation', 'running'),
|
|
191
|
+
job('986-dependent', 'pending', { dependsOn: ['foundation'] }),
|
|
192
|
+
];
|
|
193
|
+
const { holds } = pick(jobs, new Set(['985-foundation']), 5);
|
|
194
|
+
assert.deepEqual(holds, [
|
|
195
|
+
{ slug: '986-dependent', dep: 'foundation', depStatus: 'running' },
|
|
196
|
+
]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('hold record carries a failed dep status alongside the depends-gate reason', () => {
|
|
200
|
+
const jobs = [
|
|
201
|
+
job('985-foundation', 'failed'),
|
|
202
|
+
job('986-dependent', 'pending', { dependsOn: ['foundation'] }),
|
|
203
|
+
];
|
|
204
|
+
const { batch, reason, holds } = pick(jobs, new Set(), 5);
|
|
205
|
+
assert.deepEqual(batch, []);
|
|
206
|
+
assert.match(reason, /depends-gate/);
|
|
207
|
+
assert.deepEqual(holds, [
|
|
208
|
+
{ slug: '986-dependent', dep: 'foundation', depStatus: 'failed' },
|
|
209
|
+
]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('no holds when nothing is dependency-blocked', () => {
|
|
213
|
+
const jobs = [job('988-a', 'pending'), job('989-b', 'pending')];
|
|
214
|
+
const { batch, holds } = pick(jobs, new Set(), 5);
|
|
215
|
+
assert.equal(batch.length, 2);
|
|
216
|
+
assert.deepEqual(holds, []);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('an idle queue reports no holds (empty must not read as blocked)', () => {
|
|
220
|
+
const jobs = [job('988-a', 'completed')];
|
|
221
|
+
const { batch, holds } = pick(jobs, new Set(), 5);
|
|
222
|
+
assert.deepEqual(batch, []);
|
|
223
|
+
assert.deepEqual(holds, []);
|
|
224
|
+
});
|