@assistkick/create 1.26.0 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistkick/create",
3
- "version": "1.26.0",
3
+ "version": "1.28.0",
4
4
  "description": "Scaffold assistkick-product-system into any project",
5
5
  "type": "module",
6
6
  "bin": {
@@ -266,11 +266,14 @@ const terminalHandler = new TerminalWsHandler({ wss: terminalWss, authService, p
266
266
 
267
267
  // Set up WebSocket for Chat v2 streaming with permission management
268
268
  const chatWss = new WebSocketServer({ noServer: true });
269
- const chatCliBridge = new ChatCliBridge({ projectRoot: paths.projectRoot, workspacesDir: paths.workspacesDir, log });
269
+ const chatCliBridge = new ChatCliBridge({ projectRoot: paths.projectRoot, workspacesDir: paths.workspacesDir, dataDir: paths.runtimeDataDir, log });
270
270
  const permissionService = new PermissionService({ getDb, log });
271
271
  const titleGeneratorService = new TitleGeneratorService({ log });
272
272
  const chatHandler = new ChatWsHandler({ wss: chatWss, authService, chatCliBridge, permissionService, chatMessageRepository, chatSessionService, titleGeneratorService, mcpConfigService, log });
273
273
 
274
+ // Resume any CLI sessions left running by the previous server instance
275
+ chatHandler.resumeSessions();
276
+
274
277
  // Chat permission routes — used by MCP permission server (no auth — internal only)
275
278
  const chatPermissionRoutes = createChatPermissionRoutes({ permissionService, log });
276
279
  app.use('/api/chat/permission', chatPermissionRoutes);
@@ -291,7 +294,7 @@ server.on('upgrade', (req, socket, head) => {
291
294
  process.on('SIGTERM', () => {
292
295
  previewManager.stopAll();
293
296
  ptyManager.destroyAllPty();
294
- chatCliBridge.killAll();
297
+ chatCliBridge.detachAll(); // Release without killing — CLI processes survive restart
295
298
  server.close();
296
299
  });
297
300
 
@@ -1,26 +1,31 @@
1
1
  /**
2
- * ChatCliBridge — spawns Claude Code CLI in non-interactive mode for Chat v2.
2
+ * ChatCliBridge — spawns Claude Code CLI as a detached process with file-based I/O.
3
+ *
4
+ * CLI processes survive server restarts (tsx watch). Output is written to temp files
5
+ * and tailed by the server. A manifest file tracks running sessions so the next
6
+ * server instance can resume tailing.
3
7
  *
4
8
  * Each user message spawns a new CLI invocation:
5
9
  * claude -p - --output-format stream-json --verbose --include-partial-messages
6
10
  * --append-system-prompt "We are working on project-id ${projectId}"
7
11
  *
8
12
  * New sessions use --session-id <uuid>, subsequent messages use --resume <uuid>.
9
- * Permission mode controls: --dangerously-skip-permissions, --allowedTools, or --permission-prompt-tool.
10
- * When permission mode is 'prompt', a temporary MCP config file is generated
11
- * pointing to the permission MCP server, and --mcp-config is passed to the CLI.
12
- * Emits parsed stream-json events via onEvent callback for downstream WebSocket forwarding.
13
13
  */
14
14
 
15
15
  import { spawn, type ChildProcess } from 'node:child_process';
16
16
  import { join } from 'node:path';
17
- import { existsSync, mkdirSync } from 'node:fs';
17
+ import {
18
+ existsSync, mkdirSync, openSync, closeSync, readSync, fstatSync,
19
+ readFileSync, writeFileSync, unlinkSync, statSync,
20
+ } from 'node:fs';
21
+ import { tmpdir } from 'node:os';
18
22
 
19
23
  export type PermissionMode = 'skip' | 'allowed_tools';
20
24
 
21
25
  export interface ChatCliBridgeDeps {
22
26
  projectRoot: string;
23
27
  workspacesDir: string;
28
+ dataDir: string;
24
29
  log: (tag: string, ...args: unknown[]) => void;
25
30
  }
26
31
 
@@ -42,7 +47,7 @@ export interface SpawnCompletion {
42
47
  exitCode: number | null;
43
48
  claudeSessionId: string;
44
49
  sessionId: string | null;
45
- /** Captured stderr lines (if any) — useful for diagnosing non-zero exit codes */
50
+ /** Captured stderr (if any) — useful for diagnosing non-zero exit codes */
46
51
  stderr: string | null;
47
52
  /** Non-JSON lines from stdout (if any) — CLI errors often appear here */
48
53
  stdoutNonJsonLines: string | null;
@@ -55,35 +60,67 @@ export interface SpawnResult {
55
60
  systemPrompt: string | null;
56
61
  }
57
62
 
63
+ /** Persisted metadata for an in-flight CLI session. */
64
+ export interface CliSessionManifestEntry {
65
+ claudeSessionId: string;
66
+ pid: number;
67
+ stdoutPath: string;
68
+ stderrPath: string;
69
+ mcpConfigPath?: string;
70
+ startedAt: string;
71
+ /** Stream context metadata for WS handler reconstruction on resume. */
72
+ sessionId: string | null;
73
+ projectId: string;
74
+ userMessage: string;
75
+ attachmentBlocks: Array<{ type: string; path: string; name: string; mimeType: string }>;
76
+ }
77
+
78
+ /** Returned by resumeSessions() for each still-alive session. */
79
+ export interface ResumedSession {
80
+ entry: CliSessionManifestEntry;
81
+ completion: Promise<SpawnCompletion>;
82
+ }
83
+
84
+ /** Internal state for an active file tailer. */
85
+ interface TailerState {
86
+ interval: ReturnType<typeof setInterval>;
87
+ fd: number;
88
+ position: number;
89
+ lineBuf: string;
90
+ capturedSessionId: string | null;
91
+ stdoutNonJsonLines: string[];
92
+ onEvent: (event: Record<string, unknown>) => void;
93
+ }
94
+
58
95
  export class ChatCliBridge {
59
96
  private readonly projectRoot: string;
60
97
  private readonly workspacesDir: string;
98
+ private readonly dataDir: string;
61
99
  private readonly log: ChatCliBridgeDeps['log'];
100
+ /** Child processes spawned in this server instance (have 'close' event). */
62
101
  private readonly activeProcesses = new Map<string, ChildProcess>();
102
+ /** Active file tailers (both direct spawns and resumed sessions). */
103
+ private readonly activeTailers = new Map<string, TailerState>();
104
+ private readonly manifestPath: string;
63
105
 
64
- constructor({ projectRoot, workspacesDir, log }: ChatCliBridgeDeps) {
106
+ constructor({ projectRoot, workspacesDir, dataDir, log }: ChatCliBridgeDeps) {
65
107
  this.projectRoot = projectRoot;
66
108
  this.workspacesDir = workspacesDir;
109
+ this.dataDir = dataDir;
67
110
  this.log = log;
111
+ this.manifestPath = join(dataDir, 'active-cli-sessions.json');
112
+ if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true });
68
113
  }
69
114
 
70
- /**
71
- * Resolve the workspace directory path for a project (used in system prompt).
72
- */
73
115
  getWorkspacePath = (projectId: string): string => {
74
116
  return join(this.workspacesDir, projectId);
75
117
  };
76
118
 
77
- /**
78
- * Build the CLI arguments array from spawn options.
79
- * Returns both the args array and the composed system prompt text (if any).
80
- */
81
119
  buildArgs = (options: SpawnOptions): { args: string[]; systemPrompt: string | null } => {
82
120
  const { projectId, claudeSessionId, isNewSession, permissionMode, allowedTools, continuationContext } = options;
83
121
  const args: string[] = [];
84
122
  let systemPrompt: string | null = null;
85
123
 
86
- // Permission mode
87
124
  if (permissionMode === 'skip') {
88
125
  args.push('--dangerously-skip-permissions');
89
126
  } else if (permissionMode === 'allowed_tools' && allowedTools && allowedTools.length > 0) {
@@ -92,35 +129,27 @@ export class ChatCliBridge {
92
129
  }
93
130
  }
94
131
 
95
- // MCP server configuration (temp file path)
96
132
  if (options.mcpConfigPath) {
97
133
  args.push('--mcp-config', options.mcpConfigPath);
98
134
  }
99
135
 
100
- // Prompt via stdin
101
136
  args.push('-p', '-');
102
137
 
103
- // Session management
104
138
  if (isNewSession) {
105
139
  args.push('--session-id', claudeSessionId);
106
140
  } else {
107
141
  args.push('--resume', claudeSessionId);
108
142
  }
109
143
 
110
- // Output format
111
144
  args.push('--output-format', 'stream-json');
112
145
  args.push('--verbose');
113
146
  args.push('--include-partial-messages');
114
147
 
115
- // Project context system prompt (only on first message; resumed sessions already have it)
116
148
  if (isNewSession) {
117
149
  const systemParts: string[] = [];
118
-
119
- // Prepend compacted conversation context when continuing from a previous session
120
150
  if (continuationContext) {
121
151
  systemParts.push(continuationContext);
122
152
  }
123
-
124
153
  const wsPath = this.getWorkspacePath(projectId);
125
154
  systemParts.push(`We are working on project-id ${projectId}
126
155
 
@@ -145,10 +174,6 @@ export class ChatCliBridge {
145
174
  return { args, systemPrompt };
146
175
  };
147
176
 
148
- /**
149
- * Build environment variables for the CLI subprocess.
150
- * Ensures common bin paths are available.
151
- */
152
177
  buildEnv = (): Record<string, string> => {
153
178
  const env = { ...process.env } as Record<string, string>;
154
179
  const home = env.HOME || '/root';
@@ -168,8 +193,8 @@ export class ChatCliBridge {
168
193
  };
169
194
 
170
195
  /**
171
- * Spawn a Claude CLI process for a chat message.
172
- * Returns a handle to the child process and a promise that resolves on completion.
196
+ * Spawn a Claude CLI process as a detached process with file-based stdout/stderr.
197
+ * The process survives server restarts.
173
198
  */
174
199
  spawn = (options: SpawnOptions): SpawnResult => {
175
200
  const { projectId, message, claudeSessionId, isNewSession, onEvent } = options;
@@ -177,7 +202,6 @@ export class ChatCliBridge {
177
202
  const wsPath = this.getWorkspacePath(projectId);
178
203
  const { args, systemPrompt } = this.buildArgs(options);
179
204
 
180
- // Ensure workspace directory exists (Docker volumes may not have project subdirs)
181
205
  if (!existsSync(wsPath)) {
182
206
  mkdirSync(wsPath, { recursive: true });
183
207
  }
@@ -186,105 +210,80 @@ export class ChatCliBridge {
186
210
  this.log('CHAT_CLI', `Spawning claude (${mode}) for project ${projectId}, session ${claudeSessionId}`);
187
211
  this.log('CHAT_CLI', `cwd: ${cwd} (exists: ${existsSync(cwd)}), args: claude ${args.join(' ')}`);
188
212
 
213
+ // Create temp files for stdout/stderr
214
+ const stdoutPath = join(tmpdir(), `claude-stdout-${claudeSessionId}.jsonl`);
215
+ const stderrPath = join(tmpdir(), `claude-stderr-${claudeSessionId}.log`);
216
+ const stdoutFd = openSync(stdoutPath, 'w');
217
+ const stderrFd = openSync(stderrPath, 'w');
218
+
189
219
  const child = spawn('claude', args, {
190
220
  cwd,
191
221
  env: this.buildEnv(),
192
- stdio: ['pipe', 'pipe', 'pipe'],
222
+ detached: true,
223
+ stdio: ['pipe', stdoutFd, stderrFd],
193
224
  });
194
225
 
195
- // Track active process by session
196
- this.activeProcesses.set(claudeSessionId, child);
226
+ // Close write FDs in parent — kernel keeps them open for the child
227
+ closeSync(stdoutFd);
228
+ closeSync(stderrFd);
197
229
 
198
- const completion = new Promise<SpawnCompletion>((resolve, reject) => {
199
- let lineBuf = '';
200
- let capturedSessionId: string | null = null;
201
- let stdoutReceived = false;
202
- let stderrLines: string[] = [];
203
- let stdoutNonJsonLines: string[] = [];
204
-
205
- // Attach error handlers on stdio streams to prevent unhandled 'error' events
206
- // from crashing the process (e.g. EPIPE if CLI exits before stdin write completes)
207
- child.stdin!.on('error', (err) => {
208
- this.log('CHAT_CLI', `[stdin error] ${err.message}`);
209
- });
210
- child.stdout!.on('error', (err) => {
211
- this.log('CHAT_CLI', `[stdout error] ${err.message}`);
212
- });
213
- child.stderr!.on('error', (err) => {
214
- this.log('CHAT_CLI', `[stderr error] ${err.message}`);
215
- });
216
-
217
- child.stdout!.on('data', (chunk: Buffer) => {
218
- stdoutReceived = true;
219
- lineBuf += chunk.toString();
220
- const lines = lineBuf.split('\n');
221
- lineBuf = lines.pop()!;
222
-
223
- for (const line of lines) {
224
- const trimmed = line.trim();
225
- if (!trimmed) continue;
226
- this.processLine(trimmed, onEvent, (sid) => {
227
- capturedSessionId = sid;
228
- }, (nonJsonLine) => {
229
- stdoutNonJsonLines.push(nonJsonLine);
230
- });
231
- }
232
- });
230
+ const pid = child.pid!;
231
+ this.activeProcesses.set(claudeSessionId, child);
233
232
 
234
- child.stderr!.on('data', (chunk: Buffer) => {
235
- const text = chunk.toString();
236
- text.split('\n').forEach(line => {
237
- if (line.trim()) {
238
- stderrLines.push(line.trim());
239
- this.log('CHAT_CLI', `[stderr] ${line.trim()}`);
240
- }
241
- });
242
- });
233
+ // Write message to stdin and close
234
+ child.stdin!.on('error', (err) => {
235
+ this.log('CHAT_CLI', `[stdin error] ${err.message}`);
236
+ });
237
+ child.stdin!.write(message);
238
+ child.stdin!.end();
239
+ this.log('CHAT_CLI', `Message written to stdin (${message.length} chars), pid ${pid}`);
240
+
241
+ // Save to manifest so next server instance can resume tailing
242
+ this.saveManifestEntry({
243
+ claudeSessionId,
244
+ pid,
245
+ stdoutPath,
246
+ stderrPath,
247
+ mcpConfigPath: options.mcpConfigPath,
248
+ startedAt: new Date().toISOString(),
249
+ sessionId: null, // WS handler sets this via setManifestMetadata()
250
+ projectId,
251
+ userMessage: message,
252
+ attachmentBlocks: [],
253
+ });
243
254
 
244
- // Write the message to stdin and close
245
- child.stdin!.write(message);
246
- child.stdin!.end();
247
- this.log('CHAT_CLI', `Message written to stdin (${message.length} chars)`);
255
+ // Start tailing the stdout file
256
+ const tailer = this.startTailer(claudeSessionId, stdoutPath, onEvent);
248
257
 
258
+ // When child exits normally (server still running), resolve via 'close' event
259
+ const completion = new Promise<SpawnCompletion>((resolve, reject) => {
249
260
  child.on('close', (code) => {
250
- // Process any remaining data in the line buffer
251
- if (lineBuf.trim()) {
252
- this.processLine(lineBuf.trim(), onEvent, (sid) => {
253
- capturedSessionId = sid;
254
- }, (nonJsonLine) => {
255
- stdoutNonJsonLines.push(nonJsonLine);
256
- });
257
- }
258
-
261
+ this.finishTailer(claudeSessionId, tailer);
259
262
  this.activeProcesses.delete(claudeSessionId);
260
263
 
264
+ const stderr = this.readStderrFile(stderrPath);
265
+
261
266
  if (code !== 0) {
262
267
  this.log('CHAT_CLI', `Process FAILED with exit code ${code} for session ${claudeSessionId}`);
263
- if (stderrLines.length > 0) {
264
- this.log('CHAT_CLI', `[stderr summary] ${stderrLines.join(' | ')}`);
265
- }
266
- if (stdoutNonJsonLines.length > 0) {
267
- this.log('CHAT_CLI', `[stdout non-json summary] ${stdoutNonJsonLines.join(' | ')}`);
268
- }
269
- if (!stdoutReceived) {
270
- this.log('CHAT_CLI', `No stdout received — CLI may have crashed or failed to start`);
271
- }
272
268
  } else {
273
269
  this.log('CHAT_CLI', `Process exited successfully for session ${claudeSessionId}`);
274
270
  }
275
271
 
272
+ this.removeManifestEntry(claudeSessionId);
273
+ this.cleanupTempFiles(stdoutPath, stderrPath);
274
+
276
275
  resolve({
277
276
  exitCode: code,
278
277
  claudeSessionId,
279
- sessionId: capturedSessionId,
280
- stderr: stderrLines.length > 0 ? stderrLines.join('\n') : null,
281
- stdoutNonJsonLines: stdoutNonJsonLines.length > 0 ? stdoutNonJsonLines.join('\n') : null,
278
+ sessionId: tailer.capturedSessionId,
279
+ stderr,
280
+ stdoutNonJsonLines: tailer.stdoutNonJsonLines.length > 0 ? tailer.stdoutNonJsonLines.join('\n') : null,
282
281
  });
283
282
  });
284
283
 
285
284
  child.on('error', (err) => {
286
285
  this.activeProcesses.delete(claudeSessionId);
287
- this.log('CHAT_CLI', `Process spawn error for session ${claudeSessionId}: ${err.message} (${(err as NodeJS.ErrnoException).code || 'unknown'})`);
286
+ this.log('CHAT_CLI', `Process spawn error for session ${claudeSessionId}: ${err.message}`);
288
287
  reject(err);
289
288
  });
290
289
  });
@@ -292,61 +291,283 @@ export class ChatCliBridge {
292
291
  return { childProcess: child, completion, systemPrompt };
293
292
  };
294
293
 
294
+ /**
295
+ * Update manifest entry with stream context metadata (called by WS handler).
296
+ */
297
+ setManifestMetadata = (claudeSessionId: string, meta: {
298
+ sessionId?: string | null;
299
+ attachmentBlocks?: Array<{ type: string; path: string; name: string; mimeType: string }>;
300
+ }): void => {
301
+ const manifest = this.loadManifest();
302
+ const entry = manifest[claudeSessionId];
303
+ if (!entry) return;
304
+ if (meta.sessionId !== undefined) entry.sessionId = meta.sessionId;
305
+ if (meta.attachmentBlocks !== undefined) entry.attachmentBlocks = meta.attachmentBlocks;
306
+ this.writeManifest(manifest);
307
+ };
308
+
309
+ /**
310
+ * Resume tailing for CLI sessions left running by a previous server instance.
311
+ * Returns metadata + completion promises for each alive session.
312
+ */
313
+ resumeSessions = (
314
+ onEventFactory: (claudeSessionId: string) => (event: Record<string, unknown>) => void,
315
+ ): ResumedSession[] => {
316
+ const manifest = this.loadManifest();
317
+ const resumed: ResumedSession[] = [];
318
+ const alive: Record<string, CliSessionManifestEntry> = {};
319
+
320
+ for (const [id, entry] of Object.entries(manifest)) {
321
+ if (!this.isPidAlive(entry.pid) || !existsSync(entry.stdoutPath)) {
322
+ this.log('CHAT_CLI', `Cleaning up dead session ${id} (pid ${entry.pid})`);
323
+ this.cleanupTempFiles(entry.stdoutPath, entry.stderrPath, entry.mcpConfigPath);
324
+ continue;
325
+ }
326
+
327
+ this.log('CHAT_CLI', `Resuming tail for running session ${id} (pid ${entry.pid})`);
328
+ alive[id] = entry;
329
+
330
+ const onEvent = onEventFactory(id);
331
+ const tailer = this.startTailer(id, entry.stdoutPath, onEvent);
332
+
333
+ // Poll for process exit (no ChildProcess object for detached sessions from previous server)
334
+ const completion = new Promise<SpawnCompletion>((resolve) => {
335
+ const pollInterval = setInterval(() => {
336
+ if (!this.isPidAlive(entry.pid)) {
337
+ clearInterval(pollInterval);
338
+ this.finishTailer(id, tailer);
339
+
340
+ const stderr = this.readStderrFile(entry.stderrPath);
341
+ this.removeManifestEntry(id);
342
+ this.cleanupTempFiles(entry.stdoutPath, entry.stderrPath, entry.mcpConfigPath);
343
+
344
+ this.log('CHAT_CLI', `Resumed session ${id} (pid ${entry.pid}) finished`);
345
+ resolve({
346
+ exitCode: null, // Can't get exit code of previously-detached process
347
+ claudeSessionId: id,
348
+ sessionId: tailer.capturedSessionId,
349
+ stderr,
350
+ stdoutNonJsonLines: tailer.stdoutNonJsonLines.length > 0 ? tailer.stdoutNonJsonLines.join('\n') : null,
351
+ });
352
+ }
353
+ }, 500);
354
+ });
355
+
356
+ resumed.push({ entry, completion });
357
+ }
358
+
359
+ this.writeManifest(alive);
360
+ return resumed;
361
+ };
362
+
295
363
  /**
296
364
  * Kill an active CLI process by session ID.
297
- * Returns true if a process was found and killed.
298
365
  */
299
366
  kill = (claudeSessionId: string): boolean => {
367
+ // Try in-process child first
300
368
  const child = this.activeProcesses.get(claudeSessionId);
301
- if (!child) return false;
369
+ if (child) {
370
+ this.log('CHAT_CLI', `Killing process for session ${claudeSessionId}`);
371
+ child.kill('SIGTERM');
372
+ const forceKillTimer = setTimeout(() => {
373
+ if (!child.killed) {
374
+ this.log('CHAT_CLI', `Force killing process for session ${claudeSessionId}`);
375
+ child.kill('SIGKILL');
376
+ }
377
+ }, 5000);
378
+ child.on('close', () => clearTimeout(forceKillTimer));
379
+ return true;
380
+ }
302
381
 
303
- this.log('CHAT_CLI', `Killing process for session ${claudeSessionId}`);
304
- child.kill('SIGTERM');
382
+ // Fallback: kill by PID from manifest (resumed session)
383
+ const manifest = this.loadManifest();
384
+ const entry = manifest[claudeSessionId];
385
+ if (entry && this.isPidAlive(entry.pid)) {
386
+ this.log('CHAT_CLI', `Killing detached process for session ${claudeSessionId} (pid ${entry.pid})`);
387
+ try { process.kill(entry.pid, 'SIGTERM'); } catch { /* already dead */ }
388
+ setTimeout(() => {
389
+ try { process.kill(entry.pid, 'SIGKILL'); } catch { /* already dead */ }
390
+ }, 5000);
391
+ return true;
392
+ }
305
393
 
306
- // Force kill after 5 seconds if still alive
307
- const forceKillTimer = setTimeout(() => {
308
- if (!child.killed) {
309
- this.log('CHAT_CLI', `Force killing process for session ${claudeSessionId}`);
310
- child.kill('SIGKILL');
311
- }
312
- }, 5000);
394
+ return false;
395
+ };
313
396
 
314
- child.on('close', () => {
315
- clearTimeout(forceKillTimer);
316
- });
397
+ /**
398
+ * Release all processes for graceful server shutdown.
399
+ * Does NOT kill the CLI processes — they continue running.
400
+ * Stops tailers and unrefs children so the Node process can exit.
401
+ */
402
+ detachAll = (): void => {
403
+ for (const [sessionId, tailer] of this.activeTailers) {
404
+ clearInterval(tailer.interval);
405
+ try { closeSync(tailer.fd); } catch { /* ignore */ }
406
+ this.log('CHAT_CLI', `Stopped tailer for session ${sessionId} (server shutdown)`);
407
+ }
408
+ this.activeTailers.clear();
317
409
 
318
- return true;
410
+ for (const [sessionId, child] of this.activeProcesses) {
411
+ child.removeAllListeners();
412
+ child.unref();
413
+ this.log('CHAT_CLI', `Detached process for session ${sessionId} (server shutdown)`);
414
+ }
415
+ this.activeProcesses.clear();
416
+ // Manifest is intentionally preserved for the next server instance
319
417
  };
320
418
 
321
419
  /**
322
- * Kill all active CLI processes (for server shutdown).
420
+ * Kill all active CLI processes (explicit shutdown, not tsx watch restart).
323
421
  */
324
422
  killAll = (): void => {
325
423
  for (const [sessionId, child] of this.activeProcesses) {
326
- this.log('CHAT_CLI', `Killing process for session ${sessionId} (shutdown)`);
424
+ this.log('CHAT_CLI', `Killing process for session ${sessionId}`);
327
425
  child.kill('SIGTERM');
328
426
  }
427
+ // Also kill any from manifest
428
+ const manifest = this.loadManifest();
429
+ for (const [sessionId, entry] of Object.entries(manifest)) {
430
+ if (this.isPidAlive(entry.pid)) {
431
+ this.log('CHAT_CLI', `Killing detached process for session ${sessionId} (pid ${entry.pid})`);
432
+ try { process.kill(entry.pid, 'SIGTERM'); } catch { /* ignore */ }
433
+ }
434
+ }
435
+ this.writeManifest({});
329
436
  this.activeProcesses.clear();
330
437
  };
331
438
 
332
- /**
333
- * Check if a process is currently active for a session.
334
- */
335
439
  isActive = (claudeSessionId: string): boolean => {
336
- return this.activeProcesses.has(claudeSessionId);
440
+ return this.activeProcesses.has(claudeSessionId) || this.activeTailers.has(claudeSessionId);
337
441
  };
338
442
 
339
- /**
340
- * Get the number of currently active processes.
341
- */
342
443
  getActiveCount = (): number => {
343
- return this.activeProcesses.size;
444
+ return this.activeTailers.size;
344
445
  };
345
446
 
346
- /**
347
- * Parse a single line of stream-json output and emit events.
348
- * Also captures session_id from result events.
349
- */
447
+ // ── File tailing ─────────────────────────────────────────────────
448
+
449
+ private startTailer(
450
+ claudeSessionId: string,
451
+ stdoutPath: string,
452
+ onEvent: (event: Record<string, unknown>) => void,
453
+ ): TailerState {
454
+ const fd = openSync(stdoutPath, 'r');
455
+ const state: TailerState = {
456
+ interval: setInterval(() => this.readChunk(state), 50),
457
+ fd,
458
+ position: 0,
459
+ lineBuf: '',
460
+ capturedSessionId: null,
461
+ stdoutNonJsonLines: [],
462
+ onEvent,
463
+ };
464
+ this.activeTailers.set(claudeSessionId, state);
465
+ return state;
466
+ }
467
+
468
+ /** Read all remaining data, close fd, stop interval. */
469
+ private finishTailer(claudeSessionId: string, state: TailerState): void {
470
+ clearInterval(state.interval);
471
+ // Final read to pick up any remaining data
472
+ this.readChunk(state);
473
+ // Process any incomplete line left in the buffer
474
+ if (state.lineBuf.trim()) {
475
+ this.processLine(state.lineBuf.trim(), state.onEvent, (sid) => {
476
+ state.capturedSessionId = sid;
477
+ }, (nonJson) => {
478
+ state.stdoutNonJsonLines.push(nonJson);
479
+ });
480
+ state.lineBuf = '';
481
+ }
482
+ try { closeSync(state.fd); } catch { /* ignore */ }
483
+ this.activeTailers.delete(claudeSessionId);
484
+ }
485
+
486
+ private readChunk(state: TailerState): void {
487
+ try {
488
+ const stats = fstatSync(state.fd);
489
+ if (stats.size <= state.position) return;
490
+
491
+ const toRead = Math.min(stats.size - state.position, 256 * 1024);
492
+ const buf = Buffer.alloc(toRead);
493
+ const bytesRead = readSync(state.fd, buf, 0, toRead, state.position);
494
+ if (bytesRead <= 0) return;
495
+
496
+ state.position += bytesRead;
497
+ state.lineBuf += buf.subarray(0, bytesRead).toString();
498
+
499
+ const lines = state.lineBuf.split('\n');
500
+ state.lineBuf = lines.pop()!;
501
+
502
+ for (const line of lines) {
503
+ const trimmed = line.trim();
504
+ if (!trimmed) continue;
505
+ this.processLine(trimmed, state.onEvent, (sid) => {
506
+ state.capturedSessionId = sid;
507
+ }, (nonJson) => {
508
+ state.stdoutNonJsonLines.push(nonJson);
509
+ });
510
+ }
511
+ } catch {
512
+ // File may have been deleted or fd closed
513
+ }
514
+ }
515
+
516
+ // ── Manifest I/O ─────────────────────────────────────────────────
517
+
518
+ private loadManifest(): Record<string, CliSessionManifestEntry> {
519
+ try {
520
+ if (existsSync(this.manifestPath)) {
521
+ return JSON.parse(readFileSync(this.manifestPath, 'utf-8'));
522
+ }
523
+ } catch { /* corrupt or missing */ }
524
+ return {};
525
+ }
526
+
527
+ private writeManifest(manifest: Record<string, CliSessionManifestEntry>): void {
528
+ try {
529
+ writeFileSync(this.manifestPath, JSON.stringify(manifest, null, 2));
530
+ } catch { /* never break the app */ }
531
+ }
532
+
533
+ private saveManifestEntry(entry: CliSessionManifestEntry): void {
534
+ const manifest = this.loadManifest();
535
+ manifest[entry.claudeSessionId] = entry;
536
+ this.writeManifest(manifest);
537
+ }
538
+
539
+ private removeManifestEntry(claudeSessionId: string): void {
540
+ const manifest = this.loadManifest();
541
+ delete manifest[claudeSessionId];
542
+ this.writeManifest(manifest);
543
+ }
544
+
545
+ // ── Helpers ──────────────────────────────────────────────────────
546
+
547
+ private isPidAlive(pid: number): boolean {
548
+ try {
549
+ process.kill(pid, 0);
550
+ return true;
551
+ } catch {
552
+ return false;
553
+ }
554
+ }
555
+
556
+ private readStderrFile(stderrPath: string): string | null {
557
+ try {
558
+ const content = readFileSync(stderrPath, 'utf-8').trim();
559
+ return content || null;
560
+ } catch {
561
+ return null;
562
+ }
563
+ }
564
+
565
+ private cleanupTempFiles(...paths: (string | undefined)[]): void {
566
+ for (const p of paths) {
567
+ if (p) { try { unlinkSync(p); } catch { /* ignore */ } }
568
+ }
569
+ }
570
+
350
571
  private processLine = (
351
572
  line: string,
352
573
  onEvent: (event: Record<string, unknown>) => void,
@@ -356,13 +577,10 @@ export class ChatCliBridge {
356
577
  try {
357
578
  const event = JSON.parse(line) as Record<string, unknown>;
358
579
  onEvent(event);
359
-
360
- // Capture session_id from result event
361
580
  if (event.type === 'result' && typeof event.session_id === 'string') {
362
581
  onSessionId(event.session_id);
363
582
  }
364
583
  } catch {
365
- // Not valid JSON — log and capture it as it may contain useful error info
366
584
  const truncated = line.slice(0, 500);
367
585
  this.log('CHAT_CLI', `[stdout non-json] ${truncated}`);
368
586
  onNonJsonLine?.(truncated);
@@ -275,6 +275,84 @@ export class ChatWsHandler {
275
275
  }
276
276
  }
277
277
 
278
+ /**
279
+ * Resume tracking CLI sessions left running by a previous server instance.
280
+ * Called once at server startup.
281
+ */
282
+ resumeSessions = (): void => {
283
+ const resumed = this.chatCliBridge.resumeSessions((claudeSessionId) => {
284
+ return (event: Record<string, unknown>) => {
285
+ // Forward to connected WS (if any)
286
+ const currentWs = this.sessionToWs.get(claudeSessionId);
287
+ if (currentWs) {
288
+ this.sendWsMessage(currentWs, { type: 'stream_event', event });
289
+ }
290
+ // Accumulate content in stream context
291
+ this.accumulateEvent(claudeSessionId, event);
292
+ };
293
+ });
294
+
295
+ for (const { entry, completion } of resumed) {
296
+ // Reconstruct stream context
297
+ this.streamContexts.set(entry.claudeSessionId, {
298
+ sessionId: entry.sessionId,
299
+ userMessage: entry.userMessage,
300
+ attachmentBlocks: entry.attachmentBlocks as Array<{ type: 'image' | 'file'; path: string; name: string; mimeType: string }>,
301
+ lastAssistantContent: '[]',
302
+ persisted: false,
303
+ persistedAssistantMessageId: null,
304
+ persistPromise: null,
305
+ lastContextUsageJson: null,
306
+ contextWindow: null,
307
+ });
308
+
309
+ // Check if messages were already persisted (WS disconnect during previous server)
310
+ if (entry.sessionId && this.chatMessageRepository) {
311
+ this.chatMessageRepository.getMessages(entry.sessionId)
312
+ .then((messages) => {
313
+ const ctx = this.streamContexts.get(entry.claudeSessionId);
314
+ if (!ctx || !messages) return;
315
+ // If messages exist, mark as already persisted
316
+ const assistantMsg = [...messages].reverse().find(m => m.role === 'assistant');
317
+ if (assistantMsg) {
318
+ ctx.persisted = true;
319
+ ctx.persistedAssistantMessageId = assistantMsg.id;
320
+ }
321
+ })
322
+ .catch(() => { /* ignore */ });
323
+ }
324
+
325
+ // Wire up completion handler
326
+ completion
327
+ .then((result) => {
328
+ const currentWs = this.sessionToWs.get(entry.claudeSessionId);
329
+ if (currentWs) {
330
+ this.removeActiveStream(currentWs, entry.claudeSessionId);
331
+ }
332
+ this.sessionToWs.delete(entry.claudeSessionId);
333
+
334
+ this.persistMessages(entry.claudeSessionId);
335
+
336
+ if (currentWs) {
337
+ this.sendWsMessage(currentWs, {
338
+ type: 'stream_end',
339
+ exitCode: result.exitCode,
340
+ claudeSessionId: entry.claudeSessionId,
341
+ });
342
+ }
343
+ this.log('CHAT_WS', `Resumed session ${entry.claudeSessionId} completed (exit ${result.exitCode})`);
344
+ })
345
+ .catch((err: Error) => {
346
+ this.persistMessages(entry.claudeSessionId);
347
+ this.log('CHAT_WS', `Resumed session ${entry.claudeSessionId} error: ${err.message}`);
348
+ });
349
+ }
350
+
351
+ if (resumed.length > 0) {
352
+ this.log('CHAT_WS', `Resumed ${resumed.length} CLI session(s) from previous server instance`);
353
+ }
354
+ };
355
+
278
356
  /** Remove a single Claude session from a WebSocket's active stream set. */
279
357
  private removeActiveStream = (ws: WebSocket, claudeSessionId: string): void => {
280
358
  const streams = this.activeStreams.get(ws);
@@ -462,78 +540,16 @@ export class ChatWsHandler {
462
540
  if (currentWs) {
463
541
  this.sendWsMessage(currentWs, { type: 'stream_event', event });
464
542
  }
465
-
466
- // Accumulate assistant content and context usage for persistence
467
- const ctx = this.streamContexts.get(claudeSessionId);
468
- if (!ctx) return;
469
-
470
- // Extract per-call context usage from assistant events only.
471
- // The result event contains CUMULATIVE usage across all turns — not the context window size.
472
- if (event.type === 'assistant') {
473
- const message = event.message as Record<string, unknown> | undefined;
474
- const usage = message?.usage as Record<string, unknown> | undefined;
475
- if (usage) {
476
- const input = typeof usage.input_tokens === 'number' ? usage.input_tokens : 0;
477
- const output = typeof usage.output_tokens === 'number' ? usage.output_tokens : 0;
478
- const cacheCreation = typeof usage.cache_creation_input_tokens === 'number' ? usage.cache_creation_input_tokens : 0;
479
- const cacheRead = typeof usage.cache_read_input_tokens === 'number' ? usage.cache_read_input_tokens : 0;
480
- ctx.lastContextUsageJson = JSON.stringify({
481
- inputTokens: input,
482
- outputTokens: output,
483
- cacheCreationTokens: cacheCreation,
484
- cacheReadTokens: cacheRead,
485
- // totalTokens = input context only (output tokens don't count toward context window)
486
- totalTokens: input + cacheCreation + cacheRead,
487
- contextWindow: ctx.contextWindow,
488
- });
489
- }
490
- }
491
-
492
- // Extract contextWindow from result event's modelUsage
493
- if (event.type === 'result') {
494
- const modelUsage = event.modelUsage as Record<string, Record<string, unknown>> | undefined;
495
- if (modelUsage) {
496
- for (const info of Object.values(modelUsage)) {
497
- if (typeof info.contextWindow === 'number') {
498
- ctx.contextWindow = info.contextWindow;
499
- // Re-emit the last context usage JSON with the contextWindow included
500
- if (ctx.lastContextUsageJson) {
501
- try {
502
- const parsed = JSON.parse(ctx.lastContextUsageJson);
503
- parsed.contextWindow = ctx.contextWindow;
504
- ctx.lastContextUsageJson = JSON.stringify(parsed);
505
- } catch { /* ignore */ }
506
- }
507
- break;
508
- }
509
- }
510
- }
511
- }
512
-
513
- if (event.type === 'assistant') {
514
- const newBlocks = extractEventContent(event);
515
- if (newBlocks && newBlocks.length > 0) {
516
- const existing = JSON.parse(ctx.lastAssistantContent) as Array<Record<string, unknown>>;
517
- const merged = mergeAssistantContent(existing, newBlocks);
518
- ctx.lastAssistantContent = JSON.stringify(merged);
519
- }
520
- } else {
521
- // Capture tool_result blocks from human/tool_result events
522
- const toolResults = extractToolResults(event);
523
- if (toolResults.length > 0) {
524
- const existing = JSON.parse(ctx.lastAssistantContent) as Array<Record<string, unknown>>;
525
- const existingToolResultIds = new Set(
526
- existing.filter(b => b.type === 'tool_result').map(b => b.tool_use_id as string),
527
- );
528
- const newResults = toolResults.filter(b => !existingToolResultIds.has(b.tool_use_id as string));
529
- if (newResults.length > 0) {
530
- ctx.lastAssistantContent = JSON.stringify([...existing, ...newResults]);
531
- }
532
- }
533
- }
543
+ this.accumulateEvent(claudeSessionId, event);
534
544
  },
535
545
  });
536
546
 
547
+ // Update manifest with session metadata for cross-restart persistence
548
+ this.chatCliBridge.setManifestMetadata(claudeSessionId, {
549
+ sessionId: msg.sessionId || null,
550
+ attachmentBlocks: msg.attachmentBlocks || [],
551
+ });
552
+
537
553
  // Send composed system prompt to frontend and persist to session
538
554
  if (systemPrompt) {
539
555
  this.sendWsMessage(ws, { type: 'system_prompt', prompt: systemPrompt });
@@ -928,4 +944,72 @@ export class ChatWsHandler {
928
944
 
929
945
  this.log('CHAT_WS', `Client re-attached to in-flight stream for session ${claudeSessionId}`);
930
946
  };
947
+
948
+ /**
949
+ * Accumulate event data into the stream context for persistence.
950
+ * Shared by both direct spawns and resumed sessions.
951
+ */
952
+ private accumulateEvent = (claudeSessionId: string, event: Record<string, unknown>): void => {
953
+ const ctx = this.streamContexts.get(claudeSessionId);
954
+ if (!ctx) return;
955
+
956
+ if (event.type === 'assistant') {
957
+ const message = event.message as Record<string, unknown> | undefined;
958
+ const usage = message?.usage as Record<string, unknown> | undefined;
959
+ if (usage) {
960
+ const input = typeof usage.input_tokens === 'number' ? usage.input_tokens : 0;
961
+ const output = typeof usage.output_tokens === 'number' ? usage.output_tokens : 0;
962
+ const cacheCreation = typeof usage.cache_creation_input_tokens === 'number' ? usage.cache_creation_input_tokens : 0;
963
+ const cacheRead = typeof usage.cache_read_input_tokens === 'number' ? usage.cache_read_input_tokens : 0;
964
+ ctx.lastContextUsageJson = JSON.stringify({
965
+ inputTokens: input,
966
+ outputTokens: output,
967
+ cacheCreationTokens: cacheCreation,
968
+ cacheReadTokens: cacheRead,
969
+ totalTokens: input + cacheCreation + cacheRead,
970
+ contextWindow: ctx.contextWindow,
971
+ });
972
+ }
973
+ }
974
+
975
+ if (event.type === 'result') {
976
+ const modelUsage = event.modelUsage as Record<string, Record<string, unknown>> | undefined;
977
+ if (modelUsage) {
978
+ for (const info of Object.values(modelUsage)) {
979
+ if (typeof info.contextWindow === 'number') {
980
+ ctx.contextWindow = info.contextWindow;
981
+ if (ctx.lastContextUsageJson) {
982
+ try {
983
+ const parsed = JSON.parse(ctx.lastContextUsageJson);
984
+ parsed.contextWindow = ctx.contextWindow;
985
+ ctx.lastContextUsageJson = JSON.stringify(parsed);
986
+ } catch { /* ignore */ }
987
+ }
988
+ break;
989
+ }
990
+ }
991
+ }
992
+ }
993
+
994
+ if (event.type === 'assistant') {
995
+ const newBlocks = extractEventContent(event);
996
+ if (newBlocks && newBlocks.length > 0) {
997
+ const existing = JSON.parse(ctx.lastAssistantContent) as Array<Record<string, unknown>>;
998
+ const merged = mergeAssistantContent(existing, newBlocks);
999
+ ctx.lastAssistantContent = JSON.stringify(merged);
1000
+ }
1001
+ } else {
1002
+ const toolResults = extractToolResults(event);
1003
+ if (toolResults.length > 0) {
1004
+ const existing = JSON.parse(ctx.lastAssistantContent) as Array<Record<string, unknown>>;
1005
+ const existingToolResultIds = new Set(
1006
+ existing.filter(b => b.type === 'tool_result').map(b => b.tool_use_id as string),
1007
+ );
1008
+ const newResults = toolResults.filter(b => !existingToolResultIds.has(b.tool_use_id as string));
1009
+ if (newResults.length > 0) {
1010
+ ctx.lastAssistantContent = JSON.stringify([...existing, ...newResults]);
1011
+ }
1012
+ }
1013
+ }
1014
+ };
931
1015
  }
@@ -110,6 +110,8 @@ export const paths = {
110
110
  skillsDir: SKILLS_DIR,
111
111
  toolsDir: TOOLS_DIR,
112
112
  dataDir: DATA_DIR,
113
+ /** Runtime data dir for logs, manifests, etc. — survives tsx watch restarts. */
114
+ runtimeDataDir: LOG_DIR,
113
115
  developerSkillPath: DEVELOPER_SKILL_PATH,
114
116
  reviewerSkillPath: REVIEWER_SKILL_PATH,
115
117
  debuggerSkillPath: DEBUGGER_SKILL_PATH,
@@ -58,6 +58,9 @@ export class ProjectWorkspaceService {
58
58
  /** Verify that git is installed and available on the system. Throws if not. */
59
59
  verifyGitAvailable = async (): Promise<void> => {
60
60
  try {
61
+ // Ensure workspacesDir exists before spawning — if the cwd doesn't exist,
62
+ // spawn fails with ENOENT which would be misreported as "git not installed".
63
+ await this.ensureDir(this.workspacesDir);
61
64
  await this.claudeService.spawnCommand('git', ['--version'], this.workspacesDir);
62
65
  } catch {
63
66
  throw new Error('Git is required to create a project. Please install git and try again.');