@aion0/forge 0.5.20 โ†’ 0.5.22

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 (40) hide show
  1. package/.forge/agent-context.json +1 -1
  2. package/RELEASE_NOTES.md +32 -6
  3. package/app/api/code/route.ts +10 -4
  4. package/app/api/plugins/route.ts +75 -0
  5. package/components/Dashboard.tsx +1 -0
  6. package/components/PipelineEditor.tsx +135 -9
  7. package/components/PluginsPanel.tsx +472 -0
  8. package/components/ProjectDetail.tsx +36 -98
  9. package/components/SessionView.tsx +4 -4
  10. package/components/SettingsModal.tsx +160 -66
  11. package/components/SkillsPanel.tsx +14 -5
  12. package/components/TerminalLauncher.tsx +398 -0
  13. package/components/WebTerminal.tsx +84 -84
  14. package/components/WorkspaceView.tsx +371 -87
  15. package/lib/agents/index.ts +7 -4
  16. package/lib/builtin-plugins/docker.yaml +70 -0
  17. package/lib/builtin-plugins/http.yaml +66 -0
  18. package/lib/builtin-plugins/jenkins.yaml +92 -0
  19. package/lib/builtin-plugins/llm-vision.yaml +85 -0
  20. package/lib/builtin-plugins/playwright.yaml +111 -0
  21. package/lib/builtin-plugins/shell-command.yaml +60 -0
  22. package/lib/builtin-plugins/slack.yaml +48 -0
  23. package/lib/builtin-plugins/webhook.yaml +56 -0
  24. package/lib/forge-mcp-server.ts +116 -2
  25. package/lib/pipeline.ts +62 -5
  26. package/lib/plugins/executor.ts +347 -0
  27. package/lib/plugins/registry.ts +228 -0
  28. package/lib/plugins/types.ts +103 -0
  29. package/lib/project-sessions.ts +7 -2
  30. package/lib/session-utils.ts +7 -3
  31. package/lib/terminal-standalone.ts +6 -34
  32. package/lib/workspace/agent-worker.ts +1 -1
  33. package/lib/workspace/orchestrator.ts +414 -136
  34. package/lib/workspace/presets.ts +5 -3
  35. package/lib/workspace/session-monitor.ts +14 -10
  36. package/lib/workspace/types.ts +3 -1
  37. package/lib/workspace-standalone.ts +38 -21
  38. package/next-env.d.ts +1 -1
  39. package/package.json +1 -1
  40. package/qa/.forge/agent-context.json +1 -1
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useState, useEffect, useCallback, useMemo, useRef, forwardRef, useImperativeHandle, lazy, Suspense } from 'react';
4
+ import { TerminalSessionPickerLazy, fetchAgentSessions, type PickerSelection } from './TerminalLauncher';
4
5
  import {
5
6
  ReactFlow, Background, Controls, Handle, Position, useReactFlow, ReactFlowProvider,
6
7
  type Node, type NodeProps, MarkerType, type NodeChange,
@@ -27,6 +28,7 @@ interface AgentConfig {
27
28
  skipPermissions?: boolean;
28
29
  boundSessionId?: string;
29
30
  watch?: { enabled: boolean; interval: number; targets: any[]; action?: 'log' | 'analyze' | 'approve' | 'send_message'; prompt?: string; sendTo?: string };
31
+ plugins?: string[]; // plugin IDs to auto-install when agent is created
30
32
  }
31
33
 
32
34
  interface AgentState {
@@ -53,6 +55,7 @@ const COLORS = [
53
55
  // Smith status colors
54
56
  const SMITH_STATUS: Record<string, { label: string; color: string; glow?: boolean }> = {
55
57
  down: { label: 'down', color: '#30363d' },
58
+ starting: { label: 'starting', color: '#f0883e' }, // orange: ensurePersistentSession in progress
56
59
  active: { label: 'active', color: '#3fb950', glow: true },
57
60
  };
58
61
 
@@ -104,6 +107,7 @@ Rules:
104
107
  },
105
108
  {
106
109
  label: 'QA', icon: '๐Ÿงช', backend: 'cli', agentId: 'claude', dependsOn: [], outputs: ['tests/', 'docs/test-report.md'],
110
+ plugins: ['playwright', 'shell-command'],
107
111
  role: `QA Engineer โ€” You ensure quality through comprehensive testing. You find bugs, you don't fix them.
108
112
 
109
113
  Rules:
@@ -114,11 +118,28 @@ Rules:
114
118
  - Run ALL tests (existing + new) and report results
115
119
  - Report format: what failed, expected vs actual, steps to reproduce
116
120
  - Check for security issues: injection, auth bypass, data leaks
117
- - Check for performance: N+1 queries, unbounded loops, memory leaks`,
121
+ - Check for performance: N+1 queries, unbounded loops, memory leaks
122
+
123
+ Test setup (do this automatically if not already present):
124
+ - If no playwright.config.ts exists, create one based on the project's framework and structure
125
+ - If no tests/e2e/ directory exists, create it
126
+ - Detect the app's dev server command and base URL from package.json or project config
127
+ - Ensure the dev server is running before testing (start it if needed)
128
+
129
+ How to run Playwright tests:
130
+ - Run tests: npx playwright test tests/e2e/ --reporter=line
131
+ - Run headed (visible browser): npx playwright test tests/e2e/ --headed --reporter=line
132
+ - Screenshot: npx playwright screenshot http://localhost:3000 /tmp/screenshot.png
133
+ - Check URL: curl -sf -o /dev/null -w '%{http_code}' http://localhost:3000
134
+ - Run project tests: npm test
135
+
136
+ If Forge MCP tools are available (run_plugin, send_message), prefer those.
137
+ Otherwise use bash commands directly โ€” you have full terminal access.`,
118
138
  steps: [
119
- { id: 'plan', label: 'Test Plan', prompt: 'Read the PRD (docs/prd.md) and the implementation. Create a test plan in docs/test-plan.md covering: unit tests, integration tests, edge cases, error scenarios, security checks, and performance concerns. Map each test to a PRD acceptance criterion.' },
120
- { id: 'write-tests', label: 'Write Tests', prompt: 'Implement all test cases from your test plan in the tests/ directory. Follow the project\'s existing test framework and conventions. Include setup/teardown, meaningful assertions, and descriptive test names.' },
121
- { id: 'run-tests', label: 'Run & Report', prompt: 'Run ALL tests (both existing and new). Document results in docs/test-report.md: total tests, passed, failed, skipped. For each failure: test name, expected vs actual, steps to reproduce. Include a summary verdict: PASS (all green) or FAIL (with blocking issues listed).' },
139
+ { id: 'setup', label: 'Setup', prompt: 'Check the project structure. If playwright.config.ts does not exist, create one with testDir: "./tests/e2e" and baseURL from the project config. Create tests/e2e/ directory if missing. Check if the dev server is running (use check_url), start it if not.' },
140
+ { id: 'plan', label: 'Test Plan', prompt: 'Read the source code and any PRD/docs. Understand what pages and features exist. Create a test plan in docs/test-plan.md covering: E2E user flows, edge cases, error states, responsive behavior. Map each test to a feature.' },
141
+ { id: 'write-tests', label: 'Write Tests', prompt: 'Write Playwright test scripts in tests/e2e/. Cover all features from the test plan. Use page.goto(), locators, assertions. Test both happy path and error states. Use descriptive test names.' },
142
+ { id: 'run-tests', label: 'Run & Report', prompt: 'Run tests via run_plugin with Playwright. If tests fail, read the error output carefully. Fix test scripts if the test itself is wrong, but report to Engineer if the app has a bug. Write docs/test-report.md with results. Send bug reports to Engineer via send_message.' },
122
143
  ],
123
144
  },
124
145
  {
@@ -143,21 +164,69 @@ Rules:
143
164
  ],
144
165
  },
145
166
  {
146
- label: 'UI Designer', icon: '๐ŸŽจ', backend: 'cli', agentId: 'claude', dependsOn: [], outputs: ['docs/ui-spec.md'],
147
- role: `UI/UX Designer โ€” You design user interfaces and experiences. You create specs that engineers can implement.
167
+ label: 'UI Designer', icon: '๐ŸŽจ', backend: 'cli', agentId: 'claude', dependsOn: [], outputs: ['docs/ui-spec.md', 'src/'],
168
+ plugins: ['playwright', 'shell-command'],
169
+ role: `UI/UX Designer โ€” You design and implement user interfaces. You write real UI code, preview it visually, and iterate until the quality meets your standards.
148
170
 
149
171
  Rules:
172
+ - You WRITE CODE, not just specs. Implement the UI yourself.
173
+ - After writing UI code, always preview your work: take a screenshot and review it visually.
174
+ - Iterate: if the screenshot doesn't look right, fix the code and screenshot again. Aim for 3-5 review cycles.
150
175
  - Focus on user experience first, aesthetics second
151
176
  - Design for the existing tech stack (check project's UI framework)
152
177
  - Be specific: colors (hex), spacing (px/rem), typography, component hierarchy
153
178
  - Consider responsive design, accessibility (WCAG), dark/light mode
154
179
  - Include interaction states: hover, active, disabled, loading, error, empty
155
- - Provide component tree structure, not just mockups
156
- - Reference existing UI patterns in the codebase for consistency`,
180
+ - Reference existing UI patterns in the codebase for consistency
181
+
182
+ Visual review workflow:
183
+ 1. Write/modify UI code
184
+ 2. Start dev server if not running (e.g., npm run dev)
185
+ 3. Take screenshot: run_plugin({ plugin: "<playwright-instance>", action: "screenshot", params: { url: "http://localhost:3000/page" } })
186
+ 4. Read the screenshot file to visually evaluate your work
187
+ 5. Grade yourself: layout correctness, visual polish, consistency with existing UI, responsiveness
188
+ 6. If not satisfied, fix and repeat from step 2
189
+ 7. When satisfied, document the final design in docs/ui-spec.md
190
+
191
+ If reference designs or mockups exist in the project (e.g., docs/designs/), study them before implementing.`,
157
192
  steps: [
158
- { id: 'audit', label: 'UI Audit', prompt: 'Analyze the existing UI: framework used (React/Vue/etc), component library, design tokens (colors, spacing, fonts), layout patterns. Document the current design system.' },
159
- { id: 'design', label: 'Design Spec', prompt: 'Based on the PRD, design the UI. Write docs/ui-spec.md with: component hierarchy, layout (flexbox/grid), colors, typography, spacing, responsive breakpoints. Include all states (loading, empty, error, success). Use ASCII wireframes or describe precisely.' },
160
- { id: 'interactions', label: 'Interactions', prompt: 'Define all user interactions: click flows, form validation, transitions, animations, keyboard shortcuts, mobile gestures. Document accessibility requirements (aria labels, focus management, screen reader support).' },
193
+ { id: 'audit', label: 'UI Audit', prompt: 'Analyze the existing UI: framework used (React/Vue/etc), component library, design tokens (colors, spacing, fonts), layout patterns. Take screenshots of existing pages to understand the current look and feel. Document the current design system.' },
194
+ { id: 'implement', label: 'Implement UI', prompt: 'Based on the PRD, implement the UI. Write real component code. Start the dev server, take screenshots of your work, and iterate until the visual quality is high. Aim for at least 3 review cycles โ€” screenshot, evaluate, improve.' },
195
+ { id: 'polish', label: 'Polish & Document', prompt: 'Final polish pass: check all states (loading, empty, error, hover, disabled), responsive breakpoints, dark/light mode. Take final screenshots. Write docs/ui-spec.md documenting: component hierarchy, design decisions, interaction patterns, and accessibility notes.' },
196
+ ],
197
+ },
198
+ {
199
+ label: 'Design Evaluator', icon: '๐Ÿ”', backend: 'cli', agentId: 'claude', dependsOn: [], outputs: ['docs/design-review.md'],
200
+ plugins: ['playwright', 'llm-vision'],
201
+ role: `Design Evaluator โ€” You are a senior design critic. You evaluate UI implementations visually, not by reading code. You are deliberately skeptical and hold work to a high standard.
202
+
203
+ You evaluate on 4 dimensions (each scored 1-10):
204
+ 1. **Design Quality** โ€” Visual coherence, distinct identity, not generic/template-like
205
+ 2. **Originality** โ€” Evidence of intentional design decisions vs default AI patterns
206
+ 3. **Craft** โ€” Typography, spacing, color harmony, alignment, pixel-level polish
207
+ 4. **Functionality** โ€” Usability, interaction clarity, error states, responsiveness
208
+
209
+ Rules:
210
+ - NEVER modify code โ€” only evaluate and report
211
+ - Always take screenshots and visually inspect before scoring
212
+ - Use run_plugin with Playwright to screenshot every relevant page/state
213
+ - If llm-vision instances are available, use them for cross-model evaluation
214
+ - Be specific: "the spacing between header and content is 8px, should be 16px for breathing room"
215
+ - A score of 7+ means "good enough to ship". Below 7 means "needs revision"
216
+ - Send feedback to UI Designer via send_message with specific, actionable items
217
+ - If overall score < 7, request changes. If >= 7, approve with minor suggestions.
218
+
219
+ Workflow:
220
+ 1. Receive notification that UI Designer has completed work
221
+ 2. Take screenshots of all relevant pages and states (normal, loading, error, empty, mobile)
222
+ 3. Evaluate each screenshot against the 4 dimensions
223
+ 4. Optionally send screenshots to llm-vision instances for additional opinions
224
+ 5. Write docs/design-review.md with scores, specific feedback, and verdict
225
+ 6. send_message to UI Designer: APPROVE or REQUEST_CHANGES with actionable feedback`,
226
+ steps: [
227
+ { id: 'screenshot', label: 'Visual Capture', prompt: 'Take screenshots of all pages and states the UI Designer worked on. Include: default view, loading state, error state, empty state, mobile viewport (375px), tablet viewport (768px). Save all screenshots to /tmp/ and list them.' },
228
+ { id: 'evaluate', label: 'Evaluate', prompt: 'Review each screenshot. Score each page on the 4 dimensions (Design Quality, Originality, Craft, Functionality). Be critical and specific. If llm-vision plugin instances are available, send key screenshots for additional evaluation and compare opinions.' },
229
+ { id: 'report', label: 'Report & Feedback', prompt: 'Write docs/design-review.md with: overall scores, per-page breakdown, specific issues with suggested fixes. Send verdict to UI Designer via send_message: APPROVE (score >= 7) or REQUEST_CHANGES (score < 7) with the top 3-5 actionable items.' },
161
230
  ],
162
231
  },
163
232
  ];
@@ -379,6 +448,109 @@ function SessionTargetSelector({ target, agents, projectPath, onChange }: {
379
448
  );
380
449
  }
381
450
 
451
+ // โ”€โ”€โ”€ Watch Path Picker (file/directory browser) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
452
+
453
+ function WatchPathPicker({ value, projectPath, onChange }: { value: string; projectPath: string; onChange: (v: string) => void }) {
454
+ const [showBrowser, setShowBrowser] = useState(false);
455
+ const [tree, setTree] = useState<any[]>([]);
456
+ const [search, setSearch] = useState('');
457
+ const [flatFiles, setFlatFiles] = useState<string[]>([]);
458
+
459
+ const loadTree = useCallback(() => {
460
+ if (!projectPath) return;
461
+ fetch(`/api/code?dir=${encodeURIComponent(projectPath)}`)
462
+ .then(r => r.json())
463
+ .then(data => {
464
+ setTree(data.tree || []);
465
+ // Build flat list for search
466
+ const files: string[] = [];
467
+ const walk = (nodes: any[], prefix = '') => {
468
+ for (const n of nodes || []) {
469
+ const path = prefix ? `${prefix}/${n.name}` : n.name;
470
+ files.push(n.type === 'dir' ? path + '/' : path);
471
+ if (n.children) walk(n.children, path);
472
+ }
473
+ };
474
+ walk(data.tree || []);
475
+ setFlatFiles(files);
476
+ })
477
+ .catch(() => {});
478
+ }, [projectPath]);
479
+
480
+ const filtered = search ? flatFiles.filter(f => f.toLowerCase().includes(search.toLowerCase())).slice(0, 30) : [];
481
+
482
+ return (
483
+ <div className="flex-1 flex items-center gap-1 relative">
484
+ <input
485
+ value={value}
486
+ onChange={e => onChange(e.target.value)}
487
+ placeholder="./ (project root)"
488
+ className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-1 py-0.5 text-white flex-1"
489
+ />
490
+ <button onClick={() => { setShowBrowser(!showBrowser); if (!showBrowser) loadTree(); }}
491
+ className="text-[9px] px-1 py-0.5 rounded bg-[#30363d] text-gray-400 hover:text-white shrink-0">๐Ÿ“‚</button>
492
+
493
+ {showBrowser && (
494
+ <div className="absolute left-0 right-0 top-full mt-1 z-50 bg-[#0d1117] border border-[#30363d] rounded-lg shadow-xl max-h-60 overflow-hidden flex flex-col" style={{ minWidth: 250 }}>
495
+ <input
496
+ value={search}
497
+ onChange={e => setSearch(e.target.value)}
498
+ placeholder="Search files & dirs..."
499
+ autoFocus
500
+ className="text-[10px] bg-[#161b22] border-b border-[#30363d] px-2 py-1 text-white focus:outline-none"
501
+ />
502
+ <div className="overflow-y-auto flex-1">
503
+ {search ? (
504
+ // Search results
505
+ filtered.length > 0 ? filtered.map(f => (
506
+ <div key={f} onClick={() => { onChange(f); setShowBrowser(false); setSearch(''); }}
507
+ className="px-2 py-0.5 text-[9px] text-gray-300 hover:bg-[#161b22] cursor-pointer truncate font-mono">
508
+ {f.endsWith('/') ? `๐Ÿ“ ${f}` : `๐Ÿ“„ ${f}`}
509
+ </div>
510
+ )) : <div className="px-2 py-1 text-[9px] text-gray-500">No matches</div>
511
+ ) : (
512
+ // Tree view (first 2 levels)
513
+ tree.map(n => <PathTreeNode key={n.name} node={n} prefix="" onSelect={p => { onChange(p); setShowBrowser(false); }} />)
514
+ )}
515
+ </div>
516
+ <div className="flex items-center justify-between px-2 py-0.5 border-t border-[#30363d] bg-[#161b22]">
517
+ <span className="text-[8px] text-gray-600">{flatFiles.length} items</span>
518
+ <button onClick={() => setShowBrowser(false)} className="text-[8px] text-gray-500 hover:text-white">Close</button>
519
+ </div>
520
+ </div>
521
+ )}
522
+ </div>
523
+ );
524
+ }
525
+
526
+ function PathTreeNode({ node, prefix, onSelect, depth = 0 }: { node: any; prefix: string; onSelect: (path: string) => void; depth?: number }) {
527
+ const [expanded, setExpanded] = useState(depth < 1);
528
+ const path = prefix ? `${prefix}/${node.name}` : node.name;
529
+ const isDir = node.type === 'dir';
530
+
531
+ if (!isDir && depth > 1) return null; // only show files at top 2 levels
532
+
533
+ return (
534
+ <div>
535
+ <div
536
+ onClick={() => isDir ? setExpanded(!expanded) : onSelect(path)}
537
+ className="flex items-center px-2 py-0.5 text-[9px] hover:bg-[#161b22] cursor-pointer"
538
+ style={{ paddingLeft: 8 + depth * 12 }}
539
+ >
540
+ <span className="text-gray-500 mr-1 w-3">{isDir ? (expanded ? 'โ–ผ' : 'โ–ถ') : ''}</span>
541
+ <span className={isDir ? 'text-[var(--accent)]' : 'text-gray-400'}>{isDir ? '๐Ÿ“' : '๐Ÿ“„'} {node.name}</span>
542
+ {isDir && (
543
+ <button onClick={e => { e.stopPropagation(); onSelect(path + '/'); }}
544
+ className="ml-auto text-[8px] text-gray-600 hover:text-[var(--accent)]">select</button>
545
+ )}
546
+ </div>
547
+ {isDir && expanded && node.children && depth < 2 && (
548
+ node.children.map((c: any) => <PathTreeNode key={c.name} node={c} prefix={path} onSelect={onSelect} depth={depth + 1} />)
549
+ )}
550
+ </div>
551
+ );
552
+ }
553
+
382
554
  // โ”€โ”€โ”€ Fixed Session Picker โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
383
555
 
384
556
  function FixedSessionPicker({ projectPath, value, onChange }: { projectPath?: string; value: string; onChange: (v: string) => void }) {
@@ -448,6 +620,9 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
448
620
  const [agentId, setAgentId] = useState(initial.agentId || 'claude');
449
621
  const [availableAgents, setAvailableAgents] = useState<{ id: string; name: string; isProfile?: boolean; backendType?: string; base?: string; cliType?: string }[]>([]);
450
622
 
623
+ const [pluginInstances, setPluginInstances] = useState<{ id: string; name: string; icon: string; source?: string }[]>([]);
624
+ const [pluginDefs, setPluginDefs] = useState<{ id: string; name: string; icon: string }[]>([]);
625
+
451
626
  useEffect(() => {
452
627
  fetch('/api/agents').then(r => r.json()).then(data => {
453
628
  const list = (data.agents || data || []).map((a: any) => ({
@@ -459,6 +634,19 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
459
634
  }));
460
635
  setAvailableAgents(list);
461
636
  }).catch(() => {});
637
+ // Fetch both: plugin definitions + installed instances
638
+ Promise.all([
639
+ fetch('/api/plugins').then(r => r.json()),
640
+ fetch('/api/plugins?installed=true').then(r => r.json()),
641
+ ]).then(([defData, instData]) => {
642
+ setPluginDefs((defData.plugins || []).map((p: any) => ({ id: p.id, name: p.name, icon: p.icon })));
643
+ setPluginInstances((instData.plugins || []).map((p: any) => ({
644
+ id: p.id,
645
+ name: p.instanceName || p.definition?.name || p.id,
646
+ icon: p.definition?.icon || '๐Ÿ”Œ',
647
+ source: p.source,
648
+ })));
649
+ }).catch(() => {});
462
650
  }, []);
463
651
  const [workDirVal, setWorkDirVal] = useState(initial.workDir || '');
464
652
  const [outputs, setOutputs] = useState((initial.outputs || []).join(', '));
@@ -471,11 +659,14 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
471
659
  const hasPrimaryAlready = existingAgents.some(a => a.primary && a.id !== initial.id);
472
660
  const [persistentSession, setPersistentSession] = useState(initial.persistentSession || initial.primary || false);
473
661
  const [skipPermissions, setSkipPermissions] = useState(initial.skipPermissions !== false);
662
+ const [agentModel, setAgentModel] = useState(initial.model || '');
474
663
  const [watchEnabled, setWatchEnabled] = useState(initial.watch?.enabled || false);
475
664
  const [watchInterval, setWatchInterval] = useState(String(initial.watch?.interval || 60));
476
665
  const [watchAction, setWatchAction] = useState<'log' | 'analyze' | 'approve' | 'send_message'>(initial.watch?.action || 'log');
477
666
  const [watchPrompt, setWatchPrompt] = useState(initial.watch?.prompt || '');
478
667
  const [watchSendTo, setWatchSendTo] = useState(initial.watch?.sendTo || '');
668
+ const [selectedPlugins, setSelectedPlugins] = useState<string[]>(initial.plugins || []);
669
+ const [recommendedTypes, setRecommendedTypes] = useState<string[]>([]);
479
670
  const [watchDebounce, setWatchDebounce] = useState(String(initial.watch?.targets?.[0]?.debounce ?? 10));
480
671
  const [watchTargets, setWatchTargets] = useState<{ type: string; path?: string; cmd?: string; pattern?: string }[]>(
481
672
  initial.watch?.targets || []
@@ -487,14 +678,14 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
487
678
  fetch(`/api/code?dir=${encodeURIComponent(projectPath)}`)
488
679
  .then(r => r.json())
489
680
  .then(data => {
490
- // Flatten directory tree (type='dir') to list of paths
681
+ // Collect directories with depth limit (max 2 levels for readability)
491
682
  const dirs: string[] = [];
492
- const walk = (nodes: any[], prefix = '') => {
683
+ const walk = (nodes: any[], prefix = '', depth = 0) => {
493
684
  for (const n of nodes || []) {
494
685
  if (n.type === 'dir') {
495
686
  const path = prefix ? `${prefix}/${n.name}` : n.name;
496
687
  dirs.push(path);
497
- if (n.children) walk(n.children, path);
688
+ if (n.children && depth < 2) walk(n.children, path, depth + 1);
498
689
  }
499
690
  }
500
691
  };
@@ -510,6 +701,8 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
510
701
  setWorkDirVal(p.workDir || './');
511
702
  setOutputs(p.outputs.join(', '));
512
703
  setStepsText(p.steps.map(s => `${s.label}: ${s.prompt}`).join('\n'));
704
+ setRecommendedTypes(p.plugins || []);
705
+ setSelectedPlugins([]);
513
706
  };
514
707
 
515
708
  const toggleDep = (id: string) => {
@@ -619,8 +812,74 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
619
812
  {/* Role */}
620
813
  <div className="flex flex-col gap-1">
621
814
  <label className="text-[9px] text-gray-500 uppercase">Role / System Prompt</label>
622
- <textarea value={role} onChange={e => setRole(e.target.value)} rows={2}
623
- className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff] resize-none" />
815
+ <textarea value={role} onChange={e => setRole(e.target.value)} rows={5}
816
+ placeholder="Describe this agent's role, responsibilities, available tools, and decision criteria. This will be synced to CLAUDE.md in the agent's working directory."
817
+ className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff] resize-y" />
818
+ </div>
819
+
820
+ {/* Plugin Instances grouped by plugin */}
821
+ <div className="flex flex-col gap-1">
822
+ <label className="text-[9px] text-gray-500 uppercase">Plugin Instances</label>
823
+ {(() => {
824
+ const withSource = pluginInstances.filter(i => i.source);
825
+ if (withSource.length === 0) return <span className="text-[8px] text-gray-600">No instances โ€” create in Marketplace โ†’ Plugins</span>;
826
+ // Group by source plugin
827
+ const groups: Record<string, typeof withSource> = {};
828
+ for (const inst of withSource) {
829
+ const key = inst.source!;
830
+ if (!groups[key]) groups[key] = [];
831
+ groups[key].push(inst);
832
+ }
833
+ // Show recommended types that have no instances yet
834
+ const missingRecommended = recommendedTypes.filter(rt =>
835
+ !withSource.some(i => i.source === rt)
836
+ );
837
+
838
+ return <>
839
+ {Object.entries(groups).map(([sourceId, insts]) => {
840
+ const def = pluginDefs.find(d => d.id === sourceId);
841
+ const isRecommended = recommendedTypes.includes(sourceId);
842
+ return (
843
+ <div key={sourceId} className="flex items-start gap-2">
844
+ <span className={`text-[9px] shrink-0 w-20 pt-1 truncate ${isRecommended ? 'text-[#58a6ff]' : 'text-gray-500'}`} title={def?.name || sourceId}>
845
+ {def?.icon || '๐Ÿ”Œ'} {def?.name || sourceId}
846
+ {isRecommended && <span className="text-[7px] ml-0.5">โ˜…</span>}
847
+ </span>
848
+ <div className="flex flex-wrap gap-1 flex-1">
849
+ {insts.map(inst => {
850
+ const selected = selectedPlugins.includes(inst.id);
851
+ return (
852
+ <button key={inst.id}
853
+ onClick={() => setSelectedPlugins(prev => selected ? prev.filter(x => x !== inst.id) : [...prev, inst.id])}
854
+ className={`text-[9px] px-2 py-0.5 rounded border transition-colors ${
855
+ selected
856
+ ? 'border-green-500/40 text-green-400 bg-green-500/10'
857
+ : isRecommended
858
+ ? 'border-[#58a6ff]/30 text-[#58a6ff]/70 hover:text-[#58a6ff]'
859
+ : 'border-[#30363d] text-gray-500 hover:text-gray-300'
860
+ }`}>
861
+ {inst.name}
862
+ </button>
863
+ );
864
+ })}
865
+ </div>
866
+ </div>
867
+ );
868
+ })}
869
+ {missingRecommended.length > 0 && missingRecommended.map(rt => {
870
+ const def = pluginDefs.find(d => d.id === rt);
871
+ return (
872
+ <div key={rt} className="flex items-start gap-2">
873
+ <span className="text-[9px] text-[#58a6ff] shrink-0 w-20 pt-1 truncate">
874
+ {def?.icon || '๐Ÿ”Œ'} {def?.name || rt}<span className="text-[7px] ml-0.5">โ˜…</span>
875
+ </span>
876
+ <span className="text-[8px] text-[#58a6ff]/50 italic pt-1">No instances โ€” create in Marketplace โ†’ Plugins</span>
877
+ </div>
878
+ );
879
+ })}
880
+ </>;
881
+
882
+ })()}
624
883
  </div>
625
884
 
626
885
  {/* Depends On โ€” checkbox list of existing agents */}
@@ -687,7 +946,7 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
687
946
  {(() => {
688
947
  // Check if selected agent supports terminal mode (claude-code or its profiles)
689
948
  const selectedAgent = availableAgents.find(a => a.id === agentId);
690
- const isClaude = agentId === 'claude' || selectedAgent?.base === 'claude' || selectedAgent?.cliType === 'claude-code' || !selectedAgent;
949
+ const isClaude = selectedAgent?.cliType === 'claude-code' || selectedAgent?.base === 'claude' || !selectedAgent;
691
950
  const canTerminal = isClaude || isPrimary;
692
951
  return canTerminal ? (
693
952
  <>
@@ -716,6 +975,27 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
716
975
  );
717
976
  })()}
718
977
 
978
+ {/* Model override โ€” only for claude-code agents */}
979
+ {(() => {
980
+ const sa = availableAgents.find(a => a.id === agentId);
981
+ const ct = sa?.cliType || (agentId === 'claude' ? 'claude-code' : '');
982
+ if (ct !== 'claude-code') return null;
983
+ return (
984
+ <div className="flex flex-col gap-0.5">
985
+ <label className="text-[9px] text-gray-500 uppercase">Model</label>
986
+ <input value={agentModel} onChange={e => setAgentModel(e.target.value)}
987
+ placeholder="default (uses profile or system default)"
988
+ list="workspace-model-list"
989
+ className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff] font-mono" />
990
+ <datalist id="workspace-model-list">
991
+ <option value="claude-sonnet-4-6" />
992
+ <option value="claude-opus-4-6" />
993
+ <option value="claude-haiku-4-5-20251001" />
994
+ </datalist>
995
+ </div>
996
+ );
997
+ })()}
998
+
719
999
  {/* Steps */}
720
1000
  <div className="flex flex-col gap-1">
721
1001
  <label className="text-[9px] text-gray-500 uppercase">Steps (one per line โ€” Label: Prompt)</label>
@@ -785,14 +1065,15 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
785
1065
  <option value="agent_status">Agent Status</option>
786
1066
  </select>
787
1067
  {t.type === 'directory' && (
788
- <select value={t.path || ''} onChange={e => {
789
- const next = [...watchTargets];
790
- next[i] = { ...t, path: e.target.value };
791
- setWatchTargets(next);
792
- }} className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-1 py-0.5 text-white flex-1">
793
- <option value="">Project root</option>
794
- {projectDirs.map(d => <option key={d} value={d + '/'}>{d}/</option>)}
795
- </select>
1068
+ <WatchPathPicker
1069
+ value={t.path || ''}
1070
+ projectPath={projectPath || ''}
1071
+ onChange={v => {
1072
+ const next = [...watchTargets];
1073
+ next[i] = { ...t, path: v };
1074
+ setWatchTargets(next);
1075
+ }}
1076
+ />
796
1077
  )}
797
1078
  {t.type === 'agent_status' && (<>
798
1079
  <select value={t.path || ''} onChange={e => {
@@ -905,8 +1186,15 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
905
1186
  steps: parseSteps(),
906
1187
  primary: isPrimary || undefined,
907
1188
  requiresApproval: requiresApproval || undefined,
908
- persistentSession: isPrimary ? true : (persistentSession || undefined),
1189
+ persistentSession: (() => {
1190
+ if (isPrimary) return true;
1191
+ // Non-terminal agents (codex, aider, etc.) force headless
1192
+ const sa = availableAgents.find(a => a.id === agentId);
1193
+ const isClaude = sa?.cliType === 'claude-code' || sa?.base === 'claude' || !sa;
1194
+ return (isClaude || isPrimary) ? (persistentSession || undefined) : false;
1195
+ })(),
909
1196
  skipPermissions: persistentSession ? (skipPermissions ? undefined : false) : undefined,
1197
+ model: agentModel || undefined,
910
1198
  watch: watchEnabled && watchTargets.length > 0 ? {
911
1199
  enabled: true,
912
1200
  interval: Math.max(10, parseInt(watchInterval) || 60),
@@ -915,6 +1203,7 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
915
1203
  prompt: watchPrompt || undefined,
916
1204
  sendTo: watchSendTo || undefined,
917
1205
  } : undefined,
1206
+ plugins: selectedPlugins.length > 0 ? selectedPlugins : undefined,
918
1207
  } as any);
919
1208
  }} className="text-xs px-3 py-1.5 rounded bg-[#238636] text-white hover:bg-[#2ea043] disabled:opacity-40">
920
1209
  {mode === 'add' ? 'Add' : 'Save'}
@@ -2344,9 +2633,11 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
2344
2633
  )}
2345
2634
  <div className="flex-1" />
2346
2635
  <span className="flex items-center">
2347
- <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onOpenTerminal(); }}
2348
- className={`text-[9px] px-1 ${hasTmux && taskStatus === 'running' ? 'text-green-400 animate-pulse' : 'text-gray-600 hover:text-green-400'}`}
2349
- title="Open terminal">โŒจ๏ธ</button>
2636
+ <button onPointerDown={e => e.stopPropagation()}
2637
+ onClick={e => { e.stopPropagation(); if (smithStatus === 'active') onOpenTerminal(); }}
2638
+ disabled={smithStatus !== 'active'}
2639
+ className={`text-[9px] px-1 ${smithStatus !== 'active' ? 'text-gray-700 cursor-not-allowed' : hasTmux && taskStatus === 'running' ? 'text-green-400 animate-pulse' : 'text-gray-600 hover:text-green-400'}`}
2640
+ title={smithStatus === 'starting' ? 'Starting sessionโ€ฆ' : smithStatus === 'down' ? 'Smith not started' : 'Open terminal'}>โŒจ๏ธ</button>
2350
2641
  {hasTmux && !config.primary && (
2351
2642
  <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onSwitchSession(); }}
2352
2643
  className="text-[10px] text-gray-600 hover:text-yellow-400 px-0.5 py-0.5" title="Switch session">โ–พ</button>
@@ -2392,7 +2683,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2392
2683
  const [inboxTarget, setInboxTarget] = useState<{ id: string; label: string } | null>(null);
2393
2684
  const [showBusPanel, setShowBusPanel] = useState(false);
2394
2685
  const [floatingTerminals, setFloatingTerminals] = useState<{ agentId: string; label: string; icon: string; cliId: string; cliCmd?: string; cliType?: string; workDir?: string; tmuxSession?: string; sessionName: string; resumeMode?: boolean; resumeSessionId?: string; profileEnv?: Record<string, string>; isPrimary?: boolean; skipPermissions?: boolean; persistentSession?: boolean; boundSessionId?: string; initialPos?: { x: number; y: number } }[]>([]);
2395
- const [termLaunchDialog, setTermLaunchDialog] = useState<{ agent: AgentConfig; sessName: string; workDir?: string; sessions: string[]; supportsSession?: boolean; initialPos?: { x: number; y: number } } | null>(null);
2686
+ const [termPicker, setTermPicker] = useState<{ agent: AgentConfig; sessName: string; workDir?: string; supportsSession?: boolean; currentSessionId: string | null; initialPos?: { x: number; y: number } } | null>(null);
2396
2687
 
2397
2688
  // Expose focusAgent to parent
2398
2689
  useImperativeHandle(ref, () => ({
@@ -2502,48 +2793,16 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2502
2793
  // Close existing terminal (config may have changed)
2503
2794
  setFloatingTerminals(prev => prev.filter(t => t.agentId !== agent.id));
2504
2795
 
2505
- // Get node screen position for initial terminal placement
2506
2796
  const nodeEl = document.querySelector(`[data-id="${agent.id}"]`);
2507
2797
  const nodeRect = nodeEl?.getBoundingClientRect();
2508
- const initialPos = nodeRect
2509
- ? { x: nodeRect.left, y: nodeRect.bottom + 4 }
2510
- : { x: 80, y: 60 };
2511
-
2512
- const agentState = states[agent.id];
2513
- const existingTmux = agentState?.tmuxSession;
2798
+ const initialPos = nodeRect ? { x: nodeRect.left, y: nodeRect.bottom + 4 } : { x: 80, y: 60 };
2514
2799
  const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
2515
2800
  const sessName = `mw-forge-${safeName(projectName)}-${safeName(agent.label)}`;
2516
2801
  const workDir = agent.workDir && agent.workDir !== './' && agent.workDir !== '.' ? agent.workDir : undefined;
2517
-
2518
- // Always resolve launch info for this agent (cliCmd, env, model)
2802
+ // All agents: show picker (current session / new session / other sessions)
2519
2803
  const resolveRes = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id, resolveOnly: true }).catch(() => ({})) as any;
2520
- const launchInfo = {
2521
- cliCmd: resolveRes?.cliCmd || 'claude',
2522
- cliType: resolveRes?.cliType || 'claude-code',
2523
- profileEnv: {
2524
- ...(resolveRes?.env || {}),
2525
- ...(resolveRes?.model ? { CLAUDE_MODEL: resolveRes.model } : {}),
2526
- FORGE_AGENT_ID: agent.id,
2527
- FORGE_WORKSPACE_ID: workspaceId!,
2528
- FORGE_PORT: String(window.location.port || 8403),
2529
- },
2530
- };
2531
-
2532
- // All paths: let daemon create/ensure session, then attach
2533
- if (existingTmux || agent.primary || agent.persistentSession || agent.boundSessionId) {
2534
- // Daemon creates session via ensurePersistentSession (launch script, no truncation)
2535
- const res = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id }).catch(() => ({})) as any;
2536
- const tmux = existingTmux || res?.tmuxSession || sessName;
2537
- setFloatingTerminals(prev => [...prev, {
2538
- agentId: agent.id, label: agent.label, icon: agent.icon,
2539
- cliId: agent.agentId || 'claude', workDir,
2540
- tmuxSession: tmux, sessionName: sessName,
2541
- isPrimary: agent.primary, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId, initialPos,
2542
- }]);
2543
- return;
2544
- }
2545
- // No persistent session, no bound session โ†’ show launch dialog
2546
- setTermLaunchDialog({ agent, sessName, workDir, sessions: [], supportsSession: resolveRes?.supportsSession ?? true, initialPos });
2804
+ const currentSessionId = resolveRes?.currentSessionId ?? null;
2805
+ setTermPicker({ agent, sessName, workDir, supportsSession: resolveRes?.supportsSession ?? true, currentSessionId, initialPos });
2547
2806
  },
2548
2807
  onSwitchSession: async () => {
2549
2808
  if (!workspaceId) return;
@@ -2551,12 +2810,13 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2551
2810
  if (agent.id) wsApi(workspaceId, 'close_terminal', { agentId: agent.id });
2552
2811
  const nodeEl = document.querySelector(`[data-id="${agent.id}"]`);
2553
2812
  const nodeRect = nodeEl?.getBoundingClientRect();
2554
- const switchPos = nodeRect ? { x: nodeRect.left, y: nodeRect.bottom + 4 } : { x: 80, y: 60 };
2813
+ const initialPos = nodeRect ? { x: nodeRect.left, y: nodeRect.bottom + 4 } : { x: 80, y: 60 };
2555
2814
  const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
2556
2815
  const sessName = `mw-forge-${safeName(projectName)}-${safeName(agent.label)}`;
2557
2816
  const workDir = agent.workDir && agent.workDir !== './' && agent.workDir !== '.' ? agent.workDir : undefined;
2558
2817
  const resolveRes = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id, resolveOnly: true }).catch(() => ({})) as any;
2559
- setTermLaunchDialog({ agent, sessName, workDir, sessions: [], supportsSession: resolveRes?.supportsSession ?? true, initialPos: switchPos });
2818
+ const currentSessionId = resolveRes?.currentSessionId ?? null;
2819
+ setTermPicker({ agent, sessName, workDir, supportsSession: resolveRes?.supportsSession ?? true, currentSessionId, initialPos });
2560
2820
  },
2561
2821
  } satisfies AgentNodeData,
2562
2822
  };
@@ -2610,6 +2870,17 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2610
2870
  const handleAddAgent = async (cfg: Omit<AgentConfig, 'id'>) => {
2611
2871
  if (!workspaceId) return;
2612
2872
  const config: AgentConfig = { ...cfg, id: `${cfg.label.toLowerCase().replace(/\s+/g, '-')}-${Date.now()}` };
2873
+ // Auto-install base plugins if not already installed (for preset templates)
2874
+ // User-selected instances are already installed, so this is a no-op for them
2875
+ if (cfg.plugins?.length) {
2876
+ await Promise.all(cfg.plugins.map(pluginId =>
2877
+ fetch('/api/plugins', {
2878
+ method: 'POST',
2879
+ headers: { 'Content-Type': 'application/json' },
2880
+ body: JSON.stringify({ action: 'install', id: pluginId, config: {} }),
2881
+ }).catch(() => {})
2882
+ ));
2883
+ }
2613
2884
  // Optimistic update โ€” show immediately
2614
2885
  setModal(null);
2615
2886
  await wsApi(workspaceId, 'add', { config });
@@ -2911,33 +3182,46 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2911
3182
  />
2912
3183
  )}
2913
3184
 
2914
- {/* Terminal launch dialog */}
2915
- {termLaunchDialog && workspaceId && (
2916
- <TerminalLaunchDialog
2917
- agent={termLaunchDialog.agent}
2918
- workDir={termLaunchDialog.workDir}
2919
- sessName={termLaunchDialog.sessName}
2920
- projectPath={projectPath}
2921
- workspaceId={workspaceId}
2922
- supportsSession={termLaunchDialog.supportsSession}
2923
- onLaunch={async (resumeMode, sessionId) => {
2924
- const { agent, sessName, workDir } = termLaunchDialog;
2925
- setTermLaunchDialog(null);
2926
- // Save selected session as boundSessionId
2927
- if (sessionId) {
2928
- await wsApi(workspaceId, 'update', { agentId: agent.id, config: { ...agent, boundSessionId: sessionId } }).catch(() => {});
3185
+ {/* Terminal session picker */}
3186
+ {termPicker && workspaceId && (
3187
+ <TerminalSessionPickerLazy
3188
+ agentLabel={termPicker.agent.label}
3189
+ currentSessionId={termPicker.currentSessionId}
3190
+ fetchSessions={() => fetchAgentSessions(workspaceId, termPicker.agent.id)}
3191
+ supportsSession={termPicker.supportsSession}
3192
+ onSelect={async (selection: PickerSelection) => {
3193
+ const { agent, sessName, workDir } = termPicker;
3194
+ const pickerInitialPos = termPicker.initialPos;
3195
+ setTermPicker(null);
3196
+
3197
+ let boundSessionId = agent.boundSessionId;
3198
+ if (selection.mode === 'session') {
3199
+ // Bind to a specific session
3200
+ await wsApi(workspaceId, 'update', { agentId: agent.id, config: { ...agent, boundSessionId: selection.sessionId } }).catch(() => {});
3201
+ boundSessionId = selection.sessionId;
3202
+ } else if (selection.mode === 'new') {
3203
+ // Clear bound session โ†’ fresh start
3204
+ if (agent.boundSessionId) {
3205
+ await wsApi(workspaceId, 'update', { agentId: agent.id, config: { ...agent, boundSessionId: undefined } }).catch(() => {});
3206
+ }
3207
+ boundSessionId = undefined;
2929
3208
  }
2930
- // Daemon creates session (launch script), then attach
2931
- const res = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id }).catch(() => ({})) as any;
3209
+ // mode === 'current': keep existing boundSessionId
3210
+
3211
+ // 'current': just attach โ€” claude is running, don't interrupt.
3212
+ // 'session' or 'new': forceRestart โ€” rebuild launch script with correct --resume.
3213
+ const forceRestart = selection.mode !== 'current';
3214
+ const res = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id, forceRestart }).catch(() => ({})) as any;
2932
3215
  const tmux = res?.tmuxSession || sessName;
2933
3216
  setFloatingTerminals(prev => [...prev, {
2934
3217
  agentId: agent.id, label: agent.label, icon: agent.icon,
2935
3218
  cliId: agent.agentId || 'claude', workDir,
2936
3219
  tmuxSession: tmux, sessionName: sessName,
2937
- isPrimary: false, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: sessionId || agent.boundSessionId, initialPos: termLaunchDialog.initialPos,
3220
+ isPrimary: agent.primary, skipPermissions: agent.skipPermissions !== false,
3221
+ persistentSession: agent.persistentSession, boundSessionId, initialPos: pickerInitialPos,
2938
3222
  }]);
2939
3223
  }}
2940
- onCancel={() => setTermLaunchDialog(null)}
3224
+ onCancel={() => setTermPicker(null)}
2941
3225
  />
2942
3226
  )}
2943
3227