claude-code-session-manager 0.37.0 → 0.37.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/bin/cli.cjs +12 -1
- package/dist/assets/{TiptapBody-Cg8YZhVZ.js → TiptapBody-BtrSXTRp.js} +1 -1
- package/dist/assets/index-CD_LuJZF.css +32 -0
- package/dist/assets/{index-_iBXLuvt.js → index-uVGdpAGF.js} +498 -490
- package/dist/index.html +2 -2
- package/package.json +2 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +134 -36
- package/plugins/session-manager-dev/skills/develop/standards.md +24 -0
- package/plugins/session-manager-dev/skills/requesting-code-review/SKILL.md +20 -17
- package/scripts/lib/activeSessions.cjs +117 -0
- package/scripts/lib/watchdogHelpers.cjs +829 -0
- package/src/main/__tests__/classifyTranscriptLine.test.cjs +90 -0
- package/src/main/__tests__/docEdit.test.cjs +164 -2
- package/src/main/__tests__/prdCreate.test.cjs +265 -25
- package/src/main/__tests__/scheduler-admin-routes.test.cjs +150 -0
- package/src/main/__tests__/scheduler-boot-orphans.test.cjs +172 -0
- package/src/main/browserAgentServer.cjs +11 -10
- package/src/main/chatRunner.cjs +21 -2
- package/src/main/docEdit.cjs +125 -5
- package/src/main/health.cjs +15 -0
- package/src/main/index.cjs +12 -6
- package/src/main/ipcSchemas.cjs +14 -0
- package/src/main/lib/__tests__/localAdminHttp.test.cjs +120 -0
- package/src/main/lib/classifyTranscriptLine.cjs +89 -0
- package/src/main/lib/localAdminHttp.cjs +157 -0
- package/src/main/lib/personaImportHealth.cjs +161 -0
- package/src/main/lib/prdCreate.cjs +107 -17
- package/src/main/lib/rcaFeedbackHook.cjs +2 -2
- package/src/main/lib/singleInstanceGuard.cjs +20 -0
- package/src/main/scheduler.cjs +198 -54
- package/src/main/templates/PRD_AUTHORING.md +4 -4
- package/src/main/transcripts.cjs +1 -85
- package/src/preload/api.d.ts +12 -1
- package/src/preload/index.cjs +6 -0
- package/dist/assets/index-CTTjT08J.css +0 -32
- package/plugins/session-manager-dev/skills/security-review/SKILL.md +0 -105
- package/src/main/__tests__/adminServer.test.cjs +0 -380
- package/src/main/adminServer.cjs +0 -242
package/src/main/index.cjs
CHANGED
|
@@ -6,6 +6,7 @@ const fsp = require('node:fs/promises');
|
|
|
6
6
|
const os = require('node:os');
|
|
7
7
|
const { schemas, validated } = require('./ipcSchemas.cjs');
|
|
8
8
|
const { cleanChildEnv } = require('./lib/cleanEnv.cjs');
|
|
9
|
+
const { terminateLosingInstance } = require('./lib/singleInstanceGuard.cjs');
|
|
9
10
|
const { manager: ptyManager, registerPtyHandlers } = require('./pty.cjs');
|
|
10
11
|
const browserView = require('./browserView.cjs');
|
|
11
12
|
const browserCapture = require('./browserCapture.cjs');
|
|
@@ -24,8 +25,11 @@ crashDiagnostics.startCrashReporter();
|
|
|
24
25
|
const voiceHotkey = require('./voiceHotkey.cjs');
|
|
25
26
|
const voiceWizard = require('./voiceWizard.cjs');
|
|
26
27
|
const scheduler = require('./scheduler.cjs');
|
|
27
|
-
const {
|
|
28
|
-
const
|
|
28
|
+
const { createAdminHttp } = require('./lib/localAdminHttp.cjs');
|
|
29
|
+
const prdCreate = require('./lib/prdCreate.cjs');
|
|
30
|
+
const adminHttp = createAdminHttp();
|
|
31
|
+
scheduler.registerAdminRoutes(adminHttp);
|
|
32
|
+
prdCreate.registerAdminRoute(adminHttp, scheduler.remote);
|
|
29
33
|
const { createBrowserAgentServer } = require('./browserAgentServer.cjs');
|
|
30
34
|
const browserAgentServer = createBrowserAgentServer({
|
|
31
35
|
listTabs: () => browserView.listViews(),
|
|
@@ -51,7 +55,7 @@ const agentMemory = require('./agentMemory.cjs');
|
|
|
51
55
|
const git = require('./git.cjs');
|
|
52
56
|
const superagent = require('./superagent.cjs');
|
|
53
57
|
const filesIpc = require('./files.cjs');
|
|
54
|
-
const { registerDocEditHandlers } = require('./docEdit.cjs');
|
|
58
|
+
const { registerDocEditHandlers, attachWindow: attachDocEditWindow } = require('./docEdit.cjs');
|
|
55
59
|
const searchIpc = require('./search.cjs');
|
|
56
60
|
const repoAnalyzer = require('./repoAnalyzer.cjs');
|
|
57
61
|
const hivesIpc = require('./hives.cjs');
|
|
@@ -304,6 +308,7 @@ async function rebootApp() {
|
|
|
304
308
|
pluginInstall.attachWindow(mainWindow);
|
|
305
309
|
superagent.attachWindow(mainWindow);
|
|
306
310
|
chatRunner.attachWindow(mainWindow);
|
|
311
|
+
attachDocEditWindow(mainWindow);
|
|
307
312
|
rebooting = false;
|
|
308
313
|
return;
|
|
309
314
|
}
|
|
@@ -841,7 +846,7 @@ const isDev = process.env.SM_DEV === '1' || process.env.SM_E2E === '1';
|
|
|
841
846
|
if (!isDev) {
|
|
842
847
|
const gotLock = app.requestSingleInstanceLock();
|
|
843
848
|
if (!gotLock) {
|
|
844
|
-
app
|
|
849
|
+
terminateLosingInstance(app);
|
|
845
850
|
} else {
|
|
846
851
|
app.on('second-instance', () => {
|
|
847
852
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
@@ -1080,10 +1085,11 @@ app.whenReady().then(async () => {
|
|
|
1080
1085
|
superagent.attachWindow(mainWindow);
|
|
1081
1086
|
webRemote.attachWindow(mainWindow);
|
|
1082
1087
|
chatRunner.attachWindow(mainWindow);
|
|
1088
|
+
attachDocEditWindow(mainWindow);
|
|
1083
1089
|
scheduler.init().catch((e) => {
|
|
1084
1090
|
logs.writeLine({ scope: 'scheduler', level: 'error', message: 'init failed', meta: { error: e?.message } });
|
|
1085
1091
|
});
|
|
1086
|
-
|
|
1092
|
+
adminHttp.start().catch((e) => {
|
|
1087
1093
|
logs.writeLine({ scope: 'admin-server', level: 'error', message: 'init failed', meta: { error: e?.message } });
|
|
1088
1094
|
});
|
|
1089
1095
|
browserAgentServer.start().catch((e) => {
|
|
@@ -1194,7 +1200,7 @@ app.on('before-quit', () => {
|
|
|
1194
1200
|
configMgr.closeAllWatchers();
|
|
1195
1201
|
transcripts.closeAll();
|
|
1196
1202
|
watchers.manager.killAll();
|
|
1197
|
-
|
|
1203
|
+
adminHttp.stop().catch(() => {});
|
|
1198
1204
|
browserAgentServer.stop().catch(() => {});
|
|
1199
1205
|
// Best-effort flush of any pending OTEL spans. shutdown() has its own 2s
|
|
1200
1206
|
// ceiling so a wedged exporter can't hold quit.
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -420,6 +420,19 @@ const docEditRun = z.object({
|
|
|
420
420
|
path: z.string().min(1).max(4096),
|
|
421
421
|
before: z.string().min(1).max(8000),
|
|
422
422
|
instruction: z.string().min(1).max(2000),
|
|
423
|
+
// 60000 is the intended document-context budget; the extra headroom
|
|
424
|
+
// accommodates truncateDocumentText's head+tail+marker overhead (~60041
|
|
425
|
+
// chars worst case) so an already-truncated payload never gets rejected.
|
|
426
|
+
documentText: z.string().max(60100).optional(),
|
|
427
|
+
}).strict();
|
|
428
|
+
|
|
429
|
+
// docedit:run-in-session — consumed by docEdit.cjs's docEditViaSession (PRD 680: route a doc
|
|
430
|
+
// edit into an already-open, currently-idle chat session instead of an isolated claude -p).
|
|
431
|
+
const docEditRunInSession = docEditRun.omit({ path: true }).extend({
|
|
432
|
+
tabId: z.string().min(1).max(128),
|
|
433
|
+
sessionId: z.string().min(1).max(128),
|
|
434
|
+
cwd: z.string().min(1).max(4096),
|
|
435
|
+
requestId: z.string().min(1).max(128),
|
|
423
436
|
}).strict();
|
|
424
437
|
|
|
425
438
|
// ──────────────────────────────────────────── Chat runner (PRD 319)
|
|
@@ -742,6 +755,7 @@ module.exports = {
|
|
|
742
755
|
watchersKillTab,
|
|
743
756
|
filesDuplicate,
|
|
744
757
|
docEditRun,
|
|
758
|
+
docEditRunInSession,
|
|
745
759
|
chatRun,
|
|
746
760
|
chatCancel,
|
|
747
761
|
chatProbeContext,
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* localAdminHttp.test.cjs — unit tests for the generic loopback-only HTTP
|
|
3
|
+
* transport (PRD 689 — extracted from the former standalone admin HTTP server
|
|
4
|
+
* module). Covers
|
|
5
|
+
* only transport concerns: auth rejection, token persistence, route dispatch,
|
|
6
|
+
* and 404 on unknown routes. Route *logic* tests live beside their owning
|
|
7
|
+
* module (scheduler.cjs's admin routes, prdCreate.cjs's create-prd route).
|
|
8
|
+
*
|
|
9
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/localAdminHttp.test.cjs
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
import { test, expect } from 'vitest';
|
|
15
|
+
const http = require('node:http');
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const { createAdminHttp, TOKEN_PATH } = require('../localAdminHttp.cjs');
|
|
18
|
+
|
|
19
|
+
function request(port, { method = 'GET', path: reqPath, token, body }) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const headers = {};
|
|
22
|
+
if (token !== undefined) headers.Authorization = `Bearer ${token}`;
|
|
23
|
+
let payload;
|
|
24
|
+
if (body !== undefined) {
|
|
25
|
+
payload = JSON.stringify(body);
|
|
26
|
+
headers['Content-Type'] = 'application/json';
|
|
27
|
+
headers['Content-Length'] = Buffer.byteLength(payload);
|
|
28
|
+
}
|
|
29
|
+
const req = http.request({ hostname: '127.0.0.1', port, method, path: reqPath, headers }, (res) => {
|
|
30
|
+
const chunks = [];
|
|
31
|
+
res.on('data', (c) => chunks.push(c));
|
|
32
|
+
res.on('end', () => {
|
|
33
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
34
|
+
let json = null;
|
|
35
|
+
try { json = JSON.parse(text); } catch { /* leave null for non-JSON bodies */ }
|
|
36
|
+
resolve({ status: res.statusCode, json, text });
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
req.on('error', reject);
|
|
40
|
+
if (payload) req.write(payload);
|
|
41
|
+
req.end();
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test('request without bearer token returns 401', async () => {
|
|
46
|
+
const admin = createAdminHttp();
|
|
47
|
+
admin.registerRoute('GET', '/ping', async (req, res) => { res.writeHead(200); res.end('{}'); });
|
|
48
|
+
const { port } = await admin.start();
|
|
49
|
+
try {
|
|
50
|
+
const res = await request(port, { path: '/ping' });
|
|
51
|
+
expect(res.status).toBe(401);
|
|
52
|
+
expect(res.json.ok).toBe(false);
|
|
53
|
+
} finally {
|
|
54
|
+
await admin.stop();
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('request with wrong bearer token returns 401', async () => {
|
|
59
|
+
const admin = createAdminHttp();
|
|
60
|
+
admin.registerRoute('GET', '/ping', async (req, res) => { res.writeHead(200); res.end('{}'); });
|
|
61
|
+
const { port } = await admin.start();
|
|
62
|
+
try {
|
|
63
|
+
const res = await request(port, { path: '/ping', token: 'not-the-token' });
|
|
64
|
+
expect(res.status).toBe(401);
|
|
65
|
+
} finally {
|
|
66
|
+
await admin.stop();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('registered route with correct token is dispatched', async () => {
|
|
71
|
+
const admin = createAdminHttp();
|
|
72
|
+
admin.registerRoute('GET', '/ping', async (req, res) => {
|
|
73
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
74
|
+
res.end(JSON.stringify({ ok: true, pong: true }));
|
|
75
|
+
});
|
|
76
|
+
const { port, token } = await admin.start();
|
|
77
|
+
try {
|
|
78
|
+
const res = await request(port, { path: '/ping', token });
|
|
79
|
+
expect(res.status).toBe(200);
|
|
80
|
+
expect(res.json).toEqual({ ok: true, pong: true });
|
|
81
|
+
} finally {
|
|
82
|
+
await admin.stop();
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('unregistered path/method returns 404', async () => {
|
|
87
|
+
const admin = createAdminHttp();
|
|
88
|
+
const { port, token } = await admin.start();
|
|
89
|
+
try {
|
|
90
|
+
const res = await request(port, { path: '/nope', token });
|
|
91
|
+
expect(res.status).toBe(404);
|
|
92
|
+
} finally {
|
|
93
|
+
await admin.stop();
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('server binds only to 127.0.0.1', async () => {
|
|
98
|
+
const admin = createAdminHttp();
|
|
99
|
+
await admin.start();
|
|
100
|
+
try {
|
|
101
|
+
expect(admin.server.address().address).toBe('127.0.0.1');
|
|
102
|
+
} finally {
|
|
103
|
+
await admin.stop();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('start() persists token file with port + token, mode 0600', async () => {
|
|
108
|
+
const admin = createAdminHttp();
|
|
109
|
+
const { port, token } = await admin.start();
|
|
110
|
+
try {
|
|
111
|
+
const raw = fs.readFileSync(TOKEN_PATH, 'utf8');
|
|
112
|
+
const parsed = JSON.parse(raw);
|
|
113
|
+
expect(parsed.port).toBe(port);
|
|
114
|
+
expect(parsed.token).toBe(token);
|
|
115
|
+
const mode = fs.statSync(TOKEN_PATH).mode & 0o777;
|
|
116
|
+
expect(mode).toBe(0o600);
|
|
117
|
+
} finally {
|
|
118
|
+
await admin.stop();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const MAX_RAW_STR = 4096;
|
|
4
|
+
|
|
5
|
+
// Block types whose text/content fields are parsed structurally by
|
|
6
|
+
// orchestrator.ts / race.ts — truncating them produces mid-token "…" and
|
|
7
|
+
// unparseable JSON, so they are exempt from the size cap.
|
|
8
|
+
const EXEMPT_TYPES = new Set(['tool_result', 'tool_use']);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Cap string fields in a content block array so arbitrary tool output doesn't
|
|
12
|
+
* bloat the ring buffer. Blocks whose type is in EXEMPT_TYPES are passed
|
|
13
|
+
* through intact so that structured result payloads survive to the digest
|
|
14
|
+
* parsers in race.ts / orchestrator.ts.
|
|
15
|
+
*/
|
|
16
|
+
function trimContentArray(content) {
|
|
17
|
+
if (!Array.isArray(content)) return content;
|
|
18
|
+
return content.map((block) => {
|
|
19
|
+
if (!block || typeof block !== 'object') return block;
|
|
20
|
+
if (EXEMPT_TYPES.has(block.type)) return block;
|
|
21
|
+
const b = { ...block };
|
|
22
|
+
if (typeof b.text === 'string' && b.text.length > MAX_RAW_STR) {
|
|
23
|
+
b.text = b.text.slice(0, MAX_RAW_STR) + '…';
|
|
24
|
+
}
|
|
25
|
+
if (typeof b.content === 'string' && b.content.length > MAX_RAW_STR) {
|
|
26
|
+
b.content = b.content.slice(0, MAX_RAW_STR) + '…';
|
|
27
|
+
}
|
|
28
|
+
if (Array.isArray(b.content)) {
|
|
29
|
+
b.content = trimContentArray(b.content);
|
|
30
|
+
}
|
|
31
|
+
return b;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Build the slim raw projection used by race.ts and orchestrator.ts. */
|
|
36
|
+
function makeRaw(obj) {
|
|
37
|
+
const msgContent = obj?.message?.content;
|
|
38
|
+
return { message: { content: trimContentArray(msgContent) } };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parse one JSONL line defensively. Real schema drifts, so we pass through
|
|
43
|
+
* anything that parses and tag a coarse `kind`.
|
|
44
|
+
*/
|
|
45
|
+
function classifyLine(obj) {
|
|
46
|
+
if (!obj || typeof obj !== 'object') return null;
|
|
47
|
+
// Many shapes exist — try several common fields.
|
|
48
|
+
const type = obj.type || obj.event || obj.role;
|
|
49
|
+
const msg = obj.message || obj;
|
|
50
|
+
const content = msg?.content;
|
|
51
|
+
|
|
52
|
+
// Usage rollups arrive as summary events.
|
|
53
|
+
if (obj.usage || msg?.usage) {
|
|
54
|
+
return { kind: 'usage', data: obj.usage || msg.usage, raw: makeRaw(obj) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Tool uses: scan content array for tool_use blocks.
|
|
58
|
+
if (Array.isArray(content)) {
|
|
59
|
+
for (const block of content) {
|
|
60
|
+
if (block?.type === 'tool_use') {
|
|
61
|
+
if (block.name === 'TodoWrite') {
|
|
62
|
+
return { kind: 'todo_write', data: block.input?.todos || block.input || [], raw: makeRaw(obj) };
|
|
63
|
+
}
|
|
64
|
+
if (block.name === 'ExitPlanMode' || block.name === 'EnterPlanMode') {
|
|
65
|
+
return { kind: 'plan', data: block.input, raw: makeRaw(obj) };
|
|
66
|
+
}
|
|
67
|
+
if (block.name === 'Agent' || block.name === 'Task') {
|
|
68
|
+
// Include block.id as toolUseId so the live store can match the
|
|
69
|
+
// corresponding tool_result and update per-agent lastActivityAt.
|
|
70
|
+
return { kind: 'agent_spawn', data: { ...block.input, toolUseId: block.id }, raw: makeRaw(obj) };
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
kind: 'tool_use',
|
|
74
|
+
data: { name: block.name, input: block.input, id: block.id },
|
|
75
|
+
raw: makeRaw(obj),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
// tool_result carries the tool_use_id of the completed Task/Agent call.
|
|
79
|
+
// The live store uses this to update the agent's lastActivityAt bookend.
|
|
80
|
+
if (block?.type === 'tool_result' && block.tool_use_id) {
|
|
81
|
+
return { kind: 'tool_result', data: { toolUseId: block.tool_use_id }, raw: makeRaw(obj) };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { kind: type || 'message', data: obj, raw: makeRaw(obj) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { MAX_RAW_STR, EXEMPT_TYPES, trimContentArray, makeRaw, classifyLine };
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* localAdminHttp.cjs — generic loopback-only HTTP transport with bearer-token
|
|
3
|
+
* auth, extracted from the former standalone admin HTTP server module
|
|
4
|
+
* (PRD 689). This module owns
|
|
5
|
+
* ONLY the transport: token bootstrap/persistence, timing-safe comparison,
|
|
6
|
+
* body reading, JSON responses, and a route-registration/dispatch mechanism.
|
|
7
|
+
* Route *logic* (job-management, PRD creation) lives beside the module that
|
|
8
|
+
* already owns that capability — see scheduler.cjs's registerAdminRoutes and
|
|
9
|
+
* prdCreate.cjs's registerAdminRoute.
|
|
10
|
+
*
|
|
11
|
+
* Security posture (unchanged from the former module):
|
|
12
|
+
* - Binds 127.0.0.1 only, OS-assigned ephemeral port. Never reachable
|
|
13
|
+
* off-box.
|
|
14
|
+
* - Bearer token, regenerated every app boot, written to
|
|
15
|
+
* ~/.claude/session-manager/admin-api.json with 0600 perms.
|
|
16
|
+
* - Token compared with crypto.timingSafeEqual to avoid a timing
|
|
17
|
+
* side-channel on the comparison itself.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const http = require('node:http');
|
|
23
|
+
const os = require('node:os');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
const crypto = require('node:crypto');
|
|
26
|
+
const fsp = require('node:fs/promises');
|
|
27
|
+
const config = require('../config.cjs');
|
|
28
|
+
|
|
29
|
+
const TOKEN_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'admin-api.json');
|
|
30
|
+
|
|
31
|
+
function timingSafeEqualStrings(a, b) {
|
|
32
|
+
const bufA = Buffer.from(String(a ?? ''));
|
|
33
|
+
const bufB = Buffer.from(String(b ?? ''));
|
|
34
|
+
if (bufA.length !== bufB.length) {
|
|
35
|
+
// Compare against a same-length buffer anyway so the failure path still
|
|
36
|
+
// takes constant time relative to the (fixed) token length, rather than
|
|
37
|
+
// returning early on a length mismatch.
|
|
38
|
+
crypto.timingSafeEqual(bufA, bufA);
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readBody(req, maxBytes = 1024 * 1024) {
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
let total = 0;
|
|
47
|
+
const chunks = [];
|
|
48
|
+
req.on('data', (chunk) => {
|
|
49
|
+
total += chunk.length;
|
|
50
|
+
if (total > maxBytes) {
|
|
51
|
+
reject(new Error('body too large'));
|
|
52
|
+
req.destroy();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
chunks.push(chunk);
|
|
56
|
+
});
|
|
57
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
58
|
+
req.on('error', reject);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sendJson(res, status, obj) {
|
|
63
|
+
const body = JSON.stringify(obj);
|
|
64
|
+
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) });
|
|
65
|
+
res.end(body);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Create the admin HTTP transport. Route handlers are registered via
|
|
70
|
+
* `registerRoute(method, url, handler)`, where `handler` is
|
|
71
|
+
* `async (req, res, rawBody) => void` — raw body reading is the transport's
|
|
72
|
+
* job (routes call `JSON.parse` themselves as needed, matching the original
|
|
73
|
+
* per-route body handling in the former module).
|
|
74
|
+
*/
|
|
75
|
+
function createAdminHttp() {
|
|
76
|
+
let server = null;
|
|
77
|
+
let token = null;
|
|
78
|
+
const routes = new Map();
|
|
79
|
+
|
|
80
|
+
async function ensureToken() {
|
|
81
|
+
token = crypto.randomBytes(32).toString('hex');
|
|
82
|
+
await config.writeJson(TOKEN_PATH, { port: null, token });
|
|
83
|
+
try { await fsp.chmod(TOKEN_PATH, 0o600); } catch { /* best-effort on platforms without POSIX perms */ }
|
|
84
|
+
return token;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function persistPort(port) {
|
|
88
|
+
await config.writeJson(TOKEN_PATH, { port, token });
|
|
89
|
+
try { await fsp.chmod(TOKEN_PATH, 0o600); } catch { /* */ }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function authorized(req) {
|
|
93
|
+
const header = req.headers.authorization || '';
|
|
94
|
+
const match = /^Bearer (.+)$/.exec(header);
|
|
95
|
+
if (!match) return false;
|
|
96
|
+
return timingSafeEqualStrings(match[1], token);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function registerRoute(method, url, handler) {
|
|
100
|
+
routes.set(`${method} ${url}`, handler);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function handleRequest(req, res) {
|
|
104
|
+
if (!authorized(req)) {
|
|
105
|
+
sendJson(res, 401, { ok: false, error: 'unauthorized' });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const handler = routes.get(`${req.method} ${req.url}`);
|
|
110
|
+
if (!handler) {
|
|
111
|
+
sendJson(res, 404, { ok: false, error: 'not found' });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
await handler(req, res);
|
|
115
|
+
} catch (e) {
|
|
116
|
+
sendJson(res, 500, { ok: false, error: e?.message ?? 'internal error' });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function start() {
|
|
121
|
+
await ensureToken();
|
|
122
|
+
server = http.createServer((req, res) => {
|
|
123
|
+
handleRequest(req, res).catch(() => {
|
|
124
|
+
try { sendJson(res, 500, { ok: false, error: 'internal error' }); } catch { /* */ }
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
await new Promise((resolve, reject) => {
|
|
128
|
+
server.once('error', reject);
|
|
129
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
130
|
+
});
|
|
131
|
+
const { port } = server.address();
|
|
132
|
+
await persistPort(port);
|
|
133
|
+
return { port, token };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function stop() {
|
|
137
|
+
if (!server) return;
|
|
138
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
139
|
+
server = null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
start,
|
|
144
|
+
stop,
|
|
145
|
+
registerRoute,
|
|
146
|
+
get token() { return token; },
|
|
147
|
+
get server() { return server; },
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = {
|
|
152
|
+
createAdminHttp,
|
|
153
|
+
TOKEN_PATH,
|
|
154
|
+
timingSafeEqualStrings,
|
|
155
|
+
readBody,
|
|
156
|
+
sendJson,
|
|
157
|
+
};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* personaImportHealth.cjs — integrity check for Claude Code's `@path` import
|
|
5
|
+
* syntax in ~/.claude/CLAUDE.md.
|
|
6
|
+
*
|
|
7
|
+
* The `@path` import resolves a missing/moved target to nothing with zero
|
|
8
|
+
* warning — a renamed or deleted persona file silently drops that entire
|
|
9
|
+
* block of instructions from every session. This module parses the import
|
|
10
|
+
* chain (recursively, since imported files can themselves import further
|
|
11
|
+
* files) and asserts every resolved target exists and is non-empty, plus
|
|
12
|
+
* reports git ahead/behind for each unique repo the imports live in.
|
|
13
|
+
*
|
|
14
|
+
* Pure functions taking explicit inputs (a root file path), following the
|
|
15
|
+
* evaluateTickLiveness/readFreshHeartbeat pattern in health.cjs so this is
|
|
16
|
+
* independently unit-testable without touching the real home directory.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const os = require('node:os');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
const { execFileSync } = require('node:child_process');
|
|
23
|
+
|
|
24
|
+
const MAX_IMPORT_DEPTH = 5;
|
|
25
|
+
// Matches a line consisting of `@` followed by an absolute or ~-relative path,
|
|
26
|
+
// e.g. "@/home/user/foo.md" or "@~/Projects/Agents/shared/core.md".
|
|
27
|
+
const IMPORT_LINE_RE = /^@(~?\/\S+)\s*$/;
|
|
28
|
+
|
|
29
|
+
function resolveImportPath(rawPath) {
|
|
30
|
+
if (rawPath.startsWith('~/')) return path.join(os.homedir(), rawPath.slice(2));
|
|
31
|
+
if (rawPath === '~') return os.homedir();
|
|
32
|
+
return rawPath;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Extracts every `@<path>` import reference from a file's raw text content.
|
|
36
|
+
function extractImportPaths(content) {
|
|
37
|
+
const paths = [];
|
|
38
|
+
for (const line of content.split('\n')) {
|
|
39
|
+
const match = IMPORT_LINE_RE.exec(line.trim());
|
|
40
|
+
if (match) paths.push(resolveImportPath(match[1]));
|
|
41
|
+
}
|
|
42
|
+
return paths;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Recursively walks the import chain starting at rootPath, collecting a
|
|
46
|
+
// result per resolved import: { importPath, exists, nonEmpty, ok }.
|
|
47
|
+
// Depth-capped to guard against a cyclic-import pathological case. Visited
|
|
48
|
+
// paths are deduped so a diamond-shaped import graph doesn't get reported
|
|
49
|
+
// twice or blow the depth budget.
|
|
50
|
+
function walkImports(rootPath, depth = MAX_IMPORT_DEPTH, seen = new Set()) {
|
|
51
|
+
const results = [];
|
|
52
|
+
if (depth <= 0) return results;
|
|
53
|
+
if (!fs.existsSync(rootPath)) return results;
|
|
54
|
+
|
|
55
|
+
let content;
|
|
56
|
+
try {
|
|
57
|
+
content = fs.readFileSync(rootPath, 'utf8');
|
|
58
|
+
} catch {
|
|
59
|
+
return results;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const importPath of extractImportPaths(content)) {
|
|
63
|
+
if (seen.has(importPath)) continue;
|
|
64
|
+
seen.add(importPath);
|
|
65
|
+
|
|
66
|
+
let exists = false;
|
|
67
|
+
let nonEmpty = false;
|
|
68
|
+
try {
|
|
69
|
+
const stat = fs.statSync(importPath);
|
|
70
|
+
exists = true;
|
|
71
|
+
nonEmpty = stat.size > 0;
|
|
72
|
+
} catch {
|
|
73
|
+
exists = false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
results.push({ importPath, exists, nonEmpty, ok: exists && nonEmpty });
|
|
77
|
+
|
|
78
|
+
if (exists && nonEmpty) {
|
|
79
|
+
results.push(...walkImports(importPath, depth - 1, seen));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return results;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Walks upward from startDir looking for a `.git` directory, bounded at
|
|
87
|
+
// os.homedir() or the filesystem root.
|
|
88
|
+
function findGitRepoRoot(startDir) {
|
|
89
|
+
const home = os.homedir();
|
|
90
|
+
let dir = startDir;
|
|
91
|
+
for (;;) {
|
|
92
|
+
if (fs.existsSync(path.join(dir, '.git'))) return dir;
|
|
93
|
+
if (dir === home) return null;
|
|
94
|
+
const parent = path.dirname(dir);
|
|
95
|
+
if (parent === dir) return null;
|
|
96
|
+
dir = parent;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Reports ahead/behind vs upstream for a git repo, without ever fetching
|
|
101
|
+
// (rev-list reports against whatever the local remote-tracking ref already
|
|
102
|
+
// has cached). Non-fatal on any failure — no upstream configured, detached
|
|
103
|
+
// HEAD, git not installed — annotated as a caveat, never thrown.
|
|
104
|
+
function checkRepoAheadBehind(repoRoot) {
|
|
105
|
+
try {
|
|
106
|
+
const out = execFileSync(
|
|
107
|
+
'git',
|
|
108
|
+
['-C', repoRoot, 'rev-list', '--left-right', '--count', 'HEAD...@{upstream}'],
|
|
109
|
+
{ encoding: 'utf8', timeout: 5000 }
|
|
110
|
+
).trim();
|
|
111
|
+
const [ahead, behind] = out.split(/\s+/).map(Number);
|
|
112
|
+
return { repoRoot, ok: true, ahead, behind };
|
|
113
|
+
} catch (e) {
|
|
114
|
+
return {
|
|
115
|
+
repoRoot,
|
|
116
|
+
ok: false,
|
|
117
|
+
noUpstream: true,
|
|
118
|
+
error: (e.message || String(e)).split('\n')[0],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Top-level entry point: parses ~/.claude/CLAUDE.md's import chain, checks
|
|
124
|
+
// every resolved target, and reports ahead/behind for each unique repo those
|
|
125
|
+
// imports live in. claudeMdPath is injectable for testing.
|
|
126
|
+
function checkPersonaImports(claudeMdPath = path.join(os.homedir(), '.claude', 'CLAUDE.md')) {
|
|
127
|
+
if (!fs.existsSync(claudeMdPath)) {
|
|
128
|
+
return { ok: true, path: claudeMdPath, exists: false, imports: [], repos: [] };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const imports = walkImports(claudeMdPath);
|
|
132
|
+
const broken = imports.filter((i) => !i.ok);
|
|
133
|
+
|
|
134
|
+
const repoRoots = new Set();
|
|
135
|
+
for (const imp of imports) {
|
|
136
|
+
if (!imp.ok) continue;
|
|
137
|
+
const repoRoot = findGitRepoRoot(path.dirname(imp.importPath));
|
|
138
|
+
if (repoRoot) repoRoots.add(repoRoot);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const repos = [...repoRoots].map(checkRepoAheadBehind);
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
ok: broken.length === 0,
|
|
145
|
+
path: claudeMdPath,
|
|
146
|
+
exists: true,
|
|
147
|
+
imports,
|
|
148
|
+
brokenImports: broken,
|
|
149
|
+
repos,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = {
|
|
154
|
+
checkPersonaImports,
|
|
155
|
+
walkImports,
|
|
156
|
+
extractImportPaths,
|
|
157
|
+
resolveImportPath,
|
|
158
|
+
findGitRepoRoot,
|
|
159
|
+
checkRepoAheadBehind,
|
|
160
|
+
MAX_IMPORT_DEPTH,
|
|
161
|
+
};
|