@aion0/forge 0.5.21 โ 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.
- package/.forge/agent-context.json +1 -1
- package/.forge/mcp.json +1 -1
- package/RELEASE_NOTES.md +31 -9
- package/app/api/plugins/route.ts +75 -0
- package/components/Dashboard.tsx +1 -0
- package/components/PipelineEditor.tsx +135 -9
- package/components/PluginsPanel.tsx +472 -0
- package/components/ProjectDetail.tsx +36 -98
- package/components/SessionView.tsx +4 -4
- package/components/SettingsModal.tsx +160 -66
- package/components/SkillsPanel.tsx +14 -5
- package/components/TerminalLauncher.tsx +398 -0
- package/components/WebTerminal.tsx +84 -84
- package/components/WorkspaceView.tsx +256 -76
- package/lib/agents/index.ts +7 -4
- package/lib/builtin-plugins/docker.yaml +70 -0
- package/lib/builtin-plugins/http.yaml +66 -0
- package/lib/builtin-plugins/jenkins.yaml +92 -0
- package/lib/builtin-plugins/llm-vision.yaml +85 -0
- package/lib/builtin-plugins/playwright.yaml +111 -0
- package/lib/builtin-plugins/shell-command.yaml +60 -0
- package/lib/builtin-plugins/slack.yaml +48 -0
- package/lib/builtin-plugins/webhook.yaml +56 -0
- package/lib/forge-mcp-server.ts +116 -2
- package/lib/pipeline.ts +62 -5
- package/lib/plugins/executor.ts +347 -0
- package/lib/plugins/registry.ts +228 -0
- package/lib/plugins/types.ts +103 -0
- package/lib/project-sessions.ts +7 -2
- package/lib/session-utils.ts +7 -3
- package/lib/terminal-standalone.ts +6 -34
- package/lib/workspace/agent-worker.ts +1 -1
- package/lib/workspace/orchestrator.ts +414 -136
- package/lib/workspace/presets.ts +5 -3
- package/lib/workspace/session-monitor.ts +14 -10
- package/lib/workspace/types.ts +3 -1
- package/lib/workspace-standalone.ts +38 -21
- package/package.json +1 -1
- 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: '
|
|
120
|
-
{ id: '
|
|
121
|
-
{ id: '
|
|
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
|
-
|
|
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
|
-
-
|
|
156
|
-
|
|
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: '
|
|
160
|
-
{ id: '
|
|
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={
|
|
726
|
-
|
|
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 =
|
|
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:
|
|
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()}
|
|
2452
|
-
|
|
2453
|
-
|
|
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 [
|
|
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
|
|
2625
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
3019
|
-
{
|
|
3020
|
-
<
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
if (
|
|
3032
|
-
|
|
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
|
-
//
|
|
3035
|
-
|
|
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:
|
|
3220
|
+
isPrimary: agent.primary, skipPermissions: agent.skipPermissions !== false,
|
|
3221
|
+
persistentSession: agent.persistentSession, boundSessionId, initialPos: pickerInitialPos,
|
|
3042
3222
|
}]);
|
|
3043
3223
|
}}
|
|
3044
|
-
onCancel={() =>
|
|
3224
|
+
onCancel={() => setTermPicker(null)}
|
|
3045
3225
|
/>
|
|
3046
3226
|
)}
|
|
3047
3227
|
|
package/lib/agents/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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"
|