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.
Files changed (68) hide show
  1. package/README.md +2 -2
  2. package/dist/assets/{TiptapBody-CJDxVF7X.js → TiptapBody-PUx4oZTh.js} +1 -1
  3. package/dist/assets/index-BPnfPdLW.js +3222 -0
  4. package/dist/assets/{index-WUc_GHMf.css → index-CPMP2XZ_.css} +1 -1
  5. package/dist/index.html +2 -2
  6. package/package.json +1 -2
  7. package/plugins/session-manager-dev/skills/develop/SKILL.md +7 -6
  8. package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
  9. package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +1 -1
  10. package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +1 -1
  11. package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +16 -25
  12. package/plugins/session-manager-dev/skills/project-status/SKILL.md +5 -5
  13. package/scripts/lib/watchdogHelpers.cjs +0 -434
  14. package/scripts/mint-epic.cjs +6 -6
  15. package/src/main/__tests__/activeIndexMerge.test.cjs +2 -2
  16. package/src/main/__tests__/agentModelResolve.test.cjs +119 -0
  17. package/src/main/__tests__/classifyTranscriptLine.test.cjs +128 -17
  18. package/src/main/__tests__/epicContextDigest.test.cjs +100 -0
  19. package/src/main/__tests__/epicMint.test.cjs +165 -207
  20. package/src/main/__tests__/epicValidationHook.test.cjs +291 -0
  21. package/src/main/__tests__/prdMigration.test.cjs +160 -0
  22. package/src/main/__tests__/projectPages.test.cjs +151 -0
  23. package/src/main/__tests__/promptSessionEvents.test.cjs +74 -0
  24. package/src/main/__tests__/promptSessionSchema.test.cjs +101 -0
  25. package/src/main/__tests__/promptSessionsCreateEpicHandler.test.cjs +139 -0
  26. package/src/main/__tests__/rcaReport.test.cjs +188 -0
  27. package/src/main/__tests__/runVerify.test.cjs +109 -6
  28. package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +32 -12
  29. package/src/main/__tests__/scheduler-epic-digest.test.cjs +214 -0
  30. package/src/main/__tests__/scheduler-heal-refusal.test.cjs +61 -0
  31. package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +74 -0
  32. package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +5 -5
  33. package/src/main/__tests__/transcripts-doFlush-array.test.cjs +118 -0
  34. package/src/main/__tests__/transcripts-paged-reads.test.cjs +233 -0
  35. package/src/main/__tests__/uniquePrdNumbers.test.cjs +7 -2
  36. package/src/main/chatRunner.cjs +10 -1
  37. package/src/main/health.cjs +5 -1
  38. package/src/main/index.cjs +3 -5
  39. package/src/main/ipcSchemas.cjs +72 -4
  40. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +130 -0
  41. package/src/main/lib/agentModelResolve.cjs +97 -0
  42. package/src/main/lib/auditLog.cjs +2 -2
  43. package/src/main/lib/classifyTranscriptLine.cjs +131 -48
  44. package/src/main/lib/epicContextDigest.cjs +78 -0
  45. package/src/main/lib/epicMint.cjs +86 -164
  46. package/src/main/lib/epicValidationHook.cjs +192 -0
  47. package/src/main/lib/prdMigration.cjs +76 -5
  48. package/src/main/lib/promptSessionSchema.cjs +95 -0
  49. package/src/main/lib/promptSessionsCreateEpic.cjs +70 -0
  50. package/src/main/lib/{rcaFeedbackHook.cjs → rcaReport.cjs} +47 -114
  51. package/src/main/lib/schedulerBatch.cjs +70 -111
  52. package/src/main/lib/schedulerConfig.cjs +0 -1
  53. package/src/main/otel.cjs +3 -1
  54. package/src/main/projectPages.cjs +60 -14
  55. package/src/main/promptSessionEvents.cjs +16 -1
  56. package/src/main/runVerify.cjs +17 -2
  57. package/src/main/scheduler.cjs +302 -68
  58. package/src/main/templates/project-pages-default-home.html +123 -0
  59. package/src/main/transcripts.cjs +191 -32
  60. package/src/main/webRemote.cjs +8 -7
  61. package/src/preload/api.d.ts +100 -57
  62. package/src/preload/index.cjs +9 -7
  63. package/dist/assets/index-DCa3QvAi.js +0 -3227
  64. package/plugins/session-manager-dev/skills/propose-epic/SKILL.md +0 -68
  65. package/scripts/propose-epic.cjs +0 -56
  66. package/src/main/__tests__/rcaFeedbackHook.test.cjs +0 -280
  67. package/src/main/repoAnalyzer.cjs +0 -346
  68. 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
- it('legacy same-NN pending jobs keep their wave order untouched', () => {
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
 
@@ -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', 'sonnet', // pinned per "claude -p model pinning" rule
537
+ '--model', model,
529
538
  '--dangerously-skip-permissions',
530
539
  '--output-format', 'stream-json',
531
540
  '--verbose',
@@ -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
- const concurrencyCap = config.concurrencyCap ?? Infinity;
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' };
@@ -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, keybindings.json — see App.tsx::installMonacoSchemas).
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",
@@ -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), z.unknown()).refine(
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) differs from concurrencyCap's (1-20).
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
+ });