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,123 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Project Home</title>
|
|
7
|
+
<meta name="description" content="Project Home — a hosted document generated from this project's own repo, not yet generated for this project." />
|
|
8
|
+
<style>
|
|
9
|
+
/* Design tokens copied from src/renderer/lib/projectPages/library/kit.tsx's
|
|
10
|
+
PK palette/fonts — no font files are embedded here (unlike the
|
|
11
|
+
generated lenses), so the font-family stacks fall back to their system
|
|
12
|
+
alternatives (Georgia / system-ui / ui-monospace). This keeps the
|
|
13
|
+
document a zero-network, zero-JS static file while still reading as
|
|
14
|
+
the same visual system: same colors, same type scale, same shapes. */
|
|
15
|
+
:root {
|
|
16
|
+
--pk-paper: #f6efe1;
|
|
17
|
+
--pk-panel: #efe6d3;
|
|
18
|
+
--pk-card: #fbf6ec;
|
|
19
|
+
--pk-deep: #2a221a;
|
|
20
|
+
--pk-edge: #e0d3b8;
|
|
21
|
+
--pk-rule: #d9c9a8;
|
|
22
|
+
--pk-ink: #2a221a;
|
|
23
|
+
--pk-ink-soft: #5b4a36;
|
|
24
|
+
--pk-ink-mute: #8a7a60;
|
|
25
|
+
--pk-accent: #b85c34;
|
|
26
|
+
--pk-accent-deep: #9c4a26;
|
|
27
|
+
--pk-sage: #6f7d52;
|
|
28
|
+
--pk-butter: #e4b85a;
|
|
29
|
+
--pk-serif: 'Newsreader', Georgia, serif;
|
|
30
|
+
--pk-sans: 'Geist', system-ui, sans-serif;
|
|
31
|
+
--pk-mono: 'IBM Plex Mono', ui-monospace, monospace;
|
|
32
|
+
}
|
|
33
|
+
* { box-sizing: border-box; }
|
|
34
|
+
html, body { margin: 0; padding: 0; background: var(--pk-paper); color: var(--pk-ink); }
|
|
35
|
+
body { font-family: var(--pk-sans); }
|
|
36
|
+
.eyebrow {
|
|
37
|
+
font-family: var(--pk-mono); font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase;
|
|
38
|
+
color: var(--pk-accent); font-weight: 500;
|
|
39
|
+
}
|
|
40
|
+
.pill {
|
|
41
|
+
display: inline-flex; align-items: center; gap: 6px; font-family: var(--pk-mono); font-size: 11px;
|
|
42
|
+
color: var(--pk-ink-soft); background: transparent; border: 1px solid var(--pk-rule); border-radius: 999px;
|
|
43
|
+
padding: 3px 9px; white-space: nowrap;
|
|
44
|
+
}
|
|
45
|
+
h1 {
|
|
46
|
+
margin: 0; font-family: var(--pk-serif); font-weight: 500; font-size: clamp(30px, 5vw, 44px);
|
|
47
|
+
line-height: 1.1; letter-spacing: -0.015em; color: var(--pk-ink);
|
|
48
|
+
}
|
|
49
|
+
h2 {
|
|
50
|
+
margin: 0; font-family: var(--pk-serif); font-weight: 500; font-size: 24px; line-height: 1.15;
|
|
51
|
+
letter-spacing: -0.01em; color: var(--pk-ink);
|
|
52
|
+
}
|
|
53
|
+
p { margin: 0; font-family: var(--pk-sans); font-size: 16px; line-height: 1.62; color: var(--pk-ink-soft); }
|
|
54
|
+
section { padding: clamp(28px, 6vw, 72px); }
|
|
55
|
+
.hero { border-bottom: 1px solid var(--pk-rule); background: var(--pk-panel); }
|
|
56
|
+
.hero-inner { max-width: 760px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
|
57
|
+
.cta {
|
|
58
|
+
display: inline-flex; width: fit-content; align-items: center; gap: 8px; font-family: var(--pk-sans);
|
|
59
|
+
font-size: 14px; font-weight: 600; padding: 11px 20px; border-radius: 9px; border: 1px solid transparent;
|
|
60
|
+
background: var(--pk-accent); color: #fff;
|
|
61
|
+
}
|
|
62
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; max-width: 960px; margin: 0 auto; }
|
|
63
|
+
.card {
|
|
64
|
+
background: var(--pk-card); border: 1px solid var(--pk-edge); border-radius: 12px; padding: 20px;
|
|
65
|
+
display: flex; flex-direction: column; gap: 8px;
|
|
66
|
+
}
|
|
67
|
+
.card .k { font-family: var(--pk-mono); font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--pk-ink-mute); }
|
|
68
|
+
.card .t { font-family: var(--pk-serif); font-size: 18px; color: var(--pk-ink); }
|
|
69
|
+
.card .d { font-size: 13.5px; line-height: 1.55; color: var(--pk-ink-soft); }
|
|
70
|
+
.footnote { max-width: 760px; margin: 0 auto; display: flex; flex-direction: column; gap: 6px; }
|
|
71
|
+
.footnote .label { font-family: var(--pk-mono); font-size: 11px; color: var(--pk-ink-mute); }
|
|
72
|
+
</style>
|
|
73
|
+
</head>
|
|
74
|
+
<body>
|
|
75
|
+
<section class="hero">
|
|
76
|
+
<div class="hero-inner">
|
|
77
|
+
<span class="pill">Shipped default — not yet generated for this project</span>
|
|
78
|
+
<div class="eyebrow">Project Home</div>
|
|
79
|
+
<h1>This is Project Home's shipped default page.</h1>
|
|
80
|
+
<p>
|
|
81
|
+
Project Home is a hosted document — a self-contained page describing this specific
|
|
82
|
+
project: what it is, its main areas, and what it's for. Session-manager ships this
|
|
83
|
+
default so the page is never empty, but it does not describe your project yet.
|
|
84
|
+
Nothing shown on this page is specific to any real project.
|
|
85
|
+
</p>
|
|
86
|
+
<a class="cta" href="#" onclick="return false;">Generate My Project Home</a>
|
|
87
|
+
<p style="font-size: 13px; color: var(--pk-ink-mute);">
|
|
88
|
+
Press "Generate My Project Home" in the app (this link is inert inside this static
|
|
89
|
+
preview) to replace this page with one generated from your project's own repo.
|
|
90
|
+
</p>
|
|
91
|
+
</div>
|
|
92
|
+
</section>
|
|
93
|
+
<section>
|
|
94
|
+
<div class="grid">
|
|
95
|
+
<div class="card">
|
|
96
|
+
<div class="k">What it reads</div>
|
|
97
|
+
<div class="t">Your repo, live</div>
|
|
98
|
+
<div class="d">Generation reads this project's own files, git history, and CLAUDE.md conventions — never invented content.</div>
|
|
99
|
+
</div>
|
|
100
|
+
<div class="card">
|
|
101
|
+
<div class="k">What it produces</div>
|
|
102
|
+
<div class="t">A static document</div>
|
|
103
|
+
<div class="d">A self-contained HTML page — inline styling, no network calls, no live app state — that replaces this default at a fixed path.</div>
|
|
104
|
+
</div>
|
|
105
|
+
<div class="card">
|
|
106
|
+
<div class="k">How it stays fresh</div>
|
|
107
|
+
<div class="t">On demand</div>
|
|
108
|
+
<div class="d">Regenerate any time from Project Home; this default only ever shows before the first generation.</div>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</section>
|
|
112
|
+
<section>
|
|
113
|
+
<div class="footnote">
|
|
114
|
+
<div class="label">About this page</div>
|
|
115
|
+
<p style="font-size: 13.5px;">
|
|
116
|
+
Shipped with Session Manager as a build-time asset — the same design system as a
|
|
117
|
+
generated Project Home page, but honest, generic copy instead of project-specific
|
|
118
|
+
content. See "About these templates" for how a generated page is built.
|
|
119
|
+
</p>
|
|
120
|
+
</div>
|
|
121
|
+
</section>
|
|
122
|
+
</body>
|
|
123
|
+
</html>
|
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
|
|