@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
@@ -8,13 +8,13 @@ import '@xterm/xterm/css/xterm.css';
8
8
  const SESSION_NAME = 'mw-forge-help';
9
9
 
10
10
  function getWsUrl() {
11
- if (typeof window === 'undefined') return `ws://localhost:${parseInt(process.env.TERMINAL_PORT || '3001')}`;
11
+ if (typeof window === 'undefined') return `ws://localhost:${parseInt(process.env.TERMINAL_PORT || '8404')}`;
12
12
  const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
13
13
  const wsHost = window.location.hostname;
14
14
  if (wsHost !== 'localhost' && wsHost !== '127.0.0.1') {
15
15
  return `${wsProtocol}//${window.location.host}/terminal-ws`;
16
16
  }
17
- const webPort = parseInt(window.location.port) || 3000;
17
+ const webPort = parseInt(window.location.port) || 8403;
18
18
  return `${wsProtocol}//${wsHost}:${webPort + 1}`;
19
19
  }
20
20
 
@@ -27,6 +27,7 @@ export default function HelpTerminal() {
27
27
 
28
28
  let disposed = false;
29
29
  let dataDir = '~/.forge/data';
30
+ let agentCmd = 'claude';
30
31
 
31
32
  const cs = getComputedStyle(document.documentElement);
32
33
  const tv = (name: string) => cs.getPropertyValue(name).trim();
@@ -75,7 +76,7 @@ export default function HelpTerminal() {
75
76
  isNewSession = false;
76
77
  setTimeout(() => {
77
78
  if (socket.readyState === WebSocket.OPEN) {
78
- socket.send(JSON.stringify({ type: 'input', data: `cd "${dataDir}" 2>/dev/null && claude\n` }));
79
+ socket.send(JSON.stringify({ type: 'input', data: `cd "${dataDir}" 2>/dev/null && ${agentCmd}\n` }));
79
80
  }
80
81
  }, 300);
81
82
  }
@@ -96,11 +97,15 @@ export default function HelpTerminal() {
96
97
  socket.onerror = () => {};
97
98
  }
98
99
 
99
- // Fetch data dir then connect
100
- fetch('/api/help?action=status').then(r => r.json())
101
- .then(data => { if (data.dataDir) dataDir = data.dataDir; })
102
- .catch(() => {})
103
- .finally(() => { if (!disposed) connect(); });
100
+ // Fetch data dir + default agent then connect
101
+ Promise.all([
102
+ fetch('/api/help?action=status').then(r => r.json()).then(data => { if (data.dataDir) dataDir = data.dataDir; }).catch(() => {}),
103
+ fetch('/api/agents').then(r => r.json()).then(data => {
104
+ const defaultId = data.defaultAgent || 'claude';
105
+ const agent = (data.agents || []).find((a: any) => a.id === defaultId);
106
+ if (agent?.path) agentCmd = agent.path;
107
+ }).catch(() => {}),
108
+ ]).finally(() => { if (!disposed) connect(); });
104
109
 
105
110
  term.onData((data) => {
106
111
  if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data }));
@@ -0,0 +1,111 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect, useRef, lazy, Suspense } from 'react';
4
+ import type { TaskLogEntry } from '@/src/types';
5
+
6
+ const ConversationTerminalView = lazy(() => import('./ConversationTerminalView'));
7
+
8
+ // ─── Task stream hook ─────────────────────────────────────
9
+
10
+ function useTaskStreamInline(taskId: string | undefined, isRunning: boolean) {
11
+ const [log, setLog] = useState<TaskLogEntry[]>([]);
12
+ useEffect(() => {
13
+ if (!taskId || !isRunning) { setLog([]); return; }
14
+ const es = new EventSource(`/api/tasks/${taskId}/stream`);
15
+ es.onmessage = (event) => {
16
+ try {
17
+ const data = JSON.parse(event.data);
18
+ if (data.type === 'log') setLog(prev => [...prev, data.entry]);
19
+ else if (data.type === 'complete' && data.task) setLog(data.task.log);
20
+ } catch {}
21
+ };
22
+ es.onerror = () => es.close();
23
+ return () => es.close();
24
+ }, [taskId, isRunning]);
25
+ return log;
26
+ }
27
+
28
+ // ─── Live log renderer ────────────────────────────────────
29
+
30
+ function InlineLiveLog({ log }: { log: TaskLogEntry[] }) {
31
+ const endRef = useRef<HTMLDivElement>(null);
32
+ useEffect(() => { endRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [log]);
33
+ if (log.length === 0) return <span className="text-[9px] text-[var(--text-secondary)] italic">Starting...</span>;
34
+ return (
35
+ <div className="max-h-[120px] overflow-y-auto text-[8px] font-mono leading-relaxed space-y-0.5">
36
+ {log.slice(-30).map((entry, i) => (
37
+ <div key={i} className={
38
+ entry.type === 'result' ? 'text-green-400' :
39
+ entry.subtype === 'error' ? 'text-red-400' :
40
+ entry.type === 'system' ? 'text-yellow-400/70' :
41
+ 'text-[var(--text-secondary)]'
42
+ }>
43
+ {entry.type === 'assistant' && entry.subtype === 'tool_use'
44
+ ? `⚙ ${entry.tool || 'tool'}: ${entry.content.slice(0, 60)}...`
45
+ : entry.content.slice(0, 120)}{entry.content.length > 120 ? '...' : ''}
46
+ </div>
47
+ ))}
48
+ <div ref={endRef} />
49
+ </div>
50
+ );
51
+ }
52
+
53
+ // ─── DAG node card ────────────────────────────────────────
54
+
55
+ function InlineDagNode({ nodeId, node }: { nodeId: string; node: any }) {
56
+ const isRunning = node.status === 'running';
57
+ const log = useTaskStreamInline(node.taskId, isRunning);
58
+ const statusIcon = node.status === 'done' ? '✅' : node.status === 'failed' ? '❌' : node.status === 'running' ? '🔄' : node.status === 'skipped' ? '⏭' : '⏳';
59
+ return (
60
+ <div className={`border rounded p-2 ${
61
+ isRunning ? 'border-yellow-500/40 bg-yellow-500/5' :
62
+ node.status === 'done' ? 'border-green-500/20 bg-green-500/5' :
63
+ node.status === 'failed' ? 'border-red-500/20 bg-red-500/5' :
64
+ 'border-[var(--border)]'
65
+ }`}>
66
+ <div className="flex items-center gap-1.5 text-[9px]">
67
+ <span>{statusIcon}</span>
68
+ <span className="font-semibold text-[var(--text-primary)]">{nodeId}</span>
69
+ {node.taskId && <span className="text-[7px] text-[var(--accent)] font-mono">task:{node.taskId}</span>}
70
+ <span className="text-[var(--text-secondary)] ml-auto">{node.status}</span>
71
+ </div>
72
+ {isRunning && <div className="mt-1.5"><InlineLiveLog log={log} /></div>}
73
+ {node.error && <div className="text-[8px] text-red-400 mt-1">{node.error}</div>}
74
+ </div>
75
+ );
76
+ }
77
+
78
+ // ─── Main component ───────────────────────────────────────
79
+
80
+ export default function InlinePipelineView({ pipeline, onRefresh }: { pipeline: any; onRefresh: () => void }) {
81
+ useEffect(() => {
82
+ if (pipeline.status !== 'running') return;
83
+ const timer = setInterval(onRefresh, 3000);
84
+ return () => clearInterval(timer);
85
+ }, [pipeline.status, onRefresh]);
86
+
87
+ const isConversation = pipeline.type === 'conversation' && pipeline.conversation;
88
+
89
+ return (
90
+ <div className="bg-[var(--bg-tertiary)]/50">
91
+ {isConversation ? (
92
+ <div style={{ height: 450 }}>
93
+ <Suspense fallback={<div className="flex items-center justify-center h-full text-[9px] text-[var(--text-secondary)]">Loading...</div>}>
94
+ <ConversationTerminalView pipeline={pipeline} />
95
+ </Suspense>
96
+ </div>
97
+ ) : (
98
+ <div className="px-3 py-2 space-y-1.5">
99
+ {pipeline.nodeOrder.map((nodeId: string) => (
100
+ <InlineDagNode key={nodeId} nodeId={nodeId} node={pipeline.nodes[nodeId]} />
101
+ ))}
102
+ </div>
103
+ )}
104
+ {pipeline.status !== 'running' && (
105
+ <div className={`text-[8px] text-center py-1 ${pipeline.status === 'done' ? 'text-green-400' : 'text-red-400'}`}>
106
+ Pipeline {pipeline.status}
107
+ </div>
108
+ )}
109
+ </div>
110
+ );
111
+ }
@@ -19,6 +19,8 @@ export default function MobileView() {
19
19
  const [debugLevel, setDebugLevel] = useState<'off' | 'simple' | 'verbose'>('off');
20
20
  const debugLevelRef = useRef<'off' | 'simple' | 'verbose'>('off');
21
21
  const [hasSession, setHasSession] = useState(false);
22
+ const [availableAgents, setAvailableAgents] = useState<{ id: string; name: string }[]>([]);
23
+ const [selectedAgent, setSelectedAgent] = useState('claude');
22
24
  const scrollRef = useRef<HTMLDivElement>(null);
23
25
  const inputRef = useRef<HTMLInputElement>(null);
24
26
  const abortRef = useRef<AbortController | null>(null);
@@ -31,6 +33,12 @@ export default function MobileView() {
31
33
  fetch('/api/tunnel').then(r => r.json())
32
34
  .then(data => { setTunnelUrl(data.url || null); })
33
35
  .catch(() => {});
36
+ fetch('/api/agents').then(r => r.json())
37
+ .then(data => {
38
+ const agents = (data.agents || []).filter((a: any) => a.enabled && a.detected !== false);
39
+ setAvailableAgents(agents);
40
+ setSelectedAgent(data.defaultAgent || 'claude');
41
+ }).catch(() => {});
34
42
  }, []);
35
43
 
36
44
  // Auto-scroll
@@ -116,6 +124,7 @@ export default function MobileView() {
116
124
  message: text,
117
125
  projectPath: selectedProject.path,
118
126
  resume: false,
127
+ agent: selectedAgent,
119
128
  }),
120
129
  signal: abort.signal,
121
130
  });
@@ -248,6 +257,17 @@ export default function MobileView() {
248
257
  }}
249
258
  className="text-sm px-3 py-1 border border-[#30363d] rounded text-[#8b949e] active:bg-[#30363d]"
250
259
  >↻</button>
260
+ {availableAgents.length > 1 && (
261
+ <select
262
+ value={selectedAgent}
263
+ onChange={e => setSelectedAgent(e.target.value)}
264
+ className="bg-[#0d1117] border border-[#30363d] rounded px-1 py-1 text-[10px] text-[#e6edf3] w-16"
265
+ >
266
+ {availableAgents.map(a => (
267
+ <option key={a.id} value={a.id}>{a.name.split(' ')[0]}</option>
268
+ ))}
269
+ </select>
270
+ )}
251
271
  </>
252
272
  )}
253
273
  {tunnelUrl && (
@@ -4,9 +4,10 @@ import { useState, useEffect, useCallback } from 'react';
4
4
 
5
5
  interface MonitorData {
6
6
  processes: {
7
- nextjs: { running: boolean; pid: string };
8
- terminal: { running: boolean; pid: string };
9
- telegram: { running: boolean; pid: string };
7
+ nextjs: { running: boolean; pid: string; startedAt?: string };
8
+ terminal: { running: boolean; pid: string; startedAt?: string };
9
+ telegram: { running: boolean; pid: string; startedAt?: string };
10
+ workspace: { running: boolean; pid: string; startedAt?: string };
10
11
  tunnel: { running: boolean; pid: string; url: string };
11
12
  };
12
13
  sessions: { name: string; created: string; attached: boolean; windows: number }[];
@@ -47,13 +48,17 @@ export default function MonitorPanel({ onClose }: { onClose: () => void }) {
47
48
  { label: 'Next.js', ...data.processes.nextjs },
48
49
  { label: 'Terminal Server', ...data.processes.terminal },
49
50
  { label: 'Telegram Bot', ...data.processes.telegram },
51
+ { label: 'Workspace Daemon', ...data.processes.workspace },
50
52
  { label: 'Tunnel', ...data.processes.tunnel },
51
53
  ].map(p => (
52
54
  <div key={p.label} className="flex items-center gap-2 text-xs">
53
55
  <span className={p.running ? 'text-green-400' : 'text-gray-500'}>●</span>
54
56
  <span className="text-[var(--text-primary)] w-28">{p.label}</span>
55
57
  {p.running ? (
56
- <span className="text-[var(--text-secondary)] font-mono text-[10px]">pid: {p.pid}</span>
58
+ <>
59
+ <span className="text-[var(--text-secondary)] font-mono text-[10px]">pid: {p.pid}</span>
60
+ {(p as any).startedAt && <span className="text-gray-500 font-mono text-[9px]">{(p as any).startedAt}</span>}
61
+ </>
57
62
  ) : (
58
63
  <span className="text-gray-500 text-[10px]">stopped</span>
59
64
  )}
@@ -26,6 +26,7 @@ interface TaskData {
26
26
  scheduledAt?: string;
27
27
  mode?: TaskMode;
28
28
  watchConfig?: WatchConfig;
29
+ agent?: string;
29
30
  }
30
31
 
31
32
  export default function NewTaskModal({
@@ -64,6 +65,19 @@ export default function NewTaskModal({
64
65
  const [delayMinutes, setDelayMinutes] = useState(30);
65
66
  const [scheduledTime, setScheduledTime] = useState(editTask?.scheduledAt ? new Date(editTask.scheduledAt).toISOString().slice(0, 16) : '');
66
67
 
68
+ // Agent selection
69
+ const [availableAgents, setAvailableAgents] = useState<{ id: string; name: string; detected?: boolean }[]>([]);
70
+ const [selectedAgent, setSelectedAgent] = useState('');
71
+
72
+ useEffect(() => {
73
+ fetch('/api/agents').then(r => r.json())
74
+ .then(data => {
75
+ const agents = (data.agents || []).filter((a: any) => a.enabled && a.detected !== false);
76
+ setAvailableAgents(agents);
77
+ setSelectedAgent(data.defaultAgent || 'claude');
78
+ }).catch(() => {});
79
+ }, []);
80
+
67
81
  useEffect(() => {
68
82
  fetch('/api/projects').then(r => r.json()).then((p: Project[]) => {
69
83
  setProjects(p);
@@ -132,6 +146,8 @@ export default function NewTaskModal({
132
146
  data.watchConfig = wc;
133
147
  }
134
148
 
149
+ if (selectedAgent) data.agent = selectedAgent;
150
+
135
151
  onCreate(data);
136
152
  };
137
153
 
@@ -162,6 +178,22 @@ export default function NewTaskModal({
162
178
  </select>
163
179
  </div>
164
180
 
181
+ {/* Agent */}
182
+ {availableAgents.length > 1 && (
183
+ <div>
184
+ <label className="text-[11px] text-[var(--text-secondary)] block mb-1">Agent</label>
185
+ <select
186
+ value={selectedAgent}
187
+ onChange={e => setSelectedAgent(e.target.value)}
188
+ className="w-full px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
189
+ >
190
+ {availableAgents.map(a => (
191
+ <option key={a.id} value={a.id}>{a.name}</option>
192
+ ))}
193
+ </select>
194
+ </div>
195
+ )}
196
+
165
197
  {/* Task Mode */}
166
198
  <div>
167
199
  <label className="text-[11px] text-[var(--text-secondary)] block mb-1">Mode</label>
@@ -36,6 +36,9 @@ function PipelineNode({ id, data }: NodeProps<Node<NodeData>>) {
36
36
  <Handle type="target" position={Position.Top} className="!bg-[var(--accent)] !w-3 !h-3" />
37
37
 
38
38
  <div className="px-3 py-2 border-b border-[#3a3a5a] flex items-center gap-2">
39
+ <span className={`text-[8px] px-1 py-0.5 rounded font-medium ${
40
+ (data as any).mode === 'shell' ? 'bg-yellow-500/20 text-yellow-400' : 'bg-purple-500/20 text-purple-400'
41
+ }`}>{(data as any).mode === 'shell' ? 'shell' : ((data as any).agent || 'default')}</span>
39
42
  <span className="text-xs font-semibold text-white">{data.label}</span>
40
43
  <div className="ml-auto flex gap-1">
41
44
  <button onClick={() => data.onEdit(id)} className="text-[9px] text-[var(--accent)] hover:text-white">edit</button>
@@ -62,15 +65,18 @@ const nodeTypes = { pipeline: PipelineNode };
62
65
 
63
66
  // ─── Node Edit Modal ──────────────────────────────────────
64
67
 
65
- function NodeEditModal({ node, projects, onSave, onClose }: {
66
- node: { id: string; project: string; prompt: string; outputs: { name: string; extract: string }[] };
68
+ function NodeEditModal({ node, projects, agents, onSave, onClose }: {
69
+ node: { id: string; project: string; prompt: string; agent?: string; mode?: string; outputs: { name: string; extract: string }[] };
67
70
  projects: { name: string; root: string }[];
68
- onSave: (data: { id: string; project: string; prompt: string; outputs: { name: string; extract: string }[] }) => void;
71
+ agents: { id: string; name: string }[];
72
+ onSave: (data: { id: string; project: string; prompt: string; agent?: string; mode?: string; outputs: { name: string; extract: string }[] }) => void;
69
73
  onClose: () => void;
70
74
  }) {
71
75
  const [id, setId] = useState(node.id);
72
76
  const [project, setProject] = useState(node.project);
73
77
  const [prompt, setPrompt] = useState(node.prompt);
78
+ const [agent, setAgent] = useState(node.agent || '');
79
+ const [mode, setMode] = useState(node.mode || 'claude');
74
80
  const [outputs, setOutputs] = useState(node.outputs);
75
81
 
76
82
  return (
@@ -105,6 +111,34 @@ function NodeEditModal({ node, projects, onSave, onClose }: {
105
111
  ))}
106
112
  </select>
107
113
  </div>
114
+ <div className="flex gap-2">
115
+ <div className="flex-1">
116
+ <label className="text-[10px] text-gray-400 block mb-1">Mode</label>
117
+ <select
118
+ value={mode}
119
+ onChange={e => setMode(e.target.value)}
120
+ className="w-full text-xs bg-[#12122a] border border-[#3a3a5a] rounded px-2 py-1.5 text-white"
121
+ >
122
+ <option value="claude">Agent</option>
123
+ <option value="shell">Shell</option>
124
+ </select>
125
+ </div>
126
+ {mode !== 'shell' && (
127
+ <div className="flex-1">
128
+ <label className="text-[10px] text-gray-400 block mb-1">Agent</label>
129
+ <select
130
+ value={agent}
131
+ onChange={e => setAgent(e.target.value)}
132
+ className="w-full text-xs bg-[#12122a] border border-[#3a3a5a] rounded px-2 py-1.5 text-white"
133
+ >
134
+ <option value="">Default</option>
135
+ {agents.map(a => (
136
+ <option key={a.id} value={a.id}>{a.name}</option>
137
+ ))}
138
+ </select>
139
+ </div>
140
+ )}
141
+ </div>
108
142
  <div>
109
143
  <label className="text-[10px] text-gray-400 block mb-1">Prompt</label>
110
144
  <textarea
@@ -147,7 +181,7 @@ function NodeEditModal({ node, projects, onSave, onClose }: {
147
181
  <div className="px-4 py-3 border-t border-[#3a3a5a] flex gap-2 justify-end">
148
182
  <button onClick={onClose} className="text-xs px-3 py-1 text-gray-400 hover:text-white">Cancel</button>
149
183
  <button
150
- onClick={() => onSave({ id, project, prompt, outputs: outputs.filter(o => o.name) })}
184
+ onClick={() => onSave({ id, project, prompt, agent: agent || undefined, mode, outputs: outputs.filter(o => o.name) })}
151
185
  className="text-xs px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90"
152
186
  >
153
187
  Save
@@ -167,8 +201,9 @@ export default function PipelineEditor({ onSave, onClose, initialYaml }: {
167
201
  }) {
168
202
  const [nodes, setNodes, onNodesChange] = useNodesState<Node<NodeData>>([]);
169
203
  const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
170
- const [editingNode, setEditingNode] = useState<{ id: string; project: string; prompt: string; outputs: { name: string; extract: string }[] } | null>(null);
204
+ const [editingNode, setEditingNode] = useState<{ id: string; project: string; prompt: string; agent?: string; mode?: string; outputs: { name: string; extract: string }[] } | null>(null);
171
205
  const [workflowName, setWorkflowName] = useState('');
206
+ const [availableAgents, setAvailableAgents] = useState<{ id: string; name: string }[]>([]);
172
207
  const [workflowDesc, setWorkflowDesc] = useState('');
173
208
  const [varsProject, setVarsProject] = useState('');
174
209
  const [projects, setProjects] = useState<{ name: string; root: string }[]>([]);
@@ -178,6 +213,9 @@ export default function PipelineEditor({ onSave, onClose, initialYaml }: {
178
213
  fetch('/api/projects').then(r => r.json())
179
214
  .then((p: { name: string; root: string }[]) => { if (Array.isArray(p)) setProjects(p); })
180
215
  .catch(() => {});
216
+ fetch('/api/agents').then(r => r.json())
217
+ .then(data => setAvailableAgents((data.agents || []).filter((a: any) => a.enabled)))
218
+ .catch(() => {});
181
219
  }, []);
182
220
 
183
221
  // Load initial YAML if provided
@@ -271,7 +309,7 @@ export default function PipelineEditor({ onSave, onClose, initialYaml }: {
271
309
  setEdges(eds => eds.filter(e => e.source !== id && e.target !== id));
272
310
  }, [setNodes, setEdges]);
273
311
 
274
- const handleSaveNode = useCallback((data: { id: string; project: string; prompt: string; outputs: { name: string; extract: string }[] }) => {
312
+ const handleSaveNode = useCallback((data: { id: string; project: string; prompt: string; agent?: string; mode?: string; outputs: { name: string; extract: string }[] }) => {
275
313
  setNodes(nds => nds.map(n => {
276
314
  if (n.id === editingNode?.id) {
277
315
  return {
@@ -282,6 +320,8 @@ export default function PipelineEditor({ onSave, onClose, initialYaml }: {
282
320
  label: data.id,
283
321
  project: data.project,
284
322
  prompt: data.prompt,
323
+ agent: data.agent,
324
+ mode: data.mode,
285
325
  outputs: data.outputs,
286
326
  },
287
327
  };
@@ -315,6 +355,8 @@ export default function PipelineEditor({ onSave, onClose, initialYaml }: {
315
355
  project: node.data.project,
316
356
  prompt: node.data.prompt,
317
357
  };
358
+ if ((node.data as any).mode === 'shell') nodeDef.mode = 'shell';
359
+ if ((node.data as any).agent) nodeDef.agent = (node.data as any).agent;
318
360
  if (deps.length > 0) nodeDef.depends_on = deps;
319
361
  if (node.data.outputs.length > 0) nodeDef.outputs = node.data.outputs;
320
362
  workflow.nodes[node.id] = nodeDef;
@@ -392,6 +434,7 @@ export default function PipelineEditor({ onSave, onClose, initialYaml }: {
392
434
  <NodeEditModal
393
435
  node={editingNode}
394
436
  projects={projects}
437
+ agents={availableAgents}
395
438
  onSave={handleSaveNode}
396
439
  onClose={() => setEditingNode(null)}
397
440
  />