@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,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
|
+
}
|