claude-code-session-manager 0.58.0 → 0.60.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-BEBLdJl_.js → TiptapBody-BtVPGBaq.js} +1 -1
- package/dist/assets/{index-DwUffaDq.css → index-CPMP2XZ_.css} +1 -1
- package/dist/assets/{index-DEQzGYa6.js → index-DtNip-Zx.js} +1076 -1081
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/__tests__/classifyTranscriptLine.test.cjs +128 -17
- package/src/main/__tests__/epicContextDigest.test.cjs +41 -1
- 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__/scheduler-effective-concurrency.test.cjs +32 -12
- package/src/main/__tests__/scheduler-epic-digest.test.cjs +9 -2
- 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__/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/health.cjs +5 -1
- package/src/main/index.cjs +1 -1
- package/src/main/ipcSchemas.cjs +26 -3
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +130 -0
- package/src/main/lib/classifyTranscriptLine.cjs +131 -48
- package/src/main/lib/epicContextDigest.cjs +36 -0
- package/src/main/lib/epicMint.cjs +6 -1
- package/src/main/lib/epicValidationHook.cjs +192 -0
- package/src/main/lib/prdMigration.cjs +76 -5
- package/src/main/lib/promptSessionSchema.cjs +16 -0
- package/src/main/lib/promptSessionsCreateEpic.cjs +5 -3
- 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/scheduler.cjs +228 -49
- 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 +75 -9
- package/src/preload/index.cjs +3 -0
package/src/main/transcripts.cjs
CHANGED
|
@@ -50,20 +50,27 @@ const MAX_DELTA_BYTES = 8 * 1024 * 1024;
|
|
|
50
50
|
/**
|
|
51
51
|
* Read new bytes from sub.filePath into sub.offset/pending/inode in place.
|
|
52
52
|
* Resets offset+pending when the file inode changes (rename+replace rotation).
|
|
53
|
-
* Returns
|
|
53
|
+
* Returns `{ text, byteOffset, byteLength }` per complete line, so callers can
|
|
54
|
+
* build a classifyLine `ref` pointing at the exact bytes on disk without
|
|
55
|
+
* re-deriving offsets downstream. '\n' is always a single UTF-8 byte (0x0A
|
|
56
|
+
* never appears inside a multi-byte continuation sequence), so byte lengths
|
|
57
|
+
* computed from the already-decoded string segments are exact.
|
|
54
58
|
*/
|
|
55
59
|
async function readDelta(sub) {
|
|
56
60
|
const stat = await fsp.stat(sub.filePath).catch(() => null);
|
|
57
61
|
if (!stat) return [];
|
|
58
|
-
// Inode changed → file was replaced underfoot; restart from the top.
|
|
62
|
+
// Inode changed → file was replaced underfoot; restart from the top. The
|
|
63
|
+
// line index is keyed to byte offsets in the OLD file, so it's invalid too.
|
|
59
64
|
if (sub.inode !== undefined && stat.ino !== sub.inode) {
|
|
60
65
|
sub.offset = 0;
|
|
61
66
|
sub.pending = '';
|
|
67
|
+
sub.lineIndex.length = 0;
|
|
62
68
|
}
|
|
63
69
|
if (stat.size < sub.offset) {
|
|
64
|
-
// File was truncated/rotated — start over.
|
|
70
|
+
// File was truncated/rotated — start over, and the index goes with it.
|
|
65
71
|
sub.offset = 0;
|
|
66
72
|
sub.pending = '';
|
|
73
|
+
sub.lineIndex.length = 0;
|
|
67
74
|
}
|
|
68
75
|
if (stat.size === sub.offset) {
|
|
69
76
|
sub.inode = stat.ino;
|
|
@@ -72,6 +79,9 @@ async function readDelta(sub) {
|
|
|
72
79
|
let readFrom = sub.offset;
|
|
73
80
|
let length = stat.size - readFrom;
|
|
74
81
|
let skipped = false;
|
|
82
|
+
// Byte offset of the first char of `sub.pending` — it was already counted
|
|
83
|
+
// into sub.offset by the previous readDelta call, so back it out here.
|
|
84
|
+
let textStartOffset = readFrom - Buffer.byteLength(sub.pending, 'utf8');
|
|
75
85
|
if (length > MAX_DELTA_BYTES) {
|
|
76
86
|
// Huge unread span — typically a very large transcript (hundreds of MB) on
|
|
77
87
|
// first attach. Materializing the whole thing into a Buffer + decoded string
|
|
@@ -85,6 +95,7 @@ async function readDelta(sub) {
|
|
|
85
95
|
length = stat.size - readFrom;
|
|
86
96
|
sub.pending = '';
|
|
87
97
|
skipped = true;
|
|
98
|
+
textStartOffset = readFrom;
|
|
88
99
|
}
|
|
89
100
|
const fd = await fsp.open(sub.filePath, 'r');
|
|
90
101
|
try {
|
|
@@ -93,10 +104,23 @@ async function readDelta(sub) {
|
|
|
93
104
|
const text = sub.pending + buf.toString('utf8');
|
|
94
105
|
const parts = text.split('\n');
|
|
95
106
|
sub.pending = parts.pop() ?? '';
|
|
96
|
-
|
|
107
|
+
let cursor = textStartOffset;
|
|
108
|
+
if (skipped) {
|
|
109
|
+
// Discard the partial line at the seek boundary, but still advance the
|
|
110
|
+
// cursor past its bytes (+1 for the newline) so kept lines' offsets stay
|
|
111
|
+
// accurate.
|
|
112
|
+
const discarded = parts.shift() ?? '';
|
|
113
|
+
cursor += Buffer.byteLength(discarded, 'utf8') + 1;
|
|
114
|
+
}
|
|
115
|
+
const lines = [];
|
|
116
|
+
for (const part of parts) {
|
|
117
|
+
const byteLength = Buffer.byteLength(part, 'utf8');
|
|
118
|
+
if (part) lines.push({ text: part, byteOffset: cursor, byteLength });
|
|
119
|
+
cursor += byteLength + 1; // +1 for the newline separator
|
|
120
|
+
}
|
|
97
121
|
sub.offset = stat.size;
|
|
98
122
|
sub.inode = stat.ino;
|
|
99
|
-
return
|
|
123
|
+
return lines;
|
|
100
124
|
} finally {
|
|
101
125
|
await fd.close();
|
|
102
126
|
}
|
|
@@ -105,27 +129,130 @@ async function readDelta(sub) {
|
|
|
105
129
|
async function doFlush(sub, { emit = true } = {}) {
|
|
106
130
|
const lines = await readDelta(sub);
|
|
107
131
|
for (const line of lines) {
|
|
132
|
+
// Index every line — including ones that fail to parse — so line numbers
|
|
133
|
+
// and byte offsets stay correct for paged reads regardless of content.
|
|
134
|
+
// This index (not the parsed events) is what's kept in memory long-term.
|
|
135
|
+
sub.lineIndex.push({ byteOffset: line.byteOffset, byteLength: line.byteLength });
|
|
108
136
|
let obj;
|
|
109
137
|
try {
|
|
110
|
-
obj = JSON.parse(line);
|
|
138
|
+
obj = JSON.parse(line.text);
|
|
111
139
|
} catch {
|
|
112
140
|
continue;
|
|
113
141
|
}
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
142
|
+
const ref = { filePath: sub.filePath, byteOffset: line.byteOffset, byteLength: line.byteLength };
|
|
143
|
+
const events = classifyLine(obj, ref);
|
|
144
|
+
for (const ev of events) {
|
|
145
|
+
if (emit) sendIfAlive(window, `transcript:event:${sub.tabId}`, ev);
|
|
146
|
+
// Mirror to OTEL — no-op when disabled. We emit on the initial drain too
|
|
147
|
+
// so backfilled transcripts show up in the trace store. One span per
|
|
148
|
+
// emitted event, not per line. doFlush only ever processes a given
|
|
149
|
+
// line once (readDelta never re-returns already-consumed bytes), so
|
|
150
|
+
// this can't double-record — paged re-reads (readPage) go through a
|
|
151
|
+
// separate code path below that never touches OTEL.
|
|
152
|
+
otel.recordTranscriptEvent({
|
|
153
|
+
tabId: sub.tabId,
|
|
154
|
+
tabCwd: sub.cwd,
|
|
155
|
+
kind: ev.kind,
|
|
156
|
+
data: ev.data,
|
|
157
|
+
ts: Date.now(),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Read events for JSONL lines [startLine, endLine] (inclusive, 0-based) from
|
|
165
|
+
* disk using the subscription's line-offset index — never the whole file.
|
|
166
|
+
* A single positional fd.read covers the byte span for the whole requested
|
|
167
|
+
* window; lines are then re-split and parsed from that buffer only. This is
|
|
168
|
+
* the only path a caller should use to fetch history beyond what has already
|
|
169
|
+
* streamed via transcript:event — it never touches OTEL (which fires exactly
|
|
170
|
+
* once per line, in doFlush, as new bytes are indexed) and never re-emits.
|
|
171
|
+
*/
|
|
172
|
+
async function readPage(sub, startLine, endLine) {
|
|
173
|
+
const total = sub.lineIndex.length;
|
|
174
|
+
const from = Math.max(0, Math.min(startLine, total - 1));
|
|
175
|
+
const to = Math.max(0, Math.min(endLine, total - 1));
|
|
176
|
+
if (total === 0 || from > to) return { events: [], totalLines: total };
|
|
177
|
+
const first = sub.lineIndex[from];
|
|
178
|
+
const last = sub.lineIndex[to];
|
|
179
|
+
const spanOffset = first.byteOffset;
|
|
180
|
+
const spanLength = last.byteOffset + last.byteLength - spanOffset;
|
|
181
|
+
const events = [];
|
|
182
|
+
if (spanLength <= 0) return { events, totalLines: total };
|
|
183
|
+
const fd = await fsp.open(sub.filePath, 'r');
|
|
184
|
+
try {
|
|
185
|
+
const buf = Buffer.alloc(spanLength);
|
|
186
|
+
await fd.read(buf, 0, spanLength, spanOffset);
|
|
187
|
+
for (let lineNo = from; lineNo <= to; lineNo++) {
|
|
188
|
+
const entry = sub.lineIndex[lineNo];
|
|
189
|
+
const relOffset = entry.byteOffset - spanOffset;
|
|
190
|
+
const text = buf.toString('utf8', relOffset, relOffset + entry.byteLength);
|
|
191
|
+
let obj;
|
|
192
|
+
try {
|
|
193
|
+
obj = JSON.parse(text);
|
|
194
|
+
} catch {
|
|
195
|
+
continue; // malformed line: index slot preserved, nothing to render
|
|
196
|
+
}
|
|
197
|
+
const ref = { filePath: sub.filePath, byteOffset: entry.byteOffset, byteLength: entry.byteLength };
|
|
198
|
+
for (const ev of classifyLine(obj, ref)) {
|
|
199
|
+
events.push({ ...ev, lineNumber: lineNo });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} finally {
|
|
203
|
+
await fd.close();
|
|
204
|
+
}
|
|
205
|
+
return { events, totalLines: total };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function pageEvents(tabId, startLine, endLine) {
|
|
209
|
+
const sub = subs.get(tabId);
|
|
210
|
+
if (!sub) return { events: [], totalLines: 0 };
|
|
211
|
+
return readPage(sub, startLine, endLine);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Defensive ceiling on a single-line ref read (transcript:readRef). The
|
|
215
|
+
// largest real JSONL lines observed are multi-MB tool_results; 64 MB is far
|
|
216
|
+
// beyond plausible for ONE line while still bounding a hostile/corrupt ref.
|
|
217
|
+
const MAX_REF_BYTES = 64 * 1024 * 1024;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Read the exact bytes of ONE JSONL line via the classifier's byte reference
|
|
221
|
+
* ({ filePath, byteOffset, byteLength } — threaded per-event since PRD
|
|
222
|
+
* transcript-classifier-multi-emit). This is the renderer's expand-to-full-
|
|
223
|
+
* payload path: the ring-buffered event carries only a bounded preview, the
|
|
224
|
+
* full untruncated line lives on disk. Positional fd.read only — never the
|
|
225
|
+
* whole file (same OOM discipline as readDelta/readPage).
|
|
226
|
+
*
|
|
227
|
+
* filePath is renderer-supplied, so it is validated with config.cjs's
|
|
228
|
+
* realpath boundary check AND pinned to the transcripts root
|
|
229
|
+
* (~/.claude/projects/) — a ref can never read arbitrary home-dir files.
|
|
230
|
+
*/
|
|
231
|
+
async function readRef({ filePath, byteOffset, byteLength }) {
|
|
232
|
+
const { validatePath } = require('./config.cjs');
|
|
233
|
+
let real;
|
|
234
|
+
try {
|
|
235
|
+
real = validatePath(filePath);
|
|
236
|
+
} catch (e) {
|
|
237
|
+
return { ok: false, error: e?.message || 'invalid path' };
|
|
238
|
+
}
|
|
239
|
+
const transcriptsRoot = path.join(os.homedir(), '.claude', 'projects') + path.sep;
|
|
240
|
+
if (!real.startsWith(transcriptsRoot)) {
|
|
241
|
+
return { ok: false, error: 'ref outside transcripts root' };
|
|
242
|
+
}
|
|
243
|
+
if (byteLength > MAX_REF_BYTES) {
|
|
244
|
+
return { ok: false, error: 'ref byteLength exceeds cap' };
|
|
245
|
+
}
|
|
246
|
+
let fd;
|
|
247
|
+
try {
|
|
248
|
+
fd = await fsp.open(real, 'r');
|
|
249
|
+
const buf = Buffer.alloc(byteLength);
|
|
250
|
+
const { bytesRead } = await fd.read(buf, 0, byteLength, byteOffset);
|
|
251
|
+
return { ok: true, text: buf.toString('utf8', 0, bytesRead) };
|
|
252
|
+
} catch (e) {
|
|
253
|
+
return { ok: false, error: e?.message || 'read failed' };
|
|
254
|
+
} finally {
|
|
255
|
+
await fd?.close().catch(() => {});
|
|
129
256
|
}
|
|
130
257
|
}
|
|
131
258
|
|
|
@@ -227,15 +354,20 @@ async function subscribe({ tabId, cwd, sessionUuid }) {
|
|
|
227
354
|
filePath,
|
|
228
355
|
offset: 0,
|
|
229
356
|
pending: '',
|
|
230
|
-
|
|
357
|
+
// Line-offset index (byteOffset + byteLength per JSONL line) — this is
|
|
358
|
+
// what's kept in memory across the life of the subscription, not the
|
|
359
|
+
// parsed events themselves. See readPage() for how events are re-derived
|
|
360
|
+
// from it on demand.
|
|
361
|
+
lineIndex: [],
|
|
231
362
|
watcher: null,
|
|
232
363
|
flushing: null,
|
|
233
364
|
dirty: false,
|
|
234
365
|
};
|
|
235
366
|
// If the file already exists, read current content as replay. Do not emit
|
|
236
|
-
// during this initial drain — the renderer drains
|
|
237
|
-
// `transcript:buffer` after `transcript:subscribe`
|
|
238
|
-
// would race the renderer's onEvent listener
|
|
367
|
+
// during this initial drain — the renderer drains history via
|
|
368
|
+
// `transcript:buffer` (or `transcript:page`) after `transcript:subscribe`
|
|
369
|
+
// resolves. Emitting here would race the renderer's onEvent listener
|
|
370
|
+
// registration and drop events.
|
|
239
371
|
if (fs.existsSync(filePath)) {
|
|
240
372
|
await doFlush(sub, { emit: false });
|
|
241
373
|
}
|
|
@@ -257,9 +389,21 @@ function unsubscribe(tabId) {
|
|
|
257
389
|
release(tabId);
|
|
258
390
|
}
|
|
259
391
|
|
|
260
|
-
|
|
392
|
+
/**
|
|
393
|
+
* Full-history drain used by the `transcript:buffer` IPC (initial replay on
|
|
394
|
+
* subscribe / tab-switch resume). Pages the entire indexed range from disk —
|
|
395
|
+
* bounded by the index's byte offsets, never a whole-file read — rather than
|
|
396
|
+
* the old fixed 500-entry ring buffer, so scrolling to the top of a long
|
|
397
|
+
* session reaches the genuine first event instead of a truncated window.
|
|
398
|
+
* Large sessions should prefer transcript:page for a specific window; this
|
|
399
|
+
* stays for existing consumers (live.ts, chat.ts, terminalDigest.ts) that
|
|
400
|
+
* expect one full drain.
|
|
401
|
+
*/
|
|
402
|
+
async function getBuffer(tabId) {
|
|
261
403
|
const sub = subs.get(tabId);
|
|
262
|
-
|
|
404
|
+
if (!sub || sub.lineIndex.length === 0) return [];
|
|
405
|
+
const { events } = await readPage(sub, 0, sub.lineIndex.length - 1);
|
|
406
|
+
return events;
|
|
263
407
|
}
|
|
264
408
|
|
|
265
409
|
// Cap on the transcript file we'll fully re-read for a token-usage summary —
|
|
@@ -305,11 +449,13 @@ async function usageForOne(filePath) {
|
|
|
305
449
|
} catch {
|
|
306
450
|
continue;
|
|
307
451
|
}
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
452
|
+
const events = classifyLine(obj);
|
|
453
|
+
for (const ev of events) {
|
|
454
|
+
if (ev.kind !== 'usage') continue;
|
|
455
|
+
const u = ev.data || {};
|
|
456
|
+
inputTokens += u.input_tokens ?? u.inputTokens ?? 0;
|
|
457
|
+
outputTokens += u.output_tokens ?? u.outputTokens ?? 0;
|
|
458
|
+
}
|
|
313
459
|
}
|
|
314
460
|
const usage = { inputTokens, outputTokens };
|
|
315
461
|
usageCache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, usage });
|
|
@@ -354,6 +500,8 @@ function registerTranscriptHandlers() {
|
|
|
354
500
|
ipcMain.handle('transcript:buffer', v(s.transcriptTabId, ({ tabId }) => getBuffer(tabId)));
|
|
355
501
|
ipcMain.handle('transcript:path', v(s.transcriptPath, ({ cwd, sessionUuid }) => transcriptPath(cwd, sessionUuid)));
|
|
356
502
|
ipcMain.handle('transcript:usageFor', v(s.transcriptUsageFor, ({ cwd, sessionIds }) => usageFor(cwd, sessionIds)));
|
|
503
|
+
ipcMain.handle('transcript:page', v(s.transcriptPage, ({ tabId, startLine, endLine }) => pageEvents(tabId, startLine, endLine)));
|
|
504
|
+
ipcMain.handle('transcript:readRef', v(s.transcriptReadRef, (ref) => readRef(ref)));
|
|
357
505
|
}
|
|
358
506
|
|
|
359
507
|
module.exports = {
|
|
@@ -366,4 +514,15 @@ module.exports = {
|
|
|
366
514
|
transcriptPath,
|
|
367
515
|
classifyLine,
|
|
368
516
|
usageFor,
|
|
517
|
+
pageEvents,
|
|
518
|
+
readRef,
|
|
519
|
+
// Exported for unit tests exercising the array-of-events flush path
|
|
520
|
+
// (doFlush) and the line-offset index it maintains — not part of the IPC
|
|
521
|
+
// surface.
|
|
522
|
+
subscribe,
|
|
523
|
+
getBuffer,
|
|
524
|
+
// Test-only accessor onto the raw Subscription object (its lineIndex in
|
|
525
|
+
// particular) — asserting a memory ceiling requires inspecting what's
|
|
526
|
+
// actually held, not just what a read API returns.
|
|
527
|
+
__getSubForTest: (tabId) => subs.get(tabId),
|
|
369
528
|
};
|
package/src/main/webRemote.cjs
CHANGED
|
@@ -649,13 +649,14 @@ async function pollSessionWatcher(w) {
|
|
|
649
649
|
for (const line of res.lines) {
|
|
650
650
|
let obj;
|
|
651
651
|
try { obj = JSON.parse(line); } catch { continue; }
|
|
652
|
-
const
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
652
|
+
const events = require('./transcripts.cjs').classifyLine(obj);
|
|
653
|
+
for (const ev of events) {
|
|
654
|
+
const s = deriveState(ev, obj);
|
|
655
|
+
if (s) nextState = s;
|
|
656
|
+
if (ev.kind === 'assistant') {
|
|
657
|
+
const text = extractAssistantText(obj);
|
|
658
|
+
if (text) { newAssistantText = text; newMsgId = obj.uuid || obj.message?.id || `${w.tabId}:${res.size}`; }
|
|
659
|
+
}
|
|
659
660
|
}
|
|
660
661
|
}
|
|
661
662
|
|
package/src/preload/api.d.ts
CHANGED
|
@@ -171,10 +171,20 @@ export type TranscriptEventKind =
|
|
|
171
171
|
| 'message'
|
|
172
172
|
| string;
|
|
173
173
|
|
|
174
|
+
export interface TranscriptEventRef {
|
|
175
|
+
filePath: string;
|
|
176
|
+
byteOffset: number;
|
|
177
|
+
byteLength: number;
|
|
178
|
+
}
|
|
179
|
+
|
|
174
180
|
export interface TranscriptEvent {
|
|
175
181
|
kind: TranscriptEventKind;
|
|
176
182
|
data: unknown;
|
|
177
183
|
raw: unknown;
|
|
184
|
+
/** Short, bounded human-scannable preview of `data` — never the source of truth. */
|
|
185
|
+
previewText: string;
|
|
186
|
+
/** Byte range of this event's source line on disk; null when classified with no file context. */
|
|
187
|
+
ref: TranscriptEventRef | null;
|
|
178
188
|
}
|
|
179
189
|
|
|
180
190
|
export interface SubscribeResult {
|
|
@@ -396,7 +406,6 @@ export interface ScheduleConfig {
|
|
|
396
406
|
/** Minutes to wait after the 5h reset before firing pending jobs.
|
|
397
407
|
* Used by 'on-reset'. Ignored by 'when-available' and 'manual'. */
|
|
398
408
|
offsetMinutes: number;
|
|
399
|
-
concurrencyCap: number;
|
|
400
409
|
defaultCwd: string;
|
|
401
410
|
/** Auto-fire policy. Default 'when-available'. */
|
|
402
411
|
firePolicy: ScheduleFirePolicy;
|
|
@@ -547,14 +556,45 @@ export interface SchedulePollHealth {
|
|
|
547
556
|
lastFailureKind: string | null;
|
|
548
557
|
}
|
|
549
558
|
|
|
559
|
+
/** One `pending` job held by an unsatisfied dependency, and which dep. */
|
|
560
|
+
export interface ScheduleJobHold {
|
|
561
|
+
slug: string;
|
|
562
|
+
dep: string;
|
|
563
|
+
depStatus: string;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Outcome of the scheduler's last tick — why the batch was (or wasn't) fired.
|
|
567
|
+
* Every field is computed by tickQueue; nothing here is derived in the UI. */
|
|
568
|
+
export interface ScheduleLastTick {
|
|
569
|
+
fired: boolean;
|
|
570
|
+
reason: 'held' | 'slots-exhausted' | 'memory-deferred' | 'already-running' | 'drained' | 'paused' | string;
|
|
571
|
+
/** Human-readable one-liner for the binding constraint, when there is one. */
|
|
572
|
+
detail?: string;
|
|
573
|
+
deferredCount?: number;
|
|
574
|
+
runningCount?: number;
|
|
575
|
+
count?: number;
|
|
576
|
+
availableMb?: number;
|
|
577
|
+
threshold?: number;
|
|
578
|
+
holders?: { owner: string; at: string }[];
|
|
579
|
+
holds?: ScheduleJobHold[];
|
|
580
|
+
at: string;
|
|
581
|
+
}
|
|
582
|
+
|
|
550
583
|
export interface ScheduleEffectiveConcurrency {
|
|
584
|
+
/** Total slots in the machine-wide sessionSlots pool — the ONLY concurrency
|
|
585
|
+
* limit. The scheduler no longer carries a private concurrencyCap. */
|
|
551
586
|
cap: number;
|
|
552
|
-
/**
|
|
553
|
-
|
|
587
|
+
/** Slots free right now (pool total minus held, incl. chat runs). */
|
|
588
|
+
free: number;
|
|
589
|
+
/** 'env' when SM_SESSION_SLOTS pins the pool; 'pool' when the persisted
|
|
590
|
+
* Home-tab value governs. */
|
|
591
|
+
source: 'env' | 'pool';
|
|
554
592
|
}
|
|
555
593
|
|
|
556
594
|
export interface ScheduleStateSnapshot {
|
|
557
595
|
config: ScheduleConfig & { supervisor?: SupervisorConfig };
|
|
596
|
+
/** Why the last tick fired (or didn't). Null before the first tick. */
|
|
597
|
+
lastTick?: ScheduleLastTick | null;
|
|
558
598
|
jobs: ScheduleJob[];
|
|
559
599
|
scheduledFor: string | null;
|
|
560
600
|
lastRunAt: string | null;
|
|
@@ -948,17 +988,28 @@ export type ProjectBriefUpdateResult =
|
|
|
948
988
|
| { ok: true; brief: ProjectBrief }
|
|
949
989
|
| { ok: false; error: string };
|
|
950
990
|
|
|
951
|
-
// ────────────────────────────────────────────── Project Pages (PRD 929-932)
|
|
991
|
+
// ────────────────────────────────────────────── Project Pages (PRD 929-932, 969, project-home-hosted-html-spec)
|
|
952
992
|
export interface ProjectPagesOutput {
|
|
953
993
|
home: string;
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
994
|
+
/** Absent when `isDefault` is true — the shipped default only covers the home lens. */
|
|
995
|
+
marketing?: string;
|
|
996
|
+
/** Absent when `isDefault` is true — the shipped default only covers the home lens. */
|
|
997
|
+
feature?: string;
|
|
998
|
+
/** Absent when `isDefault` is true — the shipped default only covers the home lens. */
|
|
999
|
+
architecture?: string;
|
|
1000
|
+
/** Absent for output generated before the 'brief' lens existed (PRD 969), or
|
|
1001
|
+
* when `isDefault` is true — a project must regenerate to pick it up. */
|
|
1002
|
+
brief?: string;
|
|
1003
|
+
/** null for the shipped default (never generated). */
|
|
1004
|
+
generatedAt: string | null;
|
|
1005
|
+
/** True when `home` is the build-time shipped default, not this project's own
|
|
1006
|
+
* generated output — the renderer must label provenance rather than infer it. */
|
|
1007
|
+
isDefault: boolean;
|
|
958
1008
|
}
|
|
959
1009
|
|
|
960
1010
|
export interface ProjectPagesGetResult {
|
|
961
|
-
/** null
|
|
1011
|
+
/** Never null in practice — a project with no generated output still gets the
|
|
1012
|
+
* shipped default (isDefault: true). The type stays nullable defensively. */
|
|
962
1013
|
output: ProjectPagesOutput | null;
|
|
963
1014
|
}
|
|
964
1015
|
|
|
@@ -1357,6 +1408,11 @@ export interface SessionManagerAPI {
|
|
|
1357
1408
|
/** Permanently destroy the sub (genuine tab close). */
|
|
1358
1409
|
closeTab: (tabId: string) => Promise<{ ok: boolean }>;
|
|
1359
1410
|
buffer: (tabId: string) => Promise<TranscriptEvent[]>;
|
|
1411
|
+
/** Paged read over the subscribed transcript's line-offset index — [startLine, endLine] inclusive, 0-based.
|
|
1412
|
+
* Reads only the requested byte range from disk; never materializes the whole file. */
|
|
1413
|
+
page: (tabId: string, startLine: number, endLine: number) => Promise<{ events: (TranscriptEvent & { lineNumber: number })[]; totalLines: number }>;
|
|
1414
|
+
/** Full untruncated single-line read via a classifier byte reference (expand-to-full path). */
|
|
1415
|
+
readRef: (ref: TranscriptEventRef) => Promise<{ ok: boolean; text?: string; error?: string }>;
|
|
1360
1416
|
pathFor: (cwd: string, sessionUuid: string) => Promise<string>;
|
|
1361
1417
|
/** Batched token-usage totals, one map entry per requested sessionId. A
|
|
1362
1418
|
* session with no transcript file yet (or one over the main-process size
|
|
@@ -1752,6 +1808,16 @@ export interface PromptSessionsCreateEpicPayload {
|
|
|
1752
1808
|
runId?: string;
|
|
1753
1809
|
sourceTabId?: string;
|
|
1754
1810
|
};
|
|
1811
|
+
/** Full first-prompt body + its labeled sections (epicIntake.ts's
|
|
1812
|
+
* composeEpicIntake) — carried alongside goalText so the Epic's first
|
|
1813
|
+
* turn can render a structured AIM briefing card. */
|
|
1814
|
+
openingPrompt?: string;
|
|
1815
|
+
sections?: Array<{
|
|
1816
|
+
kind: 'actor' | 'injection' | 'input' | 'mission' | 'goal' | 'reference';
|
|
1817
|
+
label: string;
|
|
1818
|
+
text: string;
|
|
1819
|
+
source?: string;
|
|
1820
|
+
}>;
|
|
1755
1821
|
}
|
|
1756
1822
|
|
|
1757
1823
|
export interface PromptSessionsCreateEpicResult {
|
package/src/preload/index.cjs
CHANGED
|
@@ -121,6 +121,9 @@ contextBridge.exposeInMainWorld('api', {
|
|
|
121
121
|
unsubscribe: (tabId) => ipcRenderer.invoke('transcript:unsubscribe', { tabId }),
|
|
122
122
|
closeTab: (tabId) => ipcRenderer.invoke('transcript:close', { tabId }),
|
|
123
123
|
buffer: (tabId) => ipcRenderer.invoke('transcript:buffer', { tabId }),
|
|
124
|
+
page: (tabId, startLine, endLine) =>
|
|
125
|
+
ipcRenderer.invoke('transcript:page', { tabId, startLine, endLine }),
|
|
126
|
+
readRef: (ref) => ipcRenderer.invoke('transcript:readRef', ref),
|
|
124
127
|
pathFor: (cwd, sessionUuid) =>
|
|
125
128
|
ipcRenderer.invoke('transcript:path', { cwd, sessionUuid }),
|
|
126
129
|
usageFor: (cwd, sessionIds) =>
|