@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.
Files changed (58) hide show
  1. package/README.md +7 -3
  2. package/bin/cli.mjs +52 -52
  3. package/dist/assets/DiskUsage-CKhggLs5.js +2 -0
  4. package/dist/assets/DiskUsage-CKhggLs5.js.map +1 -0
  5. package/dist/assets/{ImportPage-b8NORa8b.js → ImportPage-wge4VhZ-.js} +2 -2
  6. package/dist/assets/{ImportPage-b8NORa8b.js.map → ImportPage-wge4VhZ-.js.map} +1 -1
  7. package/dist/assets/{ProjectMemory-aSV8UzQ9.js → ProjectMemory-Q4XX40j_.js} +2 -2
  8. package/dist/assets/{ProjectMemory-aSV8UzQ9.js.map → ProjectMemory-Q4XX40j_.js.map} +1 -1
  9. package/dist/assets/{charts-A5eNHLjX.js → charts-jxJqXXUr.js} +2 -2
  10. package/dist/assets/{charts-A5eNHLjX.js.map → charts-jxJqXXUr.js.map} +1 -1
  11. package/dist/assets/index-7aMrnHJG.js +7 -0
  12. package/dist/assets/index-7aMrnHJG.js.map +1 -0
  13. package/dist/assets/index-BOeI_J4B.css +1 -0
  14. package/dist/assets/{query-C1K1uQRu.js → query-CS7JQ86v.js} +2 -2
  15. package/dist/assets/{query-C1K1uQRu.js.map → query-CS7JQ86v.js.map} +1 -1
  16. package/dist/assets/{react-W0jzChlo.js → react-CPkiFScu.js} +10 -10
  17. package/dist/assets/{react-W0jzChlo.js.map → react-CPkiFScu.js.map} +1 -1
  18. package/dist/assets/{router-DfbutHY3.js → router-DwaHAh1G.js} +2 -2
  19. package/dist/assets/{router-DfbutHY3.js.map → router-DwaHAh1G.js.map} +1 -1
  20. package/dist/assets/vendor-Cs8vYp-N.js +27 -0
  21. package/dist/assets/vendor-Cs8vYp-N.js.map +1 -0
  22. package/dist/favicon.svg +7 -7
  23. package/dist/index.html +6 -6
  24. package/package.json +83 -72
  25. package/server/index.ts +130 -126
  26. package/server/lib/active-sessions.test.ts +119 -0
  27. package/server/lib/bundle.test.ts +182 -0
  28. package/server/lib/claude-paths.test.ts +126 -0
  29. package/server/lib/claude-paths.ts +19 -12
  30. package/server/lib/cleanup-suggestions.ts +131 -0
  31. package/server/lib/constants.ts +1 -0
  32. package/server/lib/delete.test.ts +244 -0
  33. package/server/lib/delete.ts +5 -16
  34. package/server/lib/disk-usage.ts +6 -8
  35. package/server/lib/export-import-bundle.test.ts +337 -0
  36. package/server/lib/modified-files.test.ts +280 -0
  37. package/server/lib/modified-files.ts +228 -0
  38. package/server/lib/open-folder.ts +22 -15
  39. package/server/lib/parse-jsonl.ts +35 -3
  40. package/server/lib/safe-id.test.ts +41 -0
  41. package/server/lib/safe-remove.test.ts +73 -0
  42. package/server/lib/safe-remove.ts +25 -0
  43. package/server/lib/scan.ts +103 -0
  44. package/server/lib/update.ts +67 -0
  45. package/server/lib/version.test.ts +39 -0
  46. package/server/lib/version.ts +117 -0
  47. package/server/routes/disk-cleanup.ts +54 -0
  48. package/server/routes/sessions.ts +49 -0
  49. package/server/routes/version.ts +34 -0
  50. package/shared/constants.ts +5 -0
  51. package/shared/types.ts +152 -0
  52. package/dist/assets/DiskUsage-Bq4VaoUA.js +0 -2
  53. package/dist/assets/DiskUsage-Bq4VaoUA.js.map +0 -1
  54. package/dist/assets/index-DLATR3tZ.js +0 -5
  55. package/dist/assets/index-DLATR3tZ.js.map +0 -1
  56. package/dist/assets/index-DLDtbkux.css +0 -1
  57. package/dist/assets/vendor-CH80ylbS.js +0 -19
  58. 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
@@ -17,12 +17,12 @@
17
17
  } catch (_) {}
18
18
  })();
19
19
  </script>
20
- <script type="module" crossorigin src="/assets/index-DLATR3tZ.js"></script>
21
- <link rel="modulepreload" crossorigin href="/assets/react-W0jzChlo.js">
22
- <link rel="modulepreload" crossorigin href="/assets/query-C1K1uQRu.js">
23
- <link rel="modulepreload" crossorigin href="/assets/router-DfbutHY3.js">
24
- <link rel="modulepreload" crossorigin href="/assets/vendor-CH80ylbS.js">
25
- <link rel="stylesheet" crossorigin href="/assets/index-DLDtbkux.css">
20
+ <script type="module" crossorigin src="/assets/index-7aMrnHJG.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-BOeI_J4B.css">
26
26
  </head>
27
27
  <body class="antialiased">
28
28
  <div id="root"></div>
package/package.json CHANGED
@@ -1,72 +1,83 @@
1
- {
2
- "name": "@zzusp/ccsm",
3
- "version": "1.0.0",
4
- "description": "Local web UI to view and clean up Claude Code session history (~/.claude/)",
5
- "license": "MIT",
6
- "repository": {
7
- "type": "git",
8
- "url": "git+https://github.com/zzusp/claude-code-session.git"
9
- },
10
- "homepage": "https://github.com/zzusp/claude-code-session#readme",
11
- "bugs": "https://github.com/zzusp/claude-code-session/issues",
12
- "type": "module",
13
- "engines": {
14
- "node": ">=22"
15
- },
16
- "bin": {
17
- "ccsm": "bin/cli.mjs"
18
- },
19
- "files": [
20
- "bin/",
21
- "server/",
22
- "shared/",
23
- "dist/",
24
- "README.md",
25
- "LICENSE"
26
- ],
27
- "keywords": [
28
- "claude",
29
- "claude-code",
30
- "anthropic",
31
- "session-manager",
32
- "cli"
33
- ],
34
- "publishConfig": {
35
- "access": "public",
36
- "registry": "https://registry.npmjs.org/"
37
- },
38
- "scripts": {
39
- "dev": "concurrently -k -n server,web -c blue,magenta \"npm:dev:server\" \"npm:dev:web\"",
40
- "dev:server": "node --import tsx --watch server/index.ts",
41
- "dev:web": "node scripts/wait-for-server.mjs && vite",
42
- "build": "vite build",
43
- "start": "tsx server/index.ts",
44
- "typecheck": "tsc -b",
45
- "prepublishOnly": "npm run build"
46
- },
47
- "dependencies": {
48
- "@fontsource-variable/geist-mono": "^5.2.7",
49
- "@fontsource-variable/plus-jakarta-sans": "^5.2.8",
50
- "@hono/node-server": "^1.13.7",
51
- "@tanstack/react-query": "^5.62.7",
52
- "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
- "tsx": "^4.19.2"
59
- },
60
- "devDependencies": {
61
- "@tailwindcss/vite": "^4.0.0",
62
- "@types/node": "^22.10.5",
63
- "@types/react": "^19.0.2",
64
- "@types/react-dom": "^19.0.2",
65
- "@vitejs/plugin-react": "^4.3.4",
66
- "concurrently": "^9.1.2",
67
- "playwright": "^1.59.1",
68
- "tailwindcss": "^4.0.0",
69
- "typescript": "^5.7.2",
70
- "vite": "^6.0.7"
71
- }
72
- }
1
+ {
2
+ "name": "@zzusp/ccsm",
3
+ "version": "1.0.1",
4
+ "description": "Local web UI to view and clean up Claude Code session history (~/.claude/)",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/zzusp/claude-code-session.git"
9
+ },
10
+ "homepage": "https://github.com/zzusp/claude-code-session#readme",
11
+ "bugs": "https://github.com/zzusp/claude-code-session/issues",
12
+ "type": "module",
13
+ "engines": {
14
+ "node": ">=22"
15
+ },
16
+ "bin": {
17
+ "ccsm": "bin/cli.mjs"
18
+ },
19
+ "files": [
20
+ "bin/",
21
+ "server/",
22
+ "shared/",
23
+ "dist/",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "keywords": [
28
+ "claude",
29
+ "claude-code",
30
+ "anthropic",
31
+ "session-manager",
32
+ "cli"
33
+ ],
34
+ "publishConfig": {
35
+ "access": "public",
36
+ "registry": "https://registry.npmjs.org/"
37
+ },
38
+ "scripts": {
39
+ "dev": "concurrently -k -n server,web -c blue,magenta \"npm:dev:server\" \"npm:dev:web\"",
40
+ "dev:server": "node --import tsx --watch server/index.ts",
41
+ "dev:web": "node scripts/wait-for-server.mjs && vite",
42
+ "build": "vite build",
43
+ "start": "tsx server/index.ts",
44
+ "typecheck": "tsc -b",
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"
51
+ },
52
+ "dependencies": {
53
+ "@hono/node-server": "^1.13.7",
54
+ "hono": "^4.6.14",
55
+ "tsx": "^4.19.2"
56
+ },
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",
63
+ "@tailwindcss/vite": "^4.0.0",
64
+ "@tanstack/react-query": "^5.62.7",
65
+ "@types/node": "^22.10.5",
66
+ "@types/react": "^19.0.2",
67
+ "@types/react-dom": "^19.0.2",
68
+ "@vitejs/plugin-react": "^4.3.4",
69
+ "concurrently": "^9.1.2",
70
+ "husky": "^9.1.7",
71
+ "motion": "^12.38.0",
72
+ "playwright": "^1.59.1",
73
+ "react": "^19.0.0",
74
+ "react-dom": "^19.0.0",
75
+ "react-router-dom": "^7.1.1",
76
+ "recharts": "^2.15.0",
77
+ "release-it": "^20.2.0",
78
+ "tailwindcss": "^4.0.0",
79
+ "typescript": "^5.7.2",
80
+ "vite": "^6.0.7",
81
+ "vitest": "^4.1.8"
82
+ }
83
+ }
package/server/index.ts CHANGED
@@ -1,126 +1,130 @@
1
- import { serve } from '@hono/node-server';
2
- import { serveStatic } from '@hono/node-server/serve-static';
3
- import { Hono } from 'hono';
4
- import { spawn } from 'node:child_process';
5
- import fs from 'node:fs';
6
- import path from 'node:path';
7
- import { fileURLToPath } from 'node:url';
8
- import { parseArgs } from 'node:util';
9
- import { PATHS } from './lib/claude-paths.ts';
10
- import { findAvailablePort } from './lib/port.ts';
11
- import { diskRoute } from './routes/disk.ts';
12
- import { importRoute } from './routes/import.ts';
13
- import { projectsRoute } from './routes/projects.ts';
14
- import { searchRoute } from './routes/search.ts';
15
- import { sessionsRoute } from './routes/sessions.ts';
16
-
17
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
- const projectRoot = path.resolve(__dirname, '..');
19
- const distDir = path.join(projectRoot, 'dist');
20
-
21
- const PORT_RANGE_START = 3131;
22
- const PORT_RANGE_END = 3140;
23
- const DEFAULT_HOST = '127.0.0.1';
24
-
25
- const app = new Hono();
26
-
27
- app.onError((err, c) => {
28
- console.error('[server] unhandled error', err);
29
- if (c.req.path.startsWith('/api/')) {
30
- return c.json({ error: err.message || 'internal error' }, 500);
31
- }
32
- return c.text('internal error', 500);
33
- });
34
-
35
- app.get('/api/health', (c) =>
36
- c.json({
37
- ok: true,
38
- claudeRoot: PATHS.root,
39
- claudeRootExists: fs.existsSync(PATHS.root),
40
- platform: process.platform,
41
- node: process.version,
42
- pid: process.pid,
43
- }),
44
- );
45
-
46
- app.route('/api/projects', projectsRoute);
47
- app.route('/api/sessions', sessionsRoute);
48
- app.route('/api/disk-usage', diskRoute);
49
- app.route('/api/search', searchRoute);
50
- app.route('/api/import', importRoute);
51
-
52
- if (fs.existsSync(distDir)) {
53
- app.use('/*', serveStatic({ root: path.relative(process.cwd(), distDir) || '.' }));
54
- app.get('*', serveStatic({ path: path.relative(process.cwd(), path.join(distDir, 'index.html')) }));
55
- }
56
-
57
- function parseCliArgs() {
58
- try {
59
- return parseArgs({
60
- args: process.argv.slice(2),
61
- options: {
62
- port: { type: 'string', short: 'p' },
63
- host: { type: 'string' },
64
- open: { type: 'boolean', short: 'o' },
65
- },
66
- });
67
- } catch (err) {
68
- console.error(`[server] ${(err as Error).message}`);
69
- console.error('[server] run "ccsm --help" for usage');
70
- process.exit(1);
71
- }
72
- }
73
-
74
- const { values } = parseCliArgs();
75
- const host = values.host ?? DEFAULT_HOST;
76
- const isLoopback = host === '127.0.0.1' || host === 'localhost' || host === '::1';
77
-
78
- let port: number;
79
- if (values.port !== undefined) {
80
- const requested = Number(values.port);
81
- if (!Number.isInteger(requested) || requested < 1 || requested > 65535) {
82
- console.error(`[server] invalid --port "${values.port}" (expected an integer 1..65535)`);
83
- process.exit(1);
84
- }
85
- try {
86
- port = await findAvailablePort(requested, requested, host);
87
- } catch {
88
- console.error(`[server] port ${requested} on ${host} is already in use`);
89
- process.exit(1);
90
- }
91
- } else {
92
- port = await findAvailablePort(PORT_RANGE_START, PORT_RANGE_END, host);
93
- }
94
-
95
- serve({ fetch: app.fetch, hostname: host, port }, (info) => {
96
- console.log(`[server] listening on http://${info.address}:${info.port}`);
97
- console.log(`[server] claudeRoot = ${PATHS.root}`);
98
- if (!isLoopback) {
99
- console.warn(
100
- `[server] WARNING: bound to ${host} (not loopback). The UI is now reachable from your network ` +
101
- `and has NO authentication — anyone who can reach this host:port can read and delete your ` +
102
- `Claude Code history. Only do this on a network you trust.`,
103
- );
104
- }
105
- if (!fs.existsSync(distDir)) {
106
- console.log('[server] dist/ not built yet run "npm run build" (or open the Vite dev server: npm run dev:web)');
107
- }
108
- if (values.open) {
109
- const browseHost = isLoopback ? (host === '::1' ? '[::1]' : host) : 'localhost';
110
- openInBrowser(`http://${browseHost}:${info.port}`);
111
- }
112
- });
113
-
114
- function openInBrowser(url: string): void {
115
- let cmd: string;
116
- if (process.platform === 'win32') cmd = 'explorer.exe';
117
- else if (process.platform === 'darwin') cmd = 'open';
118
- else cmd = 'xdg-open';
119
- try {
120
- const child = spawn(cmd, [url], { detached: true, stdio: 'ignore' });
121
- child.on('error', (err) => console.error('[server] could not open browser:', err.message));
122
- child.unref();
123
- } catch (err) {
124
- console.error('[server] could not open browser:', (err as Error).message);
125
- }
126
- }
1
+ import { serve } from '@hono/node-server';
2
+ import { serveStatic } from '@hono/node-server/serve-static';
3
+ import { Hono } from 'hono';
4
+ import { spawn } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { parseArgs } from 'node:util';
9
+ import { PATHS } from './lib/claude-paths.ts';
10
+ import { findAvailablePort } from './lib/port.ts';
11
+ import { diskRoute } from './routes/disk.ts';
12
+ import { diskCleanupRoute } from './routes/disk-cleanup.ts';
13
+ import { importRoute } from './routes/import.ts';
14
+ import { projectsRoute } from './routes/projects.ts';
15
+ import { searchRoute } from './routes/search.ts';
16
+ import { sessionsRoute } from './routes/sessions.ts';
17
+ import { versionRoute } from './routes/version.ts';
18
+
19
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
+ const projectRoot = path.resolve(__dirname, '..');
21
+ const distDir = path.join(projectRoot, 'dist');
22
+
23
+ const PORT_RANGE_START = 3131;
24
+ const PORT_RANGE_END = 3140;
25
+ const DEFAULT_HOST = '127.0.0.1';
26
+
27
+ const app = new Hono();
28
+
29
+ app.onError((err, c) => {
30
+ console.error('[server] unhandled error', err);
31
+ if (c.req.path.startsWith('/api/')) {
32
+ return c.json({ error: err.message || 'internal error' }, 500);
33
+ }
34
+ return c.text('internal error', 500);
35
+ });
36
+
37
+ app.get('/api/health', (c) =>
38
+ c.json({
39
+ ok: true,
40
+ claudeRoot: PATHS.root,
41
+ claudeRootExists: fs.existsSync(PATHS.root),
42
+ platform: process.platform,
43
+ node: process.version,
44
+ pid: process.pid,
45
+ }),
46
+ );
47
+
48
+ app.route('/api/projects', projectsRoute);
49
+ app.route('/api/sessions', sessionsRoute);
50
+ app.route('/api/disk-usage', diskRoute);
51
+ app.route('/api/disk-cleanup', diskCleanupRoute);
52
+ app.route('/api/search', searchRoute);
53
+ app.route('/api/import', importRoute);
54
+ app.route('/api/version', versionRoute);
55
+
56
+ if (fs.existsSync(distDir)) {
57
+ app.use('/*', serveStatic({ root: path.relative(process.cwd(), distDir) || '.' }));
58
+ app.get('*', serveStatic({ path: path.relative(process.cwd(), path.join(distDir, 'index.html')) }));
59
+ }
60
+
61
+ function parseCliArgs() {
62
+ try {
63
+ return parseArgs({
64
+ args: process.argv.slice(2),
65
+ options: {
66
+ port: { type: 'string', short: 'p' },
67
+ host: { type: 'string' },
68
+ open: { type: 'boolean', short: 'o' },
69
+ },
70
+ });
71
+ } catch (err) {
72
+ console.error(`[server] ${(err as Error).message}`);
73
+ console.error('[server] run "ccsm --help" for usage');
74
+ process.exit(1);
75
+ }
76
+ }
77
+
78
+ const { values } = parseCliArgs();
79
+ const host = values.host ?? DEFAULT_HOST;
80
+ const isLoopback = host === '127.0.0.1' || host === 'localhost' || host === '::1';
81
+
82
+ let port: number;
83
+ if (values.port !== undefined) {
84
+ const requested = Number(values.port);
85
+ if (!Number.isInteger(requested) || requested < 1 || requested > 65535) {
86
+ console.error(`[server] invalid --port "${values.port}" (expected an integer 1..65535)`);
87
+ process.exit(1);
88
+ }
89
+ try {
90
+ port = await findAvailablePort(requested, requested, host);
91
+ } catch {
92
+ console.error(`[server] port ${requested} on ${host} is already in use`);
93
+ process.exit(1);
94
+ }
95
+ } else {
96
+ port = await findAvailablePort(PORT_RANGE_START, PORT_RANGE_END, host);
97
+ }
98
+
99
+ serve({ fetch: app.fetch, hostname: host, port }, (info) => {
100
+ console.log(`[server] listening on http://${info.address}:${info.port}`);
101
+ console.log(`[server] claudeRoot = ${PATHS.root}`);
102
+ if (!isLoopback) {
103
+ console.warn(
104
+ `[server] WARNING: bound to ${host} (not loopback). The UI is now reachable from your network ` +
105
+ `and has NO authentication — anyone who can reach this host:port can read and delete your ` +
106
+ `Claude Code history. Only do this on a network you trust.`,
107
+ );
108
+ }
109
+ if (!fs.existsSync(distDir)) {
110
+ console.log('[server] dist/ not built yet — run "npm run build" (or open the Vite dev server: npm run dev:web)');
111
+ }
112
+ if (values.open) {
113
+ const browseHost = isLoopback ? (host === '::1' ? '[::1]' : host) : 'localhost';
114
+ openInBrowser(`http://${browseHost}:${info.port}`);
115
+ }
116
+ });
117
+
118
+ function openInBrowser(url: string): void {
119
+ let cmd: string;
120
+ if (process.platform === 'win32') cmd = 'explorer.exe';
121
+ else if (process.platform === 'darwin') cmd = 'open';
122
+ else cmd = 'xdg-open';
123
+ try {
124
+ const child = spawn(cmd, [url], { detached: true, stdio: 'ignore' });
125
+ child.on('error', (err) => console.error('[server] could not open browser:', err.message));
126
+ child.unref();
127
+ } catch (err) {
128
+ console.error('[server] could not open browser:', (err as Error).message);
129
+ }
130
+ }
@@ -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
+ });