@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
|
@@ -1,104 +1,104 @@
|
|
|
1
|
-
import { Hono } from 'hono';
|
|
2
|
-
import { deleteProject } from '../lib/delete-project.ts';
|
|
3
|
-
import { ExportError, exportBundle } from '../lib/export-bundle.ts';
|
|
4
|
-
import { loadProjectMemory } from '../lib/load-memory.ts';
|
|
5
|
-
import { openFolder } from '../lib/open-folder.ts';
|
|
6
|
-
import { isSafeId } from '../lib/safe-id.ts';
|
|
7
|
-
import { listProjects, listSessionsForProject, resolveProjectCwd } from '../lib/scan.ts';
|
|
8
|
-
|
|
9
|
-
export const projectsRoute = new Hono();
|
|
10
|
-
|
|
11
|
-
projectsRoute.get('/', async (c) => {
|
|
12
|
-
const projects = await listProjects();
|
|
13
|
-
return c.json(projects);
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
projectsRoute.get('/:id/sessions', async (c) => {
|
|
17
|
-
const id = c.req.param('id');
|
|
18
|
-
if (!isSafeId(id)) return c.json({ error: 'invalid project id' }, 400);
|
|
19
|
-
const sessions = await listSessionsForProject(id);
|
|
20
|
-
return c.json(sessions);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
projectsRoute.get('/:id/memory', async (c) => {
|
|
24
|
-
const id = c.req.param('id');
|
|
25
|
-
if (!isSafeId(id)) return c.json({ error: 'invalid project id' }, 400);
|
|
26
|
-
const memory = await loadProjectMemory(id);
|
|
27
|
-
return c.json(memory);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
projectsRoute.post('/:id/reveal', async (c) => {
|
|
31
|
-
if (!isAcceptableOrigin(c.req.header('origin'))) {
|
|
32
|
-
return c.json({ error: 'origin not allowed' }, 403);
|
|
33
|
-
}
|
|
34
|
-
const id = c.req.param('id');
|
|
35
|
-
if (!isSafeId(id)) return c.json({ error: 'invalid project id' }, 400);
|
|
36
|
-
|
|
37
|
-
const cwd = await resolveProjectCwd(id);
|
|
38
|
-
if (!cwd) return c.json({ error: 'project not found' }, 404);
|
|
39
|
-
if (!cwd.resolved) return c.json({ error: 'directory missing on disk' }, 404);
|
|
40
|
-
|
|
41
|
-
const result = openFolder(cwd.decoded);
|
|
42
|
-
if (!result.ok) return c.json({ error: result.error ?? 'failed to open folder' }, 500);
|
|
43
|
-
|
|
44
|
-
return c.json({ ok: true, path: cwd.decoded });
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
projectsRoute.post('/:id/export', async (c) => {
|
|
48
|
-
if (!isAcceptableOrigin(c.req.header('origin'))) {
|
|
49
|
-
return c.json({ error: 'origin not allowed' }, 403);
|
|
50
|
-
}
|
|
51
|
-
const id = c.req.param('id');
|
|
52
|
-
if (!isSafeId(id)) return c.json({ error: 'invalid project id' }, 400);
|
|
53
|
-
|
|
54
|
-
let body: { sessionIds?: unknown; destDir?: unknown };
|
|
55
|
-
try {
|
|
56
|
-
body = await c.req.json();
|
|
57
|
-
} catch {
|
|
58
|
-
return c.json({ error: 'invalid JSON body' }, 400);
|
|
59
|
-
}
|
|
60
|
-
if (typeof body.destDir !== 'string' || body.destDir.trim() === '') {
|
|
61
|
-
return c.json({ error: 'destDir is required' }, 400);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
let sessionIds: string[] | 'all';
|
|
65
|
-
if (body.sessionIds === undefined || body.sessionIds === 'all') {
|
|
66
|
-
sessionIds = 'all';
|
|
67
|
-
} else if (
|
|
68
|
-
Array.isArray(body.sessionIds) &&
|
|
69
|
-
body.sessionIds.every((s) => typeof s === 'string')
|
|
70
|
-
) {
|
|
71
|
-
sessionIds = body.sessionIds as string[];
|
|
72
|
-
} else {
|
|
73
|
-
return c.json({ error: 'sessionIds must be an array of strings or "all"' }, 400);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
try {
|
|
77
|
-
const result = await exportBundle(id, sessionIds, body.destDir);
|
|
78
|
-
return c.json(result);
|
|
79
|
-
} catch (err) {
|
|
80
|
-
if (err instanceof ExportError) return c.json({ error: err.message }, 400);
|
|
81
|
-
throw err;
|
|
82
|
-
}
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
projectsRoute.delete('/:id', async (c) => {
|
|
86
|
-
if (!isAcceptableOrigin(c.req.header('origin'))) {
|
|
87
|
-
return c.json({ error: 'origin not allowed' }, 403);
|
|
88
|
-
}
|
|
89
|
-
const id = c.req.param('id');
|
|
90
|
-
if (!isSafeId(id)) return c.json({ error: 'invalid project id' }, 400);
|
|
91
|
-
const result = await deleteProject(id);
|
|
92
|
-
return c.json(result);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
function isAcceptableOrigin(origin: string | undefined): boolean {
|
|
96
|
-
if (!origin) return false;
|
|
97
|
-
try {
|
|
98
|
-
const url = new URL(origin);
|
|
99
|
-
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
|
100
|
-
return url.hostname === 'localhost' || url.hostname === '127.0.0.1';
|
|
101
|
-
} catch {
|
|
102
|
-
return false;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { deleteProject } from '../lib/delete-project.ts';
|
|
3
|
+
import { ExportError, exportBundle } from '../lib/export-bundle.ts';
|
|
4
|
+
import { loadProjectMemory } from '../lib/load-memory.ts';
|
|
5
|
+
import { openFolder } from '../lib/open-folder.ts';
|
|
6
|
+
import { isSafeId } from '../lib/safe-id.ts';
|
|
7
|
+
import { listProjects, listSessionsForProject, resolveProjectCwd } from '../lib/scan.ts';
|
|
8
|
+
|
|
9
|
+
export const projectsRoute = new Hono();
|
|
10
|
+
|
|
11
|
+
projectsRoute.get('/', async (c) => {
|
|
12
|
+
const projects = await listProjects();
|
|
13
|
+
return c.json(projects);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
projectsRoute.get('/:id/sessions', async (c) => {
|
|
17
|
+
const id = c.req.param('id');
|
|
18
|
+
if (!isSafeId(id)) return c.json({ error: 'invalid project id' }, 400);
|
|
19
|
+
const sessions = await listSessionsForProject(id);
|
|
20
|
+
return c.json(sessions);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
projectsRoute.get('/:id/memory', async (c) => {
|
|
24
|
+
const id = c.req.param('id');
|
|
25
|
+
if (!isSafeId(id)) return c.json({ error: 'invalid project id' }, 400);
|
|
26
|
+
const memory = await loadProjectMemory(id);
|
|
27
|
+
return c.json(memory);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
projectsRoute.post('/:id/reveal', async (c) => {
|
|
31
|
+
if (!isAcceptableOrigin(c.req.header('origin'))) {
|
|
32
|
+
return c.json({ error: 'origin not allowed' }, 403);
|
|
33
|
+
}
|
|
34
|
+
const id = c.req.param('id');
|
|
35
|
+
if (!isSafeId(id)) return c.json({ error: 'invalid project id' }, 400);
|
|
36
|
+
|
|
37
|
+
const cwd = await resolveProjectCwd(id);
|
|
38
|
+
if (!cwd) return c.json({ error: 'project not found' }, 404);
|
|
39
|
+
if (!cwd.resolved) return c.json({ error: 'directory missing on disk' }, 404);
|
|
40
|
+
|
|
41
|
+
const result = openFolder(cwd.decoded);
|
|
42
|
+
if (!result.ok) return c.json({ error: result.error ?? 'failed to open folder' }, 500);
|
|
43
|
+
|
|
44
|
+
return c.json({ ok: true, path: cwd.decoded });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
projectsRoute.post('/:id/export', async (c) => {
|
|
48
|
+
if (!isAcceptableOrigin(c.req.header('origin'))) {
|
|
49
|
+
return c.json({ error: 'origin not allowed' }, 403);
|
|
50
|
+
}
|
|
51
|
+
const id = c.req.param('id');
|
|
52
|
+
if (!isSafeId(id)) return c.json({ error: 'invalid project id' }, 400);
|
|
53
|
+
|
|
54
|
+
let body: { sessionIds?: unknown; destDir?: unknown };
|
|
55
|
+
try {
|
|
56
|
+
body = await c.req.json();
|
|
57
|
+
} catch {
|
|
58
|
+
return c.json({ error: 'invalid JSON body' }, 400);
|
|
59
|
+
}
|
|
60
|
+
if (typeof body.destDir !== 'string' || body.destDir.trim() === '') {
|
|
61
|
+
return c.json({ error: 'destDir is required' }, 400);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let sessionIds: string[] | 'all';
|
|
65
|
+
if (body.sessionIds === undefined || body.sessionIds === 'all') {
|
|
66
|
+
sessionIds = 'all';
|
|
67
|
+
} else if (
|
|
68
|
+
Array.isArray(body.sessionIds) &&
|
|
69
|
+
body.sessionIds.every((s) => typeof s === 'string')
|
|
70
|
+
) {
|
|
71
|
+
sessionIds = body.sessionIds as string[];
|
|
72
|
+
} else {
|
|
73
|
+
return c.json({ error: 'sessionIds must be an array of strings or "all"' }, 400);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const result = await exportBundle(id, sessionIds, body.destDir);
|
|
78
|
+
return c.json(result);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
if (err instanceof ExportError) return c.json({ error: err.message }, 400);
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
projectsRoute.delete('/:id', async (c) => {
|
|
86
|
+
if (!isAcceptableOrigin(c.req.header('origin'))) {
|
|
87
|
+
return c.json({ error: 'origin not allowed' }, 403);
|
|
88
|
+
}
|
|
89
|
+
const id = c.req.param('id');
|
|
90
|
+
if (!isSafeId(id)) return c.json({ error: 'invalid project id' }, 400);
|
|
91
|
+
const result = await deleteProject(id);
|
|
92
|
+
return c.json(result);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
function isAcceptableOrigin(origin: string | undefined): boolean {
|
|
96
|
+
if (!origin) return false;
|
|
97
|
+
try {
|
|
98
|
+
const url = new URL(origin);
|
|
99
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
|
100
|
+
return url.hostname === 'localhost' || url.hostname === '127.0.0.1';
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
package/server/routes/search.ts
CHANGED
|
@@ -1,79 +1,79 @@
|
|
|
1
|
-
import { Hono } from 'hono';
|
|
2
|
-
import { stream } from 'hono/streaming';
|
|
3
|
-
import { searchAll, type SearchAllOpts } from '../lib/search-all.ts';
|
|
4
|
-
import type { SearchBlockKind } from '../types.ts';
|
|
5
|
-
|
|
6
|
-
export const searchRoute = new Hono();
|
|
7
|
-
|
|
8
|
-
const ALL_KINDS: ReadonlyArray<SearchBlockKind> = [
|
|
9
|
-
'text',
|
|
10
|
-
'tool_use',
|
|
11
|
-
'tool_result',
|
|
12
|
-
'thinking',
|
|
13
|
-
];
|
|
14
|
-
const DEFAULT_INCLUDE: ReadonlySet<SearchBlockKind> = new Set([
|
|
15
|
-
'text',
|
|
16
|
-
'tool_use',
|
|
17
|
-
'thinking',
|
|
18
|
-
]);
|
|
19
|
-
|
|
20
|
-
const Q_MIN = 2;
|
|
21
|
-
const Q_MAX = 200;
|
|
22
|
-
const PER_SESSION_MIN = 1;
|
|
23
|
-
const PER_SESSION_MAX = 20;
|
|
24
|
-
const PER_SESSION_DEFAULT = 5;
|
|
25
|
-
const MAX_SESSIONS_MIN = 1;
|
|
26
|
-
const MAX_SESSIONS_MAX = 200;
|
|
27
|
-
const MAX_SESSIONS_DEFAULT = 50;
|
|
28
|
-
|
|
29
|
-
searchRoute.get('/', async (c) => {
|
|
30
|
-
const q = c.req.query('q') ?? '';
|
|
31
|
-
if (q.length < Q_MIN) {
|
|
32
|
-
return c.json({ error: `q must be at least ${Q_MIN} characters` }, 400);
|
|
33
|
-
}
|
|
34
|
-
if (q.length > Q_MAX) {
|
|
35
|
-
return c.json({ error: `q exceeds max length ${Q_MAX}` }, 400);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const perSession = clampInt(c.req.query('perSession'), PER_SESSION_MIN, PER_SESSION_MAX, PER_SESSION_DEFAULT);
|
|
39
|
-
const maxSessions = clampInt(c.req.query('maxSessions'), MAX_SESSIONS_MIN, MAX_SESSIONS_MAX, MAX_SESSIONS_DEFAULT);
|
|
40
|
-
const include = parseInclude(c.req.query('include'));
|
|
41
|
-
|
|
42
|
-
const opts: SearchAllOpts = { query: q, include, perSession, maxSessions };
|
|
43
|
-
|
|
44
|
-
c.header('Content-Type', 'application/x-ndjson; charset=utf-8');
|
|
45
|
-
c.header('Cache-Control', 'no-store');
|
|
46
|
-
c.header('X-Accel-Buffering', 'no');
|
|
47
|
-
|
|
48
|
-
return stream(c, async (s) => {
|
|
49
|
-
let aborted = false;
|
|
50
|
-
s.onAbort(() => {
|
|
51
|
-
aborted = true;
|
|
52
|
-
});
|
|
53
|
-
for await (const event of searchAll(opts)) {
|
|
54
|
-
if (aborted) return;
|
|
55
|
-
await s.write(JSON.stringify(event) + '\n');
|
|
56
|
-
}
|
|
57
|
-
});
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
function clampInt(raw: string | undefined, min: number, max: number, fallback: number): number {
|
|
61
|
-
if (raw === undefined) return fallback;
|
|
62
|
-
const n = Number(raw);
|
|
63
|
-
if (!Number.isFinite(n) || !Number.isInteger(n)) return fallback;
|
|
64
|
-
if (n < min) return min;
|
|
65
|
-
if (n > max) return max;
|
|
66
|
-
return n;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function parseInclude(raw: string | undefined): ReadonlySet<SearchBlockKind> {
|
|
70
|
-
if (!raw) return DEFAULT_INCLUDE;
|
|
71
|
-
const parts = raw.split(',').map((s) => s.trim()).filter(Boolean);
|
|
72
|
-
const set = new Set<SearchBlockKind>();
|
|
73
|
-
for (const p of parts) {
|
|
74
|
-
if ((ALL_KINDS as readonly string[]).includes(p)) {
|
|
75
|
-
set.add(p as SearchBlockKind);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
return set.size > 0 ? set : DEFAULT_INCLUDE;
|
|
79
|
-
}
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { stream } from 'hono/streaming';
|
|
3
|
+
import { searchAll, type SearchAllOpts } from '../lib/search-all.ts';
|
|
4
|
+
import type { SearchBlockKind } from '../types.ts';
|
|
5
|
+
|
|
6
|
+
export const searchRoute = new Hono();
|
|
7
|
+
|
|
8
|
+
const ALL_KINDS: ReadonlyArray<SearchBlockKind> = [
|
|
9
|
+
'text',
|
|
10
|
+
'tool_use',
|
|
11
|
+
'tool_result',
|
|
12
|
+
'thinking',
|
|
13
|
+
];
|
|
14
|
+
const DEFAULT_INCLUDE: ReadonlySet<SearchBlockKind> = new Set([
|
|
15
|
+
'text',
|
|
16
|
+
'tool_use',
|
|
17
|
+
'thinking',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const Q_MIN = 2;
|
|
21
|
+
const Q_MAX = 200;
|
|
22
|
+
const PER_SESSION_MIN = 1;
|
|
23
|
+
const PER_SESSION_MAX = 20;
|
|
24
|
+
const PER_SESSION_DEFAULT = 5;
|
|
25
|
+
const MAX_SESSIONS_MIN = 1;
|
|
26
|
+
const MAX_SESSIONS_MAX = 200;
|
|
27
|
+
const MAX_SESSIONS_DEFAULT = 50;
|
|
28
|
+
|
|
29
|
+
searchRoute.get('/', async (c) => {
|
|
30
|
+
const q = c.req.query('q') ?? '';
|
|
31
|
+
if (q.length < Q_MIN) {
|
|
32
|
+
return c.json({ error: `q must be at least ${Q_MIN} characters` }, 400);
|
|
33
|
+
}
|
|
34
|
+
if (q.length > Q_MAX) {
|
|
35
|
+
return c.json({ error: `q exceeds max length ${Q_MAX}` }, 400);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const perSession = clampInt(c.req.query('perSession'), PER_SESSION_MIN, PER_SESSION_MAX, PER_SESSION_DEFAULT);
|
|
39
|
+
const maxSessions = clampInt(c.req.query('maxSessions'), MAX_SESSIONS_MIN, MAX_SESSIONS_MAX, MAX_SESSIONS_DEFAULT);
|
|
40
|
+
const include = parseInclude(c.req.query('include'));
|
|
41
|
+
|
|
42
|
+
const opts: SearchAllOpts = { query: q, include, perSession, maxSessions };
|
|
43
|
+
|
|
44
|
+
c.header('Content-Type', 'application/x-ndjson; charset=utf-8');
|
|
45
|
+
c.header('Cache-Control', 'no-store');
|
|
46
|
+
c.header('X-Accel-Buffering', 'no');
|
|
47
|
+
|
|
48
|
+
return stream(c, async (s) => {
|
|
49
|
+
let aborted = false;
|
|
50
|
+
s.onAbort(() => {
|
|
51
|
+
aborted = true;
|
|
52
|
+
});
|
|
53
|
+
for await (const event of searchAll(opts)) {
|
|
54
|
+
if (aborted) return;
|
|
55
|
+
await s.write(JSON.stringify(event) + '\n');
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
function clampInt(raw: string | undefined, min: number, max: number, fallback: number): number {
|
|
61
|
+
if (raw === undefined) return fallback;
|
|
62
|
+
const n = Number(raw);
|
|
63
|
+
if (!Number.isFinite(n) || !Number.isInteger(n)) return fallback;
|
|
64
|
+
if (n < min) return min;
|
|
65
|
+
if (n > max) return max;
|
|
66
|
+
return n;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseInclude(raw: string | undefined): ReadonlySet<SearchBlockKind> {
|
|
70
|
+
if (!raw) return DEFAULT_INCLUDE;
|
|
71
|
+
const parts = raw.split(',').map((s) => s.trim()).filter(Boolean);
|
|
72
|
+
const set = new Set<SearchBlockKind>();
|
|
73
|
+
for (const p of parts) {
|
|
74
|
+
if ((ALL_KINDS as readonly string[]).includes(p)) {
|
|
75
|
+
set.add(p as SearchBlockKind);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return set.size > 0 ? set : DEFAULT_INCLUDE;
|
|
79
|
+
}
|
|
@@ -1,81 +1,130 @@
|
|
|
1
|
-
import { Hono } from 'hono';
|
|
2
|
-
import { deleteSessions, type DeleteRequestItem } from '../lib/delete.ts';
|
|
3
|
-
import { loadSessionDetail } from '../lib/load-session.ts';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
sessionsRoute
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
if (!
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
} catch {
|
|
79
|
-
return
|
|
80
|
-
}
|
|
81
|
-
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { deleteSessions, type DeleteRequestItem } from '../lib/delete.ts';
|
|
3
|
+
import { loadSessionDetail } from '../lib/load-session.ts';
|
|
4
|
+
import { loadModifiedFiles } from '../lib/modified-files.ts';
|
|
5
|
+
import { openFile } from '../lib/open-folder.ts';
|
|
6
|
+
import { renameSession } from '../lib/rename-session.ts';
|
|
7
|
+
import { isSafeId } from '../lib/safe-id.ts';
|
|
8
|
+
|
|
9
|
+
export const sessionsRoute = new Hono();
|
|
10
|
+
|
|
11
|
+
// 注意:放在 /:projectId/:sessionId 之前——Hono trie 对静态后缀的具体路由优先匹配,
|
|
12
|
+
// 但显式按"更具体的路由先注册"是最稳的写法,避免日后引入其他通配段时被错位拦截。
|
|
13
|
+
sessionsRoute.get('/:projectId/:sessionId/modified-files', async (c) => {
|
|
14
|
+
const projectId = c.req.param('projectId');
|
|
15
|
+
const sessionId = c.req.param('sessionId');
|
|
16
|
+
if (!isSafeId(projectId) || !isSafeId(sessionId)) {
|
|
17
|
+
return c.json({ error: 'invalid id' }, 400);
|
|
18
|
+
}
|
|
19
|
+
const result = await loadModifiedFiles(projectId, sessionId);
|
|
20
|
+
if (!result) return c.json({ error: 'not found' }, 404);
|
|
21
|
+
return c.json(result);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
sessionsRoute.post('/:projectId/:sessionId/open-file', async (c) => {
|
|
25
|
+
if (!isAcceptableOrigin(c.req.header('origin'))) {
|
|
26
|
+
return c.json({ error: 'origin not allowed' }, 403);
|
|
27
|
+
}
|
|
28
|
+
const projectId = c.req.param('projectId');
|
|
29
|
+
const sessionId = c.req.param('sessionId');
|
|
30
|
+
if (!isSafeId(projectId) || !isSafeId(sessionId)) {
|
|
31
|
+
return c.json({ error: 'invalid id' }, 400);
|
|
32
|
+
}
|
|
33
|
+
let body: { filePath?: unknown };
|
|
34
|
+
try {
|
|
35
|
+
body = (await c.req.json()) as { filePath?: unknown };
|
|
36
|
+
} catch {
|
|
37
|
+
return c.json({ error: 'invalid json body' }, 400);
|
|
38
|
+
}
|
|
39
|
+
if (typeof body.filePath !== 'string' || body.filePath === '') {
|
|
40
|
+
return c.json({ error: 'filePath (string) required' }, 400);
|
|
41
|
+
}
|
|
42
|
+
// 只允许打开"本会话确实改过的文件"——从 jsonl 重新聚合校验成员资格,
|
|
43
|
+
// 杜绝客户端传任意路径来打开系统中任意文件。
|
|
44
|
+
const modified = await loadModifiedFiles(projectId, sessionId);
|
|
45
|
+
if (!modified) return c.json({ error: 'session not found' }, 404);
|
|
46
|
+
if (!modified.files.some((f) => f.filePath === body.filePath)) {
|
|
47
|
+
return c.json({ error: 'file is not part of this session' }, 400);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const result = openFile(body.filePath);
|
|
51
|
+
if (!result.ok) {
|
|
52
|
+
const status = result.error === 'path not found' ? 404 : 500;
|
|
53
|
+
return c.json({ error: result.error ?? 'failed to open file' }, status);
|
|
54
|
+
}
|
|
55
|
+
return c.json({ ok: true, path: body.filePath });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
sessionsRoute.get('/:projectId/:sessionId', async (c) => {
|
|
59
|
+
const projectId = c.req.param('projectId');
|
|
60
|
+
const sessionId = c.req.param('sessionId');
|
|
61
|
+
if (!isSafeId(projectId) || !isSafeId(sessionId)) {
|
|
62
|
+
return c.json({ error: 'invalid id' }, 400);
|
|
63
|
+
}
|
|
64
|
+
const detail = await loadSessionDetail(projectId, sessionId);
|
|
65
|
+
if (!detail) return c.json({ error: 'not found' }, 404);
|
|
66
|
+
return c.json(detail);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
sessionsRoute.patch('/:projectId/:sessionId', async (c) => {
|
|
70
|
+
if (!isAcceptableOrigin(c.req.header('origin'))) {
|
|
71
|
+
return c.json({ error: 'origin not allowed' }, 403);
|
|
72
|
+
}
|
|
73
|
+
const projectId = c.req.param('projectId');
|
|
74
|
+
const sessionId = c.req.param('sessionId');
|
|
75
|
+
let body: { customTitle?: unknown };
|
|
76
|
+
try {
|
|
77
|
+
body = (await c.req.json()) as { customTitle?: unknown };
|
|
78
|
+
} catch {
|
|
79
|
+
return c.json({ error: 'invalid json body' }, 400);
|
|
80
|
+
}
|
|
81
|
+
if (typeof body.customTitle !== 'string') {
|
|
82
|
+
return c.json({ error: 'customTitle (string) required' }, 400);
|
|
83
|
+
}
|
|
84
|
+
const result = renameSession(projectId, sessionId, body.customTitle);
|
|
85
|
+
if (!result.ok) {
|
|
86
|
+
const status = result.reason.startsWith('live PID') ? 409 : 400;
|
|
87
|
+
return c.json({ error: result.reason }, status);
|
|
88
|
+
}
|
|
89
|
+
return c.json({ customTitle: result.customTitle });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
sessionsRoute.delete('/', async (c) => {
|
|
93
|
+
if (!isAcceptableOrigin(c.req.header('origin'))) {
|
|
94
|
+
return c.json({ error: 'origin not allowed' }, 403);
|
|
95
|
+
}
|
|
96
|
+
let body: { items?: unknown };
|
|
97
|
+
try {
|
|
98
|
+
body = (await c.req.json()) as { items?: unknown };
|
|
99
|
+
} catch {
|
|
100
|
+
return c.json({ error: 'invalid json body' }, 400);
|
|
101
|
+
}
|
|
102
|
+
if (!Array.isArray(body.items) || body.items.length === 0) {
|
|
103
|
+
return c.json({ error: 'items[] required' }, 400);
|
|
104
|
+
}
|
|
105
|
+
const items: DeleteRequestItem[] = [];
|
|
106
|
+
for (const raw of body.items) {
|
|
107
|
+
if (
|
|
108
|
+
!raw ||
|
|
109
|
+
typeof raw !== 'object' ||
|
|
110
|
+
typeof (raw as { projectId?: unknown }).projectId !== 'string' ||
|
|
111
|
+
typeof (raw as { sessionId?: unknown }).sessionId !== 'string'
|
|
112
|
+
) {
|
|
113
|
+
return c.json({ error: 'each item needs projectId and sessionId strings' }, 400);
|
|
114
|
+
}
|
|
115
|
+
items.push(raw as DeleteRequestItem);
|
|
116
|
+
}
|
|
117
|
+
const result = await deleteSessions(items);
|
|
118
|
+
return c.json(result);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
function isAcceptableOrigin(origin: string | undefined): boolean {
|
|
122
|
+
if (!origin) return false;
|
|
123
|
+
try {
|
|
124
|
+
const url = new URL(origin);
|
|
125
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
|
126
|
+
return url.hostname === 'localhost' || url.hostname === '127.0.0.1';
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { runSelfUpdate } from '../lib/update.ts';
|
|
3
|
+
import { getVersionInfo } from '../lib/version.ts';
|
|
4
|
+
|
|
5
|
+
export const versionRoute = new Hono();
|
|
6
|
+
|
|
7
|
+
versionRoute.get('/', async (c) => {
|
|
8
|
+
const refresh = c.req.query('refresh') === '1';
|
|
9
|
+
const info = await getVersionInfo(refresh);
|
|
10
|
+
return c.json(info);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
versionRoute.post('/update', async (c) => {
|
|
14
|
+
if (!isAcceptableOrigin(c.req.header('origin'))) {
|
|
15
|
+
return c.json({ error: 'origin not allowed' }, 403);
|
|
16
|
+
}
|
|
17
|
+
const info = await getVersionInfo();
|
|
18
|
+
if (!info.hasUpdate) {
|
|
19
|
+
return c.json({ error: 'already up to date' }, 400);
|
|
20
|
+
}
|
|
21
|
+
const result = await runSelfUpdate(info.latest);
|
|
22
|
+
return c.json(result);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function isAcceptableOrigin(origin: string | undefined): boolean {
|
|
26
|
+
if (!origin) return false;
|
|
27
|
+
try {
|
|
28
|
+
const url = new URL(origin);
|
|
29
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
|
30
|
+
return url.hostname === 'localhost' || url.hostname === '127.0.0.1';
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
package/server/types.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export * from '../shared/types.ts';
|
|
1
|
+
export * from '../shared/types.ts';
|
package/shared/constants.ts
CHANGED
|
@@ -1,2 +1,7 @@
|
|
|
1
|
-
export const RECENT_ACTIVITY_WINDOW_MIN = 5;
|
|
2
|
-
export const MAX_SESSION_MESSAGES = 5000;
|
|
1
|
+
export const RECENT_ACTIVITY_WINDOW_MIN = 5;
|
|
2
|
+
export const MAX_SESSION_MESSAGES = 5000;
|
|
3
|
+
|
|
4
|
+
// Claude Code writes this synthetic `user` record when the operator aborts a turn
|
|
5
|
+
// (Esc / Ctrl-C). It means the turn was *stopped*, not that Claude is still
|
|
6
|
+
// working — so the "working" heuristic treats a trailing interrupt as idle.
|
|
7
|
+
export const INTERRUPTED_MARKER_RE = /^\s*\[Request interrupted by user/;
|