@zzusp/ccsm 1.0.0 → 1.0.1
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 +7 -3
- package/bin/cli.mjs +52 -52
- package/dist/assets/DiskUsage-CKhggLs5.js +2 -0
- package/dist/assets/DiskUsage-CKhggLs5.js.map +1 -0
- package/dist/assets/{ImportPage-b8NORa8b.js → ImportPage-wge4VhZ-.js} +2 -2
- package/dist/assets/{ImportPage-b8NORa8b.js.map → ImportPage-wge4VhZ-.js.map} +1 -1
- package/dist/assets/{ProjectMemory-aSV8UzQ9.js → ProjectMemory-Q4XX40j_.js} +2 -2
- package/dist/assets/{ProjectMemory-aSV8UzQ9.js.map → ProjectMemory-Q4XX40j_.js.map} +1 -1
- package/dist/assets/{charts-A5eNHLjX.js → charts-jxJqXXUr.js} +2 -2
- package/dist/assets/{charts-A5eNHLjX.js.map → charts-jxJqXXUr.js.map} +1 -1
- package/dist/assets/index-7aMrnHJG.js +7 -0
- package/dist/assets/index-7aMrnHJG.js.map +1 -0
- package/dist/assets/index-BOeI_J4B.css +1 -0
- package/dist/assets/{query-C1K1uQRu.js → query-CS7JQ86v.js} +2 -2
- package/dist/assets/{query-C1K1uQRu.js.map → query-CS7JQ86v.js.map} +1 -1
- package/dist/assets/{react-W0jzChlo.js → react-CPkiFScu.js} +10 -10
- package/dist/assets/{react-W0jzChlo.js.map → react-CPkiFScu.js.map} +1 -1
- package/dist/assets/{router-DfbutHY3.js → router-DwaHAh1G.js} +2 -2
- package/dist/assets/{router-DfbutHY3.js.map → router-DwaHAh1G.js.map} +1 -1
- package/dist/assets/vendor-Cs8vYp-N.js +27 -0
- package/dist/assets/vendor-Cs8vYp-N.js.map +1 -0
- package/dist/favicon.svg +7 -7
- package/dist/index.html +6 -6
- package/package.json +83 -72
- package/server/index.ts +130 -126
- package/server/lib/active-sessions.test.ts +119 -0
- package/server/lib/bundle.test.ts +182 -0
- package/server/lib/claude-paths.test.ts +126 -0
- package/server/lib/claude-paths.ts +19 -12
- package/server/lib/cleanup-suggestions.ts +131 -0
- package/server/lib/constants.ts +1 -0
- package/server/lib/delete.test.ts +244 -0
- package/server/lib/delete.ts +5 -16
- package/server/lib/disk-usage.ts +6 -8
- package/server/lib/export-import-bundle.test.ts +337 -0
- package/server/lib/modified-files.test.ts +280 -0
- package/server/lib/modified-files.ts +228 -0
- package/server/lib/open-folder.ts +22 -15
- package/server/lib/parse-jsonl.ts +35 -3
- package/server/lib/safe-id.test.ts +41 -0
- package/server/lib/safe-remove.test.ts +73 -0
- package/server/lib/safe-remove.ts +25 -0
- package/server/lib/scan.ts +103 -0
- package/server/lib/update.ts +67 -0
- package/server/lib/version.test.ts +39 -0
- package/server/lib/version.ts +117 -0
- package/server/routes/disk-cleanup.ts +54 -0
- package/server/routes/sessions.ts +49 -0
- package/server/routes/version.ts +34 -0
- package/shared/constants.ts +5 -0
- package/shared/types.ts +152 -0
- package/dist/assets/DiskUsage-Bq4VaoUA.js +0 -2
- package/dist/assets/DiskUsage-Bq4VaoUA.js.map +0 -1
- package/dist/assets/index-DLATR3tZ.js +0 -5
- package/dist/assets/index-DLATR3tZ.js.map +0 -1
- package/dist/assets/index-DLDtbkux.css +0 -1
- package/dist/assets/vendor-CH80ylbS.js +0 -19
- package/dist/assets/vendor-CH80ylbS.js.map +0 -1
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
// 用 CCSM_TEST_ROOT 跨进程桥接的写法跟 delete.test.ts 保持一致——vi.mock 的工厂
|
|
7
|
+
// 必须 hoist 安全,闭包里不能引用模块作用域变量。
|
|
8
|
+
vi.mock('./claude-paths.ts', () => {
|
|
9
|
+
return {
|
|
10
|
+
get PATHS() {
|
|
11
|
+
const root = process.env.CCSM_TEST_ROOT!;
|
|
12
|
+
return {
|
|
13
|
+
root,
|
|
14
|
+
projects: path.join(root, 'projects'),
|
|
15
|
+
fileHistory: path.join(root, 'file-history'),
|
|
16
|
+
sessionEnv: path.join(root, 'session-env'),
|
|
17
|
+
sessions: path.join(root, 'sessions'),
|
|
18
|
+
history: path.join(root, 'history.jsonl'),
|
|
19
|
+
};
|
|
20
|
+
},
|
|
21
|
+
isUnderClaudeRoot(target: string): boolean {
|
|
22
|
+
const root = process.env.CCSM_TEST_ROOT!;
|
|
23
|
+
const resolved = path.resolve(target);
|
|
24
|
+
return resolved === root || resolved.startsWith(root + path.sep);
|
|
25
|
+
},
|
|
26
|
+
getCacheDir(): string {
|
|
27
|
+
return path.join(process.env.CCSM_TEST_ROOT!, '_cache');
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
import { loadModifiedFiles } from './modified-files.ts';
|
|
33
|
+
|
|
34
|
+
let fakeRoot: string;
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
fakeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccsm-modfiles-test-'));
|
|
38
|
+
process.env.CCSM_TEST_ROOT = fakeRoot;
|
|
39
|
+
fs.mkdirSync(path.join(fakeRoot, 'projects'), { recursive: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
fs.rmSync(fakeRoot, { recursive: true, force: true });
|
|
44
|
+
delete process.env.CCSM_TEST_ROOT;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function writeJsonl(projectId: string, sessionId: string, lines: object[]): string {
|
|
48
|
+
const dir = path.join(fakeRoot, 'projects', projectId);
|
|
49
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
50
|
+
const file = path.join(dir, `${sessionId}.jsonl`);
|
|
51
|
+
fs.writeFileSync(file, lines.map((l) => JSON.stringify(l)).join('\n') + '\n');
|
|
52
|
+
return file;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function userMsg(uuid: string, ts: string, content: unknown): object {
|
|
56
|
+
return { type: 'user', uuid, timestamp: ts, message: { content } };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function assistantMsg(uuid: string, ts: string, content: unknown): object {
|
|
60
|
+
return { type: 'assistant', uuid, timestamp: ts, message: { content } };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function toolUse(id: string, name: string, input: unknown): object {
|
|
64
|
+
return { type: 'tool_use', id, name, input };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function toolResult(toolUseId: string, isError = false): object {
|
|
68
|
+
return { type: 'tool_result', tool_use_id: toolUseId, content: '', is_error: isError };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('loadModifiedFiles', () => {
|
|
72
|
+
it('returns null when session jsonl missing', async () => {
|
|
73
|
+
const out = await loadModifiedFiles('proj', 'no-such-sid');
|
|
74
|
+
expect(out).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('aggregates Edit/Write/MultiEdit/NotebookEdit by filePath and counts per-tool', async () => {
|
|
78
|
+
writeJsonl('proj-1', 'sid-1', [
|
|
79
|
+
{ type: 'summary', cwd: '/Users/me/repo' },
|
|
80
|
+
assistantMsg('a1', '2026-06-01T10:00:00.000Z', [
|
|
81
|
+
toolUse('t1', 'Edit', { file_path: '/Users/me/repo/src/a.ts', old_string: 'x', new_string: 'y' }),
|
|
82
|
+
]),
|
|
83
|
+
userMsg('u1', '2026-06-01T10:00:01.000Z', [toolResult('t1', false)]),
|
|
84
|
+
assistantMsg('a2', '2026-06-01T10:01:00.000Z', [
|
|
85
|
+
toolUse('t2', 'Edit', { file_path: '/Users/me/repo/src/a.ts', old_string: 'a', new_string: 'b' }),
|
|
86
|
+
]),
|
|
87
|
+
userMsg('u2', '2026-06-01T10:01:01.000Z', [toolResult('t2', false)]),
|
|
88
|
+
assistantMsg('a3', '2026-06-01T10:02:00.000Z', [
|
|
89
|
+
toolUse('t3', 'Write', { file_path: '/Users/me/repo/src/b.ts', content: 'new file' }),
|
|
90
|
+
]),
|
|
91
|
+
userMsg('u3', '2026-06-01T10:02:01.000Z', [toolResult('t3', false)]),
|
|
92
|
+
assistantMsg('a4', '2026-06-01T10:03:00.000Z', [
|
|
93
|
+
toolUse('t4', 'MultiEdit', {
|
|
94
|
+
file_path: '/Users/me/repo/src/b.ts',
|
|
95
|
+
edits: [{ old_string: 'a', new_string: 'b' }],
|
|
96
|
+
}),
|
|
97
|
+
]),
|
|
98
|
+
userMsg('u4', '2026-06-01T10:03:01.000Z', [toolResult('t4', false)]),
|
|
99
|
+
assistantMsg('a5', '2026-06-01T10:04:00.000Z', [
|
|
100
|
+
toolUse('t5', 'NotebookEdit', {
|
|
101
|
+
notebook_path: '/Users/me/repo/nb.ipynb',
|
|
102
|
+
cell_id: 'c1',
|
|
103
|
+
new_source: 'print(1)',
|
|
104
|
+
}),
|
|
105
|
+
]),
|
|
106
|
+
userMsg('u5', '2026-06-01T10:04:01.000Z', [toolResult('t5', false)]),
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
const out = await loadModifiedFiles('proj-1', 'sid-1');
|
|
110
|
+
expect(out).not.toBeNull();
|
|
111
|
+
expect(out!.cwd).toBe('/Users/me/repo');
|
|
112
|
+
expect(out!.files).toHaveLength(3);
|
|
113
|
+
|
|
114
|
+
// 排序 by lastAt desc: nb.ipynb (10:04) → b.ts (10:03) → a.ts (10:01)
|
|
115
|
+
const nb = out!.files[0]!;
|
|
116
|
+
const b = out!.files[1]!;
|
|
117
|
+
const a = out!.files[2]!;
|
|
118
|
+
expect(nb.filePath).toBe('/Users/me/repo/nb.ipynb');
|
|
119
|
+
expect(nb.notebookEditCount).toBe(1);
|
|
120
|
+
expect(nb.relativePath).toBe('nb.ipynb');
|
|
121
|
+
|
|
122
|
+
expect(b.filePath).toBe('/Users/me/repo/src/b.ts');
|
|
123
|
+
expect(b.relativePath).toBe('src/b.ts');
|
|
124
|
+
expect(b.writeCount).toBe(1);
|
|
125
|
+
expect(b.multiEditCount).toBe(1);
|
|
126
|
+
expect(b.totalCount).toBe(2);
|
|
127
|
+
|
|
128
|
+
expect(a.filePath).toBe('/Users/me/repo/src/a.ts');
|
|
129
|
+
expect(a.relativePath).toBe('src/a.ts');
|
|
130
|
+
expect(a.editCount).toBe(2);
|
|
131
|
+
expect(a.totalCount).toBe(2);
|
|
132
|
+
expect(a.errorCount).toBe(0);
|
|
133
|
+
expect(a.firstAt).toBe('2026-06-01T10:00:00.000Z');
|
|
134
|
+
expect(a.lastAt).toBe('2026-06-01T10:01:00.000Z');
|
|
135
|
+
expect(a.operations.map((o) => o.toolUseId)).toEqual(['t1', 't2']);
|
|
136
|
+
expect(a.operations.every((o) => !o.pending && !o.errored)).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('marks errored when tool_result.is_error=true and pending when no result line found', async () => {
|
|
140
|
+
writeJsonl('proj', 'sid', [
|
|
141
|
+
assistantMsg('a1', '2026-06-01T10:00:00.000Z', [
|
|
142
|
+
toolUse('t-err', 'Edit', { file_path: '/repo/x.ts', old_string: 'a', new_string: 'b' }),
|
|
143
|
+
]),
|
|
144
|
+
userMsg('u1', '2026-06-01T10:00:01.000Z', [toolResult('t-err', true)]),
|
|
145
|
+
assistantMsg('a2', '2026-06-01T10:01:00.000Z', [
|
|
146
|
+
toolUse('t-pend', 'Edit', { file_path: '/repo/x.ts', old_string: 'b', new_string: 'c' }),
|
|
147
|
+
]),
|
|
148
|
+
// 没有 t-pend 的 tool_result
|
|
149
|
+
]);
|
|
150
|
+
|
|
151
|
+
const out = await loadModifiedFiles('proj', 'sid');
|
|
152
|
+
const x = out!.files[0]!;
|
|
153
|
+
expect(x.totalCount).toBe(2);
|
|
154
|
+
expect(x.errorCount).toBe(1);
|
|
155
|
+
const err = x.operations.find((o) => o.toolUseId === 't-err')!;
|
|
156
|
+
expect(err.errored).toBe(true);
|
|
157
|
+
expect(err.pending).toBe(false);
|
|
158
|
+
const pend = x.operations.find((o) => o.toolUseId === 't-pend')!;
|
|
159
|
+
expect(pend.errored).toBe(false);
|
|
160
|
+
expect(pend.pending).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('ignores non-file-mutating tools (Bash/Read/Grep/Task)', async () => {
|
|
164
|
+
writeJsonl('proj', 'sid', [
|
|
165
|
+
assistantMsg('a1', '2026-06-01T10:00:00.000Z', [
|
|
166
|
+
toolUse('t1', 'Bash', { command: 'ls' }),
|
|
167
|
+
toolUse('t2', 'Read', { file_path: '/repo/x.ts' }),
|
|
168
|
+
toolUse('t3', 'Grep', { pattern: 'foo' }),
|
|
169
|
+
]),
|
|
170
|
+
userMsg('u1', '2026-06-01T10:00:01.000Z', [
|
|
171
|
+
toolResult('t1', false),
|
|
172
|
+
toolResult('t2', false),
|
|
173
|
+
toolResult('t3', false),
|
|
174
|
+
]),
|
|
175
|
+
]);
|
|
176
|
+
const out = await loadModifiedFiles('proj', 'sid');
|
|
177
|
+
expect(out!.files).toEqual([]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('relativePath is null when filePath sits outside cwd', async () => {
|
|
181
|
+
writeJsonl('proj', 'sid', [
|
|
182
|
+
{ type: 'summary', cwd: '/Users/me/repo' },
|
|
183
|
+
assistantMsg('a1', '2026-06-01T10:00:00.000Z', [
|
|
184
|
+
toolUse('t1', 'Write', { file_path: '/tmp/elsewhere.txt', content: 'hi' }),
|
|
185
|
+
]),
|
|
186
|
+
userMsg('u1', '2026-06-01T10:00:01.000Z', [toolResult('t1')]),
|
|
187
|
+
]);
|
|
188
|
+
const out = await loadModifiedFiles('proj', 'sid');
|
|
189
|
+
expect(out!.files[0]!.relativePath).toBeNull();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('operations within a file are ordered by ts asc', async () => {
|
|
193
|
+
writeJsonl('proj', 'sid', [
|
|
194
|
+
assistantMsg('a1', '2026-06-01T10:02:00.000Z', [
|
|
195
|
+
toolUse('t2', 'Edit', { file_path: '/repo/x.ts', old_string: 'a', new_string: 'b' }),
|
|
196
|
+
]),
|
|
197
|
+
userMsg('u1', '2026-06-01T10:02:01.000Z', [toolResult('t2')]),
|
|
198
|
+
assistantMsg('a2', '2026-06-01T10:01:00.000Z', [
|
|
199
|
+
toolUse('t1', 'Edit', { file_path: '/repo/x.ts', old_string: 'c', new_string: 'd' }),
|
|
200
|
+
]),
|
|
201
|
+
userMsg('u2', '2026-06-01T10:01:01.000Z', [toolResult('t1')]),
|
|
202
|
+
]);
|
|
203
|
+
const out = await loadModifiedFiles('proj', 'sid');
|
|
204
|
+
expect(out!.files[0]!.operations.map((o) => o.toolUseId)).toEqual(['t1', 't2']);
|
|
205
|
+
expect(out!.files[0]!.firstAt).toBe('2026-06-01T10:01:00.000Z');
|
|
206
|
+
expect(out!.files[0]!.lastAt).toBe('2026-06-01T10:02:00.000Z');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('skips malformed jsonl lines and tool_use entries without id/file_path', async () => {
|
|
210
|
+
const file = path.join(fakeRoot, 'projects', 'proj', 'sid.jsonl');
|
|
211
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
212
|
+
fs.writeFileSync(
|
|
213
|
+
file,
|
|
214
|
+
[
|
|
215
|
+
'{not json',
|
|
216
|
+
JSON.stringify(
|
|
217
|
+
assistantMsg('a1', '2026-06-01T10:00:00.000Z', [
|
|
218
|
+
toolUse('', 'Edit', { file_path: '/x.ts', old_string: 'a', new_string: 'b' }), // empty id → skip
|
|
219
|
+
toolUse('t1', 'Edit', { /* no file_path */ old_string: 'a', new_string: 'b' }), // no path → skip
|
|
220
|
+
toolUse('t2', 'Write', { file_path: '/ok.ts', content: 'hi' }),
|
|
221
|
+
]),
|
|
222
|
+
),
|
|
223
|
+
JSON.stringify(userMsg('u1', '2026-06-01T10:00:01.000Z', [toolResult('t2')])),
|
|
224
|
+
'',
|
|
225
|
+
].join('\n'),
|
|
226
|
+
);
|
|
227
|
+
const out = await loadModifiedFiles('proj', 'sid');
|
|
228
|
+
expect(out!.files).toHaveLength(1);
|
|
229
|
+
expect(out!.files[0]!.filePath).toBe('/ok.ts');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('attaches structuredPatch from toolUseResult; [] for create, null when absent', async () => {
|
|
233
|
+
writeJsonl('proj-sp', 'sid-sp', [
|
|
234
|
+
{ type: 'summary', cwd: '/repo' },
|
|
235
|
+
// Edit whose result line carries a real structuredPatch (accurate file line numbers).
|
|
236
|
+
assistantMsg('a1', '2026-06-01T10:00:00.000Z', [
|
|
237
|
+
toolUse('t1', 'Edit', { file_path: '/repo/a.ts', old_string: 'x', new_string: 'y' }),
|
|
238
|
+
]),
|
|
239
|
+
{
|
|
240
|
+
type: 'user',
|
|
241
|
+
uuid: 'u1',
|
|
242
|
+
timestamp: '2026-06-01T10:00:01.000Z',
|
|
243
|
+
message: { content: [toolResult('t1', false)] },
|
|
244
|
+
toolUseResult: {
|
|
245
|
+
type: 'update',
|
|
246
|
+
filePath: '/repo/a.ts',
|
|
247
|
+
structuredPatch: [
|
|
248
|
+
{ oldStart: 10, oldLines: 2, newStart: 10, newLines: 2, lines: [' ctx', '-x', '+y'] },
|
|
249
|
+
],
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
// Write create → structuredPatch is an empty array (render input as all-added).
|
|
253
|
+
assistantMsg('a2', '2026-06-01T10:01:00.000Z', [
|
|
254
|
+
toolUse('t2', 'Write', { file_path: '/repo/b.ts', content: 'new' }),
|
|
255
|
+
]),
|
|
256
|
+
{
|
|
257
|
+
type: 'user',
|
|
258
|
+
uuid: 'u2',
|
|
259
|
+
timestamp: '2026-06-01T10:01:01.000Z',
|
|
260
|
+
message: { content: [toolResult('t2', false)] },
|
|
261
|
+
toolUseResult: { type: 'create', filePath: '/repo/b.ts', structuredPatch: [] },
|
|
262
|
+
},
|
|
263
|
+
// Edit whose result has no toolUseResult sentinel → null (fall back to input diff).
|
|
264
|
+
assistantMsg('a3', '2026-06-01T10:02:00.000Z', [
|
|
265
|
+
toolUse('t3', 'Edit', { file_path: '/repo/c.ts', old_string: 'm', new_string: 'n' }),
|
|
266
|
+
]),
|
|
267
|
+
userMsg('u3', '2026-06-01T10:02:01.000Z', [toolResult('t3', false)]),
|
|
268
|
+
]);
|
|
269
|
+
|
|
270
|
+
const out = await loadModifiedFiles('proj-sp', 'sid-sp');
|
|
271
|
+
expect(out).not.toBeNull();
|
|
272
|
+
const byPath = new Map(out!.files.map((f) => [f.filePath, f]));
|
|
273
|
+
|
|
274
|
+
expect(byPath.get('/repo/a.ts')!.operations[0]!.structuredPatch).toEqual([
|
|
275
|
+
{ oldStart: 10, oldLines: 2, newStart: 10, newLines: 2, lines: [' ctx', '-x', '+y'] },
|
|
276
|
+
]);
|
|
277
|
+
expect(byPath.get('/repo/b.ts')!.operations[0]!.structuredPatch).toEqual([]);
|
|
278
|
+
expect(byPath.get('/repo/c.ts')!.operations[0]!.structuredPatch).toBeNull();
|
|
279
|
+
});
|
|
280
|
+
});
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import readline from 'node:readline';
|
|
4
|
+
import { PATHS } from './claude-paths.ts';
|
|
5
|
+
import type {
|
|
6
|
+
DiffHunk,
|
|
7
|
+
ModifiedFileOperation,
|
|
8
|
+
ModifiedFileSummary,
|
|
9
|
+
ModifiedFileToolName,
|
|
10
|
+
ModifiedFilesResponse,
|
|
11
|
+
} from '../types.ts';
|
|
12
|
+
|
|
13
|
+
const FILE_MOD_TOOLS = new Set<ModifiedFileToolName>([
|
|
14
|
+
'Edit',
|
|
15
|
+
'Write',
|
|
16
|
+
'MultiEdit',
|
|
17
|
+
'NotebookEdit',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
interface PendingOp {
|
|
21
|
+
toolUseId: string;
|
|
22
|
+
toolName: ModifiedFileToolName;
|
|
23
|
+
ts: string | null;
|
|
24
|
+
messageUuid: string | null;
|
|
25
|
+
filePath: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function loadModifiedFiles(
|
|
29
|
+
projectId: string,
|
|
30
|
+
sessionId: string,
|
|
31
|
+
): Promise<ModifiedFilesResponse | null> {
|
|
32
|
+
const jsonlPath = path.join(PATHS.projects, projectId, `${sessionId}.jsonl`);
|
|
33
|
+
if (!fs.existsSync(jsonlPath)) return null;
|
|
34
|
+
|
|
35
|
+
const ops: PendingOp[] = [];
|
|
36
|
+
// tool_use_id → is_error;tool_result 在 jsonl 中通常出现在对应 tool_use 之后,
|
|
37
|
+
// 但不强依赖顺序——单次扫完再回填。
|
|
38
|
+
const resultErr = new Map<string, boolean>();
|
|
39
|
+
// tool_use_id → 该次工具结果的 structuredPatch(带真实行号的 hunk)。
|
|
40
|
+
// 哨兵记录在 user 消息顶层的 obj.toolUseResult 上,与同一行 content 里的 tool_result 一一对应。
|
|
41
|
+
const resultPatch = new Map<string, DiffHunk[]>();
|
|
42
|
+
let cwd: string | null = null;
|
|
43
|
+
|
|
44
|
+
const rl = readline.createInterface({
|
|
45
|
+
input: fs.createReadStream(jsonlPath, { encoding: 'utf8' }),
|
|
46
|
+
crlfDelay: Infinity,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
for await (const raw of rl) {
|
|
50
|
+
const line = raw.trim();
|
|
51
|
+
if (!line) continue;
|
|
52
|
+
let obj: Record<string, unknown>;
|
|
53
|
+
try {
|
|
54
|
+
obj = JSON.parse(line) as Record<string, unknown>;
|
|
55
|
+
} catch {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (typeof obj.cwd === 'string' && !cwd) cwd = obj.cwd;
|
|
60
|
+
|
|
61
|
+
if (obj.type !== 'user' && obj.type !== 'assistant') continue;
|
|
62
|
+
const message = obj.message;
|
|
63
|
+
if (!message || typeof message !== 'object') continue;
|
|
64
|
+
const content = (message as { content?: unknown }).content;
|
|
65
|
+
if (!Array.isArray(content)) continue;
|
|
66
|
+
|
|
67
|
+
const ts = typeof obj.timestamp === 'string' ? obj.timestamp : null;
|
|
68
|
+
const messageUuid = typeof obj.uuid === 'string' ? obj.uuid : null;
|
|
69
|
+
|
|
70
|
+
for (const block of content) {
|
|
71
|
+
if (!block || typeof block !== 'object') continue;
|
|
72
|
+
const b = block as Record<string, unknown>;
|
|
73
|
+
if (b.type === 'tool_use') {
|
|
74
|
+
const name = b.name;
|
|
75
|
+
if (typeof name !== 'string') continue;
|
|
76
|
+
if (!FILE_MOD_TOOLS.has(name as ModifiedFileToolName)) continue;
|
|
77
|
+
const input = b.input;
|
|
78
|
+
if (!input || typeof input !== 'object') continue;
|
|
79
|
+
const filePath = extractFilePath(input as Record<string, unknown>);
|
|
80
|
+
if (!filePath) continue;
|
|
81
|
+
const id = typeof b.id === 'string' ? b.id : '';
|
|
82
|
+
if (!id) continue;
|
|
83
|
+
ops.push({
|
|
84
|
+
toolUseId: id,
|
|
85
|
+
toolName: name as ModifiedFileToolName,
|
|
86
|
+
ts,
|
|
87
|
+
messageUuid,
|
|
88
|
+
filePath,
|
|
89
|
+
});
|
|
90
|
+
} else if (b.type === 'tool_result') {
|
|
91
|
+
const id = b.tool_use_id;
|
|
92
|
+
if (typeof id !== 'string' || !id) continue;
|
|
93
|
+
// 同一 tool_use_id 理论上只对应一条 result;以首次出现为准。
|
|
94
|
+
if (!resultErr.has(id)) resultErr.set(id, b.is_error === true);
|
|
95
|
+
if (!resultPatch.has(id)) {
|
|
96
|
+
const patch = extractStructuredPatch(obj.toolUseResult);
|
|
97
|
+
if (patch) resultPatch.set(id, patch);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 按 filePath 聚合
|
|
104
|
+
const byPath = new Map<string, ModifiedFileSummary>();
|
|
105
|
+
for (const op of ops) {
|
|
106
|
+
const errored = resultErr.get(op.toolUseId) === true;
|
|
107
|
+
const pending = !resultErr.has(op.toolUseId);
|
|
108
|
+
const operation: ModifiedFileOperation = {
|
|
109
|
+
toolUseId: op.toolUseId,
|
|
110
|
+
toolName: op.toolName,
|
|
111
|
+
ts: op.ts,
|
|
112
|
+
messageUuid: op.messageUuid,
|
|
113
|
+
errored,
|
|
114
|
+
pending,
|
|
115
|
+
structuredPatch: resultPatch.has(op.toolUseId) ? resultPatch.get(op.toolUseId)! : null,
|
|
116
|
+
};
|
|
117
|
+
let summary = byPath.get(op.filePath);
|
|
118
|
+
if (!summary) {
|
|
119
|
+
summary = {
|
|
120
|
+
filePath: op.filePath,
|
|
121
|
+
relativePath: null,
|
|
122
|
+
editCount: 0,
|
|
123
|
+
writeCount: 0,
|
|
124
|
+
multiEditCount: 0,
|
|
125
|
+
notebookEditCount: 0,
|
|
126
|
+
totalCount: 0,
|
|
127
|
+
errorCount: 0,
|
|
128
|
+
firstAt: null,
|
|
129
|
+
lastAt: null,
|
|
130
|
+
operations: [],
|
|
131
|
+
};
|
|
132
|
+
byPath.set(op.filePath, summary);
|
|
133
|
+
}
|
|
134
|
+
summary.operations.push(operation);
|
|
135
|
+
summary.totalCount += 1;
|
|
136
|
+
if (errored) summary.errorCount += 1;
|
|
137
|
+
switch (op.toolName) {
|
|
138
|
+
case 'Edit':
|
|
139
|
+
summary.editCount += 1;
|
|
140
|
+
break;
|
|
141
|
+
case 'Write':
|
|
142
|
+
summary.writeCount += 1;
|
|
143
|
+
break;
|
|
144
|
+
case 'MultiEdit':
|
|
145
|
+
summary.multiEditCount += 1;
|
|
146
|
+
break;
|
|
147
|
+
case 'NotebookEdit':
|
|
148
|
+
summary.notebookEditCount += 1;
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
if (op.ts) {
|
|
152
|
+
if (!summary.firstAt || op.ts < summary.firstAt) summary.firstAt = op.ts;
|
|
153
|
+
if (!summary.lastAt || op.ts > summary.lastAt) summary.lastAt = op.ts;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const summary of byPath.values()) {
|
|
158
|
+
summary.operations.sort(compareByTs);
|
|
159
|
+
summary.relativePath = relativizeIfUnder(summary.filePath, cwd);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const files = Array.from(byPath.values()).sort((a, b) => {
|
|
163
|
+
// lastAt desc,null 排末尾
|
|
164
|
+
if (a.lastAt && b.lastAt) return a.lastAt < b.lastAt ? 1 : a.lastAt > b.lastAt ? -1 : 0;
|
|
165
|
+
if (a.lastAt) return -1;
|
|
166
|
+
if (b.lastAt) return 1;
|
|
167
|
+
return a.filePath.localeCompare(b.filePath);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return { sessionId, projectId, cwd, files };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Pull the structuredPatch hunks out of a `toolUseResult` sentinel. Returns an
|
|
174
|
+
* empty array for a brand-new file (Write/NotebookEdit create, where Claude Code
|
|
175
|
+
* records `structuredPatch: []`), and null when the field is absent/malformed. */
|
|
176
|
+
function extractStructuredPatch(tur: unknown): DiffHunk[] | null {
|
|
177
|
+
if (!tur || typeof tur !== 'object') return null;
|
|
178
|
+
const sp = (tur as Record<string, unknown>).structuredPatch;
|
|
179
|
+
if (!Array.isArray(sp)) return null;
|
|
180
|
+
const hunks: DiffHunk[] = [];
|
|
181
|
+
for (const h of sp) {
|
|
182
|
+
if (!h || typeof h !== 'object') continue;
|
|
183
|
+
const r = h as Record<string, unknown>;
|
|
184
|
+
if (
|
|
185
|
+
typeof r.oldStart === 'number' &&
|
|
186
|
+
typeof r.oldLines === 'number' &&
|
|
187
|
+
typeof r.newStart === 'number' &&
|
|
188
|
+
typeof r.newLines === 'number' &&
|
|
189
|
+
Array.isArray(r.lines)
|
|
190
|
+
) {
|
|
191
|
+
hunks.push({
|
|
192
|
+
oldStart: r.oldStart,
|
|
193
|
+
oldLines: r.oldLines,
|
|
194
|
+
newStart: r.newStart,
|
|
195
|
+
newLines: r.newLines,
|
|
196
|
+
lines: r.lines.filter((l): l is string => typeof l === 'string'),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return hunks;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function extractFilePath(input: Record<string, unknown>): string | null {
|
|
204
|
+
const fp = input.file_path;
|
|
205
|
+
if (typeof fp === 'string' && fp) return fp;
|
|
206
|
+
// NotebookEdit uses notebook_path.
|
|
207
|
+
const np = input.notebook_path;
|
|
208
|
+
if (typeof np === 'string' && np) return np;
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function relativizeIfUnder(filePath: string, cwd: string | null): string | null {
|
|
213
|
+
if (!cwd) return null;
|
|
214
|
+
// 用 posix-style 简单前缀判断即可——session 是在 macOS/Linux/Windows
|
|
215
|
+
// 各自原生路径下记录的 cwd,不跨平台。
|
|
216
|
+
const normCwd = cwd.replace(/[\\/]+$/, '');
|
|
217
|
+
if (filePath === normCwd) return '.';
|
|
218
|
+
if (filePath.startsWith(normCwd + '/')) return filePath.slice(normCwd.length + 1);
|
|
219
|
+
if (filePath.startsWith(normCwd + '\\')) return filePath.slice(normCwd.length + 1);
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function compareByTs(a: ModifiedFileOperation, b: ModifiedFileOperation): number {
|
|
224
|
+
if (a.ts && b.ts) return a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0;
|
|
225
|
+
if (a.ts) return -1;
|
|
226
|
+
if (b.ts) return 1;
|
|
227
|
+
return 0;
|
|
228
|
+
}
|
|
@@ -1,36 +1,43 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
|
|
4
|
-
export interface
|
|
4
|
+
export interface OpenResult {
|
|
5
5
|
ok: boolean;
|
|
6
6
|
error?: string;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
export function openFolder(folderPath: string):
|
|
9
|
+
export function openFolder(folderPath: string): OpenResult {
|
|
10
10
|
try {
|
|
11
11
|
const st = fs.statSync(folderPath);
|
|
12
12
|
if (!st.isDirectory()) return { ok: false, error: 'not a directory' };
|
|
13
13
|
} catch {
|
|
14
14
|
return { ok: false, error: 'path not found' };
|
|
15
15
|
}
|
|
16
|
+
return launch(folderPath);
|
|
17
|
+
}
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
cmd = 'open';
|
|
24
|
-
args = [folderPath];
|
|
25
|
-
} else {
|
|
26
|
-
cmd = 'xdg-open';
|
|
27
|
-
args = [folderPath];
|
|
19
|
+
export function openFile(filePath: string): OpenResult {
|
|
20
|
+
try {
|
|
21
|
+
const st = fs.statSync(filePath);
|
|
22
|
+
if (!st.isFile()) return { ok: false, error: 'not a file' };
|
|
23
|
+
} catch {
|
|
24
|
+
return { ok: false, error: 'path not found' };
|
|
28
25
|
}
|
|
26
|
+
return launch(filePath);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 交给系统默认程序打开(文件 → 关联应用;文件夹 → 资源管理器),等价于双击。
|
|
30
|
+
// detached + unref,不阻塞、不等子进程;spawn 失败在异步回调里只记日志。
|
|
31
|
+
function launch(target: string): OpenResult {
|
|
32
|
+
let cmd: string;
|
|
33
|
+
if (process.platform === 'win32') cmd = 'explorer.exe';
|
|
34
|
+
else if (process.platform === 'darwin') cmd = 'open';
|
|
35
|
+
else cmd = 'xdg-open';
|
|
29
36
|
|
|
30
37
|
try {
|
|
31
|
-
const child = spawn(cmd,
|
|
38
|
+
const child = spawn(cmd, [target], { detached: true, stdio: 'ignore' });
|
|
32
39
|
child.on('error', (err) => {
|
|
33
|
-
console.error(`[open
|
|
40
|
+
console.error(`[open] spawn ${cmd} failed:`, err);
|
|
34
41
|
});
|
|
35
42
|
child.unref();
|
|
36
43
|
return { ok: true };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import readline from 'node:readline';
|
|
3
|
+
import { INTERRUPTED_MARKER_RE } from './constants.ts';
|
|
3
4
|
import { SYSTEM_TAG_RE, pickTitleText } from './system-tags.ts';
|
|
4
5
|
|
|
5
6
|
export interface JsonlMeta {
|
|
@@ -10,6 +11,13 @@ export interface JsonlMeta {
|
|
|
10
11
|
lastAt: string | null;
|
|
11
12
|
messageCount: number;
|
|
12
13
|
cwdFromMessages: string | null;
|
|
14
|
+
/**
|
|
15
|
+
* The last conversation turn is unfinished — Claude still owes output. True when
|
|
16
|
+
* the final `user`/`assistant` record is either a `user` message (and not an
|
|
17
|
+
* abort marker) or an `assistant` message that ends on a `tool_use` block. This
|
|
18
|
+
* is the structural half of "working"; liveness gating happens in the caller.
|
|
19
|
+
*/
|
|
20
|
+
lastTurnIncomplete: boolean;
|
|
13
21
|
}
|
|
14
22
|
|
|
15
23
|
export async function parseJsonlMeta(filePath: string): Promise<JsonlMeta> {
|
|
@@ -20,6 +28,9 @@ export async function parseJsonlMeta(filePath: string): Promise<JsonlMeta> {
|
|
|
20
28
|
let lastAt: string | null = null;
|
|
21
29
|
let messageCount = 0;
|
|
22
30
|
let cwdFromMessages: string | null = null;
|
|
31
|
+
// Re-evaluated on every conversation record so it reflects the *last* turn once
|
|
32
|
+
// the scan finishes.
|
|
33
|
+
let lastTurnIncomplete = false;
|
|
23
34
|
|
|
24
35
|
const rl = readline.createInterface({
|
|
25
36
|
input: fs.createReadStream(filePath, { encoding: 'utf8' }),
|
|
@@ -57,11 +68,17 @@ export async function parseJsonlMeta(filePath: string): Promise<JsonlMeta> {
|
|
|
57
68
|
|
|
58
69
|
if (obj.type === 'user' || obj.type === 'assistant') {
|
|
59
70
|
messageCount += 1;
|
|
71
|
+
const msg = obj.message as { content?: unknown } | undefined;
|
|
60
72
|
|
|
61
|
-
if (
|
|
62
|
-
|
|
73
|
+
if (obj.type === 'assistant') {
|
|
74
|
+
lastTurnIncomplete = endsWithToolUse(msg?.content);
|
|
75
|
+
} else {
|
|
63
76
|
const candidate = extractUserText(msg?.content);
|
|
64
|
-
|
|
77
|
+
// A trailing user record means Claude owes a reply — unless it is the
|
|
78
|
+
// synthetic abort marker, which means the operator stopped the turn.
|
|
79
|
+
lastTurnIncomplete = !INTERRUPTED_MARKER_RE.test(candidate);
|
|
80
|
+
|
|
81
|
+
if (!firstUserTitle && candidate && !SYSTEM_TAG_RE.test(candidate)) {
|
|
65
82
|
const usable = pickTitleText(candidate);
|
|
66
83
|
if (usable) {
|
|
67
84
|
firstUserTitle = usable.slice(0, 80).replace(/\s+/g, ' ').trim();
|
|
@@ -84,9 +101,24 @@ export async function parseJsonlMeta(filePath: string): Promise<JsonlMeta> {
|
|
|
84
101
|
lastAt: reconciledLastAt,
|
|
85
102
|
messageCount,
|
|
86
103
|
cwdFromMessages,
|
|
104
|
+
lastTurnIncomplete,
|
|
87
105
|
};
|
|
88
106
|
}
|
|
89
107
|
|
|
108
|
+
// An assistant message that ends on a `tool_use` block (Anthropic `stop_reason:
|
|
109
|
+
// "tool_use"`) is mid-work: a tool is pending and Claude will continue once it
|
|
110
|
+
// returns. Verified 1:1 against `stop_reason` across real sessions.
|
|
111
|
+
function endsWithToolUse(content: unknown): boolean {
|
|
112
|
+
if (!Array.isArray(content)) return false;
|
|
113
|
+
for (let i = content.length - 1; i >= 0; i--) {
|
|
114
|
+
const block = content[i];
|
|
115
|
+
if (block && typeof block === 'object' && typeof (block as { type?: unknown }).type === 'string') {
|
|
116
|
+
return (block as { type: string }).type === 'tool_use';
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
90
122
|
function extractUserText(content: unknown): string {
|
|
91
123
|
if (typeof content === 'string') return content;
|
|
92
124
|
if (Array.isArray(content)) {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { isSafeId } from './safe-id.ts';
|
|
3
|
+
|
|
4
|
+
// 守门:URL 参数里的 sessionId / projectId 走到任何 fs.* 之前必须先过这一关,
|
|
5
|
+
// 漏一类就会让攻击者用 ../ 跳出 ~/.claude,所以四个拒绝点逐条钉死。
|
|
6
|
+
describe('isSafeId', () => {
|
|
7
|
+
it('接受常规 uuid / 编码后的 cwd', () => {
|
|
8
|
+
expect(isSafeId('019410ce-49fb-7d5c-b0a4-2d7d2b6a4b7d')).toBe(true);
|
|
9
|
+
expect(isSafeId('-Users-sunpeng-workspace-claude-code-session')).toBe(true);
|
|
10
|
+
expect(isSafeId('C--Users-sunpeng')).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('拒绝空字符串', () => {
|
|
14
|
+
expect(isSafeId('')).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('拒绝包含正斜杠的 id(path-traversal 入口)', () => {
|
|
18
|
+
expect(isSafeId('a/b')).toBe(false);
|
|
19
|
+
expect(isSafeId('../etc/passwd')).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('拒绝包含反斜杠的 id(Windows path-traversal)', () => {
|
|
23
|
+
expect(isSafeId('a\\b')).toBe(false);
|
|
24
|
+
expect(isSafeId('..\\windows')).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('拒绝包含 .. 的 id(即便不带分隔符)', () => {
|
|
28
|
+
expect(isSafeId('foo..bar')).toBe(false);
|
|
29
|
+
expect(isSafeId('..')).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('拒绝以 . 开头的 id(屏蔽 dotfile)', () => {
|
|
33
|
+
expect(isSafeId('.hidden')).toBe(false);
|
|
34
|
+
expect(isSafeId('.bak-1700000000')).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('单点开头的拒绝不影响中间含 . 的合法 id', () => {
|
|
38
|
+
expect(isSafeId('memory.md')).toBe(true);
|
|
39
|
+
expect(isSafeId('file.imported-abcd1234.md')).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
});
|