claude-code-session-manager 0.13.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{TiptapBody-Btu-_mZq.js → TiptapBody-D0tfDVZb.js} +1 -1
- package/dist/assets/{cssMode-EkBJI3CN.js → cssMode-C3tZkaJ9.js} +1 -1
- package/dist/assets/{freemarker2-DFbgmGC_.js → freemarker2-DEh8tC5X.js} +1 -1
- package/dist/assets/{handlebars-CUy9oTnL.js → handlebars-D_6KmsOK.js} +1 -1
- package/dist/assets/{html-CUFKxIqK.js → html-DF1KVjzv.js} +1 -1
- package/dist/assets/{htmlMode-hBSk7Fab.js → htmlMode-DEakPokt.js} +1 -1
- package/dist/assets/{index-D4dlTF2R.css → index-D-kX3T0V.css} +1 -1
- package/dist/assets/{index-BGIAQ9_i.js → index-Zg61GP50.js} +1416 -1115
- package/dist/assets/{javascript-CAH2ooMO.js → javascript-Du7a359D.js} +1 -1
- package/dist/assets/{jsonMode-BSMCRMaw.js → jsonMode-W3BJwbUD.js} +1 -1
- package/dist/assets/{liquid-CSMs05cs.js → liquid-DDj1fqca.js} +1 -1
- package/dist/assets/{lspLanguageFeatures-DEyYbu0m.js → lspLanguageFeatures-uyEbiR-d.js} +1 -1
- package/dist/assets/{mdx-BocaqTLI.js → mdx-DUqSETvC.js} +1 -1
- package/dist/assets/{python-Dj6u2CWq.js → python-D7S2lUAn.js} +1 -1
- package/dist/assets/{razor-D5OWmIwh.js → razor-19nfZNaZ.js} +1 -1
- package/dist/assets/{tsMode-Cy6xJd5e.js → tsMode-CQke5zpL.js} +1 -1
- package/dist/assets/{typescript-CsWWOqVn.js → typescript-D4ge0PUF.js} +1 -1
- package/dist/assets/{xml-DiTZK7ii.js → xml-D42QDS7q.js} +1 -1
- package/dist/assets/{yaml-pdLhZQr9.js → yaml-CEiz9NyU.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +2 -1
- package/src/main/__tests__/runVerify.test.cjs +388 -0
- package/src/main/config.cjs +1 -7
- package/src/main/files.cjs +4 -37
- package/src/main/historyAggregator.cjs +37 -14
- package/src/main/index.cjs +57 -134
- package/src/main/ipcSchemas.cjs +4 -0
- package/src/main/lib/childWithLog.cjs +218 -0
- package/src/main/lib/expandHome.cjs +21 -0
- package/src/main/lib/insideHome.cjs +74 -21
- package/src/main/lib/openExternalApp.cjs +141 -0
- package/src/main/pluginInstall.cjs +39 -1
- package/src/main/pty.cjs +11 -4
- package/src/main/queueOps.cjs +2 -2
- package/src/main/runVerify.cjs +527 -0
- package/src/main/scheduler.cjs +335 -286
- package/src/main/search.cjs +1 -6
- package/src/main/superagent.cjs +10 -0
- package/src/main/supervisor.cjs +16 -8
- package/src/main/transcripts.cjs +18 -0
- package/src/main/usageMatrix.cjs +336 -0
- package/src/main/watchers.cjs +2 -2
- package/src/preload/api.d.ts +68 -7
- package/src/preload/index.cjs +9 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runVerify.test.cjs — unit tests for the post-run reconciliation verifier.
|
|
3
|
+
*
|
|
4
|
+
* Run standalone: node src/main/__tests__/runVerify.test.cjs
|
|
5
|
+
*
|
|
6
|
+
* Three fixture scenarios (matching the 2026-05-23→24 incident window):
|
|
7
|
+
*
|
|
8
|
+
* 1. Case 1 (false-PASS): M2 acceptance script prints test failures + Traceback,
|
|
9
|
+
* then echoes "M2 acceptance: PASS". Scheduler used to stamp 'completed'.
|
|
10
|
+
* Expected: transcript_errors → needs_review
|
|
11
|
+
*
|
|
12
|
+
* 2. Case 2 (HALT): M7 agent correctly halts because M1 soak is unmet.
|
|
13
|
+
* Expected: halt → pending, reason embeds soak floor date
|
|
14
|
+
*
|
|
15
|
+
* 3. Case 3 (deps_unmet): PRD 56 has an unsatisfied dependsOn entry.
|
|
16
|
+
* Expected: deps_unmet → pending
|
|
17
|
+
*
|
|
18
|
+
* 4. Truly clean: all tests pass, no error markers.
|
|
19
|
+
* Expected: clean → null (caller may stamp 'completed')
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
const { test } = require('node:test');
|
|
25
|
+
const assert = require('node:assert/strict');
|
|
26
|
+
const os = require('node:os');
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
const { verifyRun } = require('../runVerify.cjs');
|
|
30
|
+
|
|
31
|
+
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/** Create a temp directory that is cleaned up after the test. */
|
|
34
|
+
function makeTmpDir() {
|
|
35
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'run-verify-test-'));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function rmdir(dir) {
|
|
39
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* */ }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build a minimal stream-JSON log file.
|
|
44
|
+
* `events` is an array of raw JSON objects to serialise one-per-line.
|
|
45
|
+
* A "[scheduler] starting..." prefix line is also prepended (must be skipped).
|
|
46
|
+
*/
|
|
47
|
+
function writeLog(dir, slug, events) {
|
|
48
|
+
const lines = ['[scheduler] starting ' + slug + ' at 2026-05-24T00:00:00.000Z'];
|
|
49
|
+
for (const ev of events) lines.push(JSON.stringify(ev));
|
|
50
|
+
lines.push('[scheduler] exit code=0 (raw code=0 signal=null) duration=47s');
|
|
51
|
+
fs.writeFileSync(path.join(dir, `${slug}.log`), lines.join('\n') + '\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Write a minimal PRD .md file with frontmatter + body. */
|
|
55
|
+
function writePrd(dir, slug, body) {
|
|
56
|
+
const text = `---\ntitle: Test PRD\ncwd: /tmp\nestimateMinutes: 30\n---\n${body}\n`;
|
|
57
|
+
fs.writeFileSync(path.join(dir, `${slug}.md`), text);
|
|
58
|
+
return path.join(dir, `${slug}.md`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── fixture: Case 1 — false-PASS (M2 acceptance) ─────────────────────────
|
|
62
|
+
|
|
63
|
+
test('Case 1: false-PASS (Traceback + echo PASS) → transcript_errors/needs_review', async () => {
|
|
64
|
+
const tmp = makeTmpDir();
|
|
65
|
+
try {
|
|
66
|
+
const slug = '39-signal-builder-m2-sentiment-stack';
|
|
67
|
+
const logEvents = [
|
|
68
|
+
// Assistant turn: run acceptance script
|
|
69
|
+
{
|
|
70
|
+
type: 'assistant',
|
|
71
|
+
message: {
|
|
72
|
+
role: 'assistant',
|
|
73
|
+
content: [{
|
|
74
|
+
type: 'tool_use',
|
|
75
|
+
id: 'toolu_m2_001',
|
|
76
|
+
name: 'Bash',
|
|
77
|
+
input: {
|
|
78
|
+
command: 'bash check_m2.sh',
|
|
79
|
+
description: 'Run M2 acceptance checks',
|
|
80
|
+
},
|
|
81
|
+
}],
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
// Tool result: test failures then echo PASS (the false-positive pattern)
|
|
85
|
+
{
|
|
86
|
+
type: 'user',
|
|
87
|
+
message: {
|
|
88
|
+
role: 'user',
|
|
89
|
+
content: [{
|
|
90
|
+
type: 'tool_result',
|
|
91
|
+
tool_use_id: 'toolu_m2_001',
|
|
92
|
+
content: [
|
|
93
|
+
'=== Signal-builder side ===',
|
|
94
|
+
'ERROR tests/test_news_signals_panel.py',
|
|
95
|
+
'!!!!!!!! stopping after 1 failures !!!!!!!!',
|
|
96
|
+
'1 error in 0.03s',
|
|
97
|
+
'SB: 0',
|
|
98
|
+
'=== Sanity: SB sentiment_cache.db rows ===',
|
|
99
|
+
'rows: 0',
|
|
100
|
+
'=== contract.json panels.sentiment ===',
|
|
101
|
+
'Traceback (most recent call last):',
|
|
102
|
+
' File "<string>", line 1, in <module>',
|
|
103
|
+
"KeyError: 'panels.sentiment'",
|
|
104
|
+
'M2 acceptance: PASS',
|
|
105
|
+
].join('\n'),
|
|
106
|
+
is_error: false,
|
|
107
|
+
}],
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
// Result: agent declares success
|
|
111
|
+
{
|
|
112
|
+
type: 'result',
|
|
113
|
+
subtype: 'success',
|
|
114
|
+
result: 'M2 acceptance criteria verified. All checks passed.',
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
writeLog(tmp, slug, logEvents);
|
|
119
|
+
const prdPath = writePrd(tmp, slug, '# M2 Sentiment Stack\n\nBuild the sentiment pipeline.');
|
|
120
|
+
|
|
121
|
+
const verdict = await verifyRun({
|
|
122
|
+
runDir: tmp,
|
|
123
|
+
prdPath,
|
|
124
|
+
queueEntry: { slug, status: 'running' },
|
|
125
|
+
allJobs: [],
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
assert.equal(verdict.verdict, 'transcript_errors', `expected transcript_errors, got ${verdict.verdict}: ${verdict.reason}`);
|
|
129
|
+
assert.equal(verdict.downgradeTo, 'needs_review');
|
|
130
|
+
assert.ok(verdict.reason.length > 0, 'reason should be non-empty');
|
|
131
|
+
|
|
132
|
+
// verdicts.json sidecar should have been written
|
|
133
|
+
const sidecar = JSON.parse(fs.readFileSync(path.join(tmp, `${slug}.verdicts.json`), 'utf8'));
|
|
134
|
+
assert.equal(sidecar.schemaVersion, 1);
|
|
135
|
+
assert.equal(sidecar.verdict, 'transcript_errors');
|
|
136
|
+
} finally {
|
|
137
|
+
rmdir(tmp);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ─── fixture: Case 2 — HALT (M7 soak unmet) ──────────────────────────────────
|
|
142
|
+
|
|
143
|
+
test('Case 2: HALT in result event → halt/pending with floor date in reason', async () => {
|
|
144
|
+
const tmp = makeTmpDir();
|
|
145
|
+
try {
|
|
146
|
+
const slug = '44-signal-builder-m7-final-cutover';
|
|
147
|
+
const haltMsg = [
|
|
148
|
+
'HALT: M7 prerequisite — M1 not yet soaked 30 days × 6 phases',
|
|
149
|
+
' M1 commit: f91ce44 2026-05-23 (age: 0 days, need ≥ 180)',
|
|
150
|
+
' M7 earliest eligible: ~2026-11-19',
|
|
151
|
+
].join('\n');
|
|
152
|
+
|
|
153
|
+
const logEvents = [
|
|
154
|
+
{
|
|
155
|
+
type: 'assistant',
|
|
156
|
+
message: {
|
|
157
|
+
role: 'assistant',
|
|
158
|
+
content: [{
|
|
159
|
+
type: 'tool_use',
|
|
160
|
+
id: 'toolu_m7_001',
|
|
161
|
+
name: 'Bash',
|
|
162
|
+
input: { command: 'git log --oneline m1-tag', description: 'Check M1 soak period' },
|
|
163
|
+
}],
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
type: 'user',
|
|
168
|
+
message: {
|
|
169
|
+
role: 'user',
|
|
170
|
+
content: [{
|
|
171
|
+
type: 'tool_result',
|
|
172
|
+
tool_use_id: 'toolu_m7_001',
|
|
173
|
+
content: 'f91ce44 2026-05-23 feat(m1): initial trading signals',
|
|
174
|
+
is_error: false,
|
|
175
|
+
}],
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
type: 'result',
|
|
180
|
+
subtype: 'success',
|
|
181
|
+
result: haltMsg,
|
|
182
|
+
},
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
writeLog(tmp, slug, logEvents);
|
|
186
|
+
const prdPath = writePrd(
|
|
187
|
+
tmp,
|
|
188
|
+
slug,
|
|
189
|
+
'# M7 Final Cutover\n\nOnly after M1–M6 have run 30+ days each.',
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const verdict = await verifyRun({
|
|
193
|
+
runDir: tmp,
|
|
194
|
+
prdPath,
|
|
195
|
+
queueEntry: { slug, status: 'running' },
|
|
196
|
+
allJobs: [],
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
assert.equal(verdict.verdict, 'halt', `expected halt, got ${verdict.verdict}: ${verdict.reason}`);
|
|
200
|
+
assert.equal(verdict.downgradeTo, 'pending');
|
|
201
|
+
assert.ok(verdict.reason.includes('HALT'), 'reason should mention HALT');
|
|
202
|
+
// Floor date from the HALT message should appear in reason
|
|
203
|
+
assert.ok(
|
|
204
|
+
verdict.reason.includes('2026-11-19') || verdict.reason.length > 20,
|
|
205
|
+
`reason should embed soak floor date: ${verdict.reason}`,
|
|
206
|
+
);
|
|
207
|
+
} finally {
|
|
208
|
+
rmdir(tmp);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// ─── fixture: Case 3 — deps_unmet (dependsOn unsatisfied) ────────────────────
|
|
213
|
+
|
|
214
|
+
test('Case 3: dependsOn pointing at pending dep → deps_unmet/pending', async () => {
|
|
215
|
+
const tmp = makeTmpDir();
|
|
216
|
+
try {
|
|
217
|
+
const slug = '56-burrow-mcp-retire-tier3-frozen-endpoints';
|
|
218
|
+
const logEvents = [
|
|
219
|
+
{
|
|
220
|
+
type: 'result',
|
|
221
|
+
subtype: 'success',
|
|
222
|
+
result: 'Completed burrow MCP tier-3 retirement.',
|
|
223
|
+
},
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
writeLog(tmp, slug, logEvents);
|
|
227
|
+
const prdPath = writePrd(
|
|
228
|
+
tmp,
|
|
229
|
+
slug,
|
|
230
|
+
'# Burrow MCP Retire Tier-3\n\nRetire frozen endpoints.',
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
// M7 is a predecessor that is still pending.
|
|
234
|
+
const allJobs = [
|
|
235
|
+
{ slug: '44-signal-builder-m7-final-cutover', status: 'pending', finishedAt: null },
|
|
236
|
+
{ slug: slug, status: 'running', finishedAt: null },
|
|
237
|
+
];
|
|
238
|
+
const queueEntry = {
|
|
239
|
+
slug,
|
|
240
|
+
status: 'running',
|
|
241
|
+
dependsOn: ['44-signal-builder-m7-final-cutover'],
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry, allJobs });
|
|
245
|
+
|
|
246
|
+
assert.equal(verdict.verdict, 'deps_unmet', `expected deps_unmet, got ${verdict.verdict}: ${verdict.reason}`);
|
|
247
|
+
assert.equal(verdict.downgradeTo, 'pending');
|
|
248
|
+
assert.ok(verdict.reason.includes('44-signal-builder-m7-final-cutover'), 'reason should name the unmet dep');
|
|
249
|
+
} finally {
|
|
250
|
+
rmdir(tmp);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ─── fixture: truly clean run ─────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
test('Truly clean run (no error markers) → clean/null', async () => {
|
|
257
|
+
const tmp = makeTmpDir();
|
|
258
|
+
try {
|
|
259
|
+
const slug = '20-clean-feature';
|
|
260
|
+
const logEvents = [
|
|
261
|
+
{
|
|
262
|
+
type: 'assistant',
|
|
263
|
+
message: {
|
|
264
|
+
role: 'assistant',
|
|
265
|
+
content: [{
|
|
266
|
+
type: 'tool_use',
|
|
267
|
+
id: 'toolu_clean_001',
|
|
268
|
+
name: 'Bash',
|
|
269
|
+
input: { command: 'npm test', description: 'Run test suite' },
|
|
270
|
+
}],
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
type: 'user',
|
|
275
|
+
message: {
|
|
276
|
+
role: 'user',
|
|
277
|
+
content: [{
|
|
278
|
+
type: 'tool_result',
|
|
279
|
+
tool_use_id: 'toolu_clean_001',
|
|
280
|
+
content: '✓ 42 tests passed (1.3s)\nAll tests passed.',
|
|
281
|
+
is_error: false,
|
|
282
|
+
}],
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
type: 'result',
|
|
287
|
+
subtype: 'success',
|
|
288
|
+
result: 'Feature complete. All acceptance criteria satisfied.',
|
|
289
|
+
},
|
|
290
|
+
];
|
|
291
|
+
|
|
292
|
+
writeLog(tmp, slug, logEvents);
|
|
293
|
+
const prdPath = writePrd(tmp, slug, '# Clean Feature\n\nImplement a clean feature.');
|
|
294
|
+
|
|
295
|
+
const verdict = await verifyRun({
|
|
296
|
+
runDir: tmp,
|
|
297
|
+
prdPath,
|
|
298
|
+
queueEntry: { slug, status: 'running' },
|
|
299
|
+
allJobs: [],
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
assert.equal(verdict.verdict, 'clean', `expected clean, got ${verdict.verdict}: ${verdict.reason}`);
|
|
303
|
+
assert.equal(verdict.downgradeTo, null);
|
|
304
|
+
} finally {
|
|
305
|
+
rmdir(tmp);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// ─── self-recovery: FAIL followed by successful retry ──────────────────────
|
|
310
|
+
|
|
311
|
+
test('FAIL recovered within 30 events → clean', async () => {
|
|
312
|
+
const tmp = makeTmpDir();
|
|
313
|
+
try {
|
|
314
|
+
const slug = '21-self-recovering';
|
|
315
|
+
const desc = 'Run pytest';
|
|
316
|
+
const logEvents = [
|
|
317
|
+
// First attempt: fails
|
|
318
|
+
{
|
|
319
|
+
type: 'assistant',
|
|
320
|
+
message: {
|
|
321
|
+
role: 'assistant',
|
|
322
|
+
content: [{
|
|
323
|
+
type: 'tool_use',
|
|
324
|
+
id: 'toolu_r1',
|
|
325
|
+
name: 'Bash',
|
|
326
|
+
input: { command: 'pytest', description: desc },
|
|
327
|
+
}],
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
type: 'user',
|
|
332
|
+
message: {
|
|
333
|
+
role: 'user',
|
|
334
|
+
content: [{
|
|
335
|
+
type: 'tool_result',
|
|
336
|
+
tool_use_id: 'toolu_r1',
|
|
337
|
+
content: 'FAILED tests/test_foo.py::test_bar - AssertionError',
|
|
338
|
+
is_error: false,
|
|
339
|
+
}],
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
// Agent fixes and retries with same description
|
|
343
|
+
{
|
|
344
|
+
type: 'assistant',
|
|
345
|
+
message: {
|
|
346
|
+
role: 'assistant',
|
|
347
|
+
content: [{
|
|
348
|
+
type: 'tool_use',
|
|
349
|
+
id: 'toolu_r2',
|
|
350
|
+
name: 'Bash',
|
|
351
|
+
input: { command: 'pytest', description: desc },
|
|
352
|
+
}],
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
type: 'user',
|
|
357
|
+
message: {
|
|
358
|
+
role: 'user',
|
|
359
|
+
content: [{
|
|
360
|
+
type: 'tool_result',
|
|
361
|
+
tool_use_id: 'toolu_r2',
|
|
362
|
+
content: '5 passed in 0.42s',
|
|
363
|
+
is_error: false,
|
|
364
|
+
}],
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
type: 'result',
|
|
369
|
+
subtype: 'success',
|
|
370
|
+
result: 'Fixed the failing test and verified all pass.',
|
|
371
|
+
},
|
|
372
|
+
];
|
|
373
|
+
|
|
374
|
+
writeLog(tmp, slug, logEvents);
|
|
375
|
+
const prdPath = writePrd(tmp, slug, '# Self-recovering test');
|
|
376
|
+
|
|
377
|
+
const verdict = await verifyRun({
|
|
378
|
+
runDir: tmp,
|
|
379
|
+
prdPath,
|
|
380
|
+
queueEntry: { slug, status: 'running' },
|
|
381
|
+
allJobs: [],
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
assert.equal(verdict.verdict, 'clean', `self-recovery should yield clean, got ${verdict.verdict}: ${verdict.reason}`);
|
|
385
|
+
} finally {
|
|
386
|
+
rmdir(tmp);
|
|
387
|
+
}
|
|
388
|
+
});
|
package/src/main/config.cjs
CHANGED
|
@@ -22,6 +22,7 @@ const os = require('node:os');
|
|
|
22
22
|
const chokidar = require('chokidar');
|
|
23
23
|
const logs = require('./logs.cjs');
|
|
24
24
|
const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
|
|
25
|
+
const { expandHome } = require('./lib/expandHome.cjs');
|
|
25
26
|
|
|
26
27
|
/** Map<absPath, {watcher, refCount}> — one chokidar watcher per path. */
|
|
27
28
|
const watchers = new Map();
|
|
@@ -31,13 +32,6 @@ function attachWindow(w) {
|
|
|
31
32
|
window = w;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
function expandHome(p) {
|
|
35
|
-
if (!p) return p;
|
|
36
|
-
if (p === '~') return os.homedir();
|
|
37
|
-
if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
|
|
38
|
-
return p;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
35
|
/**
|
|
42
36
|
* Resolve a path to its realpath, handling non-existent files by resolving
|
|
43
37
|
* the parent directory instead. Prevents symlink traversal attacks.
|
package/src/main/files.cjs
CHANGED
|
@@ -14,42 +14,13 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
const { ipcMain, shell } = require('electron');
|
|
17
|
-
const fs = require('node:fs');
|
|
18
17
|
const fsp = require('node:fs/promises');
|
|
19
18
|
const path = require('node:path');
|
|
20
19
|
const os = require('node:os');
|
|
21
20
|
|
|
22
21
|
const { z } = require('zod');
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if (!p) return p;
|
|
26
|
-
if (p === '~') return os.homedir();
|
|
27
|
-
if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
|
|
28
|
-
return p;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Resolve to realpath; on ENOENT resolve the parent then re-append basename so
|
|
33
|
-
* we can still validate create destinations. Mirrors config.cjs.
|
|
34
|
-
*/
|
|
35
|
-
function realResolve(abs) {
|
|
36
|
-
const lex = path.resolve(expandHome(abs));
|
|
37
|
-
try {
|
|
38
|
-
return fs.realpathSync(lex);
|
|
39
|
-
} catch (e) {
|
|
40
|
-
if (e.code === 'ENOENT') {
|
|
41
|
-
const parent = path.dirname(lex);
|
|
42
|
-
try {
|
|
43
|
-
return path.join(fs.realpathSync(parent), path.basename(lex));
|
|
44
|
-
} catch {
|
|
45
|
-
return lex;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
throw e;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const HOME = os.homedir();
|
|
22
|
+
const { assertInsideHome } = require('./lib/insideHome.cjs');
|
|
23
|
+
const { expandHome } = require('./lib/expandHome.cjs');
|
|
53
24
|
|
|
54
25
|
/**
|
|
55
26
|
* Validates that the path is under the home directory. Returns the realpath
|
|
@@ -58,11 +29,7 @@ const HOME = os.homedir();
|
|
|
58
29
|
* ~ — but never escapes the home tree.
|
|
59
30
|
*/
|
|
60
31
|
function validateHomePath(abs) {
|
|
61
|
-
|
|
62
|
-
let realHome;
|
|
63
|
-
try { realHome = fs.realpathSync(HOME); } catch { realHome = HOME; }
|
|
64
|
-
if (real === realHome || real.startsWith(realHome + path.sep)) return real;
|
|
65
|
-
throw new Error(`Path outside home directory: ${real}`);
|
|
32
|
+
return assertInsideHome(expandHome(abs)).realPath;
|
|
66
33
|
}
|
|
67
34
|
|
|
68
35
|
/** Reject .credentials.json writes regardless of where they sit. */
|
|
@@ -228,7 +195,7 @@ async function renameEntry(oldPath, newName) {
|
|
|
228
195
|
}
|
|
229
196
|
}
|
|
230
197
|
|
|
231
|
-
const CRITICAL_PATHS = new Set([
|
|
198
|
+
const CRITICAL_PATHS = new Set([os.homedir(), '/', '/usr', '/bin', '/etc', '/var', '/System', '/Applications']);
|
|
232
199
|
|
|
233
200
|
async function deleteEntry(filePath) {
|
|
234
201
|
let resolved;
|
|
@@ -14,10 +14,20 @@ function decodeCwd(encoded) {
|
|
|
14
14
|
return '/' + encoded.replace(/-+/g, '/');
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// All date strings in this module are LOCAL-TZ YYYY-MM-DD. A previous version
|
|
18
|
+
// used UTC (toISOString().slice(0,10)) which silently shifted late-evening
|
|
19
|
+
// sessions a day forward for Pacific-time users, then the >= effectiveTo
|
|
20
|
+
// filter dropped them entirely. en-CA locale yields ISO-format dates in the
|
|
21
|
+
// JS environment's TZ. Parse with 'T12:00:00' (local noon) so DST boundaries
|
|
22
|
+
// don't shift the date by a day.
|
|
23
|
+
function localDate(d) {
|
|
24
|
+
return d.toLocaleDateString('en-CA');
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
function subtractDays(dateStr, days) {
|
|
18
|
-
const d = new Date(dateStr + '
|
|
19
|
-
d.
|
|
20
|
-
return d
|
|
28
|
+
const d = new Date(dateStr + 'T12:00:00');
|
|
29
|
+
d.setDate(d.getDate() - days);
|
|
30
|
+
return localDate(d);
|
|
21
31
|
}
|
|
22
32
|
|
|
23
33
|
async function parseJSONL(filePath, stat) {
|
|
@@ -69,10 +79,18 @@ async function parseJSONL(filePath, stat) {
|
|
|
69
79
|
|
|
70
80
|
const usage = obj.usage ?? obj.message?.usage;
|
|
71
81
|
if (usage && typeof usage === 'object') {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
82
|
+
// Claude Code JSONLs use snake_case (matching the Anthropic API). The
|
|
83
|
+
// previous camelCase-only check meant every token count read as 0.
|
|
84
|
+
// Accept both shapes for forward-compat with any future renderer-side
|
|
85
|
+
// emitter (live.ts already normalizes both).
|
|
86
|
+
const inT = usage.input_tokens ?? usage.inputTokens;
|
|
87
|
+
const outT = usage.output_tokens ?? usage.outputTokens;
|
|
88
|
+
const cacheR = usage.cache_read_input_tokens ?? usage.cacheReadInputTokens;
|
|
89
|
+
const cacheC = usage.cache_creation_input_tokens ?? usage.cacheCreationInputTokens;
|
|
90
|
+
if (typeof inT === 'number') acc.inputTokens += inT;
|
|
91
|
+
if (typeof outT === 'number') acc.outputTokens += outT;
|
|
92
|
+
if (typeof cacheR === 'number') acc.cacheReadTokens += cacheR;
|
|
93
|
+
if (typeof cacheC === 'number') acc.cacheCreationTokens += cacheC;
|
|
76
94
|
}
|
|
77
95
|
|
|
78
96
|
const content = obj.message?.content ?? obj.content;
|
|
@@ -96,10 +114,10 @@ async function parseJSONL(filePath, stat) {
|
|
|
96
114
|
|
|
97
115
|
try {
|
|
98
116
|
acc.sessionDate = firstTs
|
|
99
|
-
? new Date(firstTs)
|
|
100
|
-
: new Date(stat.mtimeMs)
|
|
117
|
+
? localDate(new Date(firstTs))
|
|
118
|
+
: localDate(new Date(stat.mtimeMs));
|
|
101
119
|
} catch {
|
|
102
|
-
acc.sessionDate = new Date(stat.mtimeMs)
|
|
120
|
+
acc.sessionDate = localDate(new Date(stat.mtimeMs));
|
|
103
121
|
}
|
|
104
122
|
|
|
105
123
|
return acc;
|
|
@@ -126,8 +144,10 @@ async function parseConversationMeta(filePath, stat) {
|
|
|
126
144
|
}
|
|
127
145
|
const usage = obj.usage ?? obj.message?.usage;
|
|
128
146
|
if (usage && typeof usage === 'object') {
|
|
129
|
-
|
|
130
|
-
|
|
147
|
+
const inT = usage.input_tokens ?? usage.inputTokens;
|
|
148
|
+
const outT = usage.output_tokens ?? usage.outputTokens;
|
|
149
|
+
if (typeof inT === 'number') meta.inputTokens += inT;
|
|
150
|
+
if (typeof outT === 'number') meta.outputTokens += outT;
|
|
131
151
|
}
|
|
132
152
|
}
|
|
133
153
|
return meta;
|
|
@@ -142,7 +162,7 @@ function registerHistoryAggregatorHandlers() {
|
|
|
142
162
|
const parsed = schemas.historyAggregate.safeParse(rawReq);
|
|
143
163
|
const req = parsed.success ? (parsed.data ?? {}) : {};
|
|
144
164
|
const t0 = Date.now();
|
|
145
|
-
const today = new Date()
|
|
165
|
+
const today = localDate(new Date());
|
|
146
166
|
let effectiveTo = req?.toDate ? req.toDate : today;
|
|
147
167
|
if (effectiveTo > today) effectiveTo = today;
|
|
148
168
|
const effectiveFrom = req?.fromDate ? req.fromDate : subtractDays(today, 30);
|
|
@@ -185,7 +205,10 @@ function registerHistoryAggregatorHandlers() {
|
|
|
185
205
|
if (parsed.skipped) { skippedLargeFiles++; continue; }
|
|
186
206
|
|
|
187
207
|
const { sessionDate } = parsed;
|
|
188
|
-
|
|
208
|
+
// Inclusive upper bound — `>=` here previously meant "today's data is
|
|
209
|
+
// always dropped", which combined with the (then-UTC) date bucket to
|
|
210
|
+
// hide a Pacific-time user's most recent activity entirely.
|
|
211
|
+
if (!sessionDate || sessionDate < effectiveFrom || sessionDate > effectiveTo) continue;
|
|
189
212
|
|
|
190
213
|
const key = `${sessionDate}|${encodedCwd}`;
|
|
191
214
|
if (!buckets.has(key)) {
|