project-knowledge 0.1.0 → 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.
Files changed (34) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +184 -58
  3. package/_site/_test/ai-profile-test.js +59 -1
  4. package/_site/_test/baseline-schema-test.js +4 -3
  5. package/_site/_test/claude-workbench-test.js +72 -0
  6. package/_site/_test/draft-apply-test.js +12 -6
  7. package/_site/_test/kb-v2-templates-test.js +31 -43
  8. package/_site/_test/knowledge-store-logs-supervision-test.js +143 -0
  9. package/_site/_test/project-control-panel-task14-test.js +151 -0
  10. package/_site/_test/task15-20-integration-test.js +194 -0
  11. package/_site/_test/task15-20-ui-flow-test.js +144 -0
  12. package/_site/_test/ui-smoke-test.js +2 -2
  13. package/_site/index.html +1640 -90
  14. package/_site/lib/ai-adapter.js +3 -3
  15. package/_site/lib/ai-workspace.js +120 -0
  16. package/_site/lib/analysis-orchestrator.js +117 -32
  17. package/_site/lib/claude-cli-runner.js +862 -0
  18. package/_site/lib/context-pack-builder.js +19 -11
  19. package/_site/lib/draft-apply.js +80 -31
  20. package/_site/lib/index-builder.js +100 -0
  21. package/_site/lib/job-orchestrator.js +12 -9
  22. package/_site/lib/kb-v3.js +188 -0
  23. package/_site/lib/kb-validator.js +84 -0
  24. package/_site/lib/knowledge-store.js +141 -0
  25. package/_site/lib/llm-client.js +103 -56
  26. package/_site/lib/prompt-registry.js +102 -0
  27. package/_site/lib/structured-logger.js +120 -0
  28. package/_site/lib/supervision.js +103 -0
  29. package/_site/server.js +835 -19
  30. package/_site/vendor/tailwind-browser.js +947 -0
  31. package/_site/vendor/vue.global.prod.js +9 -0
  32. package/ai-profiles.json +12 -10
  33. package/docs/development-progress.md +141 -0
  34. package/package.json +7 -2
@@ -0,0 +1,862 @@
1
+ // Claude CLI Runner — spawns `claude` (Claude Code CLI) as a subprocess, streams its
2
+ // NDJSON output, and exposes a session-based subscribe/sendInput/abort API.
3
+ //
4
+ // Used by server.js to power the live Claude terminal in the dashboard.
5
+ //
6
+ // Key design:
7
+ // - One session per "kickoff" (initial analyze or follow-up). Each session has a
8
+ // server-generated sessionId; claude itself has a separate session_id captured from
9
+ // the system/init event and used for --resume on follow-ups.
10
+ // - Listeners are SSE response objects. When an event arrives, it's emitted to all
11
+ // current listeners AND appended to outputBuffer so late subscribers can replay.
12
+ // - NDJSON parsing uses a per-process line buffer — stdout chunks may split mid-line.
13
+ // - Windows: claude is installed as a .cmd shim by npm. spawn('claude', ...) without
14
+ // shell:true fails with ENOENT. We use shell:true + a single argv string built safely.
15
+
16
+ const { spawn } = require('child_process');
17
+ const crypto = require('crypto');
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const { renderPrompt } = require('./prompt-registry');
21
+ const aiWorkspace = require('./ai-workspace');
22
+
23
+ // ---- session store ----
24
+ // Map<sessionId, Session>
25
+ const sessions = new Map();
26
+ const KB_ROOT = path.resolve(__dirname, '..', '..');
27
+ const WORKBENCH_DIR = 'claude-workbench';
28
+
29
+ function newSessionId() {
30
+ return 'sess-' + crypto.randomBytes(6).toString('hex');
31
+ }
32
+
33
+ function createSession({ projectSlug, projectPath, kbPath, promptKey }) {
34
+ const sessionId = newSessionId();
35
+ const session = {
36
+ sessionId,
37
+ projectSlug,
38
+ projectPath,
39
+ kbPath: kbPath || path.join(KB_ROOT, 'projects', projectSlug),
40
+ promptKey,
41
+ state: 'idle',
42
+ model: null,
43
+ claudeSessionId: null,
44
+ startedAt: new Date().toISOString(),
45
+ endedAt: null,
46
+ exitCode: null,
47
+ listeners: new Set(),
48
+ outputBuffer: [],
49
+ subprocess: null,
50
+ turns: 0,
51
+ error: null,
52
+ claudeEnv: {},
53
+ pendingPermission: null,
54
+ pendingTurn: null,
55
+ pendingToolApproval: null,
56
+ restored: false,
57
+ };
58
+ sessions.set(sessionId, session);
59
+ persistSession(session);
60
+ return session;
61
+ }
62
+
63
+ function sessionRecordPath(session) {
64
+ if (!session || !session.projectSlug) return null;
65
+ return path.join(aiWorkspace.ensureProjectAIPath(session.projectSlug), WORKBENCH_DIR, `${session.sessionId}.json`);
66
+ }
67
+
68
+ function toPersistedSession(session) {
69
+ return {
70
+ schema: 'claude-workbench-session/v1',
71
+ sessionId: session.sessionId,
72
+ projectSlug: session.projectSlug,
73
+ projectPath: session.projectPath,
74
+ kbPath: session.kbPath,
75
+ promptKey: session.promptKey,
76
+ runner: session.runner || 'cli',
77
+ state: session.state,
78
+ model: session.model,
79
+ aiProfileId: session.aiProfileId || null,
80
+ claudeSessionId: session.claudeSessionId,
81
+ startedAt: session.startedAt,
82
+ endedAt: session.endedAt,
83
+ exitCode: session.exitCode,
84
+ turns: session.turns,
85
+ error: session.error,
86
+ pendingPermission: session.pendingPermission,
87
+ events: session.outputBuffer.slice(-5000),
88
+ updatedAt: new Date().toISOString(),
89
+ };
90
+ }
91
+
92
+ function persistSession(session) {
93
+ const file = sessionRecordPath(session);
94
+ if (!file) return;
95
+ try {
96
+ fs.mkdirSync(path.dirname(file), { recursive: true });
97
+ fs.writeFileSync(file, JSON.stringify(toPersistedSession(session), null, 2) + '\n', 'utf-8');
98
+ } catch (e) {
99
+ // Persistence should never interrupt the live Claude process.
100
+ }
101
+ }
102
+
103
+ function readPersistedRecord(file) {
104
+ try {
105
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
106
+ if (parsed && parsed.schema === 'claude-workbench-session/v1' && parsed.sessionId) return parsed;
107
+ } catch {}
108
+ return null;
109
+ }
110
+
111
+ function scanPersistedRecords(projectSlug = null) {
112
+ let projectDirs = [];
113
+ try {
114
+ const projects = JSON.parse(fs.readFileSync(path.join(KB_ROOT, 'projects.json'), 'utf-8'));
115
+ projectDirs = Object.entries(projects || {})
116
+ .filter(([slug]) => !projectSlug || slug === projectSlug)
117
+ .map(([slug, cfg]) => ({ name: slug, kbPath: cfg.kbPath || path.join(KB_ROOT, 'projects', slug) }));
118
+ } catch {
119
+ const projectsRoot = path.join(KB_ROOT, 'projects');
120
+ try {
121
+ projectDirs = fs.readdirSync(projectsRoot, { withFileTypes: true })
122
+ .filter(entry => entry.isDirectory())
123
+ .filter(entry => !projectSlug || entry.name === projectSlug)
124
+ .map(entry => ({ name: entry.name, kbPath: path.join(projectsRoot, entry.name) }));
125
+ } catch { return []; }
126
+ }
127
+ const records = [];
128
+ for (const entry of projectDirs) {
129
+ const dirs = [
130
+ path.join(aiWorkspace.projectAIPath(entry.name), WORKBENCH_DIR),
131
+ path.join(entry.kbPath, '_ai', WORKBENCH_DIR),
132
+ ];
133
+ for (const dir of dirs) {
134
+ let files = [];
135
+ try { files = fs.readdirSync(dir); } catch { continue; }
136
+ for (const file of files) {
137
+ if (!file.endsWith('.json')) continue;
138
+ const record = readPersistedRecord(path.join(dir, file));
139
+ if (record) records.push(record);
140
+ }
141
+ }
142
+ }
143
+ return records.sort((a, b) => String(b.updatedAt || b.startedAt || '').localeCompare(String(a.updatedAt || a.startedAt || '')));
144
+ }
145
+
146
+ function findPersistedRecord(sessionId) {
147
+ return scanPersistedRecords().find(record => record.sessionId === sessionId) || null;
148
+ }
149
+
150
+ function restoreSessionFromDisk(sessionId) {
151
+ if (sessions.has(sessionId)) return sessions.get(sessionId);
152
+ const record = findPersistedRecord(sessionId);
153
+ if (!record) return null;
154
+ const liveState = ['running', 'spawning', 'pending-permission'].includes(record.state) ? 'idle' : (record.state || 'idle');
155
+ const session = {
156
+ sessionId: record.sessionId,
157
+ projectSlug: record.projectSlug,
158
+ projectPath: record.projectPath,
159
+ kbPath: record.kbPath || path.join(KB_ROOT, 'projects', record.projectSlug),
160
+ promptKey: record.promptKey,
161
+ runner: record.runner || 'cli',
162
+ state: liveState,
163
+ model: record.model || null,
164
+ aiProfileId: record.aiProfileId || null,
165
+ claudeSessionId: record.claudeSessionId || null,
166
+ startedAt: record.startedAt || new Date().toISOString(),
167
+ endedAt: liveState === record.state ? record.endedAt || null : null,
168
+ exitCode: record.exitCode ?? null,
169
+ listeners: new Set(),
170
+ outputBuffer: Array.isArray(record.events) ? record.events : [],
171
+ subprocess: null,
172
+ turns: record.turns || 0,
173
+ error: record.error || null,
174
+ claudeEnv: {},
175
+ pendingPermission: null,
176
+ pendingTurn: null,
177
+ pendingToolApproval: null,
178
+ restored: true,
179
+ };
180
+ sessions.set(sessionId, session);
181
+ if (liveState !== record.state) {
182
+ emit(session, { type: 'claude/restored', fromState: record.state, state: liveState });
183
+ }
184
+ return session;
185
+ }
186
+
187
+ function buildClaudeEnvFromProfile(profile) {
188
+ const env = { CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' };
189
+ if (!profile || typeof profile !== 'object') return env;
190
+
191
+ const apiKeyFromEnv = profile.apiKeyEnv ? process.env[profile.apiKeyEnv] : '';
192
+ const apiKey = profile.apiKey || profile.authToken || profile.anthropicAuthToken || apiKeyFromEnv || '';
193
+ const baseUrl = profile.baseUrl || profile.apiBaseUrl || profile.anthropicBaseUrl || '';
194
+ const model = profile.model || '';
195
+ const timeoutMs = profile.timeoutMs || process.env.API_TIMEOUT_MS || '';
196
+ const version = profile.version || profile.anthropicVersion || '';
197
+
198
+ if (apiKey) env.ANTHROPIC_AUTH_TOKEN = String(apiKey);
199
+ if (baseUrl) env.ANTHROPIC_BASE_URL = String(baseUrl);
200
+ if (model) {
201
+ env.ANTHROPIC_MODEL = String(model);
202
+ env.ANTHROPIC_SMALL_FAST_MODEL = String(model);
203
+ env.ANTHROPIC_DEFAULT_SONNET_MODEL = String(model);
204
+ env.ANTHROPIC_DEFAULT_OPUS_MODEL = String(model);
205
+ env.ANTHROPIC_DEFAULT_HAIKU_MODEL = String(model);
206
+ }
207
+ if (timeoutMs) env.API_TIMEOUT_MS = String(timeoutMs);
208
+ if (version) env.ANTHROPIC_VERSION = String(version);
209
+
210
+ return env;
211
+ }
212
+
213
+ function getSession(sessionId) {
214
+ return sessions.get(sessionId) || restoreSessionFromDisk(sessionId);
215
+ }
216
+
217
+ function sessionSummary(s) {
218
+ return {
219
+ sessionId: s.sessionId,
220
+ projectSlug: s.projectSlug,
221
+ promptKey: s.promptKey,
222
+ runner: s.runner || 'cli',
223
+ state: s.state,
224
+ model: s.model,
225
+ aiProfileId: s.aiProfileId || null,
226
+ claudeSessionId: s.claudeSessionId,
227
+ startedAt: s.startedAt,
228
+ endedAt: s.endedAt,
229
+ exitCode: s.exitCode,
230
+ turns: s.turns,
231
+ pendingPermission: s.pendingPermission,
232
+ restored: !!s.restored,
233
+ };
234
+ }
235
+
236
+ function listSessions(filter = {}) {
237
+ const projectSlug = filter.projectSlug || null;
238
+ const byId = new Map();
239
+ for (const record of scanPersistedRecords(projectSlug)) {
240
+ byId.set(record.sessionId, sessionSummary({
241
+ ...record,
242
+ listeners: new Set(),
243
+ outputBuffer: record.events || [],
244
+ subprocess: null,
245
+ pendingPermission: ['running', 'spawning'].includes(record.state) ? null : record.pendingPermission,
246
+ restored: true,
247
+ }));
248
+ }
249
+ for (const s of sessions.values()) {
250
+ if (projectSlug && s.projectSlug !== projectSlug) continue;
251
+ byId.set(s.sessionId, sessionSummary(s));
252
+ }
253
+ return [...byId.values()].sort((a, b) => String(b.startedAt || '').localeCompare(String(a.startedAt || '')));
254
+ }
255
+
256
+ function emit(session, event) {
257
+ session.outputBuffer.push(event);
258
+ if (session.outputBuffer.length > 5000) session.outputBuffer.shift();
259
+ for (const listener of session.listeners) {
260
+ try { listener(event); } catch { /* listener may be a dead SSE */ }
261
+ }
262
+ persistSession(session);
263
+ }
264
+
265
+ function setState(session, state, extra = {}) {
266
+ session.state = state;
267
+ if (state === 'ended' || state === 'failed' || state === 'aborted') {
268
+ session.endedAt = new Date().toISOString();
269
+ }
270
+ emit(session, { type: 'claude/state', state, ...extra });
271
+ }
272
+
273
+ function newPermissionId() {
274
+ return 'perm-' + crypto.randomBytes(6).toString('hex');
275
+ }
276
+
277
+ function requiresTurnPermission(opts) {
278
+ if (!opts) return false;
279
+ if (opts.requirePermission === false) return false;
280
+ if (opts.permissionMode === 'bypassPermissions') return true;
281
+ const tools = Array.isArray(opts.allowedTools) ? opts.allowedTools : [];
282
+ return tools.some(tool => ['Bash', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(tool));
283
+ }
284
+
285
+ function summarizePermission(opts, meta) {
286
+ return {
287
+ cwd: opts.cwd || '',
288
+ model: opts.model || null,
289
+ permissionMode: opts.permissionMode || 'default',
290
+ allowedTools: Array.isArray(opts.allowedTools) ? opts.allowedTools : [],
291
+ promptPreview: String(opts.userPrompt || '').slice(0, 500),
292
+ turnKind: meta && meta.turnKind || 'analysis',
293
+ isResume: !!opts.resumeSessionId,
294
+ };
295
+ }
296
+
297
+ function beginTurn(session, opts, meta = {}) {
298
+ if (requiresTurnPermission(opts)) {
299
+ const requestId = newPermissionId();
300
+ session.pendingPermission = {
301
+ requestId,
302
+ status: 'pending',
303
+ createdAt: new Date().toISOString(),
304
+ summary: summarizePermission(opts, meta),
305
+ };
306
+ session.pendingTurn = {
307
+ opts,
308
+ isResume: !!opts.resumeSessionId,
309
+ nextTurn: session.turns + 1,
310
+ };
311
+ setState(session, 'pending-permission', { requestId, turn: session.turns + 1 });
312
+ emit(session, { type: 'claude/permission-request', ...session.pendingPermission });
313
+ return { pendingPermission: session.pendingPermission };
314
+ }
315
+ startTurn(session, opts, !!opts.resumeSessionId);
316
+ return { started: true };
317
+ }
318
+
319
+ function startTurn(session, opts, isResume) {
320
+ session.pendingPermission = null;
321
+ session.pendingTurn = null;
322
+ setState(session, 'spawning', { turn: session.turns + 1 });
323
+ spawnClaude(session, opts);
324
+ session.turns += 1;
325
+ setState(session, 'running', { turn: session.turns });
326
+ }
327
+
328
+ function resolvePermission(sessionId, requestId, decision) {
329
+ const session = getSession(sessionId);
330
+ if (!session) throw new Error(`session not found: ${sessionId}`);
331
+ if (!session.pendingPermission || session.pendingPermission.requestId !== requestId) {
332
+ throw new Error('permission request not found or already resolved');
333
+ }
334
+ const allow = decision && decision.allow === true;
335
+ const resolvedAt = new Date().toISOString();
336
+ emit(session, {
337
+ type: 'claude/permission-resolved',
338
+ requestId,
339
+ allow,
340
+ message: decision && decision.message || '',
341
+ resolvedAt,
342
+ });
343
+ if (session.pendingToolApproval && session.pendingToolApproval.requestId === requestId) {
344
+ const approval = session.pendingToolApproval;
345
+ session.pendingToolApproval = null;
346
+ session.pendingPermission = null;
347
+ setState(session, 'running', { message: allow ? 'tool permission approved' : 'tool permission denied', requestId });
348
+ approval.resolve(allow
349
+ ? { behavior: 'allow', updatedInput: approval.input, toolUseID: approval.toolUseID }
350
+ : { behavior: 'deny', message: decision && decision.message || 'User denied tool use', toolUseID: approval.toolUseID });
351
+ persistSession(session);
352
+ return { ok: true, started: true, toolPermission: true };
353
+ }
354
+ if (!allow) {
355
+ session.pendingPermission = { ...session.pendingPermission, status: 'denied', resolvedAt };
356
+ session.pendingTurn = null;
357
+ setState(session, 'idle', { message: 'permission denied', requestId });
358
+ session.pendingPermission = null;
359
+ persistSession(session);
360
+ return { ok: true, started: false };
361
+ }
362
+ const pending = session.pendingTurn;
363
+ if (!pending) throw new Error('permission has no pending turn');
364
+ startTurn(session, pending.opts, pending.isResume);
365
+ return { ok: true, started: true };
366
+ }
367
+
368
+ // ---- NDJSON parsing ----
369
+ function attachProcessHandlers(session, proc, isResume) {
370
+ let lineBuffer = '';
371
+ // Tool input is streamed across many content_block_delta events; accumulate per content block
372
+ // and emit a final tool-use event with the assembled input when the block closes.
373
+ const toolInputBuffers = new Map(); // contentBlockId -> partial_json string
374
+
375
+ proc.stdout.on('data', chunk => {
376
+ lineBuffer += chunk.toString('utf-8');
377
+ let idx;
378
+ while ((idx = lineBuffer.indexOf('\n')) >= 0) {
379
+ const line = lineBuffer.slice(0, idx).trim();
380
+ lineBuffer = lineBuffer.slice(idx + 1);
381
+ if (line) handleNdjsonLine(session, line, toolInputBuffers);
382
+ }
383
+ });
384
+
385
+ let stderrBuf = '';
386
+ proc.stderr.on('data', chunk => {
387
+ stderrBuf += chunk.toString('utf-8');
388
+ let idx;
389
+ while ((idx = stderrBuf.indexOf('\n')) >= 0) {
390
+ const line = stderrBuf.slice(0, idx).trim();
391
+ stderrBuf = stderrBuf.slice(idx + 1);
392
+ if (line) emit(session, { type: 'claude/stderr', text: line });
393
+ }
394
+ });
395
+
396
+ proc.on('error', e => {
397
+ session.error = e.message;
398
+ emit(session, { type: 'claude/error', message: `spawn error: ${e.message}` });
399
+ setState(session, 'failed', { error: e.message });
400
+ });
401
+
402
+ proc.on('close', code => {
403
+ session.exitCode = code;
404
+ session.subprocess = null;
405
+ if (lineBuffer.trim()) {
406
+ try { handleNdjsonLine(session, lineBuffer.trim()); } catch {}
407
+ }
408
+ if (session.state !== 'aborted' && session.state !== 'failed') {
409
+ if (code === 0) {
410
+ setState(session, 'idle', { exitCode: code, message: isResume ? 'follow-up complete' : 'turn complete, awaiting input or new analysis' });
411
+ emit(session, { type: 'claude/turn-end', exitCode: code });
412
+ } else {
413
+ setState(session, 'failed', { exitCode: code, error: `claude exited with code ${code}` });
414
+ emit(session, { type: 'claude/exit', exitCode: code });
415
+ }
416
+ } else {
417
+ emit(session, { type: 'claude/exit', exitCode: code });
418
+ }
419
+ });
420
+
421
+ session.subprocess = proc;
422
+ }
423
+
424
+ function handleNdjsonLine(session, line, toolInputBuffers) {
425
+ let parsed;
426
+ try {
427
+ parsed = JSON.parse(line);
428
+ } catch {
429
+ emit(session, { type: 'claude/raw', text: line });
430
+ return;
431
+ }
432
+
433
+ const t = parsed.type;
434
+
435
+ if (t === 'system' && parsed.subtype === 'init') {
436
+ if (parsed.session_id) session.claudeSessionId = parsed.session_id;
437
+ if (parsed.model) session.model = typeof parsed.model === 'string' ? parsed.model : (parsed.model.id || parsed.model.name || null);
438
+ emit(session, {
439
+ type: 'claude/init',
440
+ claudeSessionId: session.claudeSessionId,
441
+ model: session.model,
442
+ aiProfileId: session.aiProfileId || null,
443
+ tools: parsed.tools || [],
444
+ mcpServers: parsed.mcp_servers || [],
445
+ });
446
+ return;
447
+ }
448
+
449
+ if (t === 'stream_event' && parsed.event) {
450
+ const ev = parsed.event;
451
+ if (ev.type === 'message_start' && ev.message) {
452
+ emit(session, { type: 'claude/message-start', role: ev.message.role });
453
+ return;
454
+ }
455
+ if (ev.type === 'content_block_start' && ev.content_block) {
456
+ const cb = ev.content_block;
457
+ if (cb.type === 'tool_use') {
458
+ // Initialize the per-block input accumulator; emit nothing yet (input is empty).
459
+ // The full input arrives via input_json_delta events and is emitted on content_block_stop.
460
+ if (cb.id) toolInputBuffers.set(cb.id, '');
461
+ emit(session, { type: 'claude/tool-use-start', id: cb.id, name: cb.name });
462
+ return;
463
+ }
464
+ if (cb.type === 'thinking') {
465
+ emit(session, { type: 'claude/thinking-start', id: cb.id });
466
+ return;
467
+ }
468
+ return;
469
+ }
470
+ if (ev.type === 'content_block_delta' && ev.delta) {
471
+ const d = ev.delta;
472
+ if (d.type === 'text_delta') {
473
+ emit(session, { type: 'claude/text-delta', text: d.text });
474
+ return;
475
+ }
476
+ if (d.type === 'input_json_delta') {
477
+ // Accumulate into the tool-use block; also emit a low-level delta for live UIs.
478
+ if (ev.index != null && toolInputBuffers) {
479
+ // We don't know block id here; emit raw delta
480
+ }
481
+ emit(session, { type: 'claude/tool-input-delta', text: d.partial_json });
482
+ return;
483
+ }
484
+ if (d.type === 'thinking_delta') {
485
+ emit(session, { type: 'claude/thinking-delta', text: d.thinking });
486
+ return;
487
+ }
488
+ return;
489
+ }
490
+ if (ev.type === 'content_block_stop') {
491
+ // Block ended — but we don't reliably know which block. Tool input emission is
492
+ // best-effort: the assistant message envelope at message_delta carries final tools.
493
+ return;
494
+ }
495
+ if (ev.type === 'message_delta' && ev.delta) {
496
+ if (ev.delta.stop_reason) {
497
+ emit(session, { type: 'claude/message-stop', stopReason: ev.delta.stop_reason });
498
+ }
499
+ return;
500
+ }
501
+ if (ev.type === 'message_stop') {
502
+ return;
503
+ }
504
+ return;
505
+ }
506
+
507
+ if (t === 'user' || t === 'assistant') {
508
+ // On assistant turn boundaries, the full message (with completed tool_use blocks)
509
+ // appears here. Emit any tool-use blocks we haven't fully captured yet.
510
+ if (t === 'assistant' && parsed.message && Array.isArray(parsed.message.content)) {
511
+ for (const block of parsed.message.content) {
512
+ if (block && block.type === 'tool_use') {
513
+ emit(session, {
514
+ type: 'claude/tool-use',
515
+ id: block.id,
516
+ name: block.name,
517
+ input: block.input || {},
518
+ });
519
+ }
520
+ }
521
+ }
522
+ return;
523
+ }
524
+
525
+ if (t === 'result') {
526
+ emit(session, {
527
+ type: 'claude/result',
528
+ subtype: parsed.subtype || null,
529
+ costUsd: parsed.total_cost_usd,
530
+ durationMs: parsed.duration_ms,
531
+ result: typeof parsed.result === 'string' ? parsed.result : JSON.stringify(parsed.result),
532
+ isError: parsed.is_error === true,
533
+ });
534
+ return;
535
+ }
536
+
537
+ emit(session, { type: 'claude/raw', json: parsed });
538
+ }
539
+
540
+ // ---- public API ----
541
+
542
+ // spawn claude for the FIRST turn (initial analysis)
543
+ function startSession({ slug, projectPath, kbPath, promptKey, vars, aiProfile, runner }) {
544
+ const rendered = renderPrompt(promptKey, vars || {});
545
+ if (!rendered) {
546
+ throw new Error(`unknown prompt key: ${promptKey}`);
547
+ }
548
+ const session = createSession({ projectSlug: slug, projectPath, kbPath, promptKey });
549
+ session.aiProfileId = aiProfile && aiProfile.id || null;
550
+ session.claudeEnv = buildClaudeEnvFromProfile(aiProfile);
551
+ session.runner = runner || aiProfile && aiProfile.runner || 'cli';
552
+ emit(session, {
553
+ type: 'claude/system-prompt',
554
+ text: rendered.systemPrompt,
555
+ promptKey,
556
+ aiProfileId: session.aiProfileId,
557
+ });
558
+ emit(session, {
559
+ type: 'claude/user-prompt',
560
+ text: rendered.userPrompt,
561
+ promptKey,
562
+ isInitial: true,
563
+ });
564
+ const turnOpts = {
565
+ userPrompt: rendered.userPrompt,
566
+ systemPrompt: rendered.systemPrompt,
567
+ model: rendered.model,
568
+ permissionMode: rendered.permissionMode,
569
+ allowedTools: rendered.allowedTools,
570
+ cwd: session.kbPath,
571
+ env: session.claudeEnv,
572
+ };
573
+ if (session.runner === 'sdk') {
574
+ startSdkTurn(session, turnOpts, false);
575
+ return { sessionId: session.sessionId, pendingPermission: null, runner: 'sdk' };
576
+ }
577
+ const turn = beginTurn(session, turnOpts, { turnKind: 'initial-analysis' });
578
+ return { sessionId: session.sessionId, pendingPermission: turn.pendingPermission || null };
579
+ }
580
+
581
+ // spawn claude for a FOLLOW-UP turn using --resume
582
+ async function sendInput(sessionId, text, aiProfile = null, runner = null) {
583
+ const session = getSession(sessionId);
584
+ if (!session) throw new Error(`session not found: ${sessionId}`);
585
+ if (!session.claudeSessionId) {
586
+ throw new Error('claude session not initialized yet — wait for the first turn to emit claude/init');
587
+ }
588
+ if (session.subprocess) {
589
+ throw new Error(`a subprocess is already running on session ${sessionId}`);
590
+ }
591
+ if (aiProfile) {
592
+ session.aiProfileId = aiProfile.id || session.aiProfileId || null;
593
+ session.claudeEnv = buildClaudeEnvFromProfile(aiProfile);
594
+ session.runner = runner || aiProfile.runner || session.runner || 'cli';
595
+ }
596
+ emit(session, { type: 'claude/user-prompt', text, isFollowUp: true });
597
+ const turnOpts = {
598
+ userPrompt: text,
599
+ model: session.model || 'sonnet',
600
+ permissionMode: 'bypassPermissions',
601
+ allowedTools: ['Read', 'Grep', 'Glob', 'Bash', 'Edit'],
602
+ cwd: session.kbPath,
603
+ resumeSessionId: session.claudeSessionId,
604
+ systemPrompt: null,
605
+ env: session.claudeEnv,
606
+ };
607
+ if ((runner || session.runner) === 'sdk') {
608
+ startSdkTurn(session, { ...turnOpts, permissionMode: 'default' }, true);
609
+ return { started: true, pendingPermission: null, runner: 'sdk' };
610
+ }
611
+ return beginTurn(session, turnOpts, { turnKind: 'follow-up' });
612
+ }
613
+
614
+ function findClaudeExecutable() {
615
+ // Prefer the .exe path the running Claude Code sets in the env — bypasses cmd.exe shim
616
+ // entirely and avoids Windows shell quoting pitfalls on long prompts.
617
+ if (process.env.CLAUDE_CODE_EXECPATH) {
618
+ return { cmd: process.env.CLAUDE_CODE_EXECPATH, shell: false };
619
+ }
620
+ // Fallbacks
621
+ if (process.platform === 'win32') {
622
+ const npmRoot = process.env.APPDATA && require('path').join(process.env.APPDATA, 'npm');
623
+ if (npmRoot) {
624
+ const candidates = [
625
+ require('path').join(npmRoot, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
626
+ require('path').join(npmRoot, 'claude.cmd'),
627
+ ];
628
+ const fs = require('fs');
629
+ for (const c of candidates) {
630
+ if (fs.existsSync(c)) return { cmd: c, shell: c.endsWith('.cmd') };
631
+ }
632
+ }
633
+ return { cmd: 'claude', shell: true }; // last resort
634
+ }
635
+ return { cmd: 'claude', shell: false };
636
+ }
637
+
638
+ function spawnClaude(session, opts) {
639
+ const args = ['-p', opts.userPrompt, '--output-format', 'stream-json', '--verbose', '--include-partial-messages'];
640
+ if (opts.systemPrompt) {
641
+ args.push('--system-prompt', opts.systemPrompt);
642
+ }
643
+ if (opts.model) {
644
+ args.push('--model', opts.model);
645
+ }
646
+ if (opts.permissionMode === 'bypassPermissions') {
647
+ args.push('--dangerously-skip-permissions');
648
+ } else if (opts.permissionMode) {
649
+ args.push('--permission-mode', opts.permissionMode);
650
+ }
651
+ if (Array.isArray(opts.allowedTools) && opts.allowedTools.length) {
652
+ args.push('--allowedTools', opts.allowedTools.join(','));
653
+ }
654
+ if (opts.resumeSessionId) {
655
+ args.push('--resume', opts.resumeSessionId);
656
+ }
657
+
658
+ const { cmd, shell } = findClaudeExecutable();
659
+ const proc = spawn(cmd, args, {
660
+ cwd: opts.cwd,
661
+ shell,
662
+ windowsHide: false,
663
+ env: { ...process.env, ...(opts.env || {}), FORCE_COLOR: '0', NO_COLOR: '1' },
664
+ // WindowsVerbatimArguments defaults to true on win32 for native shells; keep false so
665
+ // Node's argument escaping handles the embedded double quotes in our JSON-shape prompts.
666
+ windowsVerbatimArguments: false,
667
+ });
668
+ attachProcessHandlers(session, proc, !!opts.resumeSessionId);
669
+ return proc;
670
+ }
671
+
672
+ async function runSdkTurn(session, opts, isResume) {
673
+ const { query } = await import('@anthropic-ai/claude-agent-sdk');
674
+ const toolInputBuffers = new Map();
675
+ const sdkOptions = {
676
+ cwd: opts.cwd,
677
+ env: { ...process.env, ...(opts.env || {}), FORCE_COLOR: '0', NO_COLOR: '1' },
678
+ model: opts.model,
679
+ tools: { type: 'preset', preset: 'claude_code' },
680
+ disallowedTools: [],
681
+ settingSources: ['project', 'user', 'local'],
682
+ includePartialMessages: true,
683
+ pathToClaudeCodeExecutable: findClaudeExecutable().cmd,
684
+ systemPrompt: opts.systemPrompt || { type: 'preset', preset: 'claude_code' },
685
+ permissionMode: opts.permissionMode === 'bypassPermissions' ? 'default' : (opts.permissionMode || 'default'),
686
+ canUseTool: async (toolName, input, context = {}) => {
687
+ const requestId = newPermissionId();
688
+ const summary = {
689
+ turnKind: 'tool-use',
690
+ isResume,
691
+ cwd: opts.cwd || '',
692
+ model: opts.model || null,
693
+ permissionMode: sdkOptions.permissionMode,
694
+ allowedTools: [toolName],
695
+ toolName,
696
+ toolUseID: context.toolUseID || null,
697
+ title: context.title || context.displayName || toolName,
698
+ description: context.description || context.decisionReason || '',
699
+ promptPreview: JSON.stringify(input || {}).slice(0, 500),
700
+ };
701
+ session.pendingPermission = {
702
+ requestId,
703
+ status: 'pending',
704
+ createdAt: new Date().toISOString(),
705
+ summary,
706
+ };
707
+ setState(session, 'pending-permission', { requestId, toolName, toolUseID: context.toolUseID || null });
708
+ emit(session, { type: 'claude/permission-request', ...session.pendingPermission });
709
+ return await new Promise((resolve) => {
710
+ const cancel = () => {
711
+ if (session.pendingToolApproval && session.pendingToolApproval.requestId === requestId) {
712
+ session.pendingToolApproval = null;
713
+ session.pendingPermission = null;
714
+ resolve({ behavior: 'deny', message: 'Permission request cancelled', toolUseID: context.toolUseID });
715
+ }
716
+ };
717
+ if (context.signal) {
718
+ if (context.signal.aborted) return cancel();
719
+ context.signal.addEventListener('abort', cancel, { once: true });
720
+ }
721
+ session.pendingToolApproval = {
722
+ requestId,
723
+ resolve,
724
+ input,
725
+ toolUseID: context.toolUseID,
726
+ };
727
+ });
728
+ },
729
+ };
730
+ if (opts.resumeSessionId) sdkOptions.resume = opts.resumeSessionId;
731
+
732
+ let queryInstance;
733
+ try {
734
+ queryInstance = query({ prompt: opts.userPrompt, options: sdkOptions });
735
+ session.subprocess = { kill: () => queryInstance.close && queryInstance.close() };
736
+ for await (const message of queryInstance) {
737
+ if (message.session_id) session.claudeSessionId = message.session_id;
738
+ handleNdjsonLine(session, JSON.stringify(message), toolInputBuffers);
739
+ }
740
+ if (session.state !== 'aborted' && session.state !== 'failed') {
741
+ setState(session, 'idle', { exitCode: 0, message: isResume ? 'follow-up complete' : 'turn complete, awaiting input or new analysis' });
742
+ emit(session, { type: 'claude/turn-end', exitCode: 0 });
743
+ }
744
+ } catch (e) {
745
+ session.error = e.message;
746
+ emit(session, { type: 'claude/error', message: e.message });
747
+ setState(session, 'failed', { error: e.message });
748
+ } finally {
749
+ session.subprocess = null;
750
+ session.pendingToolApproval = null;
751
+ session.pendingPermission = null;
752
+ persistSession(session);
753
+ }
754
+ }
755
+
756
+ function startSdkTurn(session, opts, isResume) {
757
+ session.pendingPermission = null;
758
+ session.pendingTurn = null;
759
+ setState(session, 'spawning', { turn: session.turns + 1, runner: 'sdk' });
760
+ session.turns += 1;
761
+ setState(session, 'running', { turn: session.turns, runner: 'sdk' });
762
+ runSdkTurn(session, opts, isResume).catch(e => {
763
+ session.error = e.message;
764
+ emit(session, { type: 'claude/error', message: e.message });
765
+ setState(session, 'failed', { error: e.message });
766
+ });
767
+ }
768
+
769
+ function abort(sessionId) {
770
+ const session = getSession(sessionId);
771
+ if (!session) throw new Error(`session not found: ${sessionId}`);
772
+ if (session.subprocess) {
773
+ try {
774
+ session.subprocess.kill();
775
+ } catch {}
776
+ setState(session, 'aborted', { reason: 'user-abort' });
777
+ emit(session, { type: 'claude/aborted', reason: 'user-abort' });
778
+ } else {
779
+ session.pendingPermission = null;
780
+ session.pendingTurn = null;
781
+ setState(session, 'aborted', { reason: 'no-active-subprocess' });
782
+ }
783
+ }
784
+
785
+ // Subscribe to live events. Callback receives each event as it arrives.
786
+ // Late subscribers also get replayed outputBuffer.
787
+ function subscribe(sessionId, onEvent) {
788
+ const session = getSession(sessionId);
789
+ if (!session) throw new Error(`session not found: ${sessionId}`);
790
+ // replay buffer first
791
+ for (const ev of session.outputBuffer) {
792
+ try { onEvent(ev); } catch {}
793
+ }
794
+ session.listeners.add(onEvent);
795
+ return () => {
796
+ session.listeners.delete(onEvent);
797
+ };
798
+ }
799
+
800
+ function getState(sessionId) {
801
+ const s = getSession(sessionId);
802
+ if (!s) return null;
803
+ return {
804
+ sessionId: s.sessionId,
805
+ projectSlug: s.projectSlug,
806
+ promptKey: s.promptKey,
807
+ runner: s.runner || 'cli',
808
+ state: s.state,
809
+ model: s.model,
810
+ aiProfileId: s.aiProfileId || null,
811
+ claudeSessionId: s.claudeSessionId,
812
+ startedAt: s.startedAt,
813
+ endedAt: s.endedAt,
814
+ exitCode: s.exitCode,
815
+ turns: s.turns,
816
+ error: s.error,
817
+ listenerCount: s.listeners.size,
818
+ bufferedEvents: s.outputBuffer.length,
819
+ pendingPermission: s.pendingPermission,
820
+ restored: !!s.restored,
821
+ };
822
+ }
823
+
824
+ function deleteSession(sessionId) {
825
+ const s = sessions.get(sessionId);
826
+ if (!s) return false;
827
+ if (s.subprocess) {
828
+ try { s.subprocess.kill(); } catch {}
829
+ }
830
+ sessions.delete(sessionId);
831
+ try {
832
+ const file = sessionRecordPath(s);
833
+ if (file) fs.rmSync(file, { force: true });
834
+ } catch {}
835
+ return true;
836
+ }
837
+
838
+ // Periodically clean up old sessions with no listeners (memory hygiene)
839
+ function pruneOldSessions(maxAgeMs = 30 * 60 * 1000) {
840
+ const now = Date.now();
841
+ for (const [id, s] of sessions) {
842
+ if (s.listeners.size === 0 && s.subprocess === null && s.endedAt) {
843
+ const age = now - new Date(s.endedAt).getTime();
844
+ if (age > maxAgeMs) sessions.delete(id);
845
+ }
846
+ }
847
+ }
848
+
849
+ setInterval(pruneOldSessions, 5 * 60 * 1000).unref();
850
+
851
+ module.exports = {
852
+ startSession,
853
+ buildClaudeEnvFromProfile,
854
+ sendInput,
855
+ resolvePermission,
856
+ abort,
857
+ subscribe,
858
+ getSession,
859
+ getState,
860
+ listSessions,
861
+ deleteSession,
862
+ };