@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
package/lib/pipeline.ts CHANGED
@@ -12,6 +12,7 @@ import YAML from 'yaml';
12
12
  import { createTask, getTask, onTaskEvent, taskModelOverrides } from './task-manager';
13
13
  import { getProjectInfo } from './projects';
14
14
  import { loadSettings } from './settings';
15
+ import { getAgent, listAgents } from './agents';
15
16
  import type { Task } from '@/src/types';
16
17
  import { getDataDir } from './dirs';
17
18
 
@@ -30,7 +31,8 @@ export interface WorkflowNode {
30
31
  id: string;
31
32
  project: string;
32
33
  prompt: string;
33
- mode?: 'claude' | 'shell'; // default: 'claude' (claude -p), 'shell' runs raw shell command
34
+ mode?: 'claude' | 'shell'; // default: 'claude' (agent -p), 'shell' runs raw shell command
35
+ agent?: string; // agent ID (default: from settings)
34
36
  branch?: string; // auto checkout this branch before running (supports templates)
35
37
  dependsOn: string[];
36
38
  outputs: { name: string; extract: 'result' | 'git_diff' | 'stdout' }[];
@@ -38,12 +40,46 @@ export interface WorkflowNode {
38
40
  maxIterations: number;
39
41
  }
40
42
 
43
+ // ─── Conversation Mode Types ──────────────────────────────
44
+
45
+ export interface ConversationAgent {
46
+ id: string; // logical ID within this conversation (e.g., 'architect', 'implementer')
47
+ agent: string; // agent registry ID (e.g., 'claude', 'codex', 'aider')
48
+ role: string; // system prompt / role description
49
+ project?: string; // project context (optional, defaults to workflow input.project)
50
+ }
51
+
52
+ export interface ConversationMessage {
53
+ round: number;
54
+ agentId: string; // logical ID from ConversationAgent
55
+ agentName: string; // display name (resolved from registry)
56
+ content: string;
57
+ timestamp: string;
58
+ taskId?: string; // backing task ID
59
+ status: 'pending' | 'running' | 'done' | 'failed';
60
+ }
61
+
62
+ export interface ConversationConfig {
63
+ agents: ConversationAgent[];
64
+ maxRounds: number; // max back-and-forth rounds
65
+ stopCondition?: string; // e.g., "all agents say DONE", "any agent says DONE"
66
+ initialPrompt: string; // the seed prompt to kick off the conversation
67
+ contextStrategy?: 'full' | 'window' | 'summary'; // how to pass history, default: 'summary'
68
+ contextWindow?: number; // for 'window'/'summary': how many recent messages to include in full (default: 4)
69
+ maxContentLength?: number; // truncate each message to this length (default: 3000)
70
+ }
71
+
72
+ // ─── Workflow ─────────────────────────────────────────────
73
+
41
74
  export interface Workflow {
42
75
  name: string;
76
+ type?: 'dag' | 'conversation'; // default: 'dag'
43
77
  description?: string;
44
78
  vars: Record<string, string>;
45
79
  input: Record<string, string>; // required input fields
46
80
  nodes: Record<string, WorkflowNode>;
81
+ // Conversation mode fields (only when type === 'conversation')
82
+ conversation?: ConversationConfig;
47
83
  }
48
84
 
49
85
  export type PipelineNodeStatus = 'pending' | 'running' | 'done' | 'failed' | 'skipped';
@@ -61,6 +97,7 @@ export interface PipelineNodeState {
61
97
  export interface Pipeline {
62
98
  id: string;
63
99
  workflowName: string;
100
+ type?: 'dag' | 'conversation'; // default: 'dag'
64
101
  status: 'running' | 'done' | 'failed' | 'cancelled';
65
102
  input: Record<string, string>;
66
103
  vars: Record<string, string>;
@@ -68,6 +105,13 @@ export interface Pipeline {
68
105
  nodeOrder: string[]; // for UI display
69
106
  createdAt: string;
70
107
  completedAt?: string;
108
+ // Conversation mode state
109
+ conversation?: {
110
+ config: ConversationConfig;
111
+ messages: ConversationMessage[];
112
+ currentRound: number;
113
+ currentAgentIndex: number; // index into config.agents
114
+ };
71
115
  }
72
116
 
73
117
  // ─── Workflow Loading ─────────────────────────────────────
@@ -192,6 +236,24 @@ nodes:
192
236
  outputs:
193
237
  - name: result
194
238
  extract: stdout
239
+ `,
240
+ 'multi-agent-collaboration': `
241
+ name: multi-agent-collaboration
242
+ type: conversation
243
+ description: "Two agents collaborate: one designs, one implements"
244
+ input:
245
+ project: "Project name"
246
+ task: "What to build or fix"
247
+ agents:
248
+ - id: architect
249
+ agent: claude
250
+ role: "You are a software architect. Round 1: design the solution with clear steps. Later rounds: review the implementation and say DONE if satisfied."
251
+ - id: implementer
252
+ agent: claude
253
+ role: "You are a developer. Implement what the architect designs. After implementing, say DONE."
254
+ max_rounds: 3
255
+ stop_condition: "both agents say DONE"
256
+ initial_prompt: "Task: {{input.task}}"
195
257
  `,
196
258
  };
197
259
 
@@ -231,6 +293,7 @@ export function getWorkflow(name: string): WorkflowWithMeta | null {
231
293
 
232
294
  function parseWorkflow(raw: string): Workflow {
233
295
  const parsed = YAML.parse(raw);
296
+ const workflowType = parsed.type || 'dag';
234
297
  const nodes: Record<string, WorkflowNode> = {};
235
298
 
236
299
  for (const [id, def] of Object.entries(parsed.nodes || {})) {
@@ -240,6 +303,7 @@ function parseWorkflow(raw: string): Workflow {
240
303
  project: n.project || '',
241
304
  prompt: n.prompt || '',
242
305
  mode: n.mode || 'claude',
306
+ agent: n.agent || undefined,
243
307
  branch: n.branch || undefined,
244
308
  dependsOn: n.depends_on || n.dependsOn || [],
245
309
  outputs: (n.outputs || []).map((o: any) => ({
@@ -254,12 +318,33 @@ function parseWorkflow(raw: string): Workflow {
254
318
  };
255
319
  }
256
320
 
321
+ // Parse conversation config
322
+ let conversation: ConversationConfig | undefined;
323
+ if (workflowType === 'conversation' && parsed.agents) {
324
+ conversation = {
325
+ agents: (parsed.agents as any[]).map((a: any) => ({
326
+ id: a.id,
327
+ agent: a.agent || 'claude',
328
+ role: a.role || '',
329
+ project: a.project || undefined,
330
+ })),
331
+ maxRounds: parsed.max_rounds || parsed.maxRounds || 10,
332
+ stopCondition: parsed.stop_condition || parsed.stopCondition || undefined,
333
+ initialPrompt: parsed.initial_prompt || parsed.initialPrompt || '',
334
+ contextStrategy: parsed.context_strategy || parsed.contextStrategy || 'summary',
335
+ contextWindow: parsed.context_window || parsed.contextWindow || 4,
336
+ maxContentLength: parsed.max_content_length || parsed.maxContentLength || 3000,
337
+ };
338
+ }
339
+
257
340
  return {
258
341
  name: parsed.name || 'unnamed',
342
+ type: workflowType,
259
343
  description: parsed.description,
260
344
  vars: parsed.vars || {},
261
345
  input: parsed.input || {},
262
346
  nodes,
347
+ conversation,
263
348
  };
264
349
  }
265
350
 
@@ -381,6 +466,11 @@ export function startPipeline(workflowName: string, input: Record<string, string
381
466
  const workflow = getWorkflow(workflowName);
382
467
  if (!workflow) throw new Error(`Workflow not found: ${workflowName}`);
383
468
 
469
+ // Conversation mode — separate execution path
470
+ if (workflow.type === 'conversation' && workflow.conversation) {
471
+ return startConversationPipeline(workflow, input);
472
+ }
473
+
384
474
  const id = randomUUID().slice(0, 8);
385
475
  const nodes: Record<string, PipelineNodeState> = {};
386
476
  const nodeOrder = topologicalSort(workflow.nodes);
@@ -415,11 +505,451 @@ export function startPipeline(workflowName: string, input: Record<string, string
415
505
  return pipeline;
416
506
  }
417
507
 
508
+ // ─── Conversation State Type (extracted to avoid Turbopack parse issues) ──
509
+ type ConversationState = {
510
+ config: ConversationConfig;
511
+ messages: ConversationMessage[];
512
+ currentRound: number;
513
+ currentAgentIndex: number;
514
+ };
515
+
516
+ // ─── Conversation Mode Execution ──────────────────────────
517
+
518
+ function startConversationPipeline(workflow: Workflow, input: Record<string, string>): Pipeline {
519
+ const conv = workflow.conversation!;
520
+ const id = randomUUID().slice(0, 8);
521
+
522
+ // Resolve agent display names
523
+ const agentNames: Record<string, string> = {};
524
+ const allAgents = listAgents();
525
+ for (const ca of conv.agents) {
526
+ const found = allAgents.find(a => a.id === ca.agent);
527
+ agentNames[ca.id] = found?.name || ca.agent;
528
+ }
529
+
530
+ const pipeline: Pipeline = {
531
+ id,
532
+ workflowName: workflow.name,
533
+ type: 'conversation',
534
+ status: 'running',
535
+ input,
536
+ vars: { ...workflow.vars },
537
+ nodes: {},
538
+ nodeOrder: [],
539
+ createdAt: new Date().toISOString(),
540
+ conversation: {
541
+ config: {
542
+ ...conv,
543
+ // Store resolved initial prompt so buildConversationContext uses it
544
+ initialPrompt: conv.initialPrompt.replace(/\{\{input\.(\w+)\}\}/g, (_, key) => input[key] || ''),
545
+ },
546
+ messages: [],
547
+ currentRound: 1,
548
+ currentAgentIndex: 0,
549
+ },
550
+ };
551
+
552
+ savePipeline(pipeline);
553
+
554
+ const resolvedPrompt = pipeline.conversation!.config.initialPrompt;
555
+
556
+ // Start the first round
557
+ scheduleNextConversationTurn(pipeline, resolvedPrompt, agentNames);
558
+
559
+ return pipeline;
560
+ }
561
+
562
+ function scheduleNextConversationTurn(pipeline: Pipeline, contextForAgent: string, agentNames?: Record<string, string>) {
563
+ const conv = pipeline.conversation!;
564
+ const config = conv.config;
565
+ const agentDef = config.agents[conv.currentAgentIndex];
566
+
567
+ if (!agentDef) {
568
+ pipeline.status = 'failed';
569
+ pipeline.completedAt = new Date().toISOString();
570
+ savePipeline(pipeline);
571
+ return;
572
+ }
573
+
574
+ // Resolve project
575
+ const projectName = agentDef.project || pipeline.input.project || '';
576
+ const projectInfo = getProjectInfo(projectName);
577
+ if (!projectInfo) {
578
+ pipeline.status = 'failed';
579
+ pipeline.completedAt = new Date().toISOString();
580
+ savePipeline(pipeline);
581
+ notifyPipelineComplete(pipeline);
582
+ return;
583
+ }
584
+
585
+ // Build the prompt: role context + conversation history + new message
586
+ const rolePrefix = agentDef.role ? `[Your role: ${agentDef.role}]\n\n` : '';
587
+ const fullPrompt = `${rolePrefix}${contextForAgent}`;
588
+
589
+ // Create a task for this agent's turn
590
+ const task = createTask({
591
+ projectName: projectInfo.name,
592
+ projectPath: projectInfo.path,
593
+ prompt: fullPrompt,
594
+ mode: 'prompt',
595
+ agent: agentDef.agent,
596
+ conversationId: '', // fresh session — no resume for conversation mode
597
+ });
598
+ pipelineTaskIds.add(task.id);
599
+
600
+ // Add pending message
601
+ const names = agentNames || resolveAgentNames(config.agents);
602
+ conv.messages.push({
603
+ round: conv.currentRound,
604
+ agentId: agentDef.id,
605
+ agentName: names[agentDef.id] || agentDef.agent,
606
+ content: '',
607
+ timestamp: new Date().toISOString(),
608
+ taskId: task.id,
609
+ status: 'running',
610
+ });
611
+
612
+ savePipeline(pipeline);
613
+
614
+ // Listen for this task to complete
615
+ setupConversationTaskListener(pipeline.id, task.id);
616
+ }
617
+
618
+ function resolveAgentNames(agents: ConversationAgent[]): Record<string, string> {
619
+ const allAgents = listAgents();
620
+ const names: Record<string, string> = {};
621
+ for (const ca of agents) {
622
+ const found = allAgents.find(a => a.id === ca.agent);
623
+ names[ca.id] = found?.name || ca.agent;
624
+ }
625
+ return names;
626
+ }
627
+
628
+ function setupConversationTaskListener(pipelineId: string, taskId: string) {
629
+ const cleanup = onTaskEvent((evtTaskId, event, data) => {
630
+ if (evtTaskId !== taskId) return;
631
+ if (event !== 'status') return;
632
+ if (data !== 'done' && data !== 'failed') return;
633
+
634
+ cleanup(); // one-shot listener
635
+
636
+ const pipeline = getPipeline(pipelineId);
637
+ if (!pipeline || pipeline.status !== 'running' || !pipeline.conversation) return;
638
+
639
+ const conv = pipeline.conversation;
640
+ const config = conv.config;
641
+ const msgIndex = conv.messages.findIndex(m => m.taskId === taskId);
642
+ if (msgIndex < 0) return;
643
+
644
+ const task = getTask(taskId);
645
+
646
+ if (data === 'failed' || !task) {
647
+ conv.messages[msgIndex].status = 'failed';
648
+ conv.messages[msgIndex].content = task?.error || 'Task failed';
649
+ pipeline.status = 'failed';
650
+ pipeline.completedAt = new Date().toISOString();
651
+ savePipeline(pipeline);
652
+ notifyPipelineComplete(pipeline);
653
+ return;
654
+ }
655
+
656
+ // Task completed — extract response
657
+ const response = task.resultSummary || '';
658
+ conv.messages[msgIndex].status = 'done';
659
+ conv.messages[msgIndex].content = response;
660
+
661
+ // Check stop condition
662
+ if (checkConversationStopCondition(conv, response)) {
663
+ finishConversation(pipeline, 'done');
664
+ return;
665
+ }
666
+
667
+ // Move to next agent in round, or next round
668
+ conv.currentAgentIndex++;
669
+ if (conv.currentAgentIndex >= config.agents.length) {
670
+ // Completed a full round
671
+ conv.currentAgentIndex = 0;
672
+ conv.currentRound++;
673
+
674
+ if (conv.currentRound > config.maxRounds) {
675
+ finishConversation(pipeline, 'done');
676
+ return;
677
+ }
678
+ }
679
+
680
+ savePipeline(pipeline);
681
+
682
+ // Build context for next agent: accumulate conversation history
683
+ const contextForNext = buildConversationContext(conv);
684
+ scheduleNextConversationTurn(pipeline, contextForNext);
685
+ });
686
+ }
687
+
688
+ /**
689
+ * Build context string for the next agent in conversation.
690
+ *
691
+ * Three strategies:
692
+ * - 'full' : pass ALL history as-is (token-heavy, good for short convos)
693
+ * - 'window' : pass only the last N messages in full, drop older ones
694
+ * - 'summary' : pass older messages as one-line summaries + last N in full (default)
695
+ */
696
+ function buildConversationContext(conv: ConversationState): string {
697
+ const config = conv.config;
698
+ const strategy = config.contextStrategy || 'summary';
699
+ const windowSize = config.contextWindow || 4;
700
+ const maxLen = config.maxContentLength || 3000;
701
+
702
+ const doneMessages = conv.messages.filter(m => m.status === 'done' && m.content);
703
+
704
+ let context = `[Conversation — Round ${conv.currentRound}]\n\n`;
705
+ context += `Task: ${config.initialPrompt}\n\n`;
706
+
707
+ if (doneMessages.length === 0) {
708
+ context += `--- Your Turn ---\nYou are the first to respond. Please address the task above. If you believe the task is complete, include "DONE" in your response.`;
709
+ return context;
710
+ }
711
+
712
+ context += `--- Conversation History ---\n\n`;
713
+
714
+ if (strategy === 'full') {
715
+ // Full: all messages, truncated per maxLen
716
+ for (const msg of doneMessages) {
717
+ context += formatMessage(msg, config, maxLen);
718
+ }
719
+ } else if (strategy === 'window') {
720
+ // Window: only last N messages
721
+ const recent = doneMessages.slice(-windowSize);
722
+ if (doneMessages.length > windowSize) {
723
+ context += `[... ${doneMessages.length - windowSize} earlier messages omitted ...]\n\n`;
724
+ }
725
+ for (const msg of recent) {
726
+ context += formatMessage(msg, config, maxLen);
727
+ }
728
+ } else {
729
+ // Summary (default): older messages as one-line summaries, recent in full
730
+ const cutoff = doneMessages.length - windowSize;
731
+ if (cutoff > 0) {
732
+ context += `[Previous rounds summary]\n`;
733
+ for (let i = 0; i < cutoff; i++) {
734
+ const msg = doneMessages[i];
735
+ const summary = extractSummaryLine(msg.content);
736
+ context += ` R${msg.round} ${msg.agentName}: ${summary}\n`;
737
+ }
738
+ context += `\n`;
739
+ }
740
+ // Recent messages in full
741
+ const recent = doneMessages.slice(Math.max(0, cutoff));
742
+ for (const msg of recent) {
743
+ context += formatMessage(msg, config, maxLen);
744
+ }
745
+ }
746
+
747
+ context += `--- Your Turn ---\nRespond based on the conversation above. If you believe the task is complete, include "DONE" in your response.`;
748
+ return context;
749
+ }
750
+
751
+ function formatMessage(msg: ConversationMessage, config: ConversationConfig, maxLen: number): string {
752
+ const agentDef = config.agents.find(a => a.id === msg.agentId);
753
+ const label = `${msg.agentName} (${agentDef?.id || '?'})`;
754
+ const content = msg.content.length > maxLen
755
+ ? msg.content.slice(0, maxLen) + '\n[... truncated]'
756
+ : msg.content;
757
+ return `[${label} — Round ${msg.round}]:\n${content}\n\n`;
758
+ }
759
+
760
+ /** Extract a one-line summary from agent output (first meaningful line or first 120 chars) */
761
+ function extractSummaryLine(content: string): string {
762
+ const lines = content.split('\n').map(l => l.trim()).filter(l => l.length > 10);
763
+ const first = lines[0] || content.slice(0, 120);
764
+ return first.length > 120 ? first.slice(0, 117) + '...' : first;
765
+ }
766
+
767
+ function checkConversationStopCondition(conv: ConversationState, latestResponse: string): boolean {
768
+ const condition = conv.config.stopCondition;
769
+ if (!condition) return false;
770
+
771
+ const lower = condition.toLowerCase();
772
+
773
+ // "any agent says DONE"
774
+ if (lower.includes('any') && lower.includes('done')) {
775
+ return latestResponse.toUpperCase().includes('DONE');
776
+ }
777
+
778
+ // "all agents say DONE" / "both agents say DONE"
779
+ if ((lower.includes('all') || lower.includes('both')) && lower.includes('done')) {
780
+ // Only check messages from the CURRENT round — don't mix rounds
781
+ const currentRound = conv.currentRound;
782
+ const agentIds = conv.config.agents.map(a => a.id);
783
+ const roundMessages = new Map<string, string>();
784
+ for (const msg of conv.messages) {
785
+ if (msg.status === 'done' && msg.round === currentRound && msg.agentId !== 'user') {
786
+ roundMessages.set(msg.agentId, msg.content);
787
+ }
788
+ }
789
+ // All agents in this round must have responded AND said DONE
790
+ return agentIds.every(id => {
791
+ const content = roundMessages.get(id);
792
+ return content && content.toUpperCase().includes('DONE');
793
+ });
794
+ }
795
+
796
+ // Default: check if latest response contains DONE
797
+ return latestResponse.toUpperCase().includes('DONE');
798
+ }
799
+
800
+ /** Cleanly finish a conversation — cancel any still-running tasks, mark messages */
801
+ function finishConversation(pipeline: Pipeline, status: 'done' | 'failed') {
802
+ const conv = pipeline.conversation!;
803
+ for (const msg of conv.messages) {
804
+ if (msg.status === 'running' && msg.taskId) {
805
+ // Cancel the running task
806
+ try { const { cancelTask } = require('./task-manager'); cancelTask(msg.taskId); } catch {}
807
+ msg.status = status === 'done' ? 'done' : 'failed';
808
+ if (!msg.content) msg.content = status === 'done' ? '(conversation ended)' : '(conversation failed)';
809
+ }
810
+ if (msg.status === 'pending') {
811
+ msg.status = 'failed';
812
+ }
813
+ }
814
+ pipeline.status = status;
815
+ pipeline.completedAt = new Date().toISOString();
816
+ savePipeline(pipeline);
817
+ notifyPipelineComplete(pipeline);
818
+ }
819
+
820
+ /** Cancel a conversation pipeline */
821
+ export function cancelConversation(pipelineId: string): boolean {
822
+ const pipeline = getPipeline(pipelineId);
823
+ if (!pipeline || pipeline.status !== 'running' || !pipeline.conversation) return false;
824
+
825
+ // Cancel any running task
826
+ for (const msg of pipeline.conversation.messages) {
827
+ if (msg.status === 'running' && msg.taskId) {
828
+ const { cancelTask } = require('./task-manager');
829
+ cancelTask(msg.taskId);
830
+ }
831
+ if (msg.status === 'pending') msg.status = 'failed';
832
+ }
833
+
834
+ pipeline.status = 'cancelled';
835
+ pipeline.completedAt = new Date().toISOString();
836
+ savePipeline(pipeline);
837
+ return true;
838
+ }
839
+
840
+ /**
841
+ * Inject a user message into a running conversation.
842
+ * Waits for current agent to finish, then sends the injected message
843
+ * as additional context to the specified agent on the next turn.
844
+ */
845
+ export function injectConversationMessage(pipelineId: string, targetAgentId: string, message: string): boolean {
846
+ const pipeline = getPipeline(pipelineId);
847
+ if (!pipeline || pipeline.status !== 'running' || !pipeline.conversation) {
848
+ throw new Error('Pipeline not running or not a conversation');
849
+ }
850
+
851
+ const conv = pipeline.conversation;
852
+ const agentDef = conv.config.agents.find(a => a.id === targetAgentId);
853
+ if (!agentDef) throw new Error(`Agent not found: ${targetAgentId}`);
854
+
855
+ // Add a "user" message to the conversation
856
+ conv.messages.push({
857
+ round: conv.currentRound,
858
+ agentId: 'user',
859
+ agentName: 'Operator',
860
+ content: `[@${targetAgentId}] ${message}`,
861
+ timestamp: new Date().toISOString(),
862
+ status: 'done',
863
+ });
864
+
865
+ savePipeline(pipeline);
866
+
867
+ // If no agent is currently running, immediately schedule the target agent
868
+ const hasRunning = conv.messages.some(m => m.status === 'running');
869
+ if (!hasRunning) {
870
+ // Point to the target agent for next turn
871
+ const targetIdx = conv.config.agents.findIndex(a => a.id === targetAgentId);
872
+ if (targetIdx >= 0) {
873
+ conv.currentAgentIndex = targetIdx;
874
+ savePipeline(pipeline);
875
+ const context = buildConversationContext(conv);
876
+ scheduleNextConversationTurn(pipeline, context);
877
+ }
878
+ }
879
+ // If an agent IS running, the injected message will be included in the next context build
880
+
881
+ return true;
882
+ }
883
+
884
+ // ─── Conversation Recovery ────────────────────────────────
885
+
886
+ function recoverConversationPipeline(pipeline: Pipeline) {
887
+ const conv = pipeline.conversation!;
888
+ const runningMsg = conv.messages.find(m => m.status === 'running');
889
+ if (!runningMsg || !runningMsg.taskId) return;
890
+
891
+ const task = getTask(runningMsg.taskId);
892
+ if (!task) {
893
+ // Task gone — mark as done with empty content, try next turn
894
+ runningMsg.status = 'done';
895
+ runningMsg.content = '(no response — task was cleaned up)';
896
+ savePipeline(pipeline);
897
+ advanceConversation(pipeline);
898
+ return;
899
+ }
900
+ if (task.status === 'done') {
901
+ runningMsg.status = 'done';
902
+ runningMsg.content = task.resultSummary || '';
903
+ savePipeline(pipeline);
904
+ advanceConversation(pipeline);
905
+ } else if (task.status === 'failed' || task.status === 'cancelled') {
906
+ runningMsg.status = 'failed';
907
+ runningMsg.content = task.error || 'Task failed';
908
+ pipeline.status = 'failed';
909
+ pipeline.completedAt = new Date().toISOString();
910
+ savePipeline(pipeline);
911
+ } else {
912
+ // Still running — re-attach listener
913
+ setupConversationTaskListener(pipeline.id, runningMsg.taskId);
914
+ }
915
+ }
916
+
917
+ function advanceConversation(pipeline: Pipeline) {
918
+ const conv = pipeline.conversation!;
919
+ const config = conv.config;
920
+ const lastDoneMsg = [...conv.messages].reverse().find(m => m.status === 'done');
921
+
922
+ if (lastDoneMsg && checkConversationStopCondition(conv, lastDoneMsg.content)) {
923
+ finishConversation(pipeline, 'done');
924
+ return;
925
+ }
926
+
927
+ conv.currentAgentIndex++;
928
+ if (conv.currentAgentIndex >= config.agents.length) {
929
+ conv.currentAgentIndex = 0;
930
+ conv.currentRound++;
931
+ if (conv.currentRound > config.maxRounds) {
932
+ finishConversation(pipeline, 'done');
933
+ return;
934
+ }
935
+ }
936
+
937
+ savePipeline(pipeline);
938
+ const contextForNext = buildConversationContext(conv);
939
+ scheduleNextConversationTurn(pipeline, contextForNext);
940
+ }
941
+
418
942
  // ─── Recovery: check for stuck pipelines ──────────────────
419
943
 
420
944
  function recoverStuckPipelines() {
421
945
  const pipelines = listPipelines().filter(p => p.status === 'running');
422
946
  for (const pipeline of pipelines) {
947
+ // Conversation mode recovery
948
+ if (pipeline.type === 'conversation' && pipeline.conversation) {
949
+ recoverConversationPipeline(pipeline);
950
+ continue;
951
+ }
952
+
423
953
  const workflow = getWorkflow(pipeline.workflowName);
424
954
  if (!workflow) continue;
425
955
 
@@ -471,6 +1001,11 @@ export function cancelPipeline(id: string): boolean {
471
1001
  const pipeline = getPipeline(id);
472
1002
  if (!pipeline || pipeline.status !== 'running') return false;
473
1003
 
1004
+ // Conversation mode
1005
+ if (pipeline.type === 'conversation') {
1006
+ return cancelConversation(id);
1007
+ }
1008
+
474
1009
  pipeline.status = 'cancelled';
475
1010
  pipeline.completedAt = new Date().toISOString();
476
1011
 
@@ -561,6 +1096,7 @@ function scheduleReadyNodes(pipeline: Pipeline, workflow: Workflow) {
561
1096
  projectPath: projectInfo.path,
562
1097
  prompt,
563
1098
  mode: taskMode as any,
1099
+ agent: nodeDef.agent || undefined,
564
1100
  });
565
1101
  pipelineTaskIds.add(task.id);
566
1102
  if (taskMode !== 'shell') {