@assistkick/create 1.26.0 → 1.27.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 +1 -1
- package/templates/assistkick-product-system/packages/backend/src/server.ts +5 -2
- package/templates/assistkick-product-system/packages/backend/src/services/chat_cli_bridge.ts +354 -136
- package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.ts +153 -69
- package/templates/assistkick-product-system/packages/backend/src/services/init.ts +2 -0
package/package.json
CHANGED
|
@@ -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.
|
|
297
|
+
chatCliBridge.detachAll(); // Release without killing — CLI processes survive restart
|
|
295
298
|
server.close();
|
|
296
299
|
});
|
|
297
300
|
|
package/templates/assistkick-product-system/packages/backend/src/services/chat_cli_bridge.ts
CHANGED
|
@@ -1,26 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ChatCliBridge — spawns Claude Code CLI
|
|
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 {
|
|
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
|
|
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
|
|
172
|
-
*
|
|
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
|
-
|
|
222
|
+
detached: true,
|
|
223
|
+
stdio: ['pipe', stdoutFd, stderrFd],
|
|
193
224
|
});
|
|
194
225
|
|
|
195
|
-
//
|
|
196
|
-
|
|
226
|
+
// Close write FDs in parent — kernel keeps them open for the child
|
|
227
|
+
closeSync(stdoutFd);
|
|
228
|
+
closeSync(stderrFd);
|
|
197
229
|
|
|
198
|
-
const
|
|
199
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
|
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}
|
|
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 (
|
|
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
|
-
|
|
304
|
-
|
|
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
|
-
|
|
307
|
-
|
|
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
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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 (
|
|
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}
|
|
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.
|
|
444
|
+
return this.activeTailers.size;
|
|
344
445
|
};
|
|
345
446
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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);
|
package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.ts
CHANGED
|
@@ -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,
|