@aion0/forge 0.4.16 → 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 (92) hide show
  1. package/README.md +1 -1
  2. package/RELEASE_NOTES.md +170 -14
  3. package/app/api/agents/route.ts +17 -0
  4. package/app/api/delivery/[id]/route.ts +62 -0
  5. package/app/api/delivery/route.ts +40 -0
  6. package/app/api/mobile-chat/route.ts +13 -7
  7. package/app/api/monitor/route.ts +10 -6
  8. package/app/api/pipelines/[id]/route.ts +16 -3
  9. package/app/api/tasks/route.ts +2 -1
  10. package/app/api/workspace/[id]/agents/route.ts +35 -0
  11. package/app/api/workspace/[id]/memory/route.ts +23 -0
  12. package/app/api/workspace/[id]/smith/route.ts +22 -0
  13. package/app/api/workspace/[id]/stream/route.ts +28 -0
  14. package/app/api/workspace/route.ts +100 -0
  15. package/app/global-error.tsx +10 -4
  16. package/app/icon.ico +0 -0
  17. package/app/layout.tsx +2 -2
  18. package/app/login/LoginForm.tsx +96 -0
  19. package/app/login/page.tsx +7 -98
  20. package/app/page.tsx +2 -2
  21. package/bin/forge-server.mjs +13 -1
  22. package/check-forge-status.sh +9 -0
  23. package/components/ConversationEditor.tsx +411 -0
  24. package/components/ConversationGraphView.tsx +347 -0
  25. package/components/ConversationTerminalView.tsx +303 -0
  26. package/components/Dashboard.tsx +36 -39
  27. package/components/DashboardWrapper.tsx +9 -0
  28. package/components/DeliveryFlowEditor.tsx +491 -0
  29. package/components/DeliveryList.tsx +230 -0
  30. package/components/DeliveryWorkspace.tsx +589 -0
  31. package/components/DocTerminal.tsx +10 -2
  32. package/components/DocsViewer.tsx +10 -2
  33. package/components/HelpTerminal.tsx +11 -6
  34. package/components/InlinePipelineView.tsx +111 -0
  35. package/components/MobileView.tsx +20 -0
  36. package/components/MonitorPanel.tsx +9 -4
  37. package/components/NewTaskModal.tsx +32 -0
  38. package/components/PipelineEditor.tsx +49 -6
  39. package/components/PipelineView.tsx +482 -64
  40. package/components/ProjectDetail.tsx +314 -56
  41. package/components/ProjectManager.tsx +49 -4
  42. package/components/SessionView.tsx +27 -13
  43. package/components/SettingsModal.tsx +790 -124
  44. package/components/SkillsPanel.tsx +31 -8
  45. package/components/TaskBoard.tsx +3 -0
  46. package/components/WebTerminal.tsx +257 -43
  47. package/components/WorkspaceTree.tsx +221 -0
  48. package/components/WorkspaceView.tsx +2224 -0
  49. package/install.sh +2 -2
  50. package/lib/agents/claude-adapter.ts +104 -0
  51. package/lib/agents/generic-adapter.ts +64 -0
  52. package/lib/agents/index.ts +242 -0
  53. package/lib/agents/types.ts +70 -0
  54. package/lib/artifacts.ts +106 -0
  55. package/lib/delivery.ts +787 -0
  56. package/lib/forge-skills/forge-inbox.md +37 -0
  57. package/lib/forge-skills/forge-send.md +40 -0
  58. package/lib/forge-skills/forge-status.md +32 -0
  59. package/lib/forge-skills/forge-workspace-sync.md +37 -0
  60. package/lib/help-docs/00-overview.md +7 -1
  61. package/lib/help-docs/01-settings.md +159 -2
  62. package/lib/help-docs/05-pipelines.md +89 -0
  63. package/lib/help-docs/07-projects.md +35 -1
  64. package/lib/help-docs/11-workspace.md +204 -0
  65. package/lib/help-docs/CLAUDE.md +5 -2
  66. package/lib/init.ts +60 -10
  67. package/lib/pipeline.ts +537 -1
  68. package/lib/settings.ts +115 -22
  69. package/lib/skills.ts +249 -372
  70. package/lib/task-manager.ts +113 -33
  71. package/lib/telegram-bot.ts +33 -1
  72. package/lib/workspace/__tests__/state-machine.test.ts +388 -0
  73. package/lib/workspace/__tests__/workspace.test.ts +311 -0
  74. package/lib/workspace/agent-bus.ts +416 -0
  75. package/lib/workspace/agent-worker.ts +667 -0
  76. package/lib/workspace/backends/api-backend.ts +262 -0
  77. package/lib/workspace/backends/cli-backend.ts +479 -0
  78. package/lib/workspace/index.ts +82 -0
  79. package/lib/workspace/manager.ts +136 -0
  80. package/lib/workspace/orchestrator.ts +1804 -0
  81. package/lib/workspace/persistence.ts +310 -0
  82. package/lib/workspace/presets.ts +170 -0
  83. package/lib/workspace/skill-installer.ts +188 -0
  84. package/lib/workspace/smith-memory.ts +498 -0
  85. package/lib/workspace/types.ts +231 -0
  86. package/lib/workspace/watch-manager.ts +288 -0
  87. package/lib/workspace-standalone.ts +790 -0
  88. package/middleware.ts +1 -0
  89. package/package.json +4 -1
  90. package/src/config/index.ts +12 -1
  91. package/src/core/db/database.ts +1 -0
  92. package/start.sh +7 -0
@@ -1,15 +1,69 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useCallback, lazy, Suspense } from 'react';
3
+ import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
4
4
  import { useSidebarResize } from '@/hooks/useSidebarResize';
5
+ import type { TaskLogEntry } from '@/src/types';
5
6
 
6
7
  const PipelineEditor = lazy(() => import('./PipelineEditor'));
8
+ const ConversationEditor = lazy(() => import('./ConversationEditor'));
9
+
10
+ // ─── Live Task Log Hook ──────────────────────────────────
11
+ // Subscribes to SSE stream for a running task, returns live log entries
12
+ function useTaskStream(taskId: string | undefined, isRunning: boolean) {
13
+ const [log, setLog] = useState<TaskLogEntry[]>([]);
14
+ const [status, setStatus] = useState<string>('');
15
+
16
+ useEffect(() => {
17
+ if (!taskId || !isRunning) { setLog([]); return; }
18
+
19
+ const es = new EventSource(`/api/tasks/${taskId}/stream`);
20
+ es.onmessage = (event) => {
21
+ try {
22
+ const data = JSON.parse(event.data);
23
+ if (data.type === 'log') setLog(prev => [...prev, data.entry]);
24
+ else if (data.type === 'status') setStatus(data.status);
25
+ else if (data.type === 'complete' && data.task) setLog(data.task.log);
26
+ } catch {}
27
+ };
28
+ es.onerror = () => es.close();
29
+ return () => es.close();
30
+ }, [taskId, isRunning]);
31
+
32
+ return { log, status };
33
+ }
34
+
35
+ // ─── Compact log renderer ─────────────────────────────────
36
+ function LiveLog({ log, maxHeight = 200 }: { log: TaskLogEntry[]; maxHeight?: number }) {
37
+ const endRef = useRef<HTMLDivElement>(null);
38
+ useEffect(() => { endRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [log]);
39
+
40
+ if (log.length === 0) return <div className="text-[10px] text-[var(--text-secondary)] italic">Starting...</div>;
41
+
42
+ return (
43
+ <div className="overflow-y-auto text-[9px] font-mono leading-relaxed space-y-0.5" style={{ maxHeight }}>
44
+ {log.slice(-50).map((entry, i) => (
45
+ <div key={i} className={
46
+ entry.type === 'result' ? 'text-green-400' :
47
+ entry.subtype === 'error' ? 'text-red-400' :
48
+ entry.type === 'system' ? 'text-yellow-400/70' :
49
+ 'text-[var(--text-secondary)]'
50
+ }>
51
+ {entry.type === 'assistant' && entry.subtype === 'tool_use'
52
+ ? `⚙ ${entry.tool || 'tool'}: ${entry.content.slice(0, 80)}${entry.content.length > 80 ? '...' : ''}`
53
+ : entry.content.slice(0, 200)}{entry.content.length > 200 ? '...' : ''}
54
+ </div>
55
+ ))}
56
+ <div ref={endRef} />
57
+ </div>
58
+ );
59
+ }
7
60
 
8
61
  interface WorkflowNode {
9
62
  id: string;
10
63
  project: string;
11
64
  prompt: string;
12
65
  mode?: 'claude' | 'shell';
66
+ agent?: string;
13
67
  branch?: string;
14
68
  dependsOn: string[];
15
69
  outputs: { name: string; extract: string }[];
@@ -19,11 +73,18 @@ interface WorkflowNode {
19
73
 
20
74
  interface Workflow {
21
75
  name: string;
76
+ type?: 'dag' | 'conversation';
22
77
  description?: string;
23
78
  builtin?: boolean;
24
79
  vars: Record<string, string>;
25
80
  input: Record<string, string>;
26
81
  nodes: Record<string, WorkflowNode>;
82
+ conversation?: {
83
+ agents: { id: string; agent: string; role: string }[];
84
+ maxRounds: number;
85
+ stopCondition?: string;
86
+ initialPrompt: string;
87
+ };
27
88
  }
28
89
 
29
90
  interface PipelineNodeState {
@@ -36,9 +97,20 @@ interface PipelineNodeState {
36
97
  error?: string;
37
98
  }
38
99
 
100
+ interface ConversationMessage {
101
+ round: number;
102
+ agentId: string;
103
+ agentName: string;
104
+ content: string;
105
+ timestamp: string;
106
+ taskId?: string;
107
+ status: 'pending' | 'running' | 'done' | 'failed';
108
+ }
109
+
39
110
  interface Pipeline {
40
111
  id: string;
41
112
  workflowName: string;
113
+ type?: 'dag' | 'conversation';
42
114
  status: 'running' | 'done' | 'failed' | 'cancelled';
43
115
  input: Record<string, string>;
44
116
  vars: Record<string, string>;
@@ -46,6 +118,17 @@ interface Pipeline {
46
118
  nodeOrder: string[];
47
119
  createdAt: string;
48
120
  completedAt?: string;
121
+ conversation?: {
122
+ config: {
123
+ agents: { id: string; agent: string; role: string; project?: string }[];
124
+ maxRounds: number;
125
+ stopCondition?: string;
126
+ initialPrompt: string;
127
+ };
128
+ messages: ConversationMessage[];
129
+ currentRound: number;
130
+ currentAgentIndex: number;
131
+ };
49
132
  }
50
133
 
51
134
  const STATUS_ICON: Record<string, string> = {
@@ -64,6 +147,290 @@ const STATUS_COLOR: Record<string, string> = {
64
147
  skipped: 'text-gray-500',
65
148
  };
66
149
 
150
+ // ─── DAG Node Card with live logs ─────────────────────────
151
+
152
+ function DagNodeCard({ nodeId, node, nodeDef, onViewTask }: {
153
+ nodeId: string;
154
+ node: PipelineNodeState;
155
+ nodeDef?: WorkflowNode;
156
+ onViewTask?: (taskId: string) => void;
157
+ }) {
158
+ const isRunning = node.status === 'running';
159
+ const { log } = useTaskStream(node.taskId, isRunning);
160
+
161
+ return (
162
+ <div className={`border rounded-lg p-3 ${
163
+ isRunning ? 'border-yellow-500/50 bg-yellow-500/5' :
164
+ node.status === 'done' ? 'border-green-500/30 bg-green-500/5' :
165
+ node.status === 'failed' ? 'border-red-500/30 bg-red-500/5' :
166
+ 'border-[var(--border)]'
167
+ }`}>
168
+ <div className="flex items-center gap-2">
169
+ <span className={STATUS_COLOR[node.status]}>{STATUS_ICON[node.status]}</span>
170
+ <span className="text-xs font-semibold text-[var(--text-primary)]">{nodeId}</span>
171
+ {nodeDef && nodeDef.mode !== 'shell' && (
172
+ <span className="text-[8px] px-1 rounded bg-purple-500/20 text-purple-400">{nodeDef.agent || 'default'}</span>
173
+ )}
174
+ {node.taskId && (
175
+ <button onClick={() => onViewTask?.(node.taskId!)} className="text-[9px] text-[var(--accent)] font-mono hover:underline">
176
+ task:{node.taskId}
177
+ </button>
178
+ )}
179
+ {node.iterations > 1 && <span className="text-[9px] text-yellow-400">iter {node.iterations}</span>}
180
+ <span className="text-[9px] text-[var(--text-secondary)] ml-auto">{node.status}</span>
181
+ </div>
182
+
183
+ {/* Live log for running nodes */}
184
+ {isRunning && (
185
+ <div className="mt-2 p-2 bg-[var(--bg-tertiary)] rounded">
186
+ <LiveLog log={log} maxHeight={160} />
187
+ </div>
188
+ )}
189
+
190
+ {node.error && <div className="text-[10px] text-red-400 mt-1">{node.error}</div>}
191
+
192
+ {/* Outputs */}
193
+ {Object.keys(node.outputs).length > 0 && (
194
+ <div className="mt-2 space-y-1">
195
+ {Object.entries(node.outputs).map(([key, val]) => (
196
+ <details key={key} className="text-[10px]">
197
+ <summary className="cursor-pointer text-[var(--accent)]">output: {key} ({val.length} chars)</summary>
198
+ <pre className="mt-1 p-2 bg-[var(--bg-tertiary)] rounded text-[9px] text-[var(--text-secondary)] max-h-32 overflow-auto whitespace-pre-wrap">
199
+ {val.slice(0, 1000)}{val.length > 1000 ? '...' : ''}
200
+ </pre>
201
+ </details>
202
+ ))}
203
+ </div>
204
+ )}
205
+
206
+ {node.startedAt && (
207
+ <div className="text-[8px] text-[var(--text-secondary)] mt-1">
208
+ {`Started: ${new Date(node.startedAt).toLocaleTimeString()}`}
209
+ {node.completedAt && ` · Done: ${new Date(node.completedAt).toLocaleTimeString()}`}
210
+ </div>
211
+ )}
212
+ </div>
213
+ );
214
+ }
215
+
216
+ // ─── Agent color palette for conversation bubbles ────────
217
+ const AGENT_COLORS = [
218
+ { bg: 'bg-blue-500/10', border: 'border-blue-500/30', badge: 'bg-blue-500/20 text-blue-400', dot: 'text-blue-400' },
219
+ { bg: 'bg-purple-500/10', border: 'border-purple-500/30', badge: 'bg-purple-500/20 text-purple-400', dot: 'text-purple-400' },
220
+ { bg: 'bg-green-500/10', border: 'border-green-500/30', badge: 'bg-green-500/20 text-green-400', dot: 'text-green-400' },
221
+ { bg: 'bg-orange-500/10', border: 'border-orange-500/30', badge: 'bg-orange-500/20 text-orange-400', dot: 'text-orange-400' },
222
+ { bg: 'bg-pink-500/10', border: 'border-pink-500/30', badge: 'bg-pink-500/20 text-pink-400', dot: 'text-pink-400' },
223
+ ];
224
+
225
+ function ConversationMessageBubble({ msg, colors, agentDef, isLeft, onViewTask }: {
226
+ msg: ConversationMessage; colors: typeof AGENT_COLORS[0]; agentDef?: { id: string; role: string };
227
+ isLeft: boolean; onViewTask?: (taskId: string) => void;
228
+ }) {
229
+ const isRunning = msg.status === 'running';
230
+ const { log } = useTaskStream(msg.taskId, isRunning);
231
+
232
+ return (
233
+ <div className={`flex ${isLeft ? 'justify-start' : 'justify-end'}`}>
234
+ <div className={`max-w-[85%] border rounded-lg p-3 ${colors.bg} ${colors.border}`}>
235
+ {/* Agent header */}
236
+ <div className="flex items-center gap-2 mb-1.5">
237
+ <span className={`text-[9px] px-1.5 py-0.5 rounded font-medium ${colors.badge}`}>{msg.agentName}</span>
238
+ <span className="text-[8px] text-[var(--text-secondary)]">
239
+ {msg.agentId}{agentDef?.role ? ` — ${agentDef.role.slice(0, 40)}${agentDef.role.length > 40 ? '...' : ''}` : ''}
240
+ </span>
241
+ {isRunning && <span className="text-[8px] text-yellow-400 animate-pulse">● running</span>}
242
+ <span className="text-[8px] text-[var(--text-secondary)] ml-auto">R{msg.round}</span>
243
+ </div>
244
+
245
+ {/* Content */}
246
+ {isRunning ? (
247
+ <LiveLog log={log} maxHeight={250} />
248
+ ) : msg.status === 'failed' ? (
249
+ <div className="text-[10px] text-red-400">{msg.content || 'Failed'}</div>
250
+ ) : (
251
+ <div className="text-[10px] text-[var(--text-primary)] whitespace-pre-wrap leading-relaxed">
252
+ {msg.content.slice(0, 3000)}{msg.content.length > 3000 ? '\n\n[... truncated]' : ''}
253
+ </div>
254
+ )}
255
+
256
+ {/* Footer */}
257
+ <div className="flex items-center gap-2 mt-1.5">
258
+ {msg.taskId && (
259
+ <button onClick={() => onViewTask?.(msg.taskId!)} className="text-[8px] text-[var(--accent)] font-mono hover:underline">
260
+ task:{msg.taskId}
261
+ </button>
262
+ )}
263
+ <span className="text-[7px] text-[var(--text-secondary)] ml-auto">{new Date(msg.timestamp).toLocaleTimeString()}</span>
264
+ </div>
265
+ </div>
266
+ </div>
267
+ );
268
+ }
269
+
270
+ const ConversationGraphView = lazy(() => import('./ConversationGraphView'));
271
+ const ConversationTerminalView = lazy(() => import('./ConversationTerminalView'));
272
+
273
+ function ConversationView({ pipeline, onViewTask }: { pipeline: Pipeline; onViewTask?: (taskId: string) => void }) {
274
+ const conv = pipeline.conversation!;
275
+ const { config, messages, currentRound } = conv;
276
+ const [injectText, setInjectText] = useState('');
277
+ const [injectTarget, setInjectTarget] = useState(config.agents[0]?.id || '');
278
+ const [injecting, setInjecting] = useState(false);
279
+ const [viewMode, setViewMode] = useState<'terminal' | 'graph' | 'chat'>('terminal');
280
+ const scrollRef = useRef<HTMLDivElement>(null);
281
+
282
+ // Assign stable colors per agent
283
+ const agentColorMap: Record<string, typeof AGENT_COLORS[0]> = {};
284
+ config.agents.forEach((a, i) => {
285
+ agentColorMap[a.id] = AGENT_COLORS[i % AGENT_COLORS.length];
286
+ });
287
+
288
+ // Auto-scroll on new messages
289
+ useEffect(() => {
290
+ scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
291
+ }, [messages.length]);
292
+
293
+ const handleInject = async () => {
294
+ if (!injectText.trim() || injecting) return;
295
+ setInjecting(true);
296
+ try {
297
+ await fetch(`/api/pipelines/${pipeline.id}`, {
298
+ method: 'POST',
299
+ headers: { 'Content-Type': 'application/json' },
300
+ body: JSON.stringify({ action: 'inject', agentId: injectTarget, message: injectText }),
301
+ });
302
+ setInjectText('');
303
+ } catch {}
304
+ setInjecting(false);
305
+ };
306
+
307
+ return (
308
+ <div className="flex-1 flex flex-col overflow-hidden">
309
+ {/* Conversation info bar */}
310
+ <div className="px-4 py-2 border-b border-[var(--border)] bg-[var(--bg-tertiary)]/50 shrink-0">
311
+ <div className="flex items-center gap-3">
312
+ <span className="text-[9px] px-1.5 py-0.5 rounded bg-[var(--accent)]/20 text-[var(--accent)] font-medium">Conversation</span>
313
+ <span className="text-[9px] text-[var(--text-secondary)]">Round {currentRound}/{config.maxRounds}</span>
314
+ <div className="flex items-center gap-2 ml-auto">
315
+ {/* View mode toggle */}
316
+ <div className="flex border border-[var(--border)] rounded overflow-hidden">
317
+ {(['terminal', 'graph', 'chat'] as const).map(mode => (
318
+ <button
319
+ key={mode}
320
+ onClick={() => setViewMode(mode)}
321
+ className={`text-[8px] px-2 py-0.5 capitalize ${viewMode === mode ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
322
+ >{mode}</button>
323
+ ))}
324
+ </div>
325
+ {config.agents.map(a => {
326
+ const colors = agentColorMap[a.id];
327
+ const isRunning = messages.some(m => m.agentId === a.id && m.status === 'running');
328
+ return (
329
+ <span key={a.id} className={`text-[8px] px-1.5 py-0.5 rounded ${colors.badge} ${isRunning ? 'ring-1 ring-yellow-400/50' : ''}`}>
330
+ {isRunning ? '● ' : ''}{a.id} ({a.agent})
331
+ </span>
332
+ );
333
+ })}
334
+ </div>
335
+ </div>
336
+ {config.stopCondition && (
337
+ <div className="text-[8px] text-[var(--text-secondary)] mt-1">Stop: {config.stopCondition}</div>
338
+ )}
339
+ </div>
340
+
341
+ {/* Terminal / Graph / Chat view */}
342
+ {viewMode === 'terminal' ? (
343
+ <div className="flex-1 min-h-0">
344
+ <Suspense fallback={<div className="flex items-center justify-center h-full text-xs text-[var(--text-secondary)]">Loading...</div>}>
345
+ <ConversationTerminalView pipeline={pipeline} onViewTask={onViewTask} />
346
+ </Suspense>
347
+ </div>
348
+ ) : viewMode === 'graph' ? (
349
+ <div className="flex-1 min-h-0">
350
+ <Suspense fallback={<div className="flex items-center justify-center h-full text-xs text-[var(--text-secondary)]">Loading graph...</div>}>
351
+ <ConversationGraphView pipeline={pipeline} />
352
+ </Suspense>
353
+ </div>
354
+ ) : (
355
+ <>
356
+ {/* Initial prompt */}
357
+ <div className="px-4 pt-3">
358
+ <div className="border border-[var(--border)] rounded-lg p-3 bg-[var(--bg-tertiary)]/50">
359
+ <div className="text-[9px] text-[var(--text-secondary)] font-medium mb-1">Initial Prompt</div>
360
+ <div className="text-[11px] text-[var(--text-primary)] whitespace-pre-wrap">{config.initialPrompt}</div>
361
+ </div>
362
+ </div>
363
+
364
+ {/* Messages — chat-like view with live logs */}
365
+ <div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
366
+ {messages.map((msg, i) => {
367
+ const colors = agentColorMap[msg.agentId] || AGENT_COLORS[0];
368
+ const agentDef = config.agents.find(a => a.id === msg.agentId);
369
+ const isLeft = config.agents.indexOf(agentDef!) % 2 === 0;
370
+
371
+ return (
372
+ <ConversationMessageBubble
373
+ key={`${msg.taskId || i}-${msg.status}`}
374
+ msg={msg}
375
+ colors={colors}
376
+ agentDef={agentDef}
377
+ isLeft={isLeft}
378
+ onViewTask={onViewTask}
379
+ />
380
+ );
381
+ })}
382
+
383
+ {/* Completion indicator */}
384
+ {pipeline.status === 'done' && (
385
+ <div className="flex justify-center py-2">
386
+ <span className="text-[10px] px-3 py-1 rounded-full bg-green-500/10 text-green-400 border border-green-500/30">
387
+ Conversation complete — {messages.length} messages in {Math.max(...messages.map(m => m.round), 0)} rounds
388
+ </span>
389
+ </div>
390
+ )}
391
+ {pipeline.status === 'failed' && (
392
+ <div className="flex justify-center py-2">
393
+ <span className="text-[10px] px-3 py-1 rounded-full bg-red-500/10 text-red-400 border border-red-500/30">
394
+ Conversation failed
395
+ </span>
396
+ </div>
397
+ )}
398
+ </div>
399
+ </>
400
+ )}
401
+
402
+ {/* Inject command bar */}
403
+ {pipeline.status === 'running' && (
404
+ <div className="px-4 py-2 border-t border-[var(--border)] bg-[var(--bg-tertiary)]/50 shrink-0">
405
+ <div className="flex items-center gap-2">
406
+ <select
407
+ value={injectTarget}
408
+ onChange={e => setInjectTarget(e.target.value)}
409
+ className="text-[10px] bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)]"
410
+ >
411
+ {config.agents.map(a => (
412
+ <option key={a.id} value={a.id}>@{a.id}</option>
413
+ ))}
414
+ </select>
415
+ <input
416
+ value={injectText}
417
+ onChange={e => setInjectText(e.target.value)}
418
+ onKeyDown={e => e.key === 'Enter' && !e.shiftKey && handleInject()}
419
+ placeholder="Send instruction to agent..."
420
+ className="flex-1 text-[10px] bg-[var(--bg-primary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
421
+ />
422
+ <button
423
+ onClick={handleInject}
424
+ disabled={!injectText.trim() || injecting}
425
+ className="text-[10px] px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
426
+ >Send</button>
427
+ </div>
428
+ </div>
429
+ )}
430
+ </div>
431
+ );
432
+ }
433
+
67
434
  export default function PipelineView({ onViewTask, focusPipelineId, onFocusHandled }: { onViewTask?: (taskId: string) => void; focusPipelineId?: string | null; onFocusHandled?: () => void }) {
68
435
  const { sidebarWidth, onSidebarDragStart } = useSidebarResize({ defaultWidth: 256, minWidth: 140, maxWidth: 480 });
69
436
  const [pipelines, setPipelines] = useState<Pipeline[]>([]);
@@ -77,21 +444,26 @@ export default function PipelineView({ onViewTask, focusPipelineId, onFocusHandl
77
444
  const [creating, setCreating] = useState(false);
78
445
  const [showEditor, setShowEditor] = useState(false);
79
446
  const [editorYaml, setEditorYaml] = useState<string | undefined>(undefined);
447
+ const [editorIsConversation, setEditorIsConversation] = useState(false);
80
448
  const [showImport, setShowImport] = useState(false);
81
449
  const [importYaml, setImportYaml] = useState('');
450
+ const [agents, setAgents] = useState<{ id: string; name: string; detected?: boolean }[]>([]);
82
451
 
83
452
  const fetchData = useCallback(async () => {
84
- const [pRes, wRes, projRes] = await Promise.all([
453
+ const [pRes, wRes, projRes, agentRes] = await Promise.all([
85
454
  fetch('/api/pipelines'),
86
455
  fetch('/api/pipelines?type=workflows'),
87
456
  fetch('/api/projects'),
457
+ fetch('/api/agents'),
88
458
  ]);
89
459
  const pData = await pRes.json();
90
460
  const wData = await wRes.json();
91
461
  const projData = await projRes.json();
462
+ const agentData = await agentRes.json();
92
463
  if (Array.isArray(pData)) setPipelines(pData);
93
464
  if (Array.isArray(wData)) setWorkflows(wData);
94
465
  if (Array.isArray(projData)) setProjects(projData.map((p: any) => ({ name: p.name, path: p.path })));
466
+ if (Array.isArray(agentData?.agents)) setAgents(agentData.agents);
95
467
  }, []);
96
468
 
97
469
  useEffect(() => {
@@ -166,6 +538,31 @@ export default function PipelineView({ onViewTask, focusPipelineId, onFocusHandl
166
538
  fetchData();
167
539
  };
168
540
 
541
+ const generateConversationTemplate = () => {
542
+ const detectedAgents = agents.filter(a => a.detected);
543
+ const agentEntries = detectedAgents.length >= 2
544
+ ? detectedAgents.slice(0, 2)
545
+ : [{ id: 'claude', name: 'Claude Code' }, { id: 'claude', name: 'Claude Code' }];
546
+
547
+ return `name: my-conversation
548
+ type: conversation
549
+ description: "Multi-agent collaboration"
550
+ input:
551
+ project: "Project name"
552
+ task: "Task description"
553
+ agents:
554
+ - id: designer
555
+ agent: ${agentEntries[0].id}
556
+ role: "You are a software architect. Design the solution and review implementations."
557
+ - id: builder
558
+ agent: ${agentEntries[1].id}
559
+ role: "You are a developer. Implement what the designer proposes."
560
+ max_rounds: 5
561
+ stop_condition: "both agents say DONE"
562
+ initial_prompt: "{{input.task}}"
563
+ `;
564
+ };
565
+
169
566
  const currentWorkflow = workflows.find(w => w.name === selectedWorkflow);
170
567
 
171
568
  return (
@@ -179,9 +576,13 @@ export default function PipelineView({ onViewTask, focusPipelineId, onFocusHandl
179
576
  className="text-[9px] text-green-400 hover:underline"
180
577
  >Import</button>
181
578
  <button
182
- onClick={() => { setEditorYaml(undefined); setShowEditor(true); }}
579
+ onClick={() => { setEditorYaml(undefined); setEditorIsConversation(false); setShowEditor(true); }}
183
580
  className="text-[9px] text-[var(--accent)] hover:underline"
184
- >+ New</button>
581
+ >+ DAG</button>
582
+ <button
583
+ onClick={() => { setImportYaml(generateConversationTemplate()); setShowImport(true); }}
584
+ className="text-[9px] text-purple-400 hover:underline"
585
+ >+ Conversation</button>
185
586
  </div>
186
587
 
187
588
  {/* Import form */}
@@ -331,7 +732,8 @@ export default function PipelineView({ onViewTask, focusPipelineId, onFocusHandl
331
732
  const res = await fetch(`/api/pipelines?type=workflow-yaml&name=${encodeURIComponent(w.name)}`);
332
733
  const data = await res.json();
333
734
  setEditorYaml(data.yaml || undefined);
334
- } catch { setEditorYaml(undefined); }
735
+ setEditorIsConversation(w.type === 'conversation' || (data.yaml || '').includes('type: conversation'));
736
+ } catch { setEditorYaml(undefined); setEditorIsConversation(false); }
335
737
  setShowEditor(true);
336
738
  }}
337
739
  className="text-[8px] text-green-400 hover:underline shrink-0"
@@ -355,6 +757,11 @@ export default function PipelineView({ onViewTask, focusPipelineId, onFocusHandl
355
757
  <div className="flex items-center gap-1.5">
356
758
  <span className={`text-[9px] ${STATUS_COLOR[p.status]}`}>●</span>
357
759
  <span className="text-[9px] text-[var(--text-secondary)] font-mono">{p.id.slice(0, 8)}</span>
760
+ {p.type === 'conversation' ? (
761
+ <span className="text-[7px] px-1 rounded bg-[var(--accent)]/15 text-[var(--accent)]">
762
+ R{p.conversation?.currentRound || 0}/{p.conversation?.config.maxRounds || '?'}
763
+ </span>
764
+ ) : (
358
765
  <div className="flex gap-0.5 ml-1">
359
766
  {p.nodeOrder.map(nodeId => (
360
767
  <span key={nodeId} className={`text-[8px] ${STATUS_COLOR[p.nodes[nodeId]?.status || 'pending']}`}>
@@ -362,6 +769,7 @@ export default function PipelineView({ onViewTask, focusPipelineId, onFocusHandl
362
769
  </span>
363
770
  ))}
364
771
  </div>
772
+ )}
365
773
  <span className="text-[8px] text-[var(--text-secondary)] ml-auto">
366
774
  {new Date(p.createdAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
367
775
  </span>
@@ -391,6 +799,23 @@ export default function PipelineView({ onViewTask, focusPipelineId, onFocusHandl
391
799
  {/* Right — Pipeline detail / Editor */}
392
800
  <main className="flex-1 flex flex-col min-w-0 overflow-hidden">
393
801
  {showEditor ? (
802
+ editorIsConversation ? (
803
+ <Suspense fallback={<div className="flex-1 flex items-center justify-center text-xs text-[var(--text-secondary)]">Loading editor...</div>}>
804
+ <ConversationEditor
805
+ initialYaml={editorYaml || ''}
806
+ onSave={async (yaml) => {
807
+ await fetch('/api/pipelines', {
808
+ method: 'POST',
809
+ headers: { 'Content-Type': 'application/json' },
810
+ body: JSON.stringify({ action: 'save-workflow', yaml }),
811
+ });
812
+ setShowEditor(false);
813
+ fetchData();
814
+ }}
815
+ onClose={() => setShowEditor(false)}
816
+ />
817
+ </Suspense>
818
+ ) : (
394
819
  <Suspense fallback={<div className="flex-1 flex items-center justify-center text-xs text-[var(--text-secondary)]">Loading editor...</div>}>
395
820
  <PipelineEditor
396
821
  initialYaml={editorYaml}
@@ -406,6 +831,7 @@ export default function PipelineView({ onViewTask, focusPipelineId, onFocusHandl
406
831
  onClose={() => setShowEditor(false)}
407
832
  />
408
833
  </Suspense>
834
+ )
409
835
  ) : selectedPipeline ? (
410
836
  <>
411
837
  {/* Header */}
@@ -415,6 +841,9 @@ export default function PipelineView({ onViewTask, focusPipelineId, onFocusHandl
415
841
  {STATUS_ICON[selectedPipeline.status]}
416
842
  </span>
417
843
  <span className="text-sm font-semibold text-[var(--text-primary)]">{selectedPipeline.workflowName}</span>
844
+ {selectedPipeline.type === 'conversation' && (
845
+ <span className="text-[8px] px-1.5 py-0.5 rounded bg-[var(--accent)]/20 text-[var(--accent)]">conversation</span>
846
+ )}
418
847
  <span className="text-[10px] text-[var(--text-secondary)] font-mono">{selectedPipeline.id}</span>
419
848
  <div className="flex items-center gap-2 ml-auto">
420
849
  {selectedPipeline.status === 'running' && (
@@ -444,75 +873,31 @@ export default function PipelineView({ onViewTask, focusPipelineId, onFocusHandl
444
873
  )}
445
874
  </div>
446
875
 
447
- {/* DAG visualization */}
448
- <div className="p-4 space-y-2">
876
+ {/* Conversation or DAG visualization */}
877
+ {selectedPipeline.type === 'conversation' && selectedPipeline.conversation ? (
878
+ <ConversationView
879
+ pipeline={selectedPipeline}
880
+ onViewTask={onViewTask}
881
+ />
882
+ ) : (
883
+ <div className="p-4 space-y-2 overflow-y-auto">
449
884
  {selectedPipeline.nodeOrder.map((nodeId, idx) => {
450
885
  const node = selectedPipeline.nodes[nodeId];
886
+ const wf = workflows.find(w => w.name === selectedPipeline.workflowName);
887
+ const nodeDef = wf?.nodes?.[nodeId];
451
888
  return (
452
889
  <div key={nodeId}>
453
- {/* Connection line */}
454
890
  {idx > 0 && (
455
891
  <div className="flex items-center pl-5 py-1">
456
892
  <div className="w-px h-4 bg-[var(--border)]" />
457
893
  </div>
458
894
  )}
459
-
460
- {/* Node card */}
461
- <div className={`border rounded-lg p-3 ${
462
- node.status === 'running' ? 'border-yellow-500/50 bg-yellow-500/5' :
463
- node.status === 'done' ? 'border-green-500/30 bg-green-500/5' :
464
- node.status === 'failed' ? 'border-red-500/30 bg-red-500/5' :
465
- 'border-[var(--border)]'
466
- }`}>
467
- <div className="flex items-center gap-2">
468
- <span className={STATUS_COLOR[node.status]}>{STATUS_ICON[node.status]}</span>
469
- <span className="text-xs font-semibold text-[var(--text-primary)]">{nodeId}</span>
470
- {node.taskId && (
471
- <button
472
- onClick={() => onViewTask?.(node.taskId!)}
473
- className="text-[9px] text-[var(--accent)] font-mono hover:underline"
474
- >
475
- task:{node.taskId}
476
- </button>
477
- )}
478
- {node.iterations > 1 && (
479
- <span className="text-[9px] text-yellow-400">iter {node.iterations}</span>
480
- )}
481
- <span className="text-[9px] text-[var(--text-secondary)] ml-auto">{node.status}</span>
482
- </div>
483
-
484
- {node.error && (
485
- <div className="text-[10px] text-red-400 mt-1">{node.error}</div>
486
- )}
487
-
488
- {/* Outputs */}
489
- {Object.keys(node.outputs).length > 0 && (
490
- <div className="mt-2 space-y-1">
491
- {Object.entries(node.outputs).map(([key, val]) => (
492
- <details key={key} className="text-[10px]">
493
- <summary className="cursor-pointer text-[var(--accent)]">
494
- output: {key} ({val.length} chars)
495
- </summary>
496
- <pre className="mt-1 p-2 bg-[var(--bg-tertiary)] rounded text-[9px] text-[var(--text-secondary)] max-h-32 overflow-auto whitespace-pre-wrap">
497
- {val.slice(0, 1000)}{val.length > 1000 ? '...' : ''}
498
- </pre>
499
- </details>
500
- ))}
501
- </div>
502
- )}
503
-
504
- {/* Timing */}
505
- {node.startedAt && (
506
- <div className="text-[8px] text-[var(--text-secondary)] mt-1">
507
- {node.startedAt && `Started: ${new Date(node.startedAt).toLocaleTimeString()}`}
508
- {node.completedAt && ` · Done: ${new Date(node.completedAt).toLocaleTimeString()}`}
509
- </div>
510
- )}
511
- </div>
895
+ <DagNodeCard nodeId={nodeId} node={node} nodeDef={nodeDef} onViewTask={onViewTask} />
512
896
  </div>
513
897
  );
514
898
  })}
515
899
  </div>
900
+ )}
516
901
  </>
517
902
  ) : activeWorkflow ? (() => {
518
903
  const w = workflows.find(wf => wf.name === activeWorkflow);
@@ -524,6 +909,7 @@ export default function PipelineView({ onViewTask, focusPipelineId, onFocusHandl
524
909
  <div className="px-4 py-3 border-b border-[var(--border)] shrink-0">
525
910
  <div className="flex items-center gap-2">
526
911
  <span className="text-sm font-semibold text-[var(--text-primary)]">{w.name}</span>
912
+ {w.type === 'conversation' && <span className="text-[8px] px-1.5 py-0.5 rounded bg-[var(--accent)]/20 text-[var(--accent)]">conversation</span>}
527
913
  {w.builtin && <span className="text-[8px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">built-in</span>}
528
914
  <div className="ml-auto flex gap-2">
529
915
  <button
@@ -536,7 +922,8 @@ export default function PipelineView({ onViewTask, focusPipelineId, onFocusHandl
536
922
  const res = await fetch(`/api/pipelines?type=workflow-yaml&name=${encodeURIComponent(w.name)}`);
537
923
  const data = await res.json();
538
924
  setEditorYaml(data.yaml || undefined);
539
- } catch { setEditorYaml(undefined); }
925
+ setEditorIsConversation(w.type === 'conversation' || (data.yaml || '').includes('type: conversation'));
926
+ } catch { setEditorYaml(undefined); setEditorIsConversation(false); }
540
927
  setShowEditor(true);
541
928
  }}
542
929
  className="text-[10px] px-3 py-1 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:text-[var(--text-primary)]"
@@ -553,7 +940,37 @@ export default function PipelineView({ onViewTask, focusPipelineId, onFocusHandl
553
940
  )}
554
941
  </div>
555
942
 
556
- {/* Node flow visualization */}
943
+ {/* Conversation or Node flow visualization */}
944
+ {w.type === 'conversation' && w.conversation ? (
945
+ <div className="p-4 space-y-3">
946
+ {/* Initial prompt */}
947
+ <div className="border border-[var(--border)] rounded-lg p-3 bg-[var(--bg-tertiary)]">
948
+ <div className="text-[9px] text-[var(--text-secondary)] font-medium mb-1">Initial Prompt</div>
949
+ <p className="text-[10px] text-[var(--text-primary)]">{w.conversation.initialPrompt}</p>
950
+ </div>
951
+ {/* Agents */}
952
+ <div className="text-[9px] text-[var(--text-secondary)] font-medium">Agents ({w.conversation.agents.length})</div>
953
+ <div className="space-y-2">
954
+ {w.conversation.agents.map((a, i) => {
955
+ const colors = AGENT_COLORS[i % AGENT_COLORS.length];
956
+ return (
957
+ <div key={a.id} className={`border rounded-lg p-3 ${colors.bg} ${colors.border}`}>
958
+ <div className="flex items-center gap-2">
959
+ <span className={`text-[9px] px-1.5 py-0.5 rounded font-medium ${colors.badge}`}>{a.agent}</span>
960
+ <span className="text-[11px] font-semibold text-[var(--text-primary)]">{a.id}</span>
961
+ </div>
962
+ {a.role && <p className="text-[9px] text-[var(--text-secondary)] mt-1">{a.role}</p>}
963
+ </div>
964
+ );
965
+ })}
966
+ </div>
967
+ {/* Config */}
968
+ <div className="text-[9px] text-[var(--text-secondary)] space-y-0.5">
969
+ <div>Max rounds: {w.conversation.maxRounds}</div>
970
+ {w.conversation.stopCondition && <div>Stop: {w.conversation.stopCondition}</div>}
971
+ </div>
972
+ </div>
973
+ ) : (
557
974
  <div className="p-4 space-y-2">
558
975
  {nodeEntries.map(([nodeId, node], i) => (
559
976
  <div key={nodeId}>
@@ -568,7 +985,7 @@ export default function PipelineView({ onViewTask, focusPipelineId, onFocusHandl
568
985
  <div className="flex items-center gap-2">
569
986
  <span className={`text-[9px] px-1.5 py-0.5 rounded font-medium ${
570
987
  node.mode === 'shell' ? 'bg-yellow-500/20 text-yellow-400' : 'bg-purple-500/20 text-purple-400'
571
- }`}>{node.mode === 'shell' ? 'shell' : 'claude'}</span>
988
+ }`}>{node.mode === 'shell' ? 'shell' : (node.agent || 'default')}</span>
572
989
  <span className="text-[11px] font-semibold text-[var(--text-primary)]">{nodeId}</span>
573
990
  {node.project && <span className="text-[9px] text-[var(--text-secondary)] ml-auto">{node.project}</span>}
574
991
  </div>
@@ -587,6 +1004,7 @@ export default function PipelineView({ onViewTask, focusPipelineId, onFocusHandl
587
1004
  </div>
588
1005
  ))}
589
1006
  </div>
1007
+ )}
590
1008
  </div>
591
1009
  );
592
1010
  })() : (