@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,668 @@
1
+ /**
2
+ * Task Manager — persistent task queue backed by SQLite.
3
+ * Tasks survive server restarts. Background runner picks up queued tasks.
4
+ */
5
+
6
+ import { randomUUID } from 'node:crypto';
7
+ import { spawn, execSync } from 'node:child_process';
8
+ import { realpathSync } from 'node:fs';
9
+ import { getDb } from '@/src/core/db/database';
10
+ import { getDbPath } from '@/src/config';
11
+ import { loadSettings } from './settings';
12
+ import { notifyTaskComplete, notifyTaskFailed } from './notify';
13
+ import type { Task, TaskLogEntry, TaskStatus, TaskMode, WatchConfig } from '@/src/types';
14
+
15
+ let runner: ReturnType<typeof setInterval> | null = null;
16
+ let currentTaskId: string | null = null;
17
+
18
+ // Per-project concurrency: track which projects have a running prompt task
19
+ const runningProjects = new Set<string>();
20
+
21
+ // Event listeners for real-time updates
22
+ type TaskListener = (taskId: string, event: 'log' | 'status', data?: any) => void;
23
+ const listeners = new Set<TaskListener>();
24
+
25
+ export function onTaskEvent(fn: TaskListener): () => void {
26
+ listeners.add(fn);
27
+ return () => listeners.delete(fn);
28
+ }
29
+
30
+ function emit(taskId: string, event: 'log' | 'status', data?: any) {
31
+ for (const fn of listeners) {
32
+ try { fn(taskId, event, data); } catch {}
33
+ }
34
+ }
35
+
36
+ function db() {
37
+ return getDb(getDbPath());
38
+ }
39
+
40
+ // ─── CRUD ────────────────────────────────────────────────────
41
+
42
+ export function createTask(opts: {
43
+ projectName: string;
44
+ projectPath: string;
45
+ prompt: string;
46
+ mode?: TaskMode;
47
+ priority?: number;
48
+ conversationId?: string; // Explicit override; otherwise auto-inherits from project
49
+ scheduledAt?: string; // ISO timestamp — task won't run until this time
50
+ watchConfig?: WatchConfig;
51
+ }): Task {
52
+ const id = randomUUID().slice(0, 8);
53
+ const mode = opts.mode || 'prompt';
54
+
55
+ // For prompt mode: auto-inherit conversation_id
56
+ // For monitor mode: conversationId is required (the session to watch)
57
+ const convId = opts.conversationId === ''
58
+ ? null
59
+ : (opts.conversationId || (mode === 'prompt' ? getProjectConversationId(opts.projectName) : null));
60
+
61
+ db().prepare(`
62
+ INSERT INTO tasks (id, project_name, project_path, prompt, mode, status, priority, conversation_id, log, scheduled_at, watch_config)
63
+ VALUES (?, ?, ?, ?, ?, 'queued', ?, ?, '[]', ?, ?)
64
+ `).run(
65
+ id, opts.projectName, opts.projectPath, opts.prompt, mode,
66
+ opts.priority || 0, convId || null, opts.scheduledAt || null,
67
+ opts.watchConfig ? JSON.stringify(opts.watchConfig) : null,
68
+ );
69
+
70
+ // Kick the runner
71
+ ensureRunnerStarted();
72
+
73
+ return getTask(id)!;
74
+ }
75
+
76
+ /**
77
+ * Get the most recent conversation_id for a project.
78
+ * This allows all tasks for the same project to share one Claude session.
79
+ */
80
+ export function getProjectConversationId(projectName: string): string | null {
81
+ const row = db().prepare(`
82
+ SELECT conversation_id FROM tasks
83
+ WHERE project_name = ? AND conversation_id IS NOT NULL AND status = 'done'
84
+ ORDER BY completed_at DESC LIMIT 1
85
+ `).get(projectName) as any;
86
+ return row?.conversation_id || null;
87
+ }
88
+
89
+ export function getTask(id: string): Task | null {
90
+ const row = db().prepare('SELECT * FROM tasks WHERE id = ?').get(id) as any;
91
+ if (!row) return null;
92
+ return rowToTask(row);
93
+ }
94
+
95
+ export function listTasks(status?: TaskStatus): Task[] {
96
+ let query = 'SELECT * FROM tasks';
97
+ const params: string[] = [];
98
+ if (status) {
99
+ query += ' WHERE status = ?';
100
+ params.push(status);
101
+ }
102
+ query += ' ORDER BY created_at DESC';
103
+ const rows = db().prepare(query).all(...params) as any[];
104
+ return rows.map(rowToTask);
105
+ }
106
+
107
+ export function cancelTask(id: string): boolean {
108
+ const task = getTask(id);
109
+ if (!task) return false;
110
+ if (task.status === 'done' || task.status === 'failed') return false;
111
+
112
+ // Cancel monitor tasks
113
+ if (task.mode === 'monitor' && activeMonitors.has(id)) {
114
+ cancelMonitor(id);
115
+ return true;
116
+ }
117
+
118
+ updateTaskStatus(id, 'cancelled');
119
+
120
+ // Clean up project lock if this was a running prompt task
121
+ if (task.status === 'running') {
122
+ runningProjects.delete(task.projectName);
123
+ }
124
+
125
+ return true;
126
+ }
127
+
128
+ export function deleteTask(id: string): boolean {
129
+ const task = getTask(id);
130
+ if (!task) return false;
131
+ if (task.status === 'running') cancelTask(id);
132
+ db().prepare('DELETE FROM tasks WHERE id = ?').run(id);
133
+ return true;
134
+ }
135
+
136
+ export function retryTask(id: string): Task | null {
137
+ const task = getTask(id);
138
+ if (!task) return null;
139
+ if (task.status !== 'failed' && task.status !== 'cancelled') return null;
140
+
141
+ // Create a new task with same params
142
+ return createTask({
143
+ projectName: task.projectName,
144
+ projectPath: task.projectPath,
145
+ prompt: task.prompt,
146
+ priority: task.priority,
147
+ });
148
+ }
149
+
150
+ // ─── Background Runner ───────────────────────────────────────
151
+
152
+ export function ensureRunnerStarted() {
153
+ if (runner) return;
154
+ runner = setInterval(processNextTask, 3000);
155
+ // Also try immediately
156
+ processNextTask();
157
+ }
158
+
159
+ export function stopRunner() {
160
+ if (runner) {
161
+ clearInterval(runner);
162
+ runner = null;
163
+ }
164
+ }
165
+
166
+ async function processNextTask() {
167
+ // Find all queued tasks ready to run
168
+ const queued = db().prepare(`
169
+ SELECT * FROM tasks WHERE status = 'queued'
170
+ AND (scheduled_at IS NULL OR scheduled_at <= datetime('now'))
171
+ ORDER BY priority DESC, created_at ASC
172
+ `).all() as any[];
173
+
174
+ for (const next of queued) {
175
+ const task = rowToTask(next);
176
+
177
+ if (task.mode === 'monitor') {
178
+ // Monitor tasks run in background, don't block the runner
179
+ startMonitorTask(task);
180
+ continue;
181
+ }
182
+
183
+ // Skip if this project already has a running prompt task
184
+ if (runningProjects.has(task.projectName)) continue;
185
+
186
+ // Run this task
187
+ runningProjects.add(task.projectName);
188
+ currentTaskId = task.id;
189
+
190
+ // Execute async — don't await so we can process tasks for other projects in parallel
191
+ executeTask(task)
192
+ .catch((err: any) => {
193
+ appendLog(task.id, { type: 'system', subtype: 'error', content: err.message, timestamp: new Date().toISOString() });
194
+ updateTaskStatus(task.id, 'failed', err.message);
195
+ })
196
+ .finally(() => {
197
+ runningProjects.delete(task.projectName);
198
+ if (currentTaskId === task.id) currentTaskId = null;
199
+ });
200
+ }
201
+ }
202
+
203
+ function executeTask(task: Task): Promise<void> {
204
+ return new Promise((resolve, reject) => {
205
+ const settings = loadSettings();
206
+ const claudePath = settings.claudePath || process.env.CLAUDE_PATH || 'claude';
207
+
208
+ const args = ['-p', '--verbose', '--output-format', 'stream-json'];
209
+
210
+ // Resume specific session to continue the conversation
211
+ if (task.conversationId) {
212
+ args.push('--resume', task.conversationId);
213
+ }
214
+
215
+ args.push(task.prompt);
216
+
217
+ const env = { ...process.env };
218
+ delete env.CLAUDECODE;
219
+
220
+ updateTaskStatus(task.id, 'running');
221
+ db().prepare('UPDATE tasks SET started_at = datetime(\'now\') WHERE id = ?').run(task.id);
222
+
223
+ // Resolve the actual claude CLI script path (claude is a symlink to a .js file)
224
+ const resolvedClaude = resolveClaudePath(claudePath);
225
+ console.log(`[task-runner] Spawning: ${resolvedClaude.cmd} ${resolvedClaude.prefix.concat(args).join(' ')}`);
226
+ console.log(`[task-runner] CWD: ${task.projectPath}`);
227
+
228
+ const child = spawn(resolvedClaude.cmd, [...resolvedClaude.prefix, ...args], {
229
+ cwd: task.projectPath,
230
+ env,
231
+ stdio: ['ignore', 'pipe', 'pipe'],
232
+ });
233
+
234
+ let buffer = '';
235
+ let resultText = '';
236
+ let totalCost = 0;
237
+ let sessionId = '';
238
+
239
+ child.on('error', (err) => {
240
+ console.error(`[task-runner] Spawn error:`, err.message);
241
+ updateTaskStatus(task.id, 'failed', err.message);
242
+ reject(err);
243
+ });
244
+
245
+ child.stdout?.on('data', (data: Buffer) => {
246
+ console.log(`[task-runner] stdout chunk: ${data.toString().slice(0, 200)}`);
247
+
248
+ // Check if cancelled
249
+ if (getTask(task.id)?.status === 'cancelled') {
250
+ child.kill('SIGTERM');
251
+ return;
252
+ }
253
+
254
+ buffer += data.toString();
255
+ const lines = buffer.split('\n');
256
+ buffer = lines.pop() || '';
257
+
258
+ for (const line of lines) {
259
+ if (!line.trim()) continue;
260
+ try {
261
+ const parsed = JSON.parse(line);
262
+ const entries = parseStreamJson(parsed);
263
+ for (const entry of entries) {
264
+ appendLog(task.id, entry);
265
+ }
266
+
267
+ if (parsed.session_id) sessionId = parsed.session_id;
268
+ if (parsed.type === 'result') {
269
+ resultText = typeof parsed.result === 'string' ? parsed.result : JSON.stringify(parsed.result);
270
+ totalCost = parsed.total_cost_usd || 0;
271
+ }
272
+ } catch {}
273
+ }
274
+ });
275
+
276
+ child.stderr?.on('data', (data: Buffer) => {
277
+ const text = data.toString().trim();
278
+ console.error(`[task-runner] stderr: ${text.slice(0, 300)}`);
279
+ if (text) {
280
+ appendLog(task.id, { type: 'system', subtype: 'error', content: text, timestamp: new Date().toISOString() });
281
+ }
282
+ });
283
+
284
+ child.on('exit', (code, signal) => {
285
+ console.log(`[task-runner] Process exited: code=${code}, signal=${signal}`);
286
+ // Process remaining buffer
287
+ if (buffer.trim()) {
288
+ try {
289
+ const parsed = JSON.parse(buffer);
290
+ const entries = parseStreamJson(parsed);
291
+ for (const entry of entries) appendLog(task.id, entry);
292
+ if (parsed.type === 'result') {
293
+ resultText = typeof parsed.result === 'string' ? parsed.result : JSON.stringify(parsed.result);
294
+ totalCost = parsed.total_cost_usd || 0;
295
+ }
296
+ } catch {}
297
+ }
298
+
299
+ // Save conversation ID for follow-up
300
+ if (sessionId) {
301
+ db().prepare('UPDATE tasks SET conversation_id = ? WHERE id = ?').run(sessionId, task.id);
302
+ }
303
+
304
+ // Capture git diff
305
+ try {
306
+ const { execSync } = require('node:child_process');
307
+ const diff = execSync('git diff HEAD', { cwd: task.projectPath, timeout: 5000 }).toString();
308
+ if (diff.trim()) {
309
+ db().prepare('UPDATE tasks SET git_diff = ? WHERE id = ?').run(diff, task.id);
310
+ }
311
+ } catch {}
312
+
313
+ const currentStatus = getTask(task.id)?.status;
314
+ if (currentStatus === 'cancelled') {
315
+ resolve();
316
+ return;
317
+ }
318
+
319
+ if (code === 0) {
320
+ db().prepare(`
321
+ UPDATE tasks SET status = 'done', result_summary = ?, cost_usd = ?, completed_at = datetime('now')
322
+ WHERE id = ?
323
+ `).run(resultText, totalCost, task.id);
324
+ emit(task.id, 'status', 'done');
325
+ const doneTask = getTask(task.id);
326
+ if (doneTask) notifyTaskComplete(doneTask).catch(() => {});
327
+ resolve();
328
+ } else {
329
+ const errMsg = `Process exited with code ${code}`;
330
+ updateTaskStatus(task.id, 'failed', errMsg);
331
+ const failedTask = getTask(task.id);
332
+ if (failedTask) notifyTaskFailed(failedTask).catch(() => {});
333
+ reject(new Error(errMsg));
334
+ }
335
+ });
336
+
337
+ child.on('error', (err) => {
338
+ updateTaskStatus(task.id, 'failed', err.message);
339
+ reject(err);
340
+ });
341
+ });
342
+ }
343
+
344
+ // ─── Helpers ─────────────────────────────────────────────────
345
+
346
+ /**
347
+ * Resolve the claude binary path. `claude` is typically a symlink to a .js file,
348
+ * which can't be spawned directly without a shell. We resolve to the real .js path
349
+ * and run it with `node`.
350
+ */
351
+ function resolveClaudePath(claudePath: string): { cmd: string; prefix: string[] } {
352
+ try {
353
+ // Try to find the real path
354
+ let resolved = claudePath;
355
+ try {
356
+ const which = execSync(`which ${claudePath}`, { encoding: 'utf-8' }).trim();
357
+ resolved = realpathSync(which);
358
+ } catch {
359
+ resolved = realpathSync(claudePath);
360
+ }
361
+
362
+ // If it's a .js file, run with node
363
+ if (resolved.endsWith('.js') || resolved.endsWith('.mjs')) {
364
+ return { cmd: process.execPath, prefix: [resolved] };
365
+ }
366
+
367
+ return { cmd: resolved, prefix: [] };
368
+ } catch {
369
+ // Fallback: use node to run it
370
+ return { cmd: process.execPath, prefix: [claudePath] };
371
+ }
372
+ }
373
+
374
+ function parseStreamJson(parsed: any): TaskLogEntry[] {
375
+ const entries: TaskLogEntry[] = [];
376
+ const ts = new Date().toISOString();
377
+
378
+ if (parsed.type === 'system' && parsed.subtype === 'init') {
379
+ entries.push({ type: 'system', subtype: 'init', content: `Model: ${parsed.model || 'unknown'}`, timestamp: ts });
380
+ return entries;
381
+ }
382
+
383
+ if (parsed.type === 'assistant' && parsed.message?.content) {
384
+ for (const block of parsed.message.content) {
385
+ if (block.type === 'text' && block.text) {
386
+ entries.push({ type: 'assistant', subtype: 'text', content: block.text, timestamp: ts });
387
+ } else if (block.type === 'tool_use') {
388
+ entries.push({
389
+ type: 'assistant',
390
+ subtype: 'tool_use',
391
+ content: typeof block.input === 'string' ? block.input : JSON.stringify(block.input || {}),
392
+ tool: block.name,
393
+ timestamp: ts,
394
+ });
395
+ } else if (block.type === 'tool_result') {
396
+ entries.push({
397
+ type: 'assistant',
398
+ subtype: 'tool_result',
399
+ content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content || ''),
400
+ timestamp: ts,
401
+ });
402
+ }
403
+ }
404
+ return entries;
405
+ }
406
+
407
+ if (parsed.type === 'result') {
408
+ entries.push({
409
+ type: 'result',
410
+ subtype: parsed.subtype || 'success',
411
+ content: typeof parsed.result === 'string' ? parsed.result : JSON.stringify(parsed.result || ''),
412
+ timestamp: ts,
413
+ });
414
+ return entries;
415
+ }
416
+
417
+ if (parsed.type === 'rate_limit_event') return entries;
418
+
419
+ entries.push({ type: 'assistant', subtype: parsed.type || 'unknown', content: JSON.stringify(parsed), timestamp: ts });
420
+ return entries;
421
+ }
422
+
423
+ function appendLog(taskId: string, entry: TaskLogEntry) {
424
+ const row = db().prepare('SELECT log FROM tasks WHERE id = ?').get(taskId) as any;
425
+ if (!row) return;
426
+ const log: TaskLogEntry[] = JSON.parse(row.log);
427
+ log.push(entry);
428
+ db().prepare('UPDATE tasks SET log = ? WHERE id = ?').run(JSON.stringify(log), taskId);
429
+ emit(taskId, 'log', entry);
430
+ }
431
+
432
+ function updateTaskStatus(id: string, status: TaskStatus, error?: string) {
433
+ if (status === 'failed' || status === 'cancelled') {
434
+ db().prepare('UPDATE tasks SET status = ?, error = ?, completed_at = datetime(\'now\') WHERE id = ?').run(status, error || null, id);
435
+ } else {
436
+ db().prepare('UPDATE tasks SET status = ? WHERE id = ?').run(status, id);
437
+ }
438
+ emit(id, 'status', status);
439
+ }
440
+
441
+ function rowToTask(row: any): Task {
442
+ return {
443
+ id: row.id,
444
+ projectName: row.project_name,
445
+ projectPath: row.project_path,
446
+ prompt: row.prompt,
447
+ mode: row.mode || 'prompt',
448
+ status: row.status,
449
+ priority: row.priority,
450
+ conversationId: row.conversation_id || undefined,
451
+ watchConfig: row.watch_config ? JSON.parse(row.watch_config) : undefined,
452
+ log: JSON.parse(row.log || '[]'),
453
+ resultSummary: row.result_summary || undefined,
454
+ gitDiff: row.git_diff || undefined,
455
+ gitBranch: row.git_branch || undefined,
456
+ costUSD: row.cost_usd || undefined,
457
+ error: row.error || undefined,
458
+ createdAt: row.created_at,
459
+ startedAt: row.started_at || undefined,
460
+ completedAt: row.completed_at || undefined,
461
+ scheduledAt: row.scheduled_at || undefined,
462
+ };
463
+ }
464
+
465
+ // ─── Monitor task execution ──────────────────────────────────
466
+
467
+ import { getSessionFilePath, readSessionEntries, tailSessionFile, type SessionEntry } from './claude-sessions';
468
+
469
+ const activeMonitors = new Map<string, () => void>(); // taskId → cleanup fn
470
+
471
+ function startMonitorTask(task: Task) {
472
+ if (!task.conversationId || !task.watchConfig) {
473
+ updateTaskStatus(task.id, 'failed', 'Monitor task requires a session and watch config');
474
+ return;
475
+ }
476
+
477
+ const config = task.watchConfig;
478
+ const fp = getSessionFilePath(task.projectName, task.conversationId);
479
+ if (!fp) {
480
+ updateTaskStatus(task.id, 'failed', `Session file not found: ${task.conversationId}`);
481
+ return;
482
+ }
483
+
484
+ console.log(`[monitor] Starting monitor ${task.id} for ${task.projectName}/${task.conversationId.slice(0, 8)} — condition: ${config.condition}, action: ${config.action}, file: ${fp}`);
485
+
486
+ updateTaskStatus(task.id, 'running');
487
+ appendLog(task.id, {
488
+ type: 'system', subtype: 'init',
489
+ content: `Monitoring session ${task.conversationId.slice(0, 12)} — condition: ${config.condition}, action: ${config.action}`,
490
+ timestamp: new Date().toISOString(),
491
+ });
492
+
493
+ // Read initial state
494
+ const initialEntries = readSessionEntries(fp);
495
+ let lastEntryCount = initialEntries.length;
496
+ let lastActivityTime = Date.now();
497
+
498
+ // Idle check timer
499
+ let idleTimer: ReturnType<typeof setInterval> | null = null;
500
+ if (config.condition === 'idle') {
501
+ const idleMs = (config.idleMinutes || 10) * 60_000;
502
+ idleTimer = setInterval(() => {
503
+ if (Date.now() - lastActivityTime > idleMs) {
504
+ triggerMonitorAction(task, `Session idle for ${config.idleMinutes || 10} minutes`);
505
+ if (!config.repeat) stopMonitor(task.id);
506
+ }
507
+ }, 30_000);
508
+ }
509
+
510
+ // Tail the file for changes (uses fs.watch + 5s polling fallback)
511
+ const stopTail = tailSessionFile(fp, (newEntries) => {
512
+ lastActivityTime = Date.now();
513
+ lastEntryCount += newEntries.length;
514
+ console.log(`[monitor] ${task.id}: +${newEntries.length} entries (${lastEntryCount} total)`);
515
+
516
+ appendLog(task.id, {
517
+ type: 'system', subtype: 'text',
518
+ content: `+${newEntries.length} entries (${lastEntryCount} total)`,
519
+ timestamp: new Date().toISOString(),
520
+ });
521
+
522
+ // Check conditions
523
+ if (config.condition === 'change') {
524
+ triggerMonitorAction(task, summarizeNewEntries(newEntries));
525
+ if (!config.repeat) stopMonitor(task.id);
526
+ }
527
+
528
+ if (config.condition === 'keyword' && config.keyword) {
529
+ const kw = config.keyword.toLowerCase();
530
+ const matched = newEntries.find(e => e.content.toLowerCase().includes(kw));
531
+ if (matched) {
532
+ triggerMonitorAction(task, `Keyword "${config.keyword}" found: ${matched.content.slice(0, 200)}`);
533
+ if (!config.repeat) stopMonitor(task.id);
534
+ }
535
+ }
536
+
537
+ if (config.condition === 'error') {
538
+ const errors = newEntries.filter(e =>
539
+ e.type === 'system' && e.content.toLowerCase().includes('error')
540
+ );
541
+ if (errors.length > 0) {
542
+ triggerMonitorAction(task, `Error detected: ${errors[0].content.slice(0, 200)}`);
543
+ if (!config.repeat) stopMonitor(task.id);
544
+ }
545
+ }
546
+
547
+ if (config.condition === 'complete') {
548
+ // Check if last assistant entry looks like completion
549
+ const lastAssistant = [...newEntries].reverse().find(e => e.type === 'assistant_text');
550
+ if (lastAssistant) {
551
+ // Heuristic: check if there are no more tool calls after the last text
552
+ const lastIdx = newEntries.lastIndexOf(lastAssistant);
553
+ const afterToolUse = newEntries.slice(lastIdx + 1).some(e => e.type === 'tool_use');
554
+ if (!afterToolUse && newEntries.length > 2) {
555
+ // Wait a bit to see if more entries come
556
+ setTimeout(() => {
557
+ if (Date.now() - lastActivityTime > 30_000) {
558
+ triggerMonitorAction(task, `Session appears complete.\n\nLast: ${lastAssistant.content.slice(0, 300)}`);
559
+ if (!config.repeat) stopMonitor(task.id);
560
+ }
561
+ }, 35_000);
562
+ }
563
+ }
564
+ }
565
+ }, (err) => {
566
+ console.error(`[monitor] ${task.id} tail error:`, err.message);
567
+ appendLog(task.id, {
568
+ type: 'system', subtype: 'error',
569
+ content: `File watch error: ${err.message}`,
570
+ timestamp: new Date().toISOString(),
571
+ });
572
+ });
573
+
574
+ const cleanup = () => {
575
+ stopTail();
576
+ if (idleTimer) clearInterval(idleTimer);
577
+ };
578
+
579
+ activeMonitors.set(task.id, cleanup);
580
+ }
581
+
582
+ function stopMonitor(taskId: string) {
583
+ const cleanup = activeMonitors.get(taskId);
584
+ if (cleanup) {
585
+ cleanup();
586
+ activeMonitors.delete(taskId);
587
+ }
588
+ updateTaskStatus(taskId, 'done');
589
+ }
590
+
591
+ // Also export for cancel
592
+ export function cancelMonitor(taskId: string) {
593
+ stopMonitor(taskId);
594
+ updateTaskStatus(taskId, 'cancelled');
595
+ }
596
+
597
+ async function triggerMonitorAction(task: Task, context: string) {
598
+ const config = task.watchConfig!;
599
+
600
+ appendLog(task.id, {
601
+ type: 'system', subtype: 'text',
602
+ content: `⚡ Triggered: ${context}`,
603
+ timestamp: new Date().toISOString(),
604
+ });
605
+
606
+ if (config.action === 'notify') {
607
+ // Send Telegram notification
608
+ const settings = loadSettings();
609
+ if (settings.telegramBotToken && settings.telegramChatId) {
610
+ const msg = config.actionPrompt
611
+ ? config.actionPrompt.replace('{{context}}', context)
612
+ : `📋 Monitor: ${task.projectName}/${task.conversationId?.slice(0, 8)}\n\n${context}`;
613
+ await sendTelegramDirect(settings.telegramBotToken, settings.telegramChatId, msg);
614
+ }
615
+ } else if (config.action === 'message' && config.actionPrompt && task.conversationId) {
616
+ // Send a message to the session by creating a prompt task (will queue if project is busy)
617
+ const newTask = createTask({
618
+ projectName: task.projectName,
619
+ projectPath: task.projectPath,
620
+ prompt: config.actionPrompt,
621
+ conversationId: task.conversationId,
622
+ });
623
+ const queued = runningProjects.has(task.projectName) ? ' (queued — project busy)' : '';
624
+ appendLog(task.id, {
625
+ type: 'system', subtype: 'text',
626
+ content: `Created follow-up task ${newTask.id}${queued}: ${config.actionPrompt.slice(0, 100)}`,
627
+ timestamp: new Date().toISOString(),
628
+ });
629
+ } else if (config.action === 'task' && config.actionPrompt) {
630
+ const project = config.actionProject || task.projectName;
631
+ createTask({
632
+ projectName: project,
633
+ projectPath: task.projectPath,
634
+ prompt: config.actionPrompt,
635
+ });
636
+ appendLog(task.id, {
637
+ type: 'system', subtype: 'text',
638
+ content: `Created new task for ${project}: ${config.actionPrompt.slice(0, 100)}`,
639
+ timestamp: new Date().toISOString(),
640
+ });
641
+ }
642
+ }
643
+
644
+ async function sendTelegramDirect(token: string, chatId: string, text: string) {
645
+ try {
646
+ const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
647
+ method: 'POST',
648
+ headers: { 'Content-Type': 'application/json' },
649
+ body: JSON.stringify({ chat_id: chatId, text, disable_web_page_preview: true }),
650
+ });
651
+ if (!res.ok) {
652
+ const body = await res.text();
653
+ console.error(`[monitor] Telegram send failed: ${res.status} ${body}`);
654
+ }
655
+ } catch (err) {
656
+ console.error('[monitor] Telegram send error:', err);
657
+ }
658
+ }
659
+
660
+ function summarizeNewEntries(entries: SessionEntry[]): string {
661
+ const parts: string[] = [];
662
+ for (const e of entries) {
663
+ if (e.type === 'user') parts.push(`👤 ${e.content.slice(0, 100)}`);
664
+ else if (e.type === 'assistant_text') parts.push(`🤖 ${e.content.slice(0, 150)}`);
665
+ else if (e.type === 'tool_use') parts.push(`🔧 ${e.toolName || 'tool'}`);
666
+ }
667
+ return parts.slice(0, 5).join('\n') || 'Activity detected';
668
+ }