@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.
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 +31 -9
  4. package/app/api/plugins/route.ts +75 -0
  5. package/components/Dashboard.tsx +1 -0
  6. package/components/PipelineEditor.tsx +135 -9
  7. package/components/PluginsPanel.tsx +472 -0
  8. package/components/ProjectDetail.tsx +36 -98
  9. package/components/SessionView.tsx +4 -4
  10. package/components/SettingsModal.tsx +160 -66
  11. package/components/SkillsPanel.tsx +14 -5
  12. package/components/TerminalLauncher.tsx +398 -0
  13. package/components/WebTerminal.tsx +84 -84
  14. package/components/WorkspaceView.tsx +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 +414 -136
  34. package/lib/workspace/presets.ts +5 -3
  35. package/lib/workspace/session-monitor.ts +14 -10
  36. package/lib/workspace/types.ts +3 -1
  37. package/lib/workspace-standalone.ts +38 -21
  38. package/package.json +1 -1
  39. package/qa/.forge/agent-context.json +1 -1
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Plugin Types — defines the plugin system for pipeline node extensions.
3
+ *
4
+ * A plugin is a reusable, configurable capability that can be used as a
5
+ * pipeline node. Plugins are declarative YAML files with config schema,
6
+ * params schema, and actions.
7
+ */
8
+
9
+ /** Plugin action execution type */
10
+ export type PluginActionType = 'http' | 'poll' | 'shell' | 'script';
11
+
12
+ /** Schema field definition for config/params */
13
+ export interface PluginFieldSchema {
14
+ type: 'string' | 'number' | 'boolean' | 'secret' | 'json' | 'select';
15
+ label?: string;
16
+ description?: string;
17
+ required?: boolean;
18
+ default?: any;
19
+ options?: string[]; // for select type
20
+ }
21
+
22
+ /** A single action a plugin can perform */
23
+ export interface PluginAction {
24
+ /** Execution type */
25
+ run: PluginActionType;
26
+
27
+ // HTTP action fields
28
+ method?: string; // GET, POST, PUT, DELETE
29
+ url?: string; // URL template (supports {{config.x}}, {{params.x}})
30
+ headers?: Record<string, string>;
31
+ body?: string; // body template
32
+
33
+ // Poll action fields (extends HTTP)
34
+ interval?: number; // poll interval in seconds
35
+ until?: string; // JSONPath condition: "$.result != null"
36
+ timeout?: number; // max wait in seconds
37
+
38
+ // Shell action fields
39
+ command?: string; // shell command template
40
+ cwd?: string; // working directory
41
+
42
+ // Script action fields
43
+ script?: string; // path to JS/Python script
44
+ runtime?: 'node' | 'python';
45
+
46
+ // Output extraction
47
+ output?: Record<string, string>; // { fieldName: "$.json.path" or "$body" or "$stdout" }
48
+ }
49
+
50
+ /** Plugin definition (loaded from plugin.yaml) */
51
+ export interface PluginDefinition {
52
+ id: string;
53
+ name: string;
54
+ icon: string;
55
+ version: string;
56
+ author?: string;
57
+ description?: string;
58
+
59
+ /** Global config — set once when installing the plugin */
60
+ config: Record<string, PluginFieldSchema>;
61
+
62
+ /** Per-use params — set each time the plugin is used in a pipeline node */
63
+ params: Record<string, PluginFieldSchema>;
64
+
65
+ /** Named actions this plugin can perform */
66
+ actions: Record<string, PluginAction>;
67
+
68
+ /** Default action to run if none specified */
69
+ defaultAction?: string;
70
+ }
71
+
72
+ /** Installed plugin instance (definition + user config values) */
73
+ export interface InstalledPlugin {
74
+ id: string; // instance ID (e.g., 'jenkins-backend')
75
+ definition: PluginDefinition;
76
+ config: Record<string, any>; // user-provided config values
77
+ installedAt: string;
78
+ enabled: boolean;
79
+ instanceName?: string; // display name (e.g., 'Jenkins Backend')
80
+ source?: string; // source plugin ID if this is an instance (e.g., 'jenkins')
81
+ }
82
+
83
+ /** Result of executing a plugin action */
84
+ export interface PluginActionResult {
85
+ ok: boolean;
86
+ output: Record<string, any>;
87
+ rawResponse?: string;
88
+ error?: string;
89
+ duration?: number;
90
+ }
91
+
92
+ /** Plugin source for marketplace */
93
+ export interface PluginSource {
94
+ id: string;
95
+ name: string;
96
+ icon: string;
97
+ version: string;
98
+ author: string;
99
+ description: string;
100
+ source: 'builtin' | 'local' | 'registry';
101
+ installed: boolean;
102
+ configCount: number; // number of config fields — 0 means no config needed
103
+ }
@@ -13,13 +13,18 @@ function getFilePath(): string {
13
13
  return join(dir, 'project-sessions.json');
14
14
  }
15
15
 
16
+ let cache: Record<string, string> | null = null;
17
+
16
18
  function loadAll(): Record<string, string> {
19
+ if (cache !== null) return cache;
17
20
  const fp = getFilePath();
18
- if (!existsSync(fp)) return {};
19
- try { return JSON.parse(readFileSync(fp, 'utf-8')); } catch { return {}; }
21
+ if (!existsSync(fp)) { cache = {}; return cache; }
22
+ try { cache = JSON.parse(readFileSync(fp, 'utf-8')) as Record<string, string>; } catch { cache = {}; }
23
+ return cache;
20
24
  }
21
25
 
22
26
  function saveAll(data: Record<string, string>): void {
27
+ cache = data;
23
28
  writeFileSync(getFilePath(), JSON.stringify(data, null, 2));
24
29
  }
25
30
 
@@ -40,10 +40,14 @@ export function buildResumeFlag(fixedSessionId: string | null, hasExistingSessio
40
40
  return '';
41
41
  }
42
42
 
43
- /** Get --mcp-config flag for claude-code. Triggers server-side mcp.json creation. */
43
+ const _mcpReady = new Set<string>();
44
+
45
+ /** Get --mcp-config flag for claude-code. Triggers server-side mcp.json creation (once per projectPath). */
44
46
  export async function getMcpFlag(projectPath: string): Promise<string> {
45
- // Ensure .forge/mcp.json exists (server generates it)
46
- await fetch(`/api/project-sessions?projectPath=${encodeURIComponent(projectPath)}`).catch(() => {});
47
+ if (!_mcpReady.has(projectPath)) {
48
+ await fetch(`/api/project-sessions?projectPath=${encodeURIComponent(projectPath)}`).catch(() => {});
49
+ _mcpReady.add(projectPath);
50
+ }
47
51
  return ` --mcp-config "${projectPath}/.forge/mcp.json"`;
48
52
  }
49
53
 
@@ -64,24 +64,6 @@ function saveTerminalState(data: unknown): void {
64
64
  }
65
65
  }
66
66
 
67
- /** Get session names that have custom labels (user-renamed) */
68
- function getRenamedSessions(): Set<string> {
69
- try {
70
- const state = loadTerminalState() as any;
71
- if (!state?.sessionLabels) return new Set();
72
- // sessionLabels: { "mw-xxx": "My Custom Name", ... }
73
- // A session is "renamed" if its label differs from default patterns
74
- const renamed = new Set<string>();
75
- for (const [sessionName, label] of Object.entries(state.sessionLabels)) {
76
- if (label && typeof label === 'string') {
77
- renamed.add(sessionName);
78
- }
79
- }
80
- return renamed;
81
- } catch {
82
- return new Set();
83
- }
84
- }
85
67
 
86
68
  // ─── tmux helpers ──────────────────────────────────────────────
87
69
 
@@ -179,11 +161,7 @@ function createTmuxSession(cols: number, rows: number): string {
179
161
  throw e;
180
162
  }
181
163
  }
182
- // Enable mouse scrolling and set large scrollback buffer
183
- try {
184
- execSync(`${TMUX} set-option -t ${name} mouse on 2>/dev/null`);
185
- execSync(`${TMUX} set-option -t ${name} history-limit 50000 2>/dev/null`);
186
- } catch {}
164
+ // Mouse and scrollback are set in attachToTmux (always called after create)
187
165
  return name;
188
166
  }
189
167
 
@@ -211,8 +189,6 @@ function tmuxSessionExists(name: string): boolean {
211
189
  /** Map from tmux session name → Set of WebSocket clients attached to it */
212
190
  const sessionClients = new Map<string, Set<WebSocket>>();
213
191
 
214
- /** Map from WebSocket → timestamp when the session was *created* (not attached) by this client */
215
- const createdAt = new Map<WebSocket, { session: string; time: number }>();
216
192
 
217
193
  function trackAttach(ws: WebSocket, sessionName: string) {
218
194
  if (!sessionClients.has(sessionName)) sessionClients.set(sessionName, new Set());
@@ -232,9 +208,11 @@ function cleanupOrphanedSessions() {
232
208
  const sessions = listTmuxSessions();
233
209
  for (const s of sessions) {
234
210
  if (s.attached) continue;
211
+ if (s.name.startsWith(`${SESSION_PREFIX}forge-`)) continue; // workspace agent session — managed by orchestrator
235
212
  if (knownSessions.has(s.name)) continue; // saved in terminal state — preserve
236
213
  const clients = sessionClients.get(s.name)?.size ?? 0;
237
214
  if (clients === 0) {
215
+ console.log(`[terminal] Orphan cleanup: killing "${s.name}"`);
238
216
  killTmuxSession(s.name);
239
217
  }
240
218
  }
@@ -362,14 +340,10 @@ wss.on('connection', (ws: WebSocket) => {
362
340
  cwd: homedir(),
363
341
  env: { ...process.env, TERM: 'xterm-256color' },
364
342
  });
365
- try {
366
- execSync(`${TMUX} set-option -t ${name} mouse on 2>/dev/null`);
367
- execSync(`${TMUX} set-option -t ${name} history-limit 50000 2>/dev/null`);
368
- } catch {}
343
+ // Mouse and scrollback are set in attachToTmux (always called after create)
369
344
  } else {
370
345
  name = createTmuxSession(cols, rows);
371
346
  }
372
- createdAt.set(ws, { session: name, time: Date.now() });
373
347
  attachToTmux(name, cols, rows);
374
348
  } catch (e: unknown) {
375
349
  const errMsg = e instanceof Error ? e.message : 'unknown error';
@@ -439,11 +413,9 @@ wss.on('connection', (ws: WebSocket) => {
439
413
  }
440
414
 
441
415
  // Untrack this client
442
- const disconnectedSession = sessionName;
443
416
  if (sessionName) trackDetach(ws, sessionName);
444
- createdAt.delete(ws);
445
417
 
446
- // Orphan cleanup is handled by the periodic cleanupOrphanedSessions() (every 30s)
447
- // which checks sessionClients and getRenamedSessions() from terminal-state.json
418
+ // Orphan cleanup is handled by the periodic cleanupOrphanedSessions() (every 60s)
419
+ // which checks sessionClients and getKnownSessions() from terminal-state.json
448
420
  });
449
421
  });
@@ -423,7 +423,7 @@ export class AgentWorker extends EventEmitter {
423
423
  let prompt: string;
424
424
  switch (reason.type) {
425
425
  case 'bus_message':
426
- prompt = `You received new messages from other agents:\n${reason.messages.map(m => m.content).join('\n')}\n\nReact accordinglyupdate your work, respond, or take action as needed.`;
426
+ prompt = `You received new messages from other agents:\n${reason.messages.map(m => m.content).join('\n')}\n\nAct on these messages. Be concise don't repeat the message content back. Use tools to inspect files or git history if you need more details.`;
427
427
  break;
428
428
  case 'upstream_changed':
429
429
  prompt = `Your upstream dependency (agent ${reason.agentId}) has produced new output: ${reason.files.join(', ')}.\n\nRe-analyze and update your work based on the new upstream output.`;