@zzusp/ccsm 1.0.0 → 1.0.2
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/LICENSE +21 -21
- package/README.md +236 -232
- package/dist/assets/DiskUsage-BY6XwffG.js +2 -0
- package/dist/assets/DiskUsage-BY6XwffG.js.map +1 -0
- package/dist/assets/{ImportPage-b8NORa8b.js → ImportPage-Cwq5bx7G.js} +2 -2
- package/dist/assets/ImportPage-Cwq5bx7G.js.map +1 -0
- package/dist/assets/MarkdownContent-BFu7Nkk_.js +2 -0
- package/dist/assets/MarkdownContent-BFu7Nkk_.js.map +1 -0
- package/dist/assets/{ProjectMemory-aSV8UzQ9.js → ProjectMemory-CcE3KbUK.js} +2 -2
- package/dist/assets/ProjectMemory-CcE3KbUK.js.map +1 -0
- 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-CrWxV6sb.css +1 -0
- package/dist/assets/index-DTbWl1jb.js +11 -0
- package/dist/assets/index-DTbWl1jb.js.map +1 -0
- package/dist/assets/markdown-Bag5rX3T.js +30 -0
- package/dist/assets/markdown-Bag5rX3T.js.map +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 +30 -30
- package/package.json +24 -11
- package/server/index.ts +4 -0
- package/server/lib/active-sessions.test.ts +119 -0
- package/server/lib/active-sessions.ts +95 -95
- package/server/lib/bundle.test.ts +182 -0
- package/server/lib/bundle.ts +86 -86
- package/server/lib/claude-paths.test.ts +126 -0
- package/server/lib/claude-paths.ts +43 -36
- package/server/lib/cleanup-suggestions.ts +131 -0
- package/server/lib/constants.ts +8 -7
- package/server/lib/delete-project.ts +100 -100
- package/server/lib/delete.test.ts +244 -0
- package/server/lib/delete.ts +192 -203
- package/server/lib/disk-usage.ts +81 -83
- package/server/lib/encode-cwd.ts +24 -24
- package/server/lib/export-bundle.ts +236 -236
- package/server/lib/export-import-bundle.test.ts +337 -0
- package/server/lib/fs-size.ts +38 -38
- package/server/lib/import-bundle.ts +488 -488
- package/server/lib/load-memory.ts +120 -120
- package/server/lib/load-session.ts +209 -209
- package/server/lib/modified-files.test.ts +280 -0
- package/server/lib/modified-files.ts +228 -0
- package/server/lib/open-folder.ts +47 -40
- package/server/lib/parse-jsonl.ts +160 -107
- package/server/lib/port.ts +23 -23
- package/server/lib/safe-id.test.ts +41 -0
- package/server/lib/safe-id.ts +6 -6
- package/server/lib/safe-remove.test.ts +73 -0
- package/server/lib/safe-remove.ts +25 -0
- package/server/lib/scan.ts +289 -183
- package/server/lib/search-all.ts +130 -130
- package/server/lib/search-session.ts +203 -203
- package/server/lib/system-tags.ts +20 -20
- 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/disk.ts +9 -9
- package/server/routes/import.ts +87 -87
- package/server/routes/projects.ts +104 -104
- package/server/routes/search.ts +79 -79
- package/server/routes/sessions.ts +130 -81
- package/server/routes/version.ts +34 -0
- package/server/types.ts +1 -1
- package/shared/constants.ts +7 -2
- package/shared/types.ts +513 -359
- package/dist/assets/DiskUsage-Bq4VaoUA.js +0 -2
- package/dist/assets/DiskUsage-Bq4VaoUA.js.map +0 -1
- package/dist/assets/ImportPage-b8NORa8b.js.map +0 -1
- package/dist/assets/ProjectMemory-aSV8UzQ9.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
package/dist/favicon.svg
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
|
2
|
-
<rect width="32" height="32" rx="7" fill="#d18a3a"/>
|
|
3
|
-
<g fill="none" stroke="#fdf6ec" stroke-width="2.4" stroke-linecap="round">
|
|
4
|
-
<path d="M23 10A8 8 0 1 0 23 22"/>
|
|
5
|
-
<path d="M19 13a4.5 4.5 0 1 0 0 6" opacity="0.55"/>
|
|
6
|
-
</g>
|
|
7
|
-
</svg>
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
|
2
|
+
<rect width="32" height="32" rx="7" fill="#d18a3a"/>
|
|
3
|
+
<g fill="none" stroke="#fdf6ec" stroke-width="2.4" stroke-linecap="round">
|
|
4
|
+
<path d="M23 10A8 8 0 1 0 23 22"/>
|
|
5
|
+
<path d="M19 13a4.5 4.5 0 1 0 0 6" opacity="0.55"/>
|
|
6
|
+
</g>
|
|
7
|
+
</svg>
|
package/dist/index.html
CHANGED
|
@@ -1,30 +1,30 @@
|
|
|
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.0" />
|
|
6
|
-
<meta name="color-scheme" content="light dark" />
|
|
7
|
-
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
8
|
-
<title>Claude Sessions · archive</title>
|
|
9
|
-
<script>
|
|
10
|
-
// Theme bootstrapping — runs before React mounts to prevent FOUC
|
|
11
|
-
(function () {
|
|
12
|
-
try {
|
|
13
|
-
var saved = localStorage.getItem("theme");
|
|
14
|
-
var prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
15
|
-
var dark = saved ? saved === "dark" : prefersDark;
|
|
16
|
-
if (dark) document.documentElement.classList.add("dark");
|
|
17
|
-
} catch (_) {}
|
|
18
|
-
})();
|
|
19
|
-
</script>
|
|
20
|
-
<script type="module" crossorigin src="/assets/index-
|
|
21
|
-
<link rel="modulepreload" crossorigin href="/assets/react-
|
|
22
|
-
<link rel="modulepreload" crossorigin href="/assets/query-
|
|
23
|
-
<link rel="modulepreload" crossorigin href="/assets/router-
|
|
24
|
-
<link rel="modulepreload" crossorigin href="/assets/vendor-
|
|
25
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
26
|
-
</head>
|
|
27
|
-
<body class="antialiased">
|
|
28
|
-
<div id="root"></div>
|
|
29
|
-
</body>
|
|
30
|
-
</html>
|
|
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.0" />
|
|
6
|
+
<meta name="color-scheme" content="light dark" />
|
|
7
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
8
|
+
<title>Claude Sessions · archive</title>
|
|
9
|
+
<script>
|
|
10
|
+
// Theme bootstrapping — runs before React mounts to prevent FOUC
|
|
11
|
+
(function () {
|
|
12
|
+
try {
|
|
13
|
+
var saved = localStorage.getItem("theme");
|
|
14
|
+
var prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
15
|
+
var dark = saved ? saved === "dark" : prefersDark;
|
|
16
|
+
if (dark) document.documentElement.classList.add("dark");
|
|
17
|
+
} catch (_) {}
|
|
18
|
+
})();
|
|
19
|
+
</script>
|
|
20
|
+
<script type="module" crossorigin src="/assets/index-DTbWl1jb.js"></script>
|
|
21
|
+
<link rel="modulepreload" crossorigin href="/assets/react-CPkiFScu.js">
|
|
22
|
+
<link rel="modulepreload" crossorigin href="/assets/query-CS7JQ86v.js">
|
|
23
|
+
<link rel="modulepreload" crossorigin href="/assets/router-DwaHAh1G.js">
|
|
24
|
+
<link rel="modulepreload" crossorigin href="/assets/vendor-Cs8vYp-N.js">
|
|
25
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CrWxV6sb.css">
|
|
26
|
+
</head>
|
|
27
|
+
<body class="antialiased">
|
|
28
|
+
<div id="root"></div>
|
|
29
|
+
</body>
|
|
30
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zzusp/ccsm",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Local web UI to view and clean up Claude Code session history (~/.claude/)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -42,31 +42,44 @@
|
|
|
42
42
|
"build": "vite build",
|
|
43
43
|
"start": "tsx server/index.ts",
|
|
44
44
|
"typecheck": "tsc -b",
|
|
45
|
-
"
|
|
45
|
+
"test": "vitest run",
|
|
46
|
+
"test:watch": "vitest",
|
|
47
|
+
"prepublishOnly": "npm run build",
|
|
48
|
+
"release": "release-it",
|
|
49
|
+
"release:dry": "release-it --dry-run",
|
|
50
|
+
"prepare": "husky"
|
|
46
51
|
},
|
|
47
52
|
"dependencies": {
|
|
48
|
-
"@fontsource-variable/geist-mono": "^5.2.7",
|
|
49
|
-
"@fontsource-variable/plus-jakarta-sans": "^5.2.8",
|
|
50
53
|
"@hono/node-server": "^1.13.7",
|
|
51
|
-
"@tanstack/react-query": "^5.62.7",
|
|
52
54
|
"hono": "^4.6.14",
|
|
53
|
-
"motion": "^12.38.0",
|
|
54
|
-
"react": "^19.0.0",
|
|
55
|
-
"react-dom": "^19.0.0",
|
|
56
|
-
"react-router-dom": "^7.1.1",
|
|
57
|
-
"recharts": "^2.15.0",
|
|
58
55
|
"tsx": "^4.19.2"
|
|
59
56
|
},
|
|
60
57
|
"devDependencies": {
|
|
58
|
+
"@commitlint/cli": "^21.0.2",
|
|
59
|
+
"@commitlint/config-conventional": "^21.0.2",
|
|
60
|
+
"@fontsource-variable/geist-mono": "^5.2.7",
|
|
61
|
+
"@fontsource-variable/plus-jakarta-sans": "^5.2.8",
|
|
62
|
+
"@release-it/conventional-changelog": "^11.0.1",
|
|
61
63
|
"@tailwindcss/vite": "^4.0.0",
|
|
64
|
+
"@tanstack/react-query": "^5.62.7",
|
|
62
65
|
"@types/node": "^22.10.5",
|
|
63
66
|
"@types/react": "^19.0.2",
|
|
64
67
|
"@types/react-dom": "^19.0.2",
|
|
65
68
|
"@vitejs/plugin-react": "^4.3.4",
|
|
66
69
|
"concurrently": "^9.1.2",
|
|
70
|
+
"husky": "^9.1.7",
|
|
71
|
+
"motion": "^12.38.0",
|
|
67
72
|
"playwright": "^1.59.1",
|
|
73
|
+
"react": "^19.0.0",
|
|
74
|
+
"react-dom": "^19.0.0",
|
|
75
|
+
"react-markdown": "^10.1.0",
|
|
76
|
+
"react-router-dom": "^7.1.1",
|
|
77
|
+
"recharts": "^2.15.0",
|
|
78
|
+
"release-it": "^20.2.0",
|
|
79
|
+
"remark-gfm": "^4.0.1",
|
|
68
80
|
"tailwindcss": "^4.0.0",
|
|
69
81
|
"typescript": "^5.7.2",
|
|
70
|
-
"vite": "^6.0.7"
|
|
82
|
+
"vite": "^6.0.7",
|
|
83
|
+
"vitest": "^4.1.8"
|
|
71
84
|
}
|
|
72
85
|
}
|
package/server/index.ts
CHANGED
|
@@ -9,10 +9,12 @@ import { parseArgs } from 'node:util';
|
|
|
9
9
|
import { PATHS } from './lib/claude-paths.ts';
|
|
10
10
|
import { findAvailablePort } from './lib/port.ts';
|
|
11
11
|
import { diskRoute } from './routes/disk.ts';
|
|
12
|
+
import { diskCleanupRoute } from './routes/disk-cleanup.ts';
|
|
12
13
|
import { importRoute } from './routes/import.ts';
|
|
13
14
|
import { projectsRoute } from './routes/projects.ts';
|
|
14
15
|
import { searchRoute } from './routes/search.ts';
|
|
15
16
|
import { sessionsRoute } from './routes/sessions.ts';
|
|
17
|
+
import { versionRoute } from './routes/version.ts';
|
|
16
18
|
|
|
17
19
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
20
|
const projectRoot = path.resolve(__dirname, '..');
|
|
@@ -46,8 +48,10 @@ app.get('/api/health', (c) =>
|
|
|
46
48
|
app.route('/api/projects', projectsRoute);
|
|
47
49
|
app.route('/api/sessions', sessionsRoute);
|
|
48
50
|
app.route('/api/disk-usage', diskRoute);
|
|
51
|
+
app.route('/api/disk-cleanup', diskCleanupRoute);
|
|
49
52
|
app.route('/api/search', searchRoute);
|
|
50
53
|
app.route('/api/import', importRoute);
|
|
54
|
+
app.route('/api/version', versionRoute);
|
|
51
55
|
|
|
52
56
|
if (fs.existsSync(distDir)) {
|
|
53
57
|
app.use('/*', serveStatic({ root: path.relative(process.cwd(), distDir) || '.' }));
|
|
@@ -0,0 +1,119 @@
|
|
|
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
|
+
// active-sessions.ts 是 delete / import "跳过活会话"安全网的事实源头。
|
|
7
|
+
// 测试覆盖 isPidAlive 的 POSIX 分支 + buildActiveSessionMap 对死/活 PID 的区分。
|
|
8
|
+
|
|
9
|
+
let fakeRoot: string;
|
|
10
|
+
|
|
11
|
+
vi.mock('./claude-paths.ts', () => ({
|
|
12
|
+
get PATHS() {
|
|
13
|
+
const root = process.env.CCSM_TEST_ROOT!;
|
|
14
|
+
return {
|
|
15
|
+
root,
|
|
16
|
+
projects: path.join(root, 'projects'),
|
|
17
|
+
fileHistory: path.join(root, 'file-history'),
|
|
18
|
+
sessionEnv: path.join(root, 'session-env'),
|
|
19
|
+
sessions: path.join(root, 'sessions'),
|
|
20
|
+
history: path.join(root, 'history.jsonl'),
|
|
21
|
+
};
|
|
22
|
+
},
|
|
23
|
+
isUnderClaudeRoot(target: string): boolean {
|
|
24
|
+
const root = process.env.CCSM_TEST_ROOT!;
|
|
25
|
+
const resolved = path.resolve(target);
|
|
26
|
+
return resolved === root || resolved.startsWith(root + path.sep);
|
|
27
|
+
},
|
|
28
|
+
getCacheDir(): string {
|
|
29
|
+
return path.join(process.env.CCSM_TEST_ROOT!, '_cache');
|
|
30
|
+
},
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
fakeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccsm-active-test-'));
|
|
35
|
+
process.env.CCSM_TEST_ROOT = fakeRoot;
|
|
36
|
+
fs.mkdirSync(path.join(fakeRoot, 'sessions'), { recursive: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
vi.restoreAllMocks();
|
|
41
|
+
delete process.env.CCSM_TEST_ROOT;
|
|
42
|
+
fs.rmSync(fakeRoot, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('isPidAlive (POSIX)', () => {
|
|
46
|
+
it('当前进程 pid 必为活', async () => {
|
|
47
|
+
const { isPidAlive } = await import('./active-sessions.ts');
|
|
48
|
+
// 仅在非 Windows 平台跑 POSIX 断言;CI 上若在 Windows 这条用 process.platform 跳过
|
|
49
|
+
if (process.platform === 'win32') return;
|
|
50
|
+
expect(isPidAlive(process.pid)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('明显不可能的 pid 不应判活', async () => {
|
|
54
|
+
const { isPidAlive } = await import('./active-sessions.ts');
|
|
55
|
+
if (process.platform === 'win32') return;
|
|
56
|
+
expect(isPidAlive(0)).toBe(false);
|
|
57
|
+
expect(isPidAlive(-1)).toBe(false);
|
|
58
|
+
expect(isPidAlive(Number.NaN)).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('一个上限附近、几乎不可能存在的 pid 判死', async () => {
|
|
62
|
+
const { isPidAlive } = await import('./active-sessions.ts');
|
|
63
|
+
if (process.platform === 'win32') return;
|
|
64
|
+
// 4194304 是 Linux 默认 pid_max;macOS 99998;两边都极不可能命中真实进程
|
|
65
|
+
expect(isPidAlive(4194303)).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('readActivePidEntries / buildActiveSessionMap', () => {
|
|
70
|
+
function writePidFile(pid: number, sessionId: string): void {
|
|
71
|
+
fs.writeFileSync(
|
|
72
|
+
path.join(fakeRoot, 'sessions', `${pid}.json`),
|
|
73
|
+
JSON.stringify({ pid, sessionId, cwd: '/Users/alice/proj' }),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
it('活进程被读出且 alive=true、死进程 alive=false', async () => {
|
|
78
|
+
const { readActivePidEntries, buildActiveSessionMap } = await import('./active-sessions.ts');
|
|
79
|
+
if (process.platform === 'win32') return;
|
|
80
|
+
|
|
81
|
+
writePidFile(process.pid, 'sid-live');
|
|
82
|
+
writePidFile(4194303, 'sid-dead');
|
|
83
|
+
|
|
84
|
+
const entries = readActivePidEntries();
|
|
85
|
+
const live = entries.find((e) => e.sessionId === 'sid-live');
|
|
86
|
+
const dead = entries.find((e) => e.sessionId === 'sid-dead');
|
|
87
|
+
expect(live?.alive).toBe(true);
|
|
88
|
+
expect(dead?.alive).toBe(false);
|
|
89
|
+
|
|
90
|
+
const map = buildActiveSessionMap();
|
|
91
|
+
expect(map.get('sid-live')).toBe(process.pid);
|
|
92
|
+
expect(map.has('sid-dead')).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('PID 文件格式不合(缺字段 / 非 JSON)静默跳过,不抛', async () => {
|
|
96
|
+
const { readActivePidEntries } = await import('./active-sessions.ts');
|
|
97
|
+
if (process.platform === 'win32') return;
|
|
98
|
+
|
|
99
|
+
fs.writeFileSync(path.join(fakeRoot, 'sessions', '111.json'), 'not json');
|
|
100
|
+
fs.writeFileSync(
|
|
101
|
+
path.join(fakeRoot, 'sessions', '222.json'),
|
|
102
|
+
JSON.stringify({ sessionId: 'no-pid-here' }),
|
|
103
|
+
);
|
|
104
|
+
fs.writeFileSync(
|
|
105
|
+
path.join(fakeRoot, 'sessions', '333.json'),
|
|
106
|
+
JSON.stringify({ pid: 333 }), // 缺 sessionId
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const entries = readActivePidEntries();
|
|
110
|
+
expect(entries).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('sessions 目录不存在时返回空(不创建副作用)', async () => {
|
|
114
|
+
const { readActivePidEntries } = await import('./active-sessions.ts');
|
|
115
|
+
fs.rmSync(path.join(fakeRoot, 'sessions'), { recursive: true, force: true });
|
|
116
|
+
expect(readActivePidEntries()).toEqual([]);
|
|
117
|
+
expect(fs.existsSync(path.join(fakeRoot, 'sessions'))).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -1,95 +1,95 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { PATHS } from './claude-paths.ts';
|
|
5
|
-
|
|
6
|
-
export interface ActivePidEntry {
|
|
7
|
-
pid: number;
|
|
8
|
-
sessionId: string;
|
|
9
|
-
cwd: string;
|
|
10
|
-
alive: boolean;
|
|
11
|
-
/** Absolute path to the PID file we read this entry from. */
|
|
12
|
-
sourceFile: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function isPidAlive(pid: number): boolean {
|
|
16
|
-
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
17
|
-
if (process.platform === 'win32') {
|
|
18
|
-
try {
|
|
19
|
-
const out = execFileSync(
|
|
20
|
-
'tasklist',
|
|
21
|
-
['/FI', `PID eq ${pid}`, '/NH', '/FO', 'CSV'],
|
|
22
|
-
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
23
|
-
);
|
|
24
|
-
return out.toLowerCase().includes(`"${pid}"`);
|
|
25
|
-
} catch {
|
|
26
|
-
return false;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
try {
|
|
30
|
-
process.kill(pid, 0);
|
|
31
|
-
return true;
|
|
32
|
-
} catch (err) {
|
|
33
|
-
return (err as NodeJS.ErrnoException).code === 'EPERM';
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Windows: enumerate every running PID with one `tasklist` call.
|
|
39
|
-
* Each call is ~400-700ms; doing it once instead of per-PID turns the
|
|
40
|
-
* cost from O(N×tasklist) into O(1×tasklist) for `readActivePidEntries`.
|
|
41
|
-
*/
|
|
42
|
-
function listAlivePidsWindows(): Set<number> {
|
|
43
|
-
const set = new Set<number>();
|
|
44
|
-
try {
|
|
45
|
-
const out = execFileSync('tasklist', ['/NH', '/FO', 'CSV'], {
|
|
46
|
-
encoding: 'utf8',
|
|
47
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
48
|
-
});
|
|
49
|
-
for (const line of out.split(/\r?\n/)) {
|
|
50
|
-
// Format: "Image Name","PID","Session Name","Session#","Mem Usage"
|
|
51
|
-
const m = line.match(/^"[^"]*","(\d+)"/);
|
|
52
|
-
if (m) set.add(Number(m[1]));
|
|
53
|
-
}
|
|
54
|
-
} catch {
|
|
55
|
-
/* return whatever we have; callers treat unknown PIDs as dead */
|
|
56
|
-
}
|
|
57
|
-
return set;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function readActivePidEntries(): ActivePidEntry[] {
|
|
61
|
-
if (!fs.existsSync(PATHS.sessions)) return [];
|
|
62
|
-
const alivePids = process.platform === 'win32' ? listAlivePidsWindows() : null;
|
|
63
|
-
const entries: ActivePidEntry[] = [];
|
|
64
|
-
for (const name of fs.readdirSync(PATHS.sessions)) {
|
|
65
|
-
if (!name.endsWith('.json')) continue;
|
|
66
|
-
const full = path.join(PATHS.sessions, name);
|
|
67
|
-
try {
|
|
68
|
-
const obj = JSON.parse(fs.readFileSync(full, 'utf8')) as {
|
|
69
|
-
pid?: number;
|
|
70
|
-
sessionId?: string;
|
|
71
|
-
cwd?: string;
|
|
72
|
-
};
|
|
73
|
-
if (typeof obj.pid !== 'number' || typeof obj.sessionId !== 'string') continue;
|
|
74
|
-
const alive = alivePids ? alivePids.has(obj.pid) : isPidAlive(obj.pid);
|
|
75
|
-
entries.push({
|
|
76
|
-
pid: obj.pid,
|
|
77
|
-
sessionId: obj.sessionId,
|
|
78
|
-
cwd: typeof obj.cwd === 'string' ? obj.cwd : '',
|
|
79
|
-
alive,
|
|
80
|
-
sourceFile: full,
|
|
81
|
-
});
|
|
82
|
-
} catch {
|
|
83
|
-
// skip malformed PID files
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
return entries;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function buildActiveSessionMap(): Map<string, number> {
|
|
90
|
-
const map = new Map<string, number>();
|
|
91
|
-
for (const e of readActivePidEntries()) {
|
|
92
|
-
if (e.alive) map.set(e.sessionId, e.pid);
|
|
93
|
-
}
|
|
94
|
-
return map;
|
|
95
|
-
}
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { PATHS } from './claude-paths.ts';
|
|
5
|
+
|
|
6
|
+
export interface ActivePidEntry {
|
|
7
|
+
pid: number;
|
|
8
|
+
sessionId: string;
|
|
9
|
+
cwd: string;
|
|
10
|
+
alive: boolean;
|
|
11
|
+
/** Absolute path to the PID file we read this entry from. */
|
|
12
|
+
sourceFile: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isPidAlive(pid: number): boolean {
|
|
16
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
17
|
+
if (process.platform === 'win32') {
|
|
18
|
+
try {
|
|
19
|
+
const out = execFileSync(
|
|
20
|
+
'tasklist',
|
|
21
|
+
['/FI', `PID eq ${pid}`, '/NH', '/FO', 'CSV'],
|
|
22
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
23
|
+
);
|
|
24
|
+
return out.toLowerCase().includes(`"${pid}"`);
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
process.kill(pid, 0);
|
|
31
|
+
return true;
|
|
32
|
+
} catch (err) {
|
|
33
|
+
return (err as NodeJS.ErrnoException).code === 'EPERM';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Windows: enumerate every running PID with one `tasklist` call.
|
|
39
|
+
* Each call is ~400-700ms; doing it once instead of per-PID turns the
|
|
40
|
+
* cost from O(N×tasklist) into O(1×tasklist) for `readActivePidEntries`.
|
|
41
|
+
*/
|
|
42
|
+
function listAlivePidsWindows(): Set<number> {
|
|
43
|
+
const set = new Set<number>();
|
|
44
|
+
try {
|
|
45
|
+
const out = execFileSync('tasklist', ['/NH', '/FO', 'CSV'], {
|
|
46
|
+
encoding: 'utf8',
|
|
47
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
48
|
+
});
|
|
49
|
+
for (const line of out.split(/\r?\n/)) {
|
|
50
|
+
// Format: "Image Name","PID","Session Name","Session#","Mem Usage"
|
|
51
|
+
const m = line.match(/^"[^"]*","(\d+)"/);
|
|
52
|
+
if (m) set.add(Number(m[1]));
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
/* return whatever we have; callers treat unknown PIDs as dead */
|
|
56
|
+
}
|
|
57
|
+
return set;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function readActivePidEntries(): ActivePidEntry[] {
|
|
61
|
+
if (!fs.existsSync(PATHS.sessions)) return [];
|
|
62
|
+
const alivePids = process.platform === 'win32' ? listAlivePidsWindows() : null;
|
|
63
|
+
const entries: ActivePidEntry[] = [];
|
|
64
|
+
for (const name of fs.readdirSync(PATHS.sessions)) {
|
|
65
|
+
if (!name.endsWith('.json')) continue;
|
|
66
|
+
const full = path.join(PATHS.sessions, name);
|
|
67
|
+
try {
|
|
68
|
+
const obj = JSON.parse(fs.readFileSync(full, 'utf8')) as {
|
|
69
|
+
pid?: number;
|
|
70
|
+
sessionId?: string;
|
|
71
|
+
cwd?: string;
|
|
72
|
+
};
|
|
73
|
+
if (typeof obj.pid !== 'number' || typeof obj.sessionId !== 'string') continue;
|
|
74
|
+
const alive = alivePids ? alivePids.has(obj.pid) : isPidAlive(obj.pid);
|
|
75
|
+
entries.push({
|
|
76
|
+
pid: obj.pid,
|
|
77
|
+
sessionId: obj.sessionId,
|
|
78
|
+
cwd: typeof obj.cwd === 'string' ? obj.cwd : '',
|
|
79
|
+
alive,
|
|
80
|
+
sourceFile: full,
|
|
81
|
+
});
|
|
82
|
+
} catch {
|
|
83
|
+
// skip malformed PID files
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return entries;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function buildActiveSessionMap(): Map<string, number> {
|
|
90
|
+
const map = new Map<string, number>();
|
|
91
|
+
for (const e of readActivePidEntries()) {
|
|
92
|
+
if (e.alive) map.set(e.sessionId, e.pid);
|
|
93
|
+
}
|
|
94
|
+
return map;
|
|
95
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
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 } from 'vitest';
|
|
5
|
+
import { rewriteLineField, SENTINEL, sha256, transformFile } from './bundle.ts';
|
|
6
|
+
|
|
7
|
+
// bundle.ts 是 export/import 共用的"占位符替换 + 流式重写"原语。
|
|
8
|
+
// 这里关心的不是某条字段被改了,而是不该改的一律不能动:
|
|
9
|
+
// 消息正文 / gitBranch / version / 不匹配的 fromValue 全部原样保留。
|
|
10
|
+
|
|
11
|
+
let tmp: string;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ccsm-bundle-test-'));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('rewriteLineField', () => {
|
|
22
|
+
it('精确匹配 fromValue 时替换为 toValue', () => {
|
|
23
|
+
const line = JSON.stringify({ type: 'user', cwd: '/Users/alice/proj', message: 'hi' });
|
|
24
|
+
const out = rewriteLineField(line, 'cwd', '/Users/alice/proj', SENTINEL);
|
|
25
|
+
expect(JSON.parse(out)).toEqual({
|
|
26
|
+
type: 'user',
|
|
27
|
+
cwd: SENTINEL,
|
|
28
|
+
message: 'hi',
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('字段不存在则走快速路径原样返回(按字节相等)', () => {
|
|
33
|
+
const line = JSON.stringify({ type: 'user', message: 'no cwd here' });
|
|
34
|
+
const out = rewriteLineField(line, 'cwd', '/whatever', SENTINEL);
|
|
35
|
+
expect(out).toBe(line);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('字段值不等于 fromValue 时不改', () => {
|
|
39
|
+
const line = JSON.stringify({ cwd: '/Users/bob/other' });
|
|
40
|
+
const out = rewriteLineField(line, 'cwd', '/Users/alice/proj', SENTINEL);
|
|
41
|
+
expect(JSON.parse(out)).toEqual({ cwd: '/Users/bob/other' });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('恰好是 fromValue 的子串但不是字段值时不改', () => {
|
|
45
|
+
// message 里出现源 cwd 不能被殃及(消息正文必须保持归档原貌)
|
|
46
|
+
const sourceCwd = '/Users/alice/proj';
|
|
47
|
+
const line = JSON.stringify({
|
|
48
|
+
type: 'assistant',
|
|
49
|
+
cwd: '/Users/bob/other',
|
|
50
|
+
message: `traceback at ${sourceCwd}/src/foo.ts`,
|
|
51
|
+
gitBranch: 'main',
|
|
52
|
+
version: '1.2.3',
|
|
53
|
+
});
|
|
54
|
+
const out = rewriteLineField(line, 'cwd', sourceCwd, SENTINEL);
|
|
55
|
+
// cwd 不等于 sourceCwd,整行原样
|
|
56
|
+
expect(out).toBe(line);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('改 cwd 字段时 message / gitBranch / version 完全不动', () => {
|
|
60
|
+
const sourceCwd = '/Users/alice/proj';
|
|
61
|
+
const line = JSON.stringify({
|
|
62
|
+
type: 'assistant',
|
|
63
|
+
cwd: sourceCwd,
|
|
64
|
+
message: `look at ${sourceCwd}/src/foo.ts please`,
|
|
65
|
+
gitBranch: sourceCwd, // 故意挑事:值跟 sourceCwd 一致
|
|
66
|
+
version: sourceCwd,
|
|
67
|
+
});
|
|
68
|
+
const out = rewriteLineField(line, 'cwd', sourceCwd, SENTINEL);
|
|
69
|
+
const obj = JSON.parse(out) as Record<string, unknown>;
|
|
70
|
+
expect(obj.cwd).toBe(SENTINEL);
|
|
71
|
+
expect(obj.message).toBe(`look at ${sourceCwd}/src/foo.ts please`);
|
|
72
|
+
expect(obj.gitBranch).toBe(sourceCwd);
|
|
73
|
+
expect(obj.version).toBe(sourceCwd);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('JSON 解析失败的行原样保留(容错)', () => {
|
|
77
|
+
const raw = '{this is not valid json but mentions "cwd" key';
|
|
78
|
+
const out = rewriteLineField(raw, 'cwd', '/x', SENTINEL);
|
|
79
|
+
expect(out).toBe(raw);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('export/import 双向对称:cwd -> sentinel -> cwd 还原到原值', () => {
|
|
83
|
+
const sourceCwd = '/Users/alice/proj';
|
|
84
|
+
const targetCwd = '/Users/alice/proj'; // roundtrip 到同一台机器
|
|
85
|
+
const line = JSON.stringify({ cwd: sourceCwd, type: 'user' });
|
|
86
|
+
|
|
87
|
+
const exported = rewriteLineField(line, 'cwd', sourceCwd, SENTINEL);
|
|
88
|
+
const reimported = rewriteLineField(exported, 'cwd', SENTINEL, targetCwd);
|
|
89
|
+
|
|
90
|
+
expect(JSON.parse(reimported)).toEqual({ cwd: targetCwd, type: 'user' });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('import 到新设备:sentinel -> 新路径', () => {
|
|
94
|
+
const exported = JSON.stringify({ cwd: SENTINEL, type: 'user' });
|
|
95
|
+
const out = rewriteLineField(exported, 'cwd', SENTINEL, '/home/bob/proj');
|
|
96
|
+
expect(JSON.parse(out)).toEqual({ cwd: '/home/bob/proj', type: 'user' });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('history.jsonl 的 project 字段(不是 cwd)也走同一原语', () => {
|
|
100
|
+
const sourceCwd = '/Users/alice/proj';
|
|
101
|
+
const line = JSON.stringify({
|
|
102
|
+
project: sourceCwd,
|
|
103
|
+
cwd: sourceCwd, // history 行里如果有 cwd 不能动,目标字段是 project
|
|
104
|
+
sessionId: 'sid-1',
|
|
105
|
+
display: 'prompt',
|
|
106
|
+
});
|
|
107
|
+
const out = rewriteLineField(line, 'project', sourceCwd, SENTINEL);
|
|
108
|
+
const obj = JSON.parse(out) as Record<string, unknown>;
|
|
109
|
+
expect(obj.project).toBe(SENTINEL);
|
|
110
|
+
expect(obj.cwd).toBe(sourceCwd); // 被显式保留
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('transformFile (流式重写整文件)', () => {
|
|
115
|
+
it('流式把每行的 cwd 替换为 sentinel,并报告行数 + sha256', async () => {
|
|
116
|
+
const src = path.join(tmp, 'src.jsonl');
|
|
117
|
+
const dest = path.join(tmp, 'dest.jsonl');
|
|
118
|
+
const sourceCwd = '/Users/alice/proj';
|
|
119
|
+
const lines = [
|
|
120
|
+
JSON.stringify({ type: 'user', cwd: sourceCwd, message: 'a' }),
|
|
121
|
+
JSON.stringify({ type: 'assistant', cwd: sourceCwd, message: 'b' }),
|
|
122
|
+
JSON.stringify({ type: 'summary' }), // 没 cwd 字段
|
|
123
|
+
];
|
|
124
|
+
fs.writeFileSync(src, lines.join('\n') + '\n');
|
|
125
|
+
|
|
126
|
+
const res = await transformFile(src, dest, 'cwd', sourceCwd, SENTINEL);
|
|
127
|
+
expect(res.lines).toBe(3);
|
|
128
|
+
|
|
129
|
+
const out = fs.readFileSync(dest, 'utf8').split('\n').filter(Boolean);
|
|
130
|
+
expect(out).toHaveLength(3);
|
|
131
|
+
const parsed = out.map((l) => JSON.parse(l) as Record<string, unknown>);
|
|
132
|
+
expect(parsed[0]!.cwd).toBe(SENTINEL);
|
|
133
|
+
expect(parsed[1]!.cwd).toBe(SENTINEL);
|
|
134
|
+
expect(parsed[2]!).toEqual({ type: 'summary' });
|
|
135
|
+
|
|
136
|
+
// sha256 必须对应实际写入字节
|
|
137
|
+
expect(res.sha256).toBe(sha256(fs.readFileSync(dest)));
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('丢空行(不携带记录),其他原样', async () => {
|
|
141
|
+
const src = path.join(tmp, 'src.jsonl');
|
|
142
|
+
const dest = path.join(tmp, 'dest.jsonl');
|
|
143
|
+
fs.writeFileSync(src, '\n' + JSON.stringify({ cwd: '/x' }) + '\n\n');
|
|
144
|
+
const res = await transformFile(src, dest, 'cwd', '/x', SENTINEL);
|
|
145
|
+
expect(res.lines).toBe(1);
|
|
146
|
+
expect(fs.readFileSync(dest, 'utf8')).toBe(JSON.stringify({ cwd: SENTINEL }) + '\n');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('roundtrip:export 写入 sentinel 后再 import 回新路径,非目标字段保持字节一致', async () => {
|
|
150
|
+
const src = path.join(tmp, 'src.jsonl');
|
|
151
|
+
const exported = path.join(tmp, 'bundle.jsonl');
|
|
152
|
+
const imported = path.join(tmp, 'imported.jsonl');
|
|
153
|
+
|
|
154
|
+
const sourceCwd = '/Users/alice/proj';
|
|
155
|
+
const targetCwd = '/Users/alice/proj'; // 同机 roundtrip
|
|
156
|
+
const original = [
|
|
157
|
+
JSON.stringify({
|
|
158
|
+
type: 'user',
|
|
159
|
+
cwd: sourceCwd,
|
|
160
|
+
message: `stack at ${sourceCwd}/foo.ts`,
|
|
161
|
+
gitBranch: 'main',
|
|
162
|
+
version: '1.0.0',
|
|
163
|
+
}),
|
|
164
|
+
JSON.stringify({ type: 'summary', cwd: sourceCwd, message: 'done' }),
|
|
165
|
+
];
|
|
166
|
+
fs.writeFileSync(src, original.join('\n') + '\n');
|
|
167
|
+
|
|
168
|
+
await transformFile(src, exported, 'cwd', sourceCwd, SENTINEL);
|
|
169
|
+
await transformFile(exported, imported, 'cwd', SENTINEL, targetCwd);
|
|
170
|
+
|
|
171
|
+
const back = fs.readFileSync(imported, 'utf8').split('\n').filter(Boolean);
|
|
172
|
+
const parsed = back.map((l) => JSON.parse(l) as Record<string, unknown>);
|
|
173
|
+
expect(parsed[0]).toEqual({
|
|
174
|
+
type: 'user',
|
|
175
|
+
cwd: targetCwd,
|
|
176
|
+
message: `stack at ${sourceCwd}/foo.ts`, // 消息正文里的源路径保留为归档原貌
|
|
177
|
+
gitBranch: 'main',
|
|
178
|
+
version: '1.0.0',
|
|
179
|
+
});
|
|
180
|
+
expect(parsed[1]).toEqual({ type: 'summary', cwd: targetCwd, message: 'done' });
|
|
181
|
+
});
|
|
182
|
+
});
|