@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.
Files changed (80) hide show
  1. package/CLAUDE.md +4 -0
  2. package/README.md +264 -0
  3. package/app/api/auth/[...nextauth]/route.ts +3 -0
  4. package/app/api/claude/[id]/route.ts +31 -0
  5. package/app/api/claude/[id]/stream/route.ts +63 -0
  6. package/app/api/claude/route.ts +28 -0
  7. package/app/api/claude-sessions/[projectName]/live/route.ts +72 -0
  8. package/app/api/claude-sessions/[projectName]/route.ts +37 -0
  9. package/app/api/claude-sessions/sync/route.ts +17 -0
  10. package/app/api/flows/route.ts +6 -0
  11. package/app/api/flows/run/route.ts +19 -0
  12. package/app/api/notify/test/route.ts +33 -0
  13. package/app/api/projects/route.ts +7 -0
  14. package/app/api/sessions/[id]/chat/route.ts +64 -0
  15. package/app/api/sessions/[id]/messages/route.ts +9 -0
  16. package/app/api/sessions/[id]/route.ts +17 -0
  17. package/app/api/sessions/route.ts +20 -0
  18. package/app/api/settings/route.ts +15 -0
  19. package/app/api/status/route.ts +12 -0
  20. package/app/api/tasks/[id]/route.ts +36 -0
  21. package/app/api/tasks/[id]/stream/route.ts +77 -0
  22. package/app/api/tasks/link/route.ts +37 -0
  23. package/app/api/tasks/route.ts +43 -0
  24. package/app/api/tasks/session/route.ts +14 -0
  25. package/app/api/templates/route.ts +6 -0
  26. package/app/api/tunnel/route.ts +20 -0
  27. package/app/api/watchers/route.ts +33 -0
  28. package/app/globals.css +26 -0
  29. package/app/icon.svg +26 -0
  30. package/app/layout.tsx +17 -0
  31. package/app/login/page.tsx +61 -0
  32. package/app/page.tsx +9 -0
  33. package/cli/mw.ts +377 -0
  34. package/components/ChatPanel.tsx +191 -0
  35. package/components/ClaudeTerminal.tsx +267 -0
  36. package/components/Dashboard.tsx +270 -0
  37. package/components/MarkdownContent.tsx +57 -0
  38. package/components/NewSessionModal.tsx +93 -0
  39. package/components/NewTaskModal.tsx +456 -0
  40. package/components/ProjectList.tsx +108 -0
  41. package/components/SessionList.tsx +74 -0
  42. package/components/SessionView.tsx +655 -0
  43. package/components/SettingsModal.tsx +366 -0
  44. package/components/StatusBar.tsx +99 -0
  45. package/components/TaskBoard.tsx +110 -0
  46. package/components/TaskDetail.tsx +351 -0
  47. package/components/TunnelToggle.tsx +163 -0
  48. package/components/WebTerminal.tsx +1069 -0
  49. package/docs/LOCAL-DEPLOY.md +144 -0
  50. package/docs/roadmap-multi-agent-workflow.md +330 -0
  51. package/instrumentation.ts +14 -0
  52. package/lib/auth.ts +47 -0
  53. package/lib/claude-process.ts +352 -0
  54. package/lib/claude-sessions.ts +267 -0
  55. package/lib/cloudflared.ts +218 -0
  56. package/lib/flows.ts +86 -0
  57. package/lib/init.ts +82 -0
  58. package/lib/notify.ts +75 -0
  59. package/lib/password.ts +77 -0
  60. package/lib/projects.ts +86 -0
  61. package/lib/session-manager.ts +156 -0
  62. package/lib/session-watcher.ts +345 -0
  63. package/lib/settings.ts +44 -0
  64. package/lib/task-manager.ts +668 -0
  65. package/lib/telegram-bot.ts +912 -0
  66. package/lib/terminal-server.ts +70 -0
  67. package/lib/terminal-standalone.ts +363 -0
  68. package/middleware.ts +33 -0
  69. package/next-env.d.ts +6 -0
  70. package/next.config.ts +16 -0
  71. package/package.json +66 -0
  72. package/postcss.config.mjs +7 -0
  73. package/src/config/index.ts +119 -0
  74. package/src/core/db/database.ts +133 -0
  75. package/src/core/memory/strategy.ts +32 -0
  76. package/src/core/providers/chat.ts +65 -0
  77. package/src/core/providers/registry.ts +60 -0
  78. package/src/core/session/manager.ts +190 -0
  79. package/src/types/index.ts +128 -0
  80. 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
+ }