@zzusp/ccsm 1.0.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/LICENSE +21 -0
- package/README.md +232 -0
- package/bin/cli.mjs +52 -0
- package/dist/assets/DiskUsage-Bq4VaoUA.js +2 -0
- package/dist/assets/DiskUsage-Bq4VaoUA.js.map +1 -0
- package/dist/assets/ImportPage-b8NORa8b.js +2 -0
- package/dist/assets/ImportPage-b8NORa8b.js.map +1 -0
- package/dist/assets/ProjectMemory-aSV8UzQ9.js +2 -0
- package/dist/assets/ProjectMemory-aSV8UzQ9.js.map +1 -0
- package/dist/assets/charts-A5eNHLjX.js +56 -0
- package/dist/assets/charts-A5eNHLjX.js.map +1 -0
- package/dist/assets/geist-mono-cyrillic-wght-normal-BZdD_g9V.woff2 +0 -0
- package/dist/assets/geist-mono-latin-ext-wght-normal-b6lpi8_2.woff2 +0 -0
- package/dist/assets/geist-mono-latin-wght-normal-Cjtb1TV-.woff2 +0 -0
- package/dist/assets/index-DLATR3tZ.js +5 -0
- package/dist/assets/index-DLATR3tZ.js.map +1 -0
- package/dist/assets/index-DLDtbkux.css +1 -0
- package/dist/assets/plus-jakarta-sans-latin-ext-wght-italic-DJWiFoht.woff2 +0 -0
- package/dist/assets/plus-jakarta-sans-latin-ext-wght-normal-DmpS2jIq.woff2 +0 -0
- package/dist/assets/plus-jakarta-sans-latin-wght-italic-DnD1KgkH.woff2 +0 -0
- package/dist/assets/plus-jakarta-sans-latin-wght-normal-eXO_dkmS.woff2 +0 -0
- package/dist/assets/plus-jakarta-sans-vietnamese-wght-italic-CPBsCcxN.woff2 +0 -0
- package/dist/assets/plus-jakarta-sans-vietnamese-wght-normal-qRpaaN48.woff2 +0 -0
- package/dist/assets/query-C1K1uQRu.js +2 -0
- package/dist/assets/query-C1K1uQRu.js.map +1 -0
- package/dist/assets/react-W0jzChlo.js +50 -0
- package/dist/assets/react-W0jzChlo.js.map +1 -0
- package/dist/assets/router-DfbutHY3.js +13 -0
- package/dist/assets/router-DfbutHY3.js.map +1 -0
- package/dist/assets/vendor-CH80ylbS.js +19 -0
- package/dist/assets/vendor-CH80ylbS.js.map +1 -0
- package/dist/favicon.svg +7 -0
- package/dist/index.html +30 -0
- package/package.json +72 -0
- package/server/index.ts +126 -0
- package/server/lib/active-sessions.ts +95 -0
- package/server/lib/bundle.ts +86 -0
- package/server/lib/claude-paths.ts +36 -0
- package/server/lib/constants.ts +7 -0
- package/server/lib/delete-project.ts +100 -0
- package/server/lib/delete.ts +203 -0
- package/server/lib/disk-usage.ts +83 -0
- package/server/lib/encode-cwd.ts +24 -0
- package/server/lib/export-bundle.ts +236 -0
- package/server/lib/fs-size.ts +38 -0
- package/server/lib/import-bundle.ts +488 -0
- package/server/lib/load-memory.ts +120 -0
- package/server/lib/load-session.ts +209 -0
- package/server/lib/open-folder.ts +40 -0
- package/server/lib/parse-jsonl.ts +107 -0
- package/server/lib/port.ts +23 -0
- package/server/lib/rename-session.ts +0 -0
- package/server/lib/safe-id.ts +6 -0
- package/server/lib/scan.ts +183 -0
- package/server/lib/search-all.ts +130 -0
- package/server/lib/search-session.ts +203 -0
- package/server/lib/system-tags.ts +20 -0
- package/server/routes/disk.ts +9 -0
- package/server/routes/import.ts +87 -0
- package/server/routes/projects.ts +104 -0
- package/server/routes/search.ts +79 -0
- package/server/routes/sessions.ts +81 -0
- package/server/types.ts +1 -0
- package/shared/constants.ts +2 -0
- package/shared/types.ts +359 -0
package/dist/favicon.svg
ADDED
|
@@ -0,0 +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>
|
package/dist/index.html
ADDED
|
@@ -0,0 +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-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">
|
|
26
|
+
</head>
|
|
27
|
+
<body class="antialiased">
|
|
28
|
+
<div id="root"></div>
|
|
29
|
+
</body>
|
|
30
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
}
|
package/server/index.ts
ADDED
|
@@ -0,0 +1,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 { 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
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import readline from 'node:readline';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The literal placeholder that stands in for the project root inside a bundle.
|
|
7
|
+
* Export replaces the device-specific absolute path with this; import swaps it
|
|
8
|
+
* back to the local target path. Single-quoted on purpose — it is a literal
|
|
9
|
+
* string, NOT a template interpolation.
|
|
10
|
+
*/
|
|
11
|
+
export const SENTINEL = '${CLAUDE_PROJECT_ROOT}';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Rewrite a single top-level string field of a JSONL line, only when its value
|
|
15
|
+
* exactly equals `fromValue`. Lines that don't carry the field (the fast path),
|
|
16
|
+
* fail to parse, or hold a different value pass through byte-for-byte unchanged —
|
|
17
|
+
* so message bodies and unrelated records are never touched. Re-serialization
|
|
18
|
+
* via JSON.stringify changes key order/whitespace only, which is semantically
|
|
19
|
+
* irrelevant to every consumer (Claude Code and this app both re-parse).
|
|
20
|
+
*/
|
|
21
|
+
export function rewriteLineField(
|
|
22
|
+
raw: string,
|
|
23
|
+
field: string,
|
|
24
|
+
fromValue: string,
|
|
25
|
+
toValue: string,
|
|
26
|
+
): string {
|
|
27
|
+
if (!raw.includes(`"${field}"`)) return raw;
|
|
28
|
+
let obj: Record<string, unknown>;
|
|
29
|
+
try {
|
|
30
|
+
obj = JSON.parse(raw) as Record<string, unknown>;
|
|
31
|
+
} catch {
|
|
32
|
+
return raw;
|
|
33
|
+
}
|
|
34
|
+
if (obj[field] !== fromValue) return raw;
|
|
35
|
+
obj[field] = toValue;
|
|
36
|
+
return JSON.stringify(obj);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Stream a JSONL/NDJSON file line-by-line, rewriting `field` from `fromValue` to
|
|
41
|
+
* `toValue` where present, into `destPath`. Never slurps the whole file. Returns
|
|
42
|
+
* the line count and the sha256 of the exact bytes written. Blank lines are
|
|
43
|
+
* dropped (they carry no record).
|
|
44
|
+
*/
|
|
45
|
+
export async function transformFile(
|
|
46
|
+
srcPath: string,
|
|
47
|
+
destPath: string,
|
|
48
|
+
field: string,
|
|
49
|
+
fromValue: string,
|
|
50
|
+
toValue: string,
|
|
51
|
+
): Promise<{ lines: number; sha256: string }> {
|
|
52
|
+
const hash = crypto.createHash('sha256');
|
|
53
|
+
const out = fs.createWriteStream(destPath, { encoding: 'utf8' });
|
|
54
|
+
const rl = readline.createInterface({
|
|
55
|
+
input: fs.createReadStream(srcPath, { encoding: 'utf8' }),
|
|
56
|
+
crlfDelay: Infinity,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
let lines = 0;
|
|
60
|
+
try {
|
|
61
|
+
for await (const raw of rl) {
|
|
62
|
+
if (!raw) continue;
|
|
63
|
+
const chunk = rewriteLineField(raw, field, fromValue, toValue) + '\n';
|
|
64
|
+
hash.update(chunk);
|
|
65
|
+
out.write(chunk);
|
|
66
|
+
lines += 1;
|
|
67
|
+
}
|
|
68
|
+
await new Promise<void>((resolve, reject) => {
|
|
69
|
+
out.end((err?: Error | null) => (err ? reject(err) : resolve()));
|
|
70
|
+
});
|
|
71
|
+
} catch (err) {
|
|
72
|
+
out.destroy();
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { lines, sha256: hash.digest('hex') };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function sha256(data: string | Buffer): string {
|
|
80
|
+
return crypto.createHash('sha256').update(data).digest('hex');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** sha256 of a file's raw bytes. For small files (memory entries); sync read. */
|
|
84
|
+
export function sha256File(p: string): string {
|
|
85
|
+
return sha256(fs.readFileSync(p));
|
|
86
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const claudeRoot = path.join(os.homedir(), '.claude');
|
|
5
|
+
|
|
6
|
+
export const PATHS = {
|
|
7
|
+
root: claudeRoot,
|
|
8
|
+
projects: path.join(claudeRoot, 'projects'),
|
|
9
|
+
fileHistory: path.join(claudeRoot, 'file-history'),
|
|
10
|
+
sessionEnv: path.join(claudeRoot, 'session-env'),
|
|
11
|
+
sessions: path.join(claudeRoot, 'sessions'),
|
|
12
|
+
history: path.join(claudeRoot, 'history.jsonl'),
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
const isWin = process.platform === 'win32';
|
|
16
|
+
|
|
17
|
+
function normalizeForCompare(p: string): string {
|
|
18
|
+
const resolved = path.resolve(p);
|
|
19
|
+
return isWin ? resolved.toLowerCase() : resolved;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const claudeRootNorm = normalizeForCompare(claudeRoot);
|
|
23
|
+
|
|
24
|
+
export function isUnderClaudeRoot(target: string): boolean {
|
|
25
|
+
const norm = normalizeForCompare(target);
|
|
26
|
+
return norm === claudeRootNorm || norm.startsWith(claudeRootNorm + path.sep);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getCacheDir(): string {
|
|
30
|
+
const env = process.env;
|
|
31
|
+
const base =
|
|
32
|
+
env.XDG_CACHE_HOME ??
|
|
33
|
+
env.LOCALAPPDATA ??
|
|
34
|
+
path.join(os.homedir(), '.cache');
|
|
35
|
+
return path.join(base, 'claude-session-viewer');
|
|
36
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { buildActiveSessionMap } from './active-sessions.ts';
|
|
4
|
+
import { isUnderClaudeRoot, PATHS } from './claude-paths.ts';
|
|
5
|
+
import { RECENT_ACTIVITY_WINDOW_MS } from './constants.ts';
|
|
6
|
+
import { deleteSessions } from './delete.ts';
|
|
7
|
+
import { isSafeId } from './safe-id.ts';
|
|
8
|
+
import type { DeleteProjectResult, SkippedItem } from '../types.ts';
|
|
9
|
+
|
|
10
|
+
const JSONL_EXT = '.jsonl';
|
|
11
|
+
|
|
12
|
+
export async function deleteProject(projectId: string): Promise<DeleteProjectResult> {
|
|
13
|
+
if (!isSafeId(projectId)) {
|
|
14
|
+
return {
|
|
15
|
+
deleted: [],
|
|
16
|
+
skipped: [{ projectId, sessionId: '', reason: 'invalid project id' }],
|
|
17
|
+
historyLinesRemoved: 0,
|
|
18
|
+
projectDirRemoved: false,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const projectDir = path.join(PATHS.projects, projectId);
|
|
23
|
+
if (!isUnderClaudeRoot(projectDir)) {
|
|
24
|
+
return {
|
|
25
|
+
deleted: [],
|
|
26
|
+
skipped: [{ projectId, sessionId: '', reason: 'path escapes ~/.claude' }],
|
|
27
|
+
historyLinesRemoved: 0,
|
|
28
|
+
projectDirRemoved: false,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
if (!fs.existsSync(projectDir)) {
|
|
32
|
+
return {
|
|
33
|
+
deleted: [],
|
|
34
|
+
skipped: [{ projectId, sessionId: '', reason: 'project directory does not exist' }],
|
|
35
|
+
historyLinesRemoved: 0,
|
|
36
|
+
projectDirRemoved: false,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const sessionIds: string[] = [];
|
|
41
|
+
for (const ent of fs.readdirSync(projectDir, { withFileTypes: true })) {
|
|
42
|
+
if (ent.isFile() && ent.name.endsWith(JSONL_EXT)) {
|
|
43
|
+
sessionIds.push(ent.name.slice(0, -JSONL_EXT.length));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// All-or-nothing precheck: refuse to touch any session if even one is live or
|
|
48
|
+
// recently active. Confirmed by the user — partial deletes leave the project
|
|
49
|
+
// half-cleared, which is more confusing than a clean "try again later".
|
|
50
|
+
const liveMap = buildActiveSessionMap();
|
|
51
|
+
const blockers: SkippedItem[] = [];
|
|
52
|
+
for (const sid of sessionIds) {
|
|
53
|
+
if (liveMap.has(sid)) {
|
|
54
|
+
blockers.push({
|
|
55
|
+
projectId,
|
|
56
|
+
sessionId: sid,
|
|
57
|
+
reason: `live PID ${liveMap.get(sid)} owns this session`,
|
|
58
|
+
});
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const jsonlPath = path.join(projectDir, `${sid}${JSONL_EXT}`);
|
|
62
|
+
try {
|
|
63
|
+
if (Date.now() - fs.statSync(jsonlPath).mtimeMs < RECENT_ACTIVITY_WINDOW_MS) {
|
|
64
|
+
blockers.push({
|
|
65
|
+
projectId,
|
|
66
|
+
sessionId: sid,
|
|
67
|
+
reason: 'jsonl modified within the last 5 minutes — could still be in use',
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
/* missing file is fine — deleteSessions will skip it */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (blockers.length > 0) {
|
|
76
|
+
return {
|
|
77
|
+
deleted: [],
|
|
78
|
+
skipped: blockers,
|
|
79
|
+
historyLinesRemoved: 0,
|
|
80
|
+
projectDirRemoved: false,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const result = await deleteSessions(
|
|
85
|
+
sessionIds.map((sessionId) => ({ projectId, sessionId })),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
let projectDirRemoved = false;
|
|
89
|
+
if (result.skipped.length === 0) {
|
|
90
|
+
try {
|
|
91
|
+
// Recursive remove also catches any orphan subdirs whose .jsonl was missing.
|
|
92
|
+
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
93
|
+
projectDirRemoved = true;
|
|
94
|
+
} catch {
|
|
95
|
+
/* leave dir for manual cleanup */
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { ...result, projectDirRemoved };
|
|
100
|
+
}
|