@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
@@ -0,0 +1,66 @@
1
+ id: http
2
+ name: HTTP Request
3
+ icon: "🌐"
4
+ version: "1.1.0"
5
+ author: forge
6
+ description: |
7
+ Make HTTP requests to any API endpoint.
8
+ Create instances for common API calls:
9
+ - "Health Check" → default_url: https://api.example.com/health, default_method: GET
10
+ - "Deploy Hook" → default_url: https://deploy.example.com/trigger, default_method: POST
11
+ - "Notify Slack" → default_url: https://hooks.slack.com/xxx, default_method: POST
12
+
13
+ config:
14
+ default_url:
15
+ type: string
16
+ label: Default URL
17
+ description: "Pre-configured URL"
18
+ default_method:
19
+ type: select
20
+ label: Default Method
21
+ options: ["GET", "POST", "PUT", "DELETE", "PATCH"]
22
+ default: "GET"
23
+ default_headers:
24
+ type: json
25
+ label: Default Headers
26
+ description: 'e.g., {"Authorization": "Bearer xxx"}'
27
+ default_body:
28
+ type: json
29
+ label: Default Body
30
+ description: "Default request body (for POST/PUT)"
31
+ expected_status:
32
+ type: string
33
+ label: Expected Status Code
34
+ description: "e.g., 200, 201 — used to validate response"
35
+ description:
36
+ type: string
37
+ label: Description
38
+ description: "What this request does (shown to agents)"
39
+
40
+ params:
41
+ url:
42
+ type: string
43
+ label: URL
44
+ description: "Overrides default_url if set"
45
+ method:
46
+ type: select
47
+ label: Method
48
+ options: ["GET", "POST", "PUT", "DELETE", "PATCH"]
49
+ headers:
50
+ type: json
51
+ label: Headers
52
+ body:
53
+ type: json
54
+ label: Body
55
+
56
+ defaultAction: request
57
+
58
+ actions:
59
+ request:
60
+ run: http
61
+ method: "{{params.method}}"
62
+ url: "{{params.url}}"
63
+ headers: "{{params.headers}}"
64
+ body: "{{params.body}}"
65
+ output:
66
+ result: "$body"
@@ -0,0 +1,92 @@
1
+ id: jenkins
2
+ name: Jenkins CI
3
+ icon: "🔧"
4
+ version: "1.1.0"
5
+ author: forge
6
+ description: |
7
+ Trigger and monitor Jenkins CI/CD jobs.
8
+ Create instances per job or per Jenkins server:
9
+ - "Build Backend" → default_job: backend-build
10
+ - "E2E Tests" → default_job: e2e-test-suite
11
+ - "Security Scan" → default_job: sonar-scan
12
+
13
+ config:
14
+ url:
15
+ type: string
16
+ label: Jenkins URL
17
+ required: true
18
+ description: "e.g., https://jenkins.example.com"
19
+ user:
20
+ type: string
21
+ label: Username
22
+ required: true
23
+ token:
24
+ type: secret
25
+ label: API Token
26
+ required: true
27
+ default_job:
28
+ type: string
29
+ label: Default Job Name
30
+ description: "Pre-configured job — no need to pass job param each time"
31
+
32
+ params:
33
+ job:
34
+ type: string
35
+ label: Job Name
36
+ description: "Overrides default_job if set"
37
+ parameters:
38
+ type: json
39
+ label: Build Parameters
40
+ description: "JSON object of build parameters"
41
+ build_number:
42
+ type: string
43
+ label: Build Number
44
+ description: "Specific build number (for log/status). Defaults to lastBuild"
45
+
46
+ defaultAction: trigger
47
+
48
+ actions:
49
+ trigger:
50
+ run: http
51
+ method: POST
52
+ url: "{{config.url}}/job/{{params.job}}/buildWithParameters"
53
+ headers:
54
+ Authorization: "Basic {{config.user}}:{{config.token}}"
55
+ body: "{{params.parameters | json}}"
56
+ output:
57
+ queue_url: "$.headers.location"
58
+
59
+ wait:
60
+ run: poll
61
+ url: "{{config.url}}/job/{{params.job}}/lastBuild/api/json"
62
+ headers:
63
+ Authorization: "Basic {{config.user}}:{{config.token}}"
64
+ interval: 30
65
+ until: "$.result != null"
66
+ timeout: 3600
67
+ output:
68
+ result: "$.result"
69
+ duration: "$.duration"
70
+ url: "$.url"
71
+ build_number: "$.number"
72
+
73
+ get_log:
74
+ run: http
75
+ method: GET
76
+ url: "{{config.url}}/job/{{params.job}}/{{params.build_number}}/consoleText"
77
+ headers:
78
+ Authorization: "Basic {{config.user}}:{{config.token}}"
79
+ output:
80
+ log: "$body"
81
+
82
+ status:
83
+ run: http
84
+ method: GET
85
+ url: "{{config.url}}/job/{{params.job}}/lastBuild/api/json"
86
+ headers:
87
+ Authorization: "Basic {{config.user}}:{{config.token}}"
88
+ output:
89
+ result: "$.result"
90
+ building: "$.building"
91
+ number: "$.number"
92
+ timestamp: "$.timestamp"
@@ -0,0 +1,85 @@
1
+ id: llm-vision
2
+ name: LLM Vision
3
+ icon: "👁"
4
+ version: "0.1.0"
5
+ author: forge
6
+ description: |
7
+ Send images to vision-capable LLMs for evaluation.
8
+ Supports OpenAI (GPT-4o), Anthropic (Claude), Google (Gemini).
9
+ Create instances per model for multi-model evaluation.
10
+
11
+ config:
12
+ provider:
13
+ type: select
14
+ label: Provider
15
+ options: ["openai", "anthropic", "google"]
16
+ required: true
17
+ api_key:
18
+ type: secret
19
+ label: API Key
20
+ required: true
21
+ model:
22
+ type: string
23
+ label: Model
24
+ description: "e.g., gpt-4o, claude-sonnet-4-6, gemini-2.0-flash"
25
+ default: "gpt-4o"
26
+ default_prompt:
27
+ type: string
28
+ label: Default Evaluation Prompt
29
+ description: "Default prompt if none provided per-call"
30
+
31
+ params:
32
+ image:
33
+ type: string
34
+ label: Image file path
35
+ required: true
36
+ description: "Local path to image file"
37
+ prompt:
38
+ type: string
39
+ label: Evaluation prompt
40
+ description: "What to evaluate — overrides default_prompt"
41
+ max_tokens:
42
+ type: number
43
+ label: Max tokens
44
+ default: 1024
45
+
46
+ defaultAction: evaluate
47
+
48
+ actions:
49
+ evaluate:
50
+ run: shell
51
+ command: |
52
+ PROVIDER="{{config.provider}}"
53
+ IMAGE="{{params.image}}"
54
+ PROMPT="{{params.prompt}}"
55
+ MODEL="{{config.model}}"
56
+ MAX_TOKENS="{{params.max_tokens}}"
57
+ [ -z "$MAX_TOKENS" ] && MAX_TOKENS=1024
58
+ IMG_BASE64=$(base64 < "$IMAGE" | tr -d '\n')
59
+ MIME="image/png"
60
+ case "$IMAGE" in *.jpg|*.jpeg) MIME="image/jpeg";; *.webp) MIME="image/webp";; esac
61
+
62
+ if [ "$PROVIDER" = "openai" ]; then
63
+ curl -s https://api.openai.com/v1/chat/completions \
64
+ -H "Authorization: Bearer {{config.api_key}}" \
65
+ -H "Content-Type: application/json" \
66
+ -d "{\"model\":\"$MODEL\",\"max_tokens\":$MAX_TOKENS,\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"$PROMPT\"},{\"type\":\"image_url\",\"image_url\":{\"url\":\"data:$MIME;base64,$IMG_BASE64\"}}]}]}" \
67
+ | python3 -c "import sys,json; print(json.load(sys.stdin)['choices'][0]['message']['content'])" 2>&1
68
+
69
+ elif [ "$PROVIDER" = "anthropic" ]; then
70
+ curl -s https://api.anthropic.com/v1/messages \
71
+ -H "x-api-key: {{config.api_key}}" \
72
+ -H "anthropic-version: 2023-06-01" \
73
+ -H "Content-Type: application/json" \
74
+ -d "{\"model\":\"$MODEL\",\"max_tokens\":$MAX_TOKENS,\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"$PROMPT\"},{\"type\":\"image\",\"source\":{\"type\":\"base64\",\"media_type\":\"$MIME\",\"data\":\"$IMG_BASE64\"}}]}]}" \
75
+ | python3 -c "import sys,json; r=json.load(sys.stdin); print(r['content'][0]['text'])" 2>&1
76
+
77
+ elif [ "$PROVIDER" = "google" ]; then
78
+ curl -s "https://generativelanguage.googleapis.com/v1beta/models/$MODEL:generateContent?key={{config.api_key}}" \
79
+ -H "Content-Type: application/json" \
80
+ -d "{\"contents\":[{\"parts\":[{\"text\":\"$PROMPT\"},{\"inline_data\":{\"mime_type\":\"$MIME\",\"data\":\"$IMG_BASE64\"}}]}]}" \
81
+ | python3 -c "import sys,json; r=json.load(sys.stdin); print(r['candidates'][0]['content']['parts'][0]['text'])" 2>&1
82
+ fi
83
+ timeout: 120
84
+ output:
85
+ result: "$stdout"
@@ -0,0 +1,111 @@
1
+ id: playwright
2
+ name: Playwright
3
+ icon: "🎭"
4
+ version: "0.3.0"
5
+ author: forge
6
+ description: |
7
+ Browser testing and screenshots via Playwright.
8
+ Create one instance per environment (local, docker, or remote).
9
+ Forge does not install Playwright. Set up your own environment:
10
+ - Local: https://playwright.dev/docs/intro
11
+ - Docker: docker pull mcr.microsoft.com/playwright:v1.52.0-noble
12
+ - Remote: https://www.browserless.io/docs
13
+
14
+ config:
15
+ mode:
16
+ type: select
17
+ label: Execution Mode
18
+ options: ["local", "docker", "remote"]
19
+ default: "local"
20
+ required: true
21
+ base_url:
22
+ type: string
23
+ label: Application URL
24
+ description: "URL of the app being tested"
25
+ default: "http://localhost:3000"
26
+ required: true
27
+ server_url:
28
+ type: string
29
+ label: Remote WebSocket URL
30
+ description: "For remote mode (e.g., ws://playwright:3000)"
31
+ docker_image:
32
+ type: string
33
+ label: Docker Image
34
+ default: "mcr.microsoft.com/playwright:v1.52.0-noble"
35
+ project_dir:
36
+ type: string
37
+ label: Project Directory
38
+ description: "For docker mode — local path to mount as /app"
39
+ headless:
40
+ type: select
41
+ label: Headless Mode
42
+ options: ["true", "false"]
43
+ default: "true"
44
+ description: "false = show browser window (local mode only)"
45
+
46
+ params:
47
+ test_file:
48
+ type: string
49
+ label: Test file or directory
50
+ url:
51
+ type: string
52
+ label: URL to screenshot
53
+ args:
54
+ type: string
55
+ label: Extra CLI arguments
56
+
57
+ defaultAction: test
58
+
59
+ actions:
60
+ # Local mode
61
+ local_test:
62
+ run: shell
63
+ command: "BASE_URL={{config.base_url}} npx playwright test {{params.test_file}} {{params.args}} --reporter=line $([ '{{config.headless}}' = 'false' ] && echo '--headed') 2>&1"
64
+ timeout: 600
65
+ output:
66
+ result: "$stdout"
67
+
68
+ local_screenshot:
69
+ run: shell
70
+ command: "npx playwright screenshot --wait-for-timeout=3000 {{config.base_url}} /tmp/forge-screenshot-$(date +%s).png 2>&1"
71
+ timeout: 60
72
+ output:
73
+ result: "$stdout"
74
+
75
+ # Docker mode
76
+ docker_test:
77
+ run: shell
78
+ command: "docker run --rm -e BASE_URL={{config.base_url}} -v {{config.project_dir}}:/app -w /app {{config.docker_image}} npx playwright test {{params.test_file}} {{params.args}} --reporter=line 2>&1"
79
+ timeout: 600
80
+ output:
81
+ result: "$stdout"
82
+
83
+ docker_screenshot:
84
+ run: shell
85
+ command: "docker run --rm {{config.docker_image}} npx playwright screenshot --wait-for-timeout=3000 {{config.base_url}} /tmp/screenshot.png 2>&1"
86
+ timeout: 60
87
+ output:
88
+ result: "$stdout"
89
+
90
+ # Remote mode
91
+ remote_test:
92
+ run: shell
93
+ command: "PW_TEST_CONNECT_WS_ENDPOINT={{config.server_url}} BASE_URL={{config.base_url}} npx playwright test {{params.test_file}} {{params.args}} --reporter=line 2>&1"
94
+ timeout: 600
95
+ output:
96
+ result: "$stdout"
97
+
98
+ remote_screenshot:
99
+ run: shell
100
+ command: "PW_TEST_CONNECT_WS_ENDPOINT={{config.server_url}} npx playwright screenshot --wait-for-timeout=3000 {{config.base_url}} /tmp/forge-screenshot-$(date +%s).png 2>&1"
101
+ timeout: 60
102
+ output:
103
+ result: "$stdout"
104
+
105
+ # Common (all modes)
106
+ check_url:
107
+ run: shell
108
+ command: "curl -sf -o /dev/null -w '%{http_code}' {{config.base_url}}"
109
+ timeout: 10
110
+ output:
111
+ status_code: "$stdout"
@@ -0,0 +1,60 @@
1
+ id: shell-command
2
+ name: Shell Command
3
+ icon: "💻"
4
+ version: "1.1.0"
5
+ author: forge
6
+ description: |
7
+ Run shell commands with structured output.
8
+ Create instances for common operations:
9
+ - "Run Tests" → default_command: npm test
10
+ - "Build" → default_command: npm run build
11
+ - "Deploy Staging" → default_command: ssh staging 'cd /app && git pull && pm2 restart'
12
+
13
+ config:
14
+ default_command:
15
+ type: string
16
+ label: Default Command
17
+ description: "Pre-configured command to run if none provided"
18
+ default_cwd:
19
+ type: string
20
+ label: Working Directory
21
+ description: "Default working directory"
22
+ default_timeout:
23
+ type: number
24
+ label: Timeout (seconds)
25
+ default: 300
26
+ expected_output:
27
+ type: string
28
+ label: Expected Output Pattern
29
+ description: "Regex pattern to validate output (e.g., 'SUCCESS|PASSED')"
30
+ description:
31
+ type: string
32
+ label: Description
33
+ description: "What this command does (shown to agents)"
34
+
35
+ params:
36
+ command:
37
+ type: string
38
+ label: Command
39
+ description: "Overrides default_command if set"
40
+ cwd:
41
+ type: string
42
+ label: Working Directory
43
+ timeout:
44
+ type: number
45
+ label: Timeout (seconds)
46
+ args:
47
+ type: string
48
+ label: Extra Arguments
49
+ description: "Appended to the command"
50
+
51
+ defaultAction: run
52
+
53
+ actions:
54
+ run:
55
+ run: shell
56
+ command: "{{params.command}} {{params.args}}"
57
+ cwd: "{{params.cwd}}"
58
+ timeout: 300
59
+ output:
60
+ stdout: "$stdout"
@@ -0,0 +1,48 @@
1
+ id: slack
2
+ name: Slack
3
+ icon: "📢"
4
+ version: "1.1.0"
5
+ author: forge
6
+ description: |
7
+ Send messages to Slack channels via Incoming Webhook.
8
+ Create instances per channel or workspace:
9
+ - "Dev Alerts" → webhook for #dev-alerts
10
+ - "Deploy Notify" → webhook for #deployments
11
+
12
+ config:
13
+ webhook_url:
14
+ type: secret
15
+ label: Webhook URL
16
+ required: true
17
+ description: "Slack Incoming Webhook URL"
18
+ default_channel:
19
+ type: string
20
+ label: Default Channel
21
+ description: "Override channel (e.g., #dev-alerts)"
22
+ description:
23
+ type: string
24
+ label: Description
25
+ description: "What this webhook is for (shown to agents)"
26
+
27
+ params:
28
+ text:
29
+ type: string
30
+ label: Message
31
+ required: true
32
+ channel:
33
+ type: string
34
+ label: Channel
35
+ description: "Overrides default_channel"
36
+
37
+ defaultAction: send
38
+
39
+ actions:
40
+ send:
41
+ run: http
42
+ method: POST
43
+ url: "{{config.webhook_url}}"
44
+ headers:
45
+ Content-Type: application/json
46
+ body: '{"text":"{{params.text}}","channel":"{{params.channel}}"}'
47
+ output:
48
+ status: "$body"
@@ -0,0 +1,56 @@
1
+ id: webhook
2
+ name: Webhook
3
+ icon: "🪝"
4
+ version: "1.1.0"
5
+ author: forge
6
+ description: |
7
+ Send webhook notifications to any URL.
8
+ Create instances per endpoint:
9
+ - "Deploy Hook" → url: https://deploy.example.com/hook
10
+ - "CI Trigger" → url: https://ci.example.com/trigger
11
+
12
+ config:
13
+ url:
14
+ type: string
15
+ label: Webhook URL
16
+ required: true
17
+ description: "Target URL to send webhooks to"
18
+ secret:
19
+ type: secret
20
+ label: Secret
21
+ description: "Shared secret for signature verification (optional)"
22
+ default_event:
23
+ type: string
24
+ label: Default Event Type
25
+ default: "forge.notification"
26
+ description: "Sent as X-Forge-Event header"
27
+ default_method:
28
+ type: select
29
+ label: HTTP Method
30
+ options: ["POST", "PUT", "PATCH"]
31
+ default: "POST"
32
+
33
+ params:
34
+ payload:
35
+ type: json
36
+ label: Payload
37
+ description: "JSON body to send"
38
+ event:
39
+ type: string
40
+ label: Event Type
41
+ description: "Overrides default_event"
42
+
43
+ defaultAction: send
44
+
45
+ actions:
46
+ send:
47
+ run: http
48
+ method: "{{params.method}}"
49
+ url: "{{config.url}}"
50
+ headers:
51
+ Content-Type: application/json
52
+ X-Forge-Event: "{{params.event}}"
53
+ X-Forge-Secret: "{{config.secret}}"
54
+ body: "{{params | json}}"
55
+ output:
56
+ status: "$body"
@@ -288,6 +288,119 @@ function createForgeMcpServer(sessionId: string): McpServer {
288
288
  }
289
289
  );
290
290
 
291
+ // ── trigger_pipeline ──────────────────────────
292
+ server.tool(
293
+ 'trigger_pipeline',
294
+ 'Trigger a pipeline workflow. Lists available workflows if called without arguments.',
295
+ {
296
+ workflow: z.string().optional().describe('Workflow name to trigger. Omit to list available workflows.'),
297
+ input: z.record(z.string(), z.string()).optional().describe('Input variables for the pipeline (e.g., { project: "my-app" })'),
298
+ },
299
+ async (params) => {
300
+ try {
301
+ if (!params.workflow) {
302
+ // List available workflows
303
+ const { listWorkflows } = await import('./pipeline');
304
+ const workflows = listWorkflows();
305
+ if (workflows.length === 0) {
306
+ return { content: [{ type: 'text', text: 'No workflows found. Create .yaml files in ~/.forge/flows/' }] };
307
+ }
308
+ const list = workflows.map((w: any) => `• ${w.name}${w.description ? ' — ' + w.description : ''} (${Object.keys(w.nodes || {}).length} nodes)`).join('\n');
309
+ return { content: [{ type: 'text', text: `Available workflows:\n${list}` }] };
310
+ }
311
+
312
+ const { startPipeline } = await import('./pipeline');
313
+ const pipeline = startPipeline(params.workflow, (params.input || {}) as Record<string, string>);
314
+ return { content: [{ type: 'text', text: `Pipeline started: ${pipeline.id} (workflow: ${params.workflow}, status: ${pipeline.status})` }] };
315
+ } catch (err: any) {
316
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
317
+ }
318
+ }
319
+ );
320
+
321
+ // ── run_plugin ──────────────────────────────────
322
+ server.tool(
323
+ 'run_plugin',
324
+ 'Run an installed plugin action directly. Lists installed plugins if called without arguments.',
325
+ {
326
+ plugin: z.string().optional().describe('Plugin ID (e.g., "jenkins", "shell-command", "docker"). Omit to list installed plugins.'),
327
+ action: z.string().optional().describe('Action name (e.g., "trigger", "run", "build"). Uses default action if omitted.'),
328
+ params: z.record(z.string(), z.string()).optional().describe('Parameters for the action. Keys matching plugin config fields will override config values.'),
329
+ wait: z.boolean().optional().describe('Auto-run "wait" action after main action (for async operations like Jenkins builds)'),
330
+ },
331
+ async (params) => {
332
+ try {
333
+ const { listInstalledPlugins, getInstalledPlugin } = await import('./plugins/registry');
334
+
335
+ if (!params.plugin) {
336
+ const installed = listInstalledPlugins();
337
+ if (installed.length === 0) {
338
+ return { content: [{ type: 'text', text: 'No plugins installed. Install from the Plugins page.' }] };
339
+ }
340
+ const list = installed.map((p: any) => {
341
+ const actions = Object.keys(p.definition.actions).join(', ');
342
+ return `• ${p.definition.icon} ${p.id} — ${p.definition.description || p.definition.name}\n actions: ${actions}`;
343
+ }).join('\n');
344
+ return { content: [{ type: 'text', text: `Installed plugins:\n${list}` }] };
345
+ }
346
+
347
+ const inst = getInstalledPlugin(params.plugin);
348
+ if (!inst) return { content: [{ type: 'text', text: `Plugin "${params.plugin}" not installed.` }] };
349
+ if (!inst.enabled) return { content: [{ type: 'text', text: `Plugin "${params.plugin}" is disabled.` }] };
350
+
351
+ const { executePluginWithWait } = await import('./plugins/executor');
352
+ const actionName = params.action || inst.definition.defaultAction || Object.keys(inst.definition.actions)[0];
353
+
354
+ if (!inst.definition.actions[actionName]) {
355
+ const available = Object.keys(inst.definition.actions).join(', ');
356
+ return { content: [{ type: 'text', text: `Action "${actionName}" not found. Available: ${available}` }] };
357
+ }
358
+
359
+ const result = await executePluginWithWait(inst, actionName, params.params || {}, params.wait || false);
360
+
361
+ const output = JSON.stringify(result.output, null, 2);
362
+ const status = result.ok ? 'OK' : 'FAILED';
363
+ const duration = result.duration ? ` (${result.duration}ms)` : '';
364
+ const error = result.error ? `\nError: ${result.error}` : '';
365
+
366
+ return { content: [{ type: 'text', text: `${status}${duration}${error}\n${output}` }] };
367
+ } catch (err: any) {
368
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
369
+ }
370
+ }
371
+ );
372
+
373
+ // ── get_pipeline_status ────────────────────────
374
+ server.tool(
375
+ 'get_pipeline_status',
376
+ 'Check the status and results of a running or completed pipeline.',
377
+ {
378
+ pipeline_id: z.string().describe('Pipeline ID to check'),
379
+ },
380
+ async (params) => {
381
+ try {
382
+ const { getPipeline } = await import('./pipeline');
383
+ const pipeline = getPipeline(params.pipeline_id);
384
+ if (!pipeline) return { content: [{ type: 'text', text: `Pipeline "${params.pipeline_id}" not found.` }] };
385
+
386
+ const nodes = Object.entries(pipeline.nodes).map(([id, n]: [string, any]) => {
387
+ let line = ` ${id}: ${n.status}`;
388
+ if (n.error) line += ` — ${n.error}`;
389
+ if (n.outputs && Object.keys(n.outputs).length > 0) {
390
+ for (const [k, v] of Object.entries(n.outputs)) {
391
+ line += `\n ${k}: ${String(v).slice(0, 200)}`;
392
+ }
393
+ }
394
+ return line;
395
+ }).join('\n');
396
+
397
+ return { content: [{ type: 'text', text: `Pipeline ${pipeline.id} [${pipeline.status}] (${pipeline.workflowName})\n${nodes}` }] };
398
+ } catch (err: any) {
399
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
400
+ }
401
+ }
402
+ );
403
+
291
404
  return server;
292
405
  }
293
406
 
@@ -325,8 +438,9 @@ export async function startMcpServer(port: number): Promise<void> {
325
438
  // Each session gets its own MCP server with context
326
439
  const server = createForgeMcpServer(sessionId);
327
440
  await server.connect(transport);
328
- const agentLabel = workspaceId ? (getOrch(workspaceId)?.getSnapshot()?.agents?.find((a: any) => a.id === agentId)?.label || agentId) : 'unknown';
329
- console.log(`[forge-mcp] Client connected: ${agentLabel} (ws=${workspaceId.slice(0, 8)}, session=${sessionId})`);
441
+ let agentLabel = 'unknown';
442
+ try { agentLabel = workspaceId ? (getOrch(workspaceId)?.getSnapshot()?.agents?.find((a: any) => a.id === agentId)?.label || agentId) : 'unknown'; } catch {}
443
+ console.log(`[forge-mcp] Client connected: ${agentLabel} (ws=${workspaceId?.slice(0, 8) || '?'}, session=${sessionId})`);
330
444
  return;
331
445
  }
332
446