@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
@@ -3,4 +3,4 @@
3
3
  "agentId": "engineer-1774920478256",
4
4
  "agentLabel": "Engineer",
5
5
  "forgePort": 8403
6
- }
6
+ }
package/.forge/mcp.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "forge": {
4
4
  "type": "sse",
5
- "url": "http://localhost:8406/sse?workspaceId=656c9e65-9d73-4cb6-a065-60d966e1fc78&agentId=engineer-1774920478256"
5
+ "url": "http://localhost:8406/sse?workspaceId=656c9e65-9d73-4cb6-a065-60d966e1fc78"
6
6
  }
7
7
  }
8
8
  }
package/RELEASE_NOTES.md CHANGED
@@ -1,16 +1,12 @@
1
- # Forge v0.5.21
1
+ # Forge v0.5.23
2
2
 
3
- Released: 2026-04-01
3
+ Released: 2026-04-03
4
4
 
5
- ## Changes since v0.5.20
5
+ ## Changes since v0.5.22
6
6
 
7
7
  ### Bug Fixes
8
- - fix: code search excludes node_modules + handles grep exit code 1
9
- - Revert "fix: hook only broadcasts/suppresses when transitioning from running"
10
- - fix: hook only broadcasts/suppresses when transitioning from running
8
+ - fix: agent deletion now removes from settings (was only removing from local state)
9
+ - fix: fallback to default agent when configured agentId is deleted from Settings
11
10
 
12
- ### Other
13
- - Revert "fix: hook only broadcasts/suppresses when transitioning from running"
14
11
 
15
-
16
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.20...v0.5.21
12
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.22...v0.5.23
@@ -0,0 +1,75 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { listPlugins, getPlugin, installPlugin, uninstallPlugin, updatePluginConfig, listInstalledPlugins, getInstalledPlugin } from '@/lib/plugins/registry';
3
+ import { executePluginAction } from '@/lib/plugins/executor';
4
+
5
+ // GET: list plugins or get plugin details
6
+ export async function GET(req: Request) {
7
+ const url = new URL(req.url);
8
+ const id = url.searchParams.get('id');
9
+ const installed = url.searchParams.get('installed');
10
+
11
+ if (id) {
12
+ // Try as plugin definition first, then as installed instance
13
+ const plugin = getPlugin(id);
14
+ const inst = getInstalledPlugin(id);
15
+ if (!plugin && !inst) return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
16
+ return NextResponse.json({
17
+ plugin: inst?.definition || plugin,
18
+ installed: !!inst,
19
+ config: inst?.config,
20
+ instanceName: inst?.instanceName,
21
+ source: inst?.source,
22
+ });
23
+ }
24
+
25
+ if (installed === 'true') {
26
+ return NextResponse.json({ plugins: listInstalledPlugins() });
27
+ }
28
+
29
+ return NextResponse.json({ plugins: listPlugins() });
30
+ }
31
+
32
+ // POST: install, uninstall, update config, or test a plugin action
33
+ export async function POST(req: Request) {
34
+ const body = await req.json();
35
+ const { action, id, config, actionName, params } = body;
36
+
37
+ switch (action) {
38
+ case 'install': {
39
+ if (!id) return NextResponse.json({ error: 'id required' }, { status: 400 });
40
+ const ok = installPlugin(id, config || {}, body.source ? { source: body.source, name: body.name } : undefined);
41
+ return NextResponse.json({ ok });
42
+ }
43
+ case 'create_instance': {
44
+ const { source, name, instanceId } = body;
45
+ if (!source || !name) return NextResponse.json({ error: 'source and name required' }, { status: 400 });
46
+ const iid = instanceId || name.toLowerCase().replace(/[^a-z0-9]+/g, '-');
47
+ // Check for duplicate ID
48
+ const existing = getInstalledPlugin(iid);
49
+ if (existing) {
50
+ return NextResponse.json({ error: `Instance ID "${iid}" already exists (used by ${existing.instanceName || existing.definition.name}). Choose a different name.` }, { status: 409 });
51
+ }
52
+ const ok = installPlugin(iid, config || {}, { source, name });
53
+ return NextResponse.json({ ok, instanceId: iid });
54
+ }
55
+ case 'uninstall': {
56
+ if (!id) return NextResponse.json({ error: 'id required' }, { status: 400 });
57
+ const ok = uninstallPlugin(id);
58
+ return NextResponse.json({ ok });
59
+ }
60
+ case 'update_config': {
61
+ if (!id || !config) return NextResponse.json({ error: 'id and config required' }, { status: 400 });
62
+ const ok = updatePluginConfig(id, config);
63
+ return NextResponse.json({ ok });
64
+ }
65
+ case 'test': {
66
+ if (!id || !actionName) return NextResponse.json({ error: 'id and actionName required' }, { status: 400 });
67
+ const inst = getInstalledPlugin(id);
68
+ if (!inst) return NextResponse.json({ error: 'Plugin not installed' }, { status: 400 });
69
+ const result = await executePluginAction(inst, actionName, params || {});
70
+ return NextResponse.json(result);
71
+ }
72
+ default:
73
+ return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
74
+ }
75
+ }
@@ -710,6 +710,7 @@ export default function Dashboard({ user }: { user: any }) {
710
710
  </Suspense>
711
711
  )}
712
712
 
713
+
713
714
  {/* Usage */}
714
715
  {viewMode === 'usage' && (
715
716
  <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
@@ -37,8 +37,10 @@ function PipelineNode({ id, data }: NodeProps<Node<NodeData>>) {
37
37
 
38
38
  <div className="px-3 py-2 border-b border-[#3a3a5a] flex items-center gap-2">
39
39
  <span className={`text-[8px] px-1 py-0.5 rounded font-medium ${
40
- (data as any).mode === 'shell' ? 'bg-yellow-500/20 text-yellow-400' : 'bg-purple-500/20 text-purple-400'
41
- }`}>{(data as any).mode === 'shell' ? 'shell' : ((data as any).agent || 'default')}</span>
40
+ (data as any).mode === 'shell' ? 'bg-yellow-500/20 text-yellow-400' :
41
+ (data as any).mode === 'plugin' ? 'bg-green-500/20 text-green-400' :
42
+ 'bg-purple-500/20 text-purple-400'
43
+ }`}>{(data as any).mode === 'plugin' ? `🔌 ${(data as any).plugin || 'plugin'}` : (data as any).mode === 'shell' ? 'shell' : ((data as any).agent || 'default')}</span>
42
44
  <span className="text-xs font-semibold text-white">{data.label}</span>
43
45
  <div className="ml-auto flex gap-1">
44
46
  <button onClick={() => data.onEdit(id)} className="text-[9px] text-[var(--accent)] hover:text-white">edit</button>
@@ -66,10 +68,10 @@ const nodeTypes = { pipeline: PipelineNode };
66
68
  // ─── Node Edit Modal ──────────────────────────────────────
67
69
 
68
70
  function NodeEditModal({ node, projects, agents, onSave, onClose }: {
69
- node: { id: string; project: string; prompt: string; agent?: string; mode?: string; outputs: { name: string; extract: string }[] };
71
+ node: { id: string; project: string; prompt: string; agent?: string; mode?: string; plugin?: string; pluginAction?: string; pluginParams?: Record<string, any>; pluginWait?: boolean; outputs: { name: string; extract: string }[] };
70
72
  projects: { name: string; root: string }[];
71
73
  agents: { id: string; name: string }[];
72
- onSave: (data: { id: string; project: string; prompt: string; agent?: string; mode?: string; outputs: { name: string; extract: string }[] }) => void;
74
+ onSave: (data: any) => void;
73
75
  onClose: () => void;
74
76
  }) {
75
77
  const [id, setId] = useState(node.id);
@@ -77,6 +79,32 @@ function NodeEditModal({ node, projects, agents, onSave, onClose }: {
77
79
  const [prompt, setPrompt] = useState(node.prompt);
78
80
  const [agent, setAgent] = useState(node.agent || '');
79
81
  const [mode, setMode] = useState(node.mode || 'claude');
82
+ // Plugin fields
83
+ const [pluginId, setPluginId] = useState(node.plugin || '');
84
+ const [pluginAction, setPluginAction] = useState(node.pluginAction || '');
85
+ const [pluginParams, setPluginParams] = useState(JSON.stringify(node.pluginParams || {}, null, 2));
86
+ const [pluginWait, setPluginWait] = useState(node.pluginWait || false);
87
+ const [availablePlugins, setAvailablePlugins] = useState<{ id: string; name: string; icon: string; installed: boolean; actions?: Record<string, any> }[]>([]);
88
+ const [selectedPluginDef, setSelectedPluginDef] = useState<any>(null);
89
+
90
+ // Fetch installed plugins
91
+ useEffect(() => {
92
+ fetch('/api/plugins?installed=true')
93
+ .then(r => r.json())
94
+ .then(d => {
95
+ const plugins = (d.plugins || []).map((p: any) => ({
96
+ id: p.id, name: p.definition?.name || p.id, icon: p.definition?.icon || '🔌',
97
+ installed: true, actions: p.definition?.actions || {},
98
+ params: p.definition?.params || {},
99
+ }));
100
+ setAvailablePlugins(plugins);
101
+ if (pluginId) {
102
+ const sel = plugins.find((p: any) => p.id === pluginId);
103
+ if (sel) setSelectedPluginDef(sel);
104
+ }
105
+ })
106
+ .catch(() => {});
107
+ }, []);
80
108
  const [outputs, setOutputs] = useState(node.outputs);
81
109
 
82
110
  return (
@@ -116,11 +144,12 @@ function NodeEditModal({ node, projects, agents, onSave, onClose }: {
116
144
  <label className="text-[10px] text-gray-400 block mb-1">Mode</label>
117
145
  <select
118
146
  value={mode}
119
- onChange={e => setMode(e.target.value)}
147
+ onChange={e => { setMode(e.target.value); if (e.target.value !== 'plugin') { setPluginId(''); setSelectedPluginDef(null); } }}
120
148
  className="w-full text-xs bg-[#12122a] border border-[#3a3a5a] rounded px-2 py-1.5 text-white"
121
149
  >
122
150
  <option value="claude">Agent</option>
123
151
  <option value="shell">Shell</option>
152
+ <option value="plugin">Plugin</option>
124
153
  </select>
125
154
  </div>
126
155
  {mode !== 'shell' && (
@@ -139,8 +168,71 @@ function NodeEditModal({ node, projects, agents, onSave, onClose }: {
139
168
  </div>
140
169
  )}
141
170
  </div>
171
+ {/* Plugin config */}
172
+ {mode === 'plugin' && (
173
+ <div className="space-y-2 p-2 bg-[#12122a] rounded border border-[#3a3a5a]">
174
+ <div>
175
+ <label className="text-[10px] text-gray-400 block mb-1">Plugin</label>
176
+ <select
177
+ value={pluginId}
178
+ onChange={e => {
179
+ setPluginId(e.target.value);
180
+ const sel = availablePlugins.find(p => p.id === e.target.value);
181
+ setSelectedPluginDef(sel || null);
182
+ setPluginAction('');
183
+ }}
184
+ className="w-full text-xs bg-[#1e1e3a] border border-[#3a3a5a] rounded px-2 py-1.5 text-white"
185
+ >
186
+ <option value="">Select plugin...</option>
187
+ {availablePlugins.map(p => (
188
+ <option key={p.id} value={p.id}>{p.icon} {p.name}</option>
189
+ ))}
190
+ </select>
191
+ {availablePlugins.length === 0 && (
192
+ <div className="text-[9px] text-yellow-400 mt-1">No plugins installed. Install from Settings → Plugins.</div>
193
+ )}
194
+ </div>
195
+ {selectedPluginDef && (
196
+ <>
197
+ <div>
198
+ <label className="text-[10px] text-gray-400 block mb-1">Action</label>
199
+ <select
200
+ value={pluginAction}
201
+ onChange={e => setPluginAction(e.target.value)}
202
+ className="w-full text-xs bg-[#1e1e3a] border border-[#3a3a5a] rounded px-2 py-1.5 text-white"
203
+ >
204
+ <option value="">Default</option>
205
+ {Object.keys(selectedPluginDef.actions || {}).map((a: string) => (
206
+ <option key={a} value={a}>{a}</option>
207
+ ))}
208
+ </select>
209
+ </div>
210
+ <div>
211
+ <label className="text-[10px] text-gray-400 block mb-1">Parameters (JSON)</label>
212
+ <textarea
213
+ value={pluginParams}
214
+ onChange={e => setPluginParams(e.target.value)}
215
+ rows={4}
216
+ className="w-full text-xs bg-[#1e1e3a] border border-[#3a3a5a] rounded px-2 py-1.5 text-white font-mono resize-y"
217
+ placeholder='{ "key": "value" }'
218
+ />
219
+ {selectedPluginDef.params && Object.keys(selectedPluginDef.params).length > 0 && (
220
+ <div className="text-[8px] text-gray-500 mt-0.5">
221
+ Available: {Object.entries(selectedPluginDef.params).map(([k, v]: [string, any]) => `${k}${v.required ? '*' : ''}`).join(', ')}
222
+ </div>
223
+ )}
224
+ </div>
225
+ <div className="flex items-center gap-2">
226
+ <input type="checkbox" id="pluginWait" checked={pluginWait} onChange={e => setPluginWait(e.target.checked)} className="accent-[var(--accent)]" />
227
+ <label htmlFor="pluginWait" className="text-[10px] text-gray-400">Wait for completion (poll)</label>
228
+ </div>
229
+ </>
230
+ )}
231
+ </div>
232
+ )}
233
+
142
234
  <div>
143
- <label className="text-[10px] text-gray-400 block mb-1">Prompt</label>
235
+ <label className="text-[10px] text-gray-400 block mb-1">{mode === 'plugin' ? 'Notes (optional)' : 'Prompt'}</label>
144
236
  <textarea
145
237
  value={prompt}
146
238
  onChange={e => setPrompt(e.target.value)}
@@ -181,7 +273,18 @@ function NodeEditModal({ node, projects, agents, onSave, onClose }: {
181
273
  <div className="px-4 py-3 border-t border-[#3a3a5a] flex gap-2 justify-end">
182
274
  <button onClick={onClose} className="text-xs px-3 py-1 text-gray-400 hover:text-white">Cancel</button>
183
275
  <button
184
- onClick={() => onSave({ id, project, prompt, agent: agent || undefined, mode, outputs: outputs.filter(o => o.name) })}
276
+ onClick={() => {
277
+ let parsedParams: Record<string, any> = {};
278
+ try { parsedParams = JSON.parse(pluginParams || '{}'); } catch {}
279
+ onSave({
280
+ id, project, prompt, agent: agent || undefined, mode,
281
+ plugin: mode === 'plugin' ? pluginId : undefined,
282
+ pluginAction: mode === 'plugin' ? pluginAction || undefined : undefined,
283
+ pluginParams: mode === 'plugin' ? parsedParams : undefined,
284
+ pluginWait: mode === 'plugin' ? pluginWait : undefined,
285
+ outputs: outputs.filter(o => o.name),
286
+ });
287
+ }}
185
288
  className="text-xs px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90"
186
289
  >
187
290
  Save
@@ -201,7 +304,7 @@ export default function PipelineEditor({ onSave, onClose, initialYaml }: {
201
304
  }) {
202
305
  const [nodes, setNodes, onNodesChange] = useNodesState<Node<NodeData>>([]);
203
306
  const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
204
- const [editingNode, setEditingNode] = useState<{ id: string; project: string; prompt: string; agent?: string; mode?: string; outputs: { name: string; extract: string }[] } | null>(null);
307
+ const [editingNode, setEditingNode] = useState<{ id: string; project: string; prompt: string; agent?: string; mode?: string; plugin?: string; pluginAction?: string; pluginParams?: Record<string, any>; pluginWait?: boolean; outputs: { name: string; extract: string }[] } | null>(null);
205
308
  const [workflowName, setWorkflowName] = useState('');
206
309
  const [availableAgents, setAvailableAgents] = useState<{ id: string; name: string }[]>([]);
207
310
  const [workflowDesc, setWorkflowDesc] = useState('');
@@ -240,6 +343,12 @@ export default function PipelineEditor({ onSave, onClose, initialYaml }: {
240
343
  label: id,
241
344
  project: def.project || '',
242
345
  prompt: def.prompt || '',
346
+ agent: def.agent,
347
+ mode: def.mode || (def.plugin ? 'plugin' : undefined),
348
+ plugin: def.plugin,
349
+ pluginAction: def.plugin_action,
350
+ pluginParams: def.params,
351
+ pluginWait: def.wait,
243
352
  outputs: (def.outputs || []).map((o: any) => ({ name: o.name, extract: o.extract || 'result' })),
244
353
  onEdit: (nid: string) => handleEditNode(nid),
245
354
  onDelete: (nid: string) => handleDeleteNode(nid),
@@ -297,6 +406,12 @@ export default function PipelineEditor({ onSave, onClose, initialYaml }: {
297
406
  id: node.id,
298
407
  project: node.data.project,
299
408
  prompt: node.data.prompt,
409
+ agent: (node.data as any).agent,
410
+ mode: (node.data as any).mode,
411
+ plugin: (node.data as any).plugin,
412
+ pluginAction: (node.data as any).pluginAction,
413
+ pluginParams: (node.data as any).pluginParams,
414
+ pluginWait: (node.data as any).pluginWait,
300
415
  outputs: node.data.outputs,
301
416
  });
302
417
  }
@@ -309,7 +424,7 @@ export default function PipelineEditor({ onSave, onClose, initialYaml }: {
309
424
  setEdges(eds => eds.filter(e => e.source !== id && e.target !== id));
310
425
  }, [setNodes, setEdges]);
311
426
 
312
- const handleSaveNode = useCallback((data: { id: string; project: string; prompt: string; agent?: string; mode?: string; outputs: { name: string; extract: string }[] }) => {
427
+ const handleSaveNode = useCallback((data: { id: string; project: string; prompt: string; agent?: string; mode?: string; plugin?: string; pluginAction?: string; pluginParams?: Record<string, any>; pluginWait?: boolean; outputs: { name: string; extract: string }[] }) => {
313
428
  setNodes(nds => nds.map(n => {
314
429
  if (n.id === editingNode?.id) {
315
430
  return {
@@ -322,6 +437,10 @@ export default function PipelineEditor({ onSave, onClose, initialYaml }: {
322
437
  prompt: data.prompt,
323
438
  agent: data.agent,
324
439
  mode: data.mode,
440
+ plugin: data.plugin,
441
+ pluginAction: data.pluginAction,
442
+ pluginParams: data.pluginParams,
443
+ pluginWait: data.pluginWait,
325
444
  outputs: data.outputs,
326
445
  },
327
446
  };
@@ -356,6 +475,13 @@ export default function PipelineEditor({ onSave, onClose, initialYaml }: {
356
475
  prompt: node.data.prompt,
357
476
  };
358
477
  if ((node.data as any).mode === 'shell') nodeDef.mode = 'shell';
478
+ if ((node.data as any).mode === 'plugin') {
479
+ nodeDef.mode = 'plugin';
480
+ nodeDef.plugin = (node.data as any).plugin;
481
+ if ((node.data as any).pluginAction) nodeDef.plugin_action = (node.data as any).pluginAction;
482
+ if ((node.data as any).pluginParams && Object.keys((node.data as any).pluginParams).length > 0) nodeDef.params = (node.data as any).pluginParams;
483
+ if ((node.data as any).pluginWait) nodeDef.wait = true;
484
+ }
359
485
  if ((node.data as any).agent) nodeDef.agent = (node.data as any).agent;
360
486
  if (deps.length > 0) nodeDef.depends_on = deps;
361
487
  if (node.data.outputs.length > 0) nodeDef.outputs = node.data.outputs;