@aion0/forge 0.1.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/CLAUDE.md +4 -0
- package/README.md +264 -0
- package/app/api/auth/[...nextauth]/route.ts +3 -0
- package/app/api/claude/[id]/route.ts +31 -0
- package/app/api/claude/[id]/stream/route.ts +63 -0
- package/app/api/claude/route.ts +28 -0
- package/app/api/claude-sessions/[projectName]/live/route.ts +72 -0
- package/app/api/claude-sessions/[projectName]/route.ts +37 -0
- package/app/api/claude-sessions/sync/route.ts +17 -0
- package/app/api/flows/route.ts +6 -0
- package/app/api/flows/run/route.ts +19 -0
- package/app/api/notify/test/route.ts +33 -0
- package/app/api/projects/route.ts +7 -0
- package/app/api/sessions/[id]/chat/route.ts +64 -0
- package/app/api/sessions/[id]/messages/route.ts +9 -0
- package/app/api/sessions/[id]/route.ts +17 -0
- package/app/api/sessions/route.ts +20 -0
- package/app/api/settings/route.ts +15 -0
- package/app/api/status/route.ts +12 -0
- package/app/api/tasks/[id]/route.ts +36 -0
- package/app/api/tasks/[id]/stream/route.ts +77 -0
- package/app/api/tasks/link/route.ts +37 -0
- package/app/api/tasks/route.ts +43 -0
- package/app/api/tasks/session/route.ts +14 -0
- package/app/api/templates/route.ts +6 -0
- package/app/api/tunnel/route.ts +20 -0
- package/app/api/watchers/route.ts +33 -0
- package/app/globals.css +26 -0
- package/app/icon.svg +26 -0
- package/app/layout.tsx +17 -0
- package/app/login/page.tsx +61 -0
- package/app/page.tsx +9 -0
- package/cli/mw.ts +377 -0
- package/components/ChatPanel.tsx +191 -0
- package/components/ClaudeTerminal.tsx +267 -0
- package/components/Dashboard.tsx +270 -0
- package/components/MarkdownContent.tsx +57 -0
- package/components/NewSessionModal.tsx +93 -0
- package/components/NewTaskModal.tsx +456 -0
- package/components/ProjectList.tsx +108 -0
- package/components/SessionList.tsx +74 -0
- package/components/SessionView.tsx +655 -0
- package/components/SettingsModal.tsx +366 -0
- package/components/StatusBar.tsx +99 -0
- package/components/TaskBoard.tsx +110 -0
- package/components/TaskDetail.tsx +351 -0
- package/components/TunnelToggle.tsx +163 -0
- package/components/WebTerminal.tsx +1069 -0
- package/docs/LOCAL-DEPLOY.md +144 -0
- package/docs/roadmap-multi-agent-workflow.md +330 -0
- package/instrumentation.ts +14 -0
- package/lib/auth.ts +47 -0
- package/lib/claude-process.ts +352 -0
- package/lib/claude-sessions.ts +267 -0
- package/lib/cloudflared.ts +218 -0
- package/lib/flows.ts +86 -0
- package/lib/init.ts +82 -0
- package/lib/notify.ts +75 -0
- package/lib/password.ts +77 -0
- package/lib/projects.ts +86 -0
- package/lib/session-manager.ts +156 -0
- package/lib/session-watcher.ts +345 -0
- package/lib/settings.ts +44 -0
- package/lib/task-manager.ts +668 -0
- package/lib/telegram-bot.ts +912 -0
- package/lib/terminal-server.ts +70 -0
- package/lib/terminal-standalone.ts +363 -0
- package/middleware.ts +33 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +16 -0
- package/package.json +66 -0
- package/postcss.config.mjs +7 -0
- package/src/config/index.ts +119 -0
- package/src/core/db/database.ts +133 -0
- package/src/core/memory/strategy.ts +32 -0
- package/src/core/providers/chat.ts +65 -0
- package/src/core/providers/registry.ts +60 -0
- package/src/core/session/manager.ts +190 -0
- package/src/types/index.ts +128 -0
- package/tsconfig.json +41 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code Process Manager
|
|
3
|
+
*
|
|
4
|
+
* Uses `claude -p --verbose --output-format stream-json` for structured output.
|
|
5
|
+
* Runs on your Claude Code subscription, not API key.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
9
|
+
import { loadSettings } from './settings';
|
|
10
|
+
|
|
11
|
+
export interface ClaudeMessage {
|
|
12
|
+
type: 'system' | 'assistant' | 'result';
|
|
13
|
+
subtype?: string; // e.g. 'tool_use', 'text', 'init'
|
|
14
|
+
content: string;
|
|
15
|
+
tool?: string; // tool name if tool_use
|
|
16
|
+
costUSD?: number;
|
|
17
|
+
sessionId?: string;
|
|
18
|
+
timestamp: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ClaudeProcess {
|
|
22
|
+
id: string;
|
|
23
|
+
projectName: string;
|
|
24
|
+
projectPath: string;
|
|
25
|
+
status: 'running' | 'idle' | 'exited';
|
|
26
|
+
createdAt: string;
|
|
27
|
+
messages: ClaudeMessage[];
|
|
28
|
+
conversationId?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ManagedProcess {
|
|
32
|
+
info: ClaudeProcess;
|
|
33
|
+
child: ChildProcess | null;
|
|
34
|
+
listeners: Set<(msg: ClaudeMessage) => void>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const processes = new Map<string, ManagedProcess>();
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Create a new Claude Code session for a project.
|
|
41
|
+
*/
|
|
42
|
+
export function createClaudeSession(projectName: string, projectPath: string): ClaudeProcess {
|
|
43
|
+
const id = `claude-${projectName}-${Date.now().toString(36)}`;
|
|
44
|
+
|
|
45
|
+
const info: ClaudeProcess = {
|
|
46
|
+
id,
|
|
47
|
+
projectName,
|
|
48
|
+
projectPath,
|
|
49
|
+
status: 'idle',
|
|
50
|
+
createdAt: new Date().toISOString(),
|
|
51
|
+
messages: [],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const managed: ManagedProcess = {
|
|
55
|
+
info,
|
|
56
|
+
child: null,
|
|
57
|
+
listeners: new Set(),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
processes.set(id, managed);
|
|
61
|
+
return info;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Send a message to Claude Code and stream the response.
|
|
66
|
+
*/
|
|
67
|
+
export function sendToClaudeSession(
|
|
68
|
+
id: string,
|
|
69
|
+
message: string,
|
|
70
|
+
conversationId?: string
|
|
71
|
+
): boolean {
|
|
72
|
+
const managed = processes.get(id);
|
|
73
|
+
if (!managed) return false;
|
|
74
|
+
if (managed.info.status === 'running') return false; // Already processing
|
|
75
|
+
|
|
76
|
+
const settings = loadSettings();
|
|
77
|
+
const claudePath = settings.claudePath || process.env.CLAUDE_PATH || 'claude';
|
|
78
|
+
|
|
79
|
+
// Build command args — --verbose is required for stream-json
|
|
80
|
+
const args = ['-p', '--verbose', '--output-format', 'stream-json'];
|
|
81
|
+
|
|
82
|
+
// Continue conversation if we have a session ID
|
|
83
|
+
const continueId = conversationId || managed.info.conversationId;
|
|
84
|
+
if (continueId) {
|
|
85
|
+
args.push('--resume', continueId);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Message goes last
|
|
89
|
+
args.push(message);
|
|
90
|
+
|
|
91
|
+
// Remove CLAUDECODE env var to avoid nesting detection
|
|
92
|
+
const env = { ...process.env };
|
|
93
|
+
delete env.CLAUDECODE;
|
|
94
|
+
|
|
95
|
+
const child = spawn(claudePath, args, {
|
|
96
|
+
cwd: managed.info.projectPath,
|
|
97
|
+
env,
|
|
98
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
99
|
+
shell: '/bin/zsh',
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
managed.child = child;
|
|
103
|
+
managed.info.status = 'running';
|
|
104
|
+
|
|
105
|
+
// Add user message
|
|
106
|
+
const userMsg: ClaudeMessage = {
|
|
107
|
+
type: 'system',
|
|
108
|
+
subtype: 'user_input',
|
|
109
|
+
content: message,
|
|
110
|
+
timestamp: new Date().toISOString(),
|
|
111
|
+
};
|
|
112
|
+
managed.info.messages.push(userMsg);
|
|
113
|
+
broadcast(managed, userMsg);
|
|
114
|
+
|
|
115
|
+
let buffer = '';
|
|
116
|
+
|
|
117
|
+
child.stdout?.on('data', (data: Buffer) => {
|
|
118
|
+
buffer += data.toString();
|
|
119
|
+
// stream-json outputs one JSON object per line
|
|
120
|
+
const lines = buffer.split('\n');
|
|
121
|
+
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
|
122
|
+
|
|
123
|
+
for (const line of lines) {
|
|
124
|
+
if (!line.trim()) continue;
|
|
125
|
+
try {
|
|
126
|
+
const parsed = JSON.parse(line);
|
|
127
|
+
const messages = parseClaudeOutput(parsed);
|
|
128
|
+
for (const msg of messages) {
|
|
129
|
+
managed.info.messages.push(msg);
|
|
130
|
+
|
|
131
|
+
// Capture session ID for conversation continuity
|
|
132
|
+
if (parsed.session_id && !managed.info.conversationId) {
|
|
133
|
+
managed.info.conversationId = parsed.session_id;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
broadcast(managed, msg);
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
// Non-JSON line, treat as text
|
|
140
|
+
const msg: ClaudeMessage = {
|
|
141
|
+
type: 'assistant',
|
|
142
|
+
subtype: 'text',
|
|
143
|
+
content: line,
|
|
144
|
+
timestamp: new Date().toISOString(),
|
|
145
|
+
};
|
|
146
|
+
managed.info.messages.push(msg);
|
|
147
|
+
broadcast(managed, msg);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
child.stderr?.on('data', (data: Buffer) => {
|
|
153
|
+
const text = data.toString().trim();
|
|
154
|
+
if (!text) return;
|
|
155
|
+
const msg: ClaudeMessage = {
|
|
156
|
+
type: 'system',
|
|
157
|
+
subtype: 'error',
|
|
158
|
+
content: text,
|
|
159
|
+
timestamp: new Date().toISOString(),
|
|
160
|
+
};
|
|
161
|
+
managed.info.messages.push(msg);
|
|
162
|
+
broadcast(managed, msg);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
child.on('exit', (code) => {
|
|
166
|
+
// Process remaining buffer
|
|
167
|
+
if (buffer.trim()) {
|
|
168
|
+
try {
|
|
169
|
+
const parsed = JSON.parse(buffer);
|
|
170
|
+
const messages = parseClaudeOutput(parsed);
|
|
171
|
+
for (const msg of messages) {
|
|
172
|
+
managed.info.messages.push(msg);
|
|
173
|
+
broadcast(managed, msg);
|
|
174
|
+
}
|
|
175
|
+
} catch {}
|
|
176
|
+
buffer = '';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
managed.info.status = 'idle';
|
|
180
|
+
managed.child = null;
|
|
181
|
+
|
|
182
|
+
const msg: ClaudeMessage = {
|
|
183
|
+
type: 'system',
|
|
184
|
+
subtype: 'complete',
|
|
185
|
+
content: `Done (exit ${code})`,
|
|
186
|
+
timestamp: new Date().toISOString(),
|
|
187
|
+
};
|
|
188
|
+
managed.info.messages.push(msg);
|
|
189
|
+
broadcast(managed, msg);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Parse actual claude stream-json output into ClaudeMessage(s).
|
|
197
|
+
*
|
|
198
|
+
* Real format examples:
|
|
199
|
+
* - {type: "system", subtype: "init", session_id: "...", model: "...", ...}
|
|
200
|
+
* - {type: "assistant", message: {content: [{type: "text", text: "..."}, {type: "tool_use", name: "...", input: {...}}]}, session_id: "..."}
|
|
201
|
+
* - {type: "result", subtype: "success", result: "...", total_cost_usd: 0.06, session_id: "..."}
|
|
202
|
+
*/
|
|
203
|
+
function parseClaudeOutput(parsed: any): ClaudeMessage[] {
|
|
204
|
+
const msgs: ClaudeMessage[] = [];
|
|
205
|
+
const ts = new Date().toISOString();
|
|
206
|
+
|
|
207
|
+
// System init message
|
|
208
|
+
if (parsed.type === 'system' && parsed.subtype === 'init') {
|
|
209
|
+
msgs.push({
|
|
210
|
+
type: 'system',
|
|
211
|
+
subtype: 'init',
|
|
212
|
+
content: `Model: ${parsed.model || 'unknown'}`,
|
|
213
|
+
sessionId: parsed.session_id,
|
|
214
|
+
timestamp: ts,
|
|
215
|
+
});
|
|
216
|
+
return msgs;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Assistant message — contains content array with text and tool_use blocks
|
|
220
|
+
if (parsed.type === 'assistant' && parsed.message?.content) {
|
|
221
|
+
const content = parsed.message.content;
|
|
222
|
+
if (Array.isArray(content)) {
|
|
223
|
+
for (const block of content) {
|
|
224
|
+
if (block.type === 'text' && block.text) {
|
|
225
|
+
msgs.push({
|
|
226
|
+
type: 'assistant',
|
|
227
|
+
subtype: 'text',
|
|
228
|
+
content: block.text,
|
|
229
|
+
sessionId: parsed.session_id,
|
|
230
|
+
timestamp: ts,
|
|
231
|
+
});
|
|
232
|
+
} else if (block.type === 'tool_use') {
|
|
233
|
+
msgs.push({
|
|
234
|
+
type: 'assistant',
|
|
235
|
+
subtype: 'tool_use',
|
|
236
|
+
content: typeof block.input === 'string'
|
|
237
|
+
? block.input
|
|
238
|
+
: JSON.stringify(block.input || {}),
|
|
239
|
+
tool: block.name,
|
|
240
|
+
sessionId: parsed.session_id,
|
|
241
|
+
timestamp: ts,
|
|
242
|
+
});
|
|
243
|
+
} else if (block.type === 'tool_result') {
|
|
244
|
+
msgs.push({
|
|
245
|
+
type: 'assistant',
|
|
246
|
+
subtype: 'tool_result',
|
|
247
|
+
content: typeof block.content === 'string'
|
|
248
|
+
? block.content
|
|
249
|
+
: JSON.stringify(block.content || ''),
|
|
250
|
+
tool: block.tool_use_id,
|
|
251
|
+
sessionId: parsed.session_id,
|
|
252
|
+
timestamp: ts,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return msgs;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Result message
|
|
261
|
+
if (parsed.type === 'result') {
|
|
262
|
+
msgs.push({
|
|
263
|
+
type: 'result',
|
|
264
|
+
subtype: parsed.subtype || 'success',
|
|
265
|
+
content: typeof parsed.result === 'string'
|
|
266
|
+
? parsed.result
|
|
267
|
+
: JSON.stringify(parsed.result || ''),
|
|
268
|
+
costUSD: parsed.total_cost_usd,
|
|
269
|
+
sessionId: parsed.session_id,
|
|
270
|
+
timestamp: ts,
|
|
271
|
+
});
|
|
272
|
+
return msgs;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Skip rate_limit_event and other internal events silently
|
|
276
|
+
if (parsed.type === 'rate_limit_event') {
|
|
277
|
+
return msgs;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Generic fallback — still show it
|
|
281
|
+
msgs.push({
|
|
282
|
+
type: 'assistant',
|
|
283
|
+
subtype: parsed.type || 'unknown',
|
|
284
|
+
content: JSON.stringify(parsed),
|
|
285
|
+
timestamp: ts,
|
|
286
|
+
});
|
|
287
|
+
return msgs;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function broadcast(managed: ManagedProcess, msg: ClaudeMessage) {
|
|
291
|
+
for (const listener of managed.listeners) {
|
|
292
|
+
try { listener(msg); } catch {}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Attach a listener to receive messages from a Claude session.
|
|
298
|
+
*/
|
|
299
|
+
export function attachToProcess(id: string, onMessage: (msg: ClaudeMessage) => void): (() => void) | null {
|
|
300
|
+
const managed = processes.get(id);
|
|
301
|
+
if (!managed) return null;
|
|
302
|
+
|
|
303
|
+
// Send history first
|
|
304
|
+
for (const msg of managed.info.messages) {
|
|
305
|
+
onMessage(msg);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
managed.listeners.add(onMessage);
|
|
309
|
+
return () => { managed.listeners.delete(onMessage); };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Kill current running command (not the session).
|
|
314
|
+
*/
|
|
315
|
+
export function killProcess(id: string): boolean {
|
|
316
|
+
const managed = processes.get(id);
|
|
317
|
+
if (!managed) return false;
|
|
318
|
+
if (managed.child) {
|
|
319
|
+
managed.child.kill('SIGTERM');
|
|
320
|
+
managed.info.status = 'idle';
|
|
321
|
+
}
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Delete a session entirely.
|
|
327
|
+
*/
|
|
328
|
+
export function deleteSession(id: string): boolean {
|
|
329
|
+
const managed = processes.get(id);
|
|
330
|
+
if (!managed) return false;
|
|
331
|
+
if (managed.child) managed.child.kill('SIGTERM');
|
|
332
|
+
processes.delete(id);
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* List all sessions.
|
|
338
|
+
*/
|
|
339
|
+
export function listProcesses(): ClaudeProcess[] {
|
|
340
|
+
return Array.from(processes.values()).map(m => ({
|
|
341
|
+
...m.info,
|
|
342
|
+
messages: [], // Don't send full history in list
|
|
343
|
+
}));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Get a single session.
|
|
348
|
+
*/
|
|
349
|
+
export function getProcess(id: string): ClaudeProcess | null {
|
|
350
|
+
const managed = processes.get(id);
|
|
351
|
+
return managed?.info || null;
|
|
352
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Sessions — read Claude Code's on-disk session JSONL files.
|
|
3
|
+
* Enables live-tailing of local CLI sessions from the web UI.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync, statSync, readdirSync, watch, openSync, readSync, closeSync, unlinkSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { getProjectInfo } from './projects';
|
|
10
|
+
|
|
11
|
+
export interface ClaudeSessionInfo {
|
|
12
|
+
sessionId: string;
|
|
13
|
+
summary?: string;
|
|
14
|
+
firstPrompt?: string;
|
|
15
|
+
messageCount?: number;
|
|
16
|
+
created?: string;
|
|
17
|
+
modified?: string;
|
|
18
|
+
gitBranch?: string;
|
|
19
|
+
fileSize: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SessionEntry {
|
|
23
|
+
type: 'user' | 'assistant_text' | 'tool_use' | 'tool_result' | 'thinking' | 'system';
|
|
24
|
+
content: string;
|
|
25
|
+
toolName?: string;
|
|
26
|
+
model?: string;
|
|
27
|
+
timestamp?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Convert a project path to the Claude projects directory.
|
|
32
|
+
* Claude uses: ~/.claude/projects/<path-with-slashes-replaced-by-dashes>/
|
|
33
|
+
*/
|
|
34
|
+
export function projectPathToClaudeDir(projectPath: string): string {
|
|
35
|
+
const hash = projectPath.replace(/\//g, '-');
|
|
36
|
+
return join(homedir(), '.claude', 'projects', hash);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get the Claude sessions directory for a project by name.
|
|
41
|
+
*/
|
|
42
|
+
export function getClaudeDirForProject(projectName: string): string | null {
|
|
43
|
+
const project = getProjectInfo(projectName);
|
|
44
|
+
if (!project) return null;
|
|
45
|
+
const dir = projectPathToClaudeDir(project.path);
|
|
46
|
+
return existsSync(dir) ? dir : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* List all sessions for a project.
|
|
51
|
+
*/
|
|
52
|
+
export function listClaudeSessions(projectName: string): ClaudeSessionInfo[] {
|
|
53
|
+
const dir = getClaudeDirForProject(projectName);
|
|
54
|
+
if (!dir) return [];
|
|
55
|
+
|
|
56
|
+
// Try reading sessions-index.json first
|
|
57
|
+
const indexPath = join(dir, 'sessions-index.json');
|
|
58
|
+
if (existsSync(indexPath)) {
|
|
59
|
+
try {
|
|
60
|
+
const index = JSON.parse(readFileSync(indexPath, 'utf-8'));
|
|
61
|
+
const sessions: ClaudeSessionInfo[] = (index.entries || []).map((e: any) => ({
|
|
62
|
+
sessionId: e.sessionId,
|
|
63
|
+
summary: e.summary,
|
|
64
|
+
firstPrompt: e.firstPrompt,
|
|
65
|
+
messageCount: e.messageCount,
|
|
66
|
+
created: e.created,
|
|
67
|
+
modified: e.modified,
|
|
68
|
+
gitBranch: e.gitBranch,
|
|
69
|
+
fileSize: 0,
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
// Enrich with file size
|
|
73
|
+
for (const s of sessions) {
|
|
74
|
+
const fp = join(dir, `${s.sessionId}.jsonl`);
|
|
75
|
+
try { s.fileSize = statSync(fp).size; } catch {}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return sessions.sort((a, b) => (b.modified || '').localeCompare(a.modified || ''));
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Fallback: scan for .jsonl files
|
|
83
|
+
try {
|
|
84
|
+
const files = readdirSync(dir).filter(f => f.endsWith('.jsonl'));
|
|
85
|
+
return files.map(f => {
|
|
86
|
+
const sessionId = f.replace('.jsonl', '');
|
|
87
|
+
const fp = join(dir, f);
|
|
88
|
+
const stat = statSync(fp);
|
|
89
|
+
return {
|
|
90
|
+
sessionId,
|
|
91
|
+
fileSize: stat.size,
|
|
92
|
+
modified: stat.mtime.toISOString(),
|
|
93
|
+
created: stat.birthtime.toISOString(),
|
|
94
|
+
};
|
|
95
|
+
}).sort((a, b) => (b.modified || '').localeCompare(a.modified || ''));
|
|
96
|
+
} catch {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Parse a single JSONL line into displayable SessionEntry items.
|
|
103
|
+
* One JSONL line can produce multiple entries (e.g., assistant message with text + tool_use).
|
|
104
|
+
*/
|
|
105
|
+
export function parseSessionLine(line: string): SessionEntry[] {
|
|
106
|
+
try {
|
|
107
|
+
const obj = JSON.parse(line);
|
|
108
|
+
const entries: SessionEntry[] = [];
|
|
109
|
+
const ts = obj.timestamp;
|
|
110
|
+
|
|
111
|
+
// Skip internal types
|
|
112
|
+
if (obj.type === 'queue-operation' || obj.type === 'last-prompt' || obj.type === 'rate_limit_event') {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// User message
|
|
117
|
+
if (obj.type === 'user' && obj.message) {
|
|
118
|
+
const content = typeof obj.message.content === 'string'
|
|
119
|
+
? obj.message.content
|
|
120
|
+
: JSON.stringify(obj.message.content);
|
|
121
|
+
entries.push({ type: 'user', content, timestamp: ts });
|
|
122
|
+
return entries;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Assistant message — can contain multiple content blocks
|
|
126
|
+
if (obj.type === 'assistant' && obj.message?.content) {
|
|
127
|
+
const model = obj.message.model;
|
|
128
|
+
for (const block of obj.message.content) {
|
|
129
|
+
if (block.type === 'thinking' && block.thinking) {
|
|
130
|
+
entries.push({ type: 'thinking', content: block.thinking, model, timestamp: ts });
|
|
131
|
+
} else if (block.type === 'text' && block.text) {
|
|
132
|
+
entries.push({ type: 'assistant_text', content: block.text, model, timestamp: ts });
|
|
133
|
+
} else if (block.type === 'tool_use') {
|
|
134
|
+
entries.push({
|
|
135
|
+
type: 'tool_use',
|
|
136
|
+
content: JSON.stringify(block.input || {}, null, 2),
|
|
137
|
+
toolName: block.name,
|
|
138
|
+
model,
|
|
139
|
+
timestamp: ts,
|
|
140
|
+
});
|
|
141
|
+
} else if (block.type === 'tool_result') {
|
|
142
|
+
const resultContent = typeof block.content === 'string'
|
|
143
|
+
? block.content
|
|
144
|
+
: JSON.stringify(block.content);
|
|
145
|
+
entries.push({ type: 'tool_result', content: resultContent, timestamp: ts });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return entries;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Tool result message (separate line)
|
|
152
|
+
if (obj.type === 'tool_result' && obj.message?.content) {
|
|
153
|
+
for (const block of obj.message.content) {
|
|
154
|
+
if (block.type === 'tool_result') {
|
|
155
|
+
const content = typeof block.content === 'string'
|
|
156
|
+
? block.content
|
|
157
|
+
: JSON.stringify(block.content);
|
|
158
|
+
entries.push({ type: 'tool_result', content, timestamp: ts });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return entries;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// System messages
|
|
165
|
+
if (obj.type === 'system') {
|
|
166
|
+
entries.push({ type: 'system', content: obj.content || JSON.stringify(obj), timestamp: ts });
|
|
167
|
+
return entries;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return entries;
|
|
171
|
+
} catch {
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Get the JSONL file path for a session.
|
|
178
|
+
*/
|
|
179
|
+
export function getSessionFilePath(projectName: string, sessionId: string): string | null {
|
|
180
|
+
const dir = getClaudeDirForProject(projectName);
|
|
181
|
+
if (!dir) return null;
|
|
182
|
+
const fp = join(dir, `${sessionId}.jsonl`);
|
|
183
|
+
return existsSync(fp) ? fp : null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Read all entries from a session file.
|
|
188
|
+
*/
|
|
189
|
+
export function readSessionEntries(filePath: string): SessionEntry[] {
|
|
190
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
191
|
+
const entries: SessionEntry[] = [];
|
|
192
|
+
for (const line of content.split('\n')) {
|
|
193
|
+
if (!line.trim()) continue;
|
|
194
|
+
entries.push(...parseSessionLine(line));
|
|
195
|
+
}
|
|
196
|
+
return entries;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Delete a session file and its cache entry.
|
|
201
|
+
*/
|
|
202
|
+
export function deleteSession(projectName: string, sessionId: string): boolean {
|
|
203
|
+
const dir = getClaudeDirForProject(projectName);
|
|
204
|
+
if (!dir) return false;
|
|
205
|
+
const fp = join(dir, `${sessionId}.jsonl`);
|
|
206
|
+
if (!existsSync(fp)) return false;
|
|
207
|
+
unlinkSync(fp);
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Tail a session file — calls onNewEntries when new lines are appended.
|
|
213
|
+
* Returns a cleanup function.
|
|
214
|
+
*/
|
|
215
|
+
export function tailSessionFile(
|
|
216
|
+
filePath: string,
|
|
217
|
+
onNewEntries: (entries: SessionEntry[], raw: string) => void,
|
|
218
|
+
onError?: (err: Error) => void,
|
|
219
|
+
): () => void {
|
|
220
|
+
let bytesRead = 0;
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
bytesRead = statSync(filePath).size;
|
|
224
|
+
} catch {}
|
|
225
|
+
|
|
226
|
+
const readNewBytes = () => {
|
|
227
|
+
try {
|
|
228
|
+
const stat = statSync(filePath);
|
|
229
|
+
if (stat.size <= bytesRead) return;
|
|
230
|
+
|
|
231
|
+
const fd = openSync(filePath, 'r');
|
|
232
|
+
const buf = Buffer.alloc(stat.size - bytesRead);
|
|
233
|
+
readSync(fd, buf, 0, buf.length, bytesRead);
|
|
234
|
+
closeSync(fd);
|
|
235
|
+
bytesRead = stat.size;
|
|
236
|
+
|
|
237
|
+
const newText = buf.toString('utf-8');
|
|
238
|
+
const lines = newText.split('\n').filter(l => l.trim());
|
|
239
|
+
const entries: SessionEntry[] = [];
|
|
240
|
+
for (const line of lines) {
|
|
241
|
+
entries.push(...parseSessionLine(line));
|
|
242
|
+
}
|
|
243
|
+
if (entries.length > 0) {
|
|
244
|
+
onNewEntries(entries, newText);
|
|
245
|
+
}
|
|
246
|
+
} catch (err) {
|
|
247
|
+
onError?.(err as Error);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// Use both fs.watch AND polling as fallback (fs.watch is unreliable on macOS)
|
|
252
|
+
const watcher = watch(filePath, (eventType) => {
|
|
253
|
+
if (eventType === 'change') {
|
|
254
|
+
readNewBytes();
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
watcher.on('error', (err) => onError?.(err));
|
|
259
|
+
|
|
260
|
+
// Poll every 5 seconds as fallback
|
|
261
|
+
const pollTimer = setInterval(readNewBytes, 5000);
|
|
262
|
+
|
|
263
|
+
return () => {
|
|
264
|
+
watcher.close();
|
|
265
|
+
clearInterval(pollTimer);
|
|
266
|
+
};
|
|
267
|
+
}
|