@aion0/forge 0.4.15 → 0.5.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 (100) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +2 -2
  3. package/RELEASE_NOTES.md +170 -13
  4. package/app/api/agents/route.ts +17 -0
  5. package/app/api/delivery/[id]/route.ts +62 -0
  6. package/app/api/delivery/route.ts +40 -0
  7. package/app/api/mobile-chat/route.ts +13 -7
  8. package/app/api/monitor/route.ts +10 -6
  9. package/app/api/pipelines/[id]/route.ts +16 -3
  10. package/app/api/tasks/route.ts +2 -1
  11. package/app/api/workspace/[id]/agents/route.ts +35 -0
  12. package/app/api/workspace/[id]/memory/route.ts +23 -0
  13. package/app/api/workspace/[id]/smith/route.ts +22 -0
  14. package/app/api/workspace/[id]/stream/route.ts +28 -0
  15. package/app/api/workspace/route.ts +100 -0
  16. package/app/global-error.tsx +10 -4
  17. package/app/icon.ico +0 -0
  18. package/app/layout.tsx +2 -2
  19. package/app/login/LoginForm.tsx +96 -0
  20. package/app/login/page.tsx +7 -98
  21. package/app/page.tsx +2 -2
  22. package/bin/forge-server.mjs +23 -4
  23. package/check-forge-status.sh +9 -0
  24. package/cli/mw.ts +2 -2
  25. package/components/ConversationEditor.tsx +411 -0
  26. package/components/ConversationGraphView.tsx +347 -0
  27. package/components/ConversationTerminalView.tsx +303 -0
  28. package/components/Dashboard.tsx +36 -39
  29. package/components/DashboardWrapper.tsx +9 -0
  30. package/components/DeliveryFlowEditor.tsx +491 -0
  31. package/components/DeliveryList.tsx +230 -0
  32. package/components/DeliveryWorkspace.tsx +589 -0
  33. package/components/DocTerminal.tsx +12 -4
  34. package/components/DocsViewer.tsx +10 -2
  35. package/components/HelpTerminal.tsx +13 -8
  36. package/components/InlinePipelineView.tsx +111 -0
  37. package/components/MobileView.tsx +20 -0
  38. package/components/MonitorPanel.tsx +9 -4
  39. package/components/NewTaskModal.tsx +32 -0
  40. package/components/PipelineEditor.tsx +49 -6
  41. package/components/PipelineView.tsx +482 -64
  42. package/components/ProjectDetail.tsx +314 -56
  43. package/components/ProjectManager.tsx +49 -4
  44. package/components/SessionView.tsx +27 -13
  45. package/components/SettingsModal.tsx +790 -124
  46. package/components/SkillsPanel.tsx +34 -8
  47. package/components/TaskBoard.tsx +3 -0
  48. package/components/WebTerminal.tsx +259 -45
  49. package/components/WorkspaceTree.tsx +221 -0
  50. package/components/WorkspaceView.tsx +2224 -0
  51. package/docs/LOCAL-DEPLOY.md +15 -15
  52. package/install.sh +2 -2
  53. package/lib/agents/claude-adapter.ts +104 -0
  54. package/lib/agents/generic-adapter.ts +64 -0
  55. package/lib/agents/index.ts +242 -0
  56. package/lib/agents/types.ts +70 -0
  57. package/lib/artifacts.ts +106 -0
  58. package/lib/cloudflared.ts +1 -1
  59. package/lib/delivery.ts +787 -0
  60. package/lib/forge-skills/forge-inbox.md +37 -0
  61. package/lib/forge-skills/forge-send.md +40 -0
  62. package/lib/forge-skills/forge-status.md +32 -0
  63. package/lib/forge-skills/forge-workspace-sync.md +37 -0
  64. package/lib/help-docs/00-overview.md +8 -2
  65. package/lib/help-docs/01-settings.md +159 -2
  66. package/lib/help-docs/05-pipelines.md +95 -6
  67. package/lib/help-docs/07-projects.md +35 -1
  68. package/lib/help-docs/11-workspace.md +204 -0
  69. package/lib/help-docs/CLAUDE.md +5 -2
  70. package/lib/init.ts +62 -12
  71. package/lib/pipeline.ts +537 -1
  72. package/lib/settings.ts +115 -22
  73. package/lib/skills.ts +249 -372
  74. package/lib/task-manager.ts +113 -33
  75. package/lib/telegram-bot.ts +33 -1
  76. package/lib/telegram-standalone.ts +1 -1
  77. package/lib/terminal-server.ts +2 -2
  78. package/lib/terminal-standalone.ts +1 -1
  79. package/lib/workspace/__tests__/state-machine.test.ts +388 -0
  80. package/lib/workspace/__tests__/workspace.test.ts +311 -0
  81. package/lib/workspace/agent-bus.ts +416 -0
  82. package/lib/workspace/agent-worker.ts +667 -0
  83. package/lib/workspace/backends/api-backend.ts +262 -0
  84. package/lib/workspace/backends/cli-backend.ts +479 -0
  85. package/lib/workspace/index.ts +82 -0
  86. package/lib/workspace/manager.ts +136 -0
  87. package/lib/workspace/orchestrator.ts +1804 -0
  88. package/lib/workspace/persistence.ts +310 -0
  89. package/lib/workspace/presets.ts +170 -0
  90. package/lib/workspace/skill-installer.ts +188 -0
  91. package/lib/workspace/smith-memory.ts +498 -0
  92. package/lib/workspace/types.ts +231 -0
  93. package/lib/workspace/watch-manager.ts +288 -0
  94. package/lib/workspace-standalone.ts +790 -0
  95. package/middleware.ts +1 -0
  96. package/next-env.d.ts +1 -1
  97. package/package.json +5 -2
  98. package/src/config/index.ts +13 -2
  99. package/src/core/db/database.ts +1 -0
  100. package/start.sh +10 -0
@@ -60,9 +60,11 @@ export function createTask(opts: {
60
60
  conversationId?: string; // Explicit override; otherwise auto-inherits from project
61
61
  scheduledAt?: string; // ISO timestamp — task won't run until this time
62
62
  watchConfig?: WatchConfig;
63
+ agent?: string; // Agent ID (default: from settings)
63
64
  }): Task {
64
65
  const id = randomUUID().slice(0, 8);
65
66
  const mode = opts.mode || 'prompt';
67
+ const agent = opts.agent || '';
66
68
 
67
69
  // For prompt mode: auto-inherit conversation_id
68
70
  // For monitor mode: conversationId is required (the session to watch)
@@ -71,12 +73,13 @@ export function createTask(opts: {
71
73
  : (opts.conversationId || (mode === 'prompt' ? getProjectConversationId(opts.projectName) : null));
72
74
 
73
75
  db().prepare(`
74
- INSERT INTO tasks (id, project_name, project_path, prompt, mode, status, priority, conversation_id, log, scheduled_at, watch_config)
75
- VALUES (?, ?, ?, ?, ?, 'queued', ?, ?, '[]', ?, ?)
76
+ INSERT INTO tasks (id, project_name, project_path, prompt, mode, status, priority, conversation_id, log, scheduled_at, watch_config, agent)
77
+ VALUES (?, ?, ?, ?, ?, 'queued', ?, ?, '[]', ?, ?, ?)
76
78
  `).run(
77
79
  id, opts.projectName, opts.projectPath, opts.prompt, mode,
78
80
  opts.priority || 0, convId || null, opts.scheduledAt || null,
79
81
  opts.watchConfig ? JSON.stringify(opts.watchConfig) : null,
82
+ agent || null,
80
83
  );
81
84
 
82
85
  // Kick the runner
@@ -180,12 +183,13 @@ export function retryTask(id: string): Task | null {
180
183
  if (!task) return null;
181
184
  if (task.status !== 'failed' && task.status !== 'cancelled') return null;
182
185
 
183
- // Create a new task with same params
186
+ // Create a new task with same params (including agent)
184
187
  return createTask({
185
188
  projectName: task.projectName,
186
189
  projectPath: task.projectPath,
187
190
  prompt: task.prompt,
188
191
  priority: task.priority,
192
+ agent: (task as any).agent || undefined,
189
193
  });
190
194
  }
191
195
 
@@ -284,38 +288,103 @@ function executeTask(task: Task): Promise<void> {
284
288
 
285
289
  return new Promise((resolve, reject) => {
286
290
  const settings = loadSettings();
287
- const claudePath = settings.claudePath || process.env.CLAUDE_PATH || 'claude';
288
-
289
- const args = ['-p', '--verbose', '--output-format', 'stream-json', '--dangerously-skip-permissions'];
290
-
291
- // Use model override if set, otherwise fall back to taskModel setting
292
- const model = taskModelOverrides.get(task.id) || settings.taskModel;
293
- if (model && model !== 'default') {
294
- args.push('--model', model);
295
- }
296
-
297
- // Resume specific session to continue the conversation
298
- if (task.conversationId) {
299
- args.push('--resume', task.conversationId);
300
- }
301
-
302
- args.push(task.prompt);
291
+ const { getAgent } = require('./agents');
292
+ const agentId = (task as any).agent || settings.defaultAgent || 'claude';
293
+ const adapter = getAgent(agentId);
294
+
295
+ // Model: per-task override > agent scene model > global fallback
296
+ const agentCfg = settings.agents?.[agentId];
297
+ const isPipeline = (() => { try { const { pipelineTaskIds: p } = require('./pipeline'); return p.has(task.id); } catch { return false; } })();
298
+ const model = taskModelOverrides.get(task.id) || (isPipeline ? agentCfg?.models?.task : agentCfg?.models?.task) || agentCfg?.models?.task || settings.taskModel;
299
+ const supportsModel = adapter.config.capabilities?.supportsModel;
300
+ const spawnOpts = adapter.buildTaskSpawn({
301
+ projectPath: task.projectPath,
302
+ prompt: task.prompt,
303
+ model: supportsModel && model && model !== 'default' ? model : undefined,
304
+ conversationId: task.conversationId || undefined,
305
+ skipPermissions: true,
306
+ outputFormat: adapter.config.capabilities?.supportsStreamJson ? 'stream-json' : undefined,
307
+ });
303
308
 
304
- const env = { ...process.env };
309
+ const env = { ...process.env, ...(spawnOpts.env || {}) };
305
310
  delete env.CLAUDECODE;
306
311
 
307
312
  updateTaskStatus(task.id, 'running');
308
313
  db().prepare('UPDATE tasks SET started_at = datetime(\'now\') WHERE id = ?').run(task.id);
309
314
 
310
- // Resolve the actual claude CLI script path (claude is a symlink to a .js file)
311
- const resolvedClaude = resolveClaudePath(claudePath);
312
- console.log(`[task] ${task.projectName} [${model || 'default'}]: "${task.prompt.slice(0, 60)}..."`);
315
+ const agentName = adapter.config.name || agentId;
316
+ console.log(`[task] ${task.projectName} [${agentName}${supportsModel && model ? '/' + model : ''}]: "${task.prompt.slice(0, 60)}..."`);
317
+
318
+ // Log agent info as first entry
319
+ appendLog(task.id, { type: 'system', subtype: 'init', content: `Agent: ${agentName}${supportsModel && model && model !== 'default' ? ` | Model: ${model}` : ''}`, timestamp: new Date().toISOString() });
320
+
321
+ const needsTTY = adapter.config.capabilities?.requiresTTY;
322
+ let child: any;
323
+ let ptyProcess: any = null;
324
+
325
+ if (needsTTY) {
326
+ // Use node-pty for agents that require a terminal environment
327
+ const pty = require('node-pty');
328
+ ptyProcess = pty.spawn(spawnOpts.cmd, spawnOpts.args, {
329
+ name: 'xterm-256color',
330
+ cols: 120,
331
+ rows: 40,
332
+ cwd: task.projectPath,
333
+ env,
334
+ });
335
+ // Strip terminal control codes from PTY output for clean logging
336
+ const stripAnsi = (s: string) => s
337
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '') // CSI sequences
338
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC sequences
339
+ .replace(/\x1b[()][0-9A-B]/g, '') // charset
340
+ .replace(/\x1b[=>]/g, '') // keypad
341
+ .replace(/\r/g, '') // carriage return
342
+ .replace(/\x07/g, ''); // bell
343
+
344
+ // Auto-kill PTY after idle (interactive agents don't exit on their own)
345
+ let ptyBytes = 0;
346
+ let ptyIdleTimer: any = null;
347
+ const PTY_IDLE_MS = 15000; // 15s idle = done
348
+
349
+ // Create a child-like interface for pty
350
+ let exitCb: Function | null = null;
351
+
352
+ ptyProcess.onData((data: string) => {
353
+ const clean = stripAnsi(data);
354
+ ptyBytes += clean.length;
355
+ if (dataCallback) dataCallback(Buffer.from(clean));
356
+ // Reset idle timer
357
+ if (ptyIdleTimer) clearTimeout(ptyIdleTimer);
358
+ if (ptyBytes > 500) {
359
+ ptyIdleTimer = setTimeout(() => {
360
+ console.log(`[task] PTY idle timeout — killing process (${ptyBytes} bytes received)`);
361
+ try { ptyProcess.kill(); } catch {}
362
+ }, PTY_IDLE_MS);
363
+ }
364
+ });
365
+
366
+ ptyProcess.onExit(({ exitCode }: any) => {
367
+ if (ptyIdleTimer) clearTimeout(ptyIdleTimer);
368
+ if (exitCb) exitCb(exitCode, null);
369
+ });
313
370
 
314
- const child = spawn(resolvedClaude.cmd, [...resolvedClaude.prefix, ...args], {
315
- cwd: task.projectPath,
316
- env,
317
- stdio: ['ignore', 'pipe', 'pipe'],
318
- });
371
+ let dataCallback: Function | null = null;
372
+ child = {
373
+ stdout: { on: (evt: string, cb: Function) => { if (evt === 'data') dataCallback = cb; } },
374
+ stderr: { on: (_evt: string, _cb: Function) => {} },
375
+ on: (evt: string, cb: Function) => { if (evt === 'exit') exitCb = cb; if (evt === 'error') {} },
376
+ kill: (sig: string) => { if (ptyIdleTimer) clearTimeout(ptyIdleTimer); try { ptyProcess.kill(sig); } catch {} },
377
+ stdin: null,
378
+ pid: ptyProcess.pid,
379
+ };
380
+ } else {
381
+ child = spawn(spawnOpts.cmd, spawnOpts.args, {
382
+ cwd: task.projectPath,
383
+ env,
384
+ stdio: ['pipe', 'pipe', 'pipe'],
385
+ });
386
+ child.stdin?.end();
387
+ }
319
388
 
320
389
  let buffer = '';
321
390
  let resultText = '';
@@ -325,7 +394,7 @@ function executeTask(task: Task): Promise<void> {
325
394
  let totalInputTokens = 0;
326
395
  let totalOutputTokens = 0;
327
396
 
328
- child.on('error', (err) => {
397
+ child.on('error', (err: any) => {
329
398
  console.error(`[task-runner] Spawn error:`, err.message);
330
399
  updateTaskStatus(task.id, 'failed', err.message);
331
400
  reject(err);
@@ -346,10 +415,14 @@ function executeTask(task: Task): Promise<void> {
346
415
 
347
416
  for (const line of lines) {
348
417
  if (!line.trim()) continue;
418
+ let jsonParsed = false;
349
419
  try {
350
420
  const parsed = JSON.parse(line);
421
+ jsonParsed = true;
351
422
  const entries = parseStreamJson(parsed);
352
423
  for (const entry of entries) {
424
+ // Skip Claude's Model init line for non-claude agents (we already logged our own)
425
+ if (entry.subtype === 'init' && agentId !== 'claude' && entry.content?.startsWith('Model:')) continue;
353
426
  appendLog(task.id, entry);
354
427
  }
355
428
 
@@ -368,7 +441,13 @@ function executeTask(task: Task): Promise<void> {
368
441
  if (parsed.total_input_tokens) totalInputTokens = parsed.total_input_tokens;
369
442
  if (parsed.total_output_tokens) totalOutputTokens = parsed.total_output_tokens;
370
443
  }
371
- } catch {}
444
+ } catch {
445
+ // Non-JSON output (generic agents) — log as raw text
446
+ if (!jsonParsed) {
447
+ resultText += (resultText ? '\n' : '') + line;
448
+ appendLog(task.id, { type: 'system', subtype: 'text', content: line, timestamp: new Date().toISOString() });
449
+ }
450
+ }
372
451
  }
373
452
  });
374
453
 
@@ -380,7 +459,7 @@ function executeTask(task: Task): Promise<void> {
380
459
  }
381
460
  });
382
461
 
383
- child.on('exit', (code, signal) => {
462
+ child.on('exit', (code: any, signal: any) => {
384
463
  // Process exit handled below
385
464
  // Process remaining buffer
386
465
  if (buffer.trim()) {
@@ -453,7 +532,7 @@ function executeTask(task: Task): Promise<void> {
453
532
  }
454
533
  });
455
534
 
456
- child.on('error', (err) => {
535
+ child.on('error', (err: any) => {
457
536
  updateTaskStatus(task.id, 'failed', err.message);
458
537
  reject(err);
459
538
  });
@@ -637,7 +716,8 @@ function rowToTask(row: any): Task {
637
716
  startedAt: toIsoUTC(row.started_at) ?? undefined,
638
717
  completedAt: toIsoUTC(row.completed_at) ?? undefined,
639
718
  scheduledAt: toIsoUTC(row.scheduled_at) ?? undefined,
640
- };
719
+ agent: row.agent || undefined,
720
+ } as Task;
641
721
  }
642
722
 
643
723
  // ─── Monitor task execution ──────────────────────────────────
@@ -223,6 +223,9 @@ async function handleMessage(msg: any) {
223
223
  case '/p':
224
224
  await sendProjectList(chatId);
225
225
  break;
226
+ case '/agents':
227
+ await sendAgentList(chatId);
228
+ break;
226
229
  case '/watch':
227
230
  case '/w':
228
231
  if (args.length > 0) {
@@ -309,10 +312,30 @@ async function sendHelp(chatId: number) {
309
312
  `🌐 /tunnel — status\n` +
310
313
  `/tunnel_start / /tunnel_stop\n` +
311
314
  `/tunnel_code <admin_pw> — get session code\n\n` +
315
+ `🤖 /agents — list available agents\n` +
316
+ `Use @agent in /task to select (e.g. /task app @codex: review)\n\n` +
312
317
  `Reply number to select`
313
318
  );
314
319
  }
315
320
 
321
+ async function sendAgentList(chatId: number) {
322
+ try {
323
+ const { listAgents, getDefaultAgentId } = require('./agents');
324
+ const agents = listAgents();
325
+ const defaultId = getDefaultAgentId();
326
+ if (agents.length === 0) {
327
+ await send(chatId, 'No agents detected.');
328
+ return;
329
+ }
330
+ const lines = agents.map((a: any) =>
331
+ `${a.id === defaultId ? '⭐' : ' '} ${a.name} (${a.id})${a.detected === false ? ' ⚠️ not installed' : ''}`
332
+ );
333
+ await send(chatId, `🤖 Agents:\n\n${lines.join('\n')}\n\nUse @agent in /task command`);
334
+ } catch {
335
+ await send(chatId, 'Failed to list agents.');
336
+ }
337
+ }
338
+
316
339
  async function sendNumberedTaskList(chatId: number, statusFilter?: string) {
317
340
  // Get running/queued first, then recent done/failed
318
341
  const allTasks = listTasks(statusFilter as any || undefined);
@@ -671,10 +694,11 @@ async function handleNewTask(chatId: number, input: string) {
671
694
  await send(chatId,
672
695
  'Usage:\nproject: instructions\n\n' +
673
696
  'Options:\n' +
697
+ ' @agent — use specific agent (e.g. @codex @aider)\n' +
674
698
  ' -s <sessionId> — resume specific session\n' +
675
699
  ' -in 30m — delay (e.g. 10m, 2h, 1d)\n' +
676
700
  ' -at 18:00 — schedule at time\n\n' +
677
- 'Example:\nmy-app: Fix the login bug\nmy-app -s abc123 -in 1h: continue work'
701
+ 'Example:\nmy-app: Fix the login bug\nmy-app @codex: review code\nmy-app -s abc123 -in 1h: continue work'
678
702
  );
679
703
  return;
680
704
  }
@@ -709,6 +733,7 @@ async function handleNewTask(chatId: number, input: string) {
709
733
  // Parse flags
710
734
  let sessionId: string | undefined;
711
735
  let scheduledAt: string | undefined;
736
+ let agentId: string | undefined;
712
737
  let tokens = restPart.split(/\s+/);
713
738
  const promptTokens: string[] = [];
714
739
 
@@ -719,6 +744,8 @@ async function handleNewTask(chatId: number, input: string) {
719
744
  scheduledAt = parseDelay(tokens[++i]);
720
745
  } else if (tokens[i] === '-at' && i + 1 < tokens.length) {
721
746
  scheduledAt = parseTimeAt(tokens[++i]);
747
+ } else if (tokens[i].startsWith('@')) {
748
+ agentId = tokens[i].slice(1); // @codex → codex
722
749
  } else {
723
750
  promptTokens.push(tokens[i]);
724
751
  }
@@ -730,12 +757,17 @@ async function handleNewTask(chatId: number, input: string) {
730
757
  return;
731
758
  }
732
759
 
760
+ // Use @agent if specified, else telegram default, else global default
761
+ const settings = loadSettings();
762
+ const resolvedAgent = agentId || settings.telegramAgent || undefined;
763
+
733
764
  const task = createTask({
734
765
  projectName: project.name,
735
766
  projectPath: project.path,
736
767
  prompt,
737
768
  conversationId: sessionId,
738
769
  scheduledAt,
770
+ agent: resolvedAgent,
739
771
  });
740
772
 
741
773
  let statusLine = 'Status: queued';
@@ -61,7 +61,7 @@ async function poll() {
61
61
 
62
62
  // Forward to Next.js API for processing
63
63
  try {
64
- await fetch(`http://localhost:${process.env.PORT || 3000}/api/telegram`, {
64
+ await fetch(`http://localhost:${process.env.PORT || 8403}/api/telegram`, {
65
65
  method: 'POST',
66
66
  headers: { 'Content-Type': 'application/json', 'x-telegram-secret': TOKEN },
67
67
  body: JSON.stringify(update.message),
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Terminal Server — standalone WebSocket PTY server.
3
- * Runs on port 3001 alongside the Next.js dev server on 3000.
3
+ * Runs on port 8404 alongside the Next.js server on 8403.
4
4
  */
5
5
 
6
6
  import { WebSocketServer, WebSocket } from 'ws';
@@ -9,7 +9,7 @@ import { homedir } from 'node:os';
9
9
 
10
10
  let wss: WebSocketServer | null = null;
11
11
 
12
- export function startTerminalServer(port = 3001) {
12
+ export function startTerminalServer(port = 8404) {
13
13
  if (wss) return;
14
14
 
15
15
  wss = new WebSocketServer({ port });
@@ -33,7 +33,7 @@ import { getDataDir } from './dirs';
33
33
  import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
34
34
  import { join } from 'node:path';
35
35
 
36
- const PORT = Number(process.env.TERMINAL_PORT) || 3001;
36
+ const PORT = Number(process.env.TERMINAL_PORT) || 8404;
37
37
  // Session prefix based on DATA_DIR hash — default instance keeps 'mw-' for backward compat
38
38
  const _dataDir = process.env.FORGE_DATA_DIR || '';
39
39
  const _isDefault = !_dataDir || _dataDir.endsWith('/data') || _dataDir.endsWith('/.forge');