@aion0/forge 0.5.21 โ†’ 0.5.23

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 (39) hide show
  1. package/.forge/agent-context.json +1 -1
  2. package/.forge/mcp.json +1 -1
  3. package/RELEASE_NOTES.md +6 -10
  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 +166 -67
  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 +256 -76
  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 +443 -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/package.json +1 -1
  39. 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
  ];
@@ -551,6 +620,9 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
551
620
  const [agentId, setAgentId] = useState(initial.agentId || 'claude');
552
621
  const [availableAgents, setAvailableAgents] = useState<{ id: string; name: string; isProfile?: boolean; backendType?: string; base?: string; cliType?: string }[]>([]);
553
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
+
554
626
  useEffect(() => {
555
627
  fetch('/api/agents').then(r => r.json()).then(data => {
556
628
  const list = (data.agents || data || []).map((a: any) => ({
@@ -562,6 +634,19 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
562
634
  }));
563
635
  setAvailableAgents(list);
564
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(() => {});
565
650
  }, []);
566
651
  const [workDirVal, setWorkDirVal] = useState(initial.workDir || '');
567
652
  const [outputs, setOutputs] = useState((initial.outputs || []).join(', '));
@@ -574,11 +659,14 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
574
659
  const hasPrimaryAlready = existingAgents.some(a => a.primary && a.id !== initial.id);
575
660
  const [persistentSession, setPersistentSession] = useState(initial.persistentSession || initial.primary || false);
576
661
  const [skipPermissions, setSkipPermissions] = useState(initial.skipPermissions !== false);
662
+ const [agentModel, setAgentModel] = useState(initial.model || '');
577
663
  const [watchEnabled, setWatchEnabled] = useState(initial.watch?.enabled || false);
578
664
  const [watchInterval, setWatchInterval] = useState(String(initial.watch?.interval || 60));
579
665
  const [watchAction, setWatchAction] = useState<'log' | 'analyze' | 'approve' | 'send_message'>(initial.watch?.action || 'log');
580
666
  const [watchPrompt, setWatchPrompt] = useState(initial.watch?.prompt || '');
581
667
  const [watchSendTo, setWatchSendTo] = useState(initial.watch?.sendTo || '');
668
+ const [selectedPlugins, setSelectedPlugins] = useState<string[]>(initial.plugins || []);
669
+ const [recommendedTypes, setRecommendedTypes] = useState<string[]>([]);
582
670
  const [watchDebounce, setWatchDebounce] = useState(String(initial.watch?.targets?.[0]?.debounce ?? 10));
583
671
  const [watchTargets, setWatchTargets] = useState<{ type: string; path?: string; cmd?: string; pattern?: string }[]>(
584
672
  initial.watch?.targets || []
@@ -613,6 +701,8 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
613
701
  setWorkDirVal(p.workDir || './');
614
702
  setOutputs(p.outputs.join(', '));
615
703
  setStepsText(p.steps.map(s => `${s.label}: ${s.prompt}`).join('\n'));
704
+ setRecommendedTypes(p.plugins || []);
705
+ setSelectedPlugins([]);
616
706
  };
617
707
 
618
708
  const toggleDep = (id: string) => {
@@ -722,8 +812,74 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
722
812
  {/* Role */}
723
813
  <div className="flex flex-col gap-1">
724
814
  <label className="text-[9px] text-gray-500 uppercase">Role / System Prompt</label>
725
- <textarea value={role} onChange={e => setRole(e.target.value)} rows={2}
726
- 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
+ })()}
727
883
  </div>
728
884
 
729
885
  {/* Depends On โ€” checkbox list of existing agents */}
@@ -790,7 +946,7 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
790
946
  {(() => {
791
947
  // Check if selected agent supports terminal mode (claude-code or its profiles)
792
948
  const selectedAgent = availableAgents.find(a => a.id === agentId);
793
- const isClaude = agentId === 'claude' || selectedAgent?.base === 'claude' || selectedAgent?.cliType === 'claude-code' || !selectedAgent;
949
+ const isClaude = selectedAgent?.cliType === 'claude-code' || selectedAgent?.base === 'claude' || !selectedAgent;
794
950
  const canTerminal = isClaude || isPrimary;
795
951
  return canTerminal ? (
796
952
  <>
@@ -819,6 +975,27 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
819
975
  );
820
976
  })()}
821
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
+
822
999
  {/* Steps */}
823
1000
  <div className="flex flex-col gap-1">
824
1001
  <label className="text-[9px] text-gray-500 uppercase">Steps (one per line โ€” Label: Prompt)</label>
@@ -1009,8 +1186,15 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
1009
1186
  steps: parseSteps(),
1010
1187
  primary: isPrimary || undefined,
1011
1188
  requiresApproval: requiresApproval || undefined,
1012
- 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
+ })(),
1013
1196
  skipPermissions: persistentSession ? (skipPermissions ? undefined : false) : undefined,
1197
+ model: agentModel || undefined,
1014
1198
  watch: watchEnabled && watchTargets.length > 0 ? {
1015
1199
  enabled: true,
1016
1200
  interval: Math.max(10, parseInt(watchInterval) || 60),
@@ -1019,6 +1203,7 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
1019
1203
  prompt: watchPrompt || undefined,
1020
1204
  sendTo: watchSendTo || undefined,
1021
1205
  } : undefined,
1206
+ plugins: selectedPlugins.length > 0 ? selectedPlugins : undefined,
1022
1207
  } as any);
1023
1208
  }} className="text-xs px-3 py-1.5 rounded bg-[#238636] text-white hover:bg-[#2ea043] disabled:opacity-40">
1024
1209
  {mode === 'add' ? 'Add' : 'Save'}
@@ -2448,9 +2633,11 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
2448
2633
  )}
2449
2634
  <div className="flex-1" />
2450
2635
  <span className="flex items-center">
2451
- <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onOpenTerminal(); }}
2452
- className={`text-[9px] px-1 ${hasTmux && taskStatus === 'running' ? 'text-green-400 animate-pulse' : 'text-gray-600 hover:text-green-400'}`}
2453
- 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>
2454
2641
  {hasTmux && !config.primary && (
2455
2642
  <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onSwitchSession(); }}
2456
2643
  className="text-[10px] text-gray-600 hover:text-yellow-400 px-0.5 py-0.5" title="Switch session">โ–พ</button>
@@ -2496,7 +2683,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2496
2683
  const [inboxTarget, setInboxTarget] = useState<{ id: string; label: string } | null>(null);
2497
2684
  const [showBusPanel, setShowBusPanel] = useState(false);
2498
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 } }[]>([]);
2499
- 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);
2500
2687
 
2501
2688
  // Expose focusAgent to parent
2502
2689
  useImperativeHandle(ref, () => ({
@@ -2606,48 +2793,16 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2606
2793
  // Close existing terminal (config may have changed)
2607
2794
  setFloatingTerminals(prev => prev.filter(t => t.agentId !== agent.id));
2608
2795
 
2609
- // Get node screen position for initial terminal placement
2610
2796
  const nodeEl = document.querySelector(`[data-id="${agent.id}"]`);
2611
2797
  const nodeRect = nodeEl?.getBoundingClientRect();
2612
- const initialPos = nodeRect
2613
- ? { x: nodeRect.left, y: nodeRect.bottom + 4 }
2614
- : { x: 80, y: 60 };
2615
-
2616
- const agentState = states[agent.id];
2617
- const existingTmux = agentState?.tmuxSession;
2798
+ const initialPos = nodeRect ? { x: nodeRect.left, y: nodeRect.bottom + 4 } : { x: 80, y: 60 };
2618
2799
  const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
2619
2800
  const sessName = `mw-forge-${safeName(projectName)}-${safeName(agent.label)}`;
2620
2801
  const workDir = agent.workDir && agent.workDir !== './' && agent.workDir !== '.' ? agent.workDir : undefined;
2621
-
2622
- // Always resolve launch info for this agent (cliCmd, env, model)
2802
+ // All agents: show picker (current session / new session / other sessions)
2623
2803
  const resolveRes = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id, resolveOnly: true }).catch(() => ({})) as any;
2624
- const launchInfo = {
2625
- cliCmd: resolveRes?.cliCmd || 'claude',
2626
- cliType: resolveRes?.cliType || 'claude-code',
2627
- profileEnv: {
2628
- ...(resolveRes?.env || {}),
2629
- ...(resolveRes?.model ? { CLAUDE_MODEL: resolveRes.model } : {}),
2630
- FORGE_AGENT_ID: agent.id,
2631
- FORGE_WORKSPACE_ID: workspaceId!,
2632
- FORGE_PORT: String(window.location.port || 8403),
2633
- },
2634
- };
2635
-
2636
- // All paths: let daemon create/ensure session, then attach
2637
- if (existingTmux || agent.primary || agent.persistentSession || agent.boundSessionId) {
2638
- // Daemon creates session via ensurePersistentSession (launch script, no truncation)
2639
- const res = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id }).catch(() => ({})) as any;
2640
- const tmux = existingTmux || res?.tmuxSession || sessName;
2641
- setFloatingTerminals(prev => [...prev, {
2642
- agentId: agent.id, label: agent.label, icon: agent.icon,
2643
- cliId: agent.agentId || 'claude', workDir,
2644
- tmuxSession: tmux, sessionName: sessName,
2645
- isPrimary: agent.primary, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId, initialPos,
2646
- }]);
2647
- return;
2648
- }
2649
- // No persistent session, no bound session โ†’ show launch dialog
2650
- 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 });
2651
2806
  },
2652
2807
  onSwitchSession: async () => {
2653
2808
  if (!workspaceId) return;
@@ -2655,12 +2810,13 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2655
2810
  if (agent.id) wsApi(workspaceId, 'close_terminal', { agentId: agent.id });
2656
2811
  const nodeEl = document.querySelector(`[data-id="${agent.id}"]`);
2657
2812
  const nodeRect = nodeEl?.getBoundingClientRect();
2658
- 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 };
2659
2814
  const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
2660
2815
  const sessName = `mw-forge-${safeName(projectName)}-${safeName(agent.label)}`;
2661
2816
  const workDir = agent.workDir && agent.workDir !== './' && agent.workDir !== '.' ? agent.workDir : undefined;
2662
2817
  const resolveRes = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id, resolveOnly: true }).catch(() => ({})) as any;
2663
- 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 });
2664
2820
  },
2665
2821
  } satisfies AgentNodeData,
2666
2822
  };
@@ -2714,6 +2870,17 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2714
2870
  const handleAddAgent = async (cfg: Omit<AgentConfig, 'id'>) => {
2715
2871
  if (!workspaceId) return;
2716
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
+ }
2717
2884
  // Optimistic update โ€” show immediately
2718
2885
  setModal(null);
2719
2886
  await wsApi(workspaceId, 'add', { config });
@@ -3015,33 +3182,46 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
3015
3182
  />
3016
3183
  )}
3017
3184
 
3018
- {/* Terminal launch dialog */}
3019
- {termLaunchDialog && workspaceId && (
3020
- <TerminalLaunchDialog
3021
- agent={termLaunchDialog.agent}
3022
- workDir={termLaunchDialog.workDir}
3023
- sessName={termLaunchDialog.sessName}
3024
- projectPath={projectPath}
3025
- workspaceId={workspaceId}
3026
- supportsSession={termLaunchDialog.supportsSession}
3027
- onLaunch={async (resumeMode, sessionId) => {
3028
- const { agent, sessName, workDir } = termLaunchDialog;
3029
- setTermLaunchDialog(null);
3030
- // Save selected session as boundSessionId
3031
- if (sessionId) {
3032
- 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;
3033
3208
  }
3034
- // Daemon creates session (launch script), then attach
3035
- 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;
3036
3215
  const tmux = res?.tmuxSession || sessName;
3037
3216
  setFloatingTerminals(prev => [...prev, {
3038
3217
  agentId: agent.id, label: agent.label, icon: agent.icon,
3039
3218
  cliId: agent.agentId || 'claude', workDir,
3040
3219
  tmuxSession: tmux, sessionName: sessName,
3041
- 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,
3042
3222
  }]);
3043
3223
  }}
3044
- onCancel={() => setTermLaunchDialog(null)}
3224
+ onCancel={() => setTermPicker(null)}
3045
3225
  />
3046
3226
  )}
3047
3227
 
@@ -95,6 +95,7 @@ export function listAgents(): AgentConfig[] {
95
95
  flags,
96
96
  enabled: cfg.enabled !== false,
97
97
  detected: !!detected,
98
+ cliType: cfg.cliType,
98
99
  } as any);
99
100
  }
100
101
  }
@@ -218,16 +219,18 @@ export function resolveTerminalLaunch(agentId?: string): TerminalLaunchInfo {
218
219
  // Resolve env/model: either from this agent's own profile fields, or from linked profile
219
220
  let env: Record<string, string> | undefined;
220
221
  let model: string | undefined;
221
- if (agentCfg.base || agentCfg.env || agentCfg.model) {
222
- // This agent IS a profile โ€” read env/model directly
222
+ if (agentCfg.base || agentCfg.env || agentCfg.model || agentCfg.models) {
223
+ // This agent has profile-like config โ€” read env/model directly
223
224
  if (agentCfg.env) env = { ...agentCfg.env };
224
- if (agentCfg.model) model = agentCfg.model;
225
+ model = agentCfg.model || agentCfg.models?.terminal;
226
+ if (model === 'default') model = undefined; // 'default' means no override
225
227
  } else if (agentCfg.profile) {
226
228
  // Agent links to a separate profile โ€” read from that
227
229
  const profileCfg = settings.agents?.[agentCfg.profile];
228
230
  if (profileCfg) {
229
231
  if (profileCfg.env) env = { ...profileCfg.env };
230
- if (profileCfg.model) model = profileCfg.model;
232
+ model = profileCfg.model || profileCfg.models?.terminal;
233
+ if (model === 'default') model = undefined;
231
234
  }
232
235
  }
233
236
 
@@ -0,0 +1,70 @@
1
+ id: docker
2
+ name: Docker
3
+ icon: "๐Ÿณ"
4
+ version: "1.1.0"
5
+ author: forge
6
+ description: |
7
+ Build and manage Docker images.
8
+ Create instances per project or registry:
9
+ - "Build App" โ†’ default_image: my-app, default_context: .
10
+ - "Push to Harbor" โ†’ registry: harbor.example.com
11
+
12
+ config:
13
+ registry:
14
+ type: string
15
+ label: Registry URL
16
+ description: "e.g., docker.io, ghcr.io, harbor.example.com"
17
+ default_image:
18
+ type: string
19
+ label: Default Image Name
20
+ description: "Pre-configured image name"
21
+ default_tag:
22
+ type: string
23
+ label: Default Tag
24
+ default: "latest"
25
+ default_dockerfile:
26
+ type: string
27
+ label: Default Dockerfile
28
+ default: "Dockerfile"
29
+ default_context:
30
+ type: string
31
+ label: Default Build Context
32
+ default: "."
33
+
34
+ params:
35
+ image:
36
+ type: string
37
+ label: Image Name
38
+ description: "Overrides default_image"
39
+ tag:
40
+ type: string
41
+ label: Tag
42
+ dockerfile:
43
+ type: string
44
+ label: Dockerfile Path
45
+ context:
46
+ type: string
47
+ label: Build Context
48
+
49
+ defaultAction: build
50
+
51
+ actions:
52
+ build:
53
+ run: shell
54
+ command: "docker build -t {{params.image}}:{{params.tag}} -f {{params.dockerfile}} {{params.context}}"
55
+ timeout: 600
56
+ output:
57
+ result: "$stdout"
58
+
59
+ push:
60
+ run: shell
61
+ command: "docker push {{config.registry}}/{{params.image}}:{{params.tag}}"
62
+ timeout: 300
63
+ output:
64
+ result: "$stdout"
65
+
66
+ tag:
67
+ run: shell
68
+ command: "docker tag {{params.image}}:{{params.tag}} {{config.registry}}/{{params.image}}:{{params.tag}}"
69
+ output:
70
+ result: "$stdout"