@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
@@ -71,8 +71,9 @@ Rules:
71
71
  - Read docs/qa/ to see what was already tested. Skip tests that already passed for unchanged features.
72
72
  - Test plans are versioned: docs/qa/test-plan-v1.1.md
73
73
  - Test reports are versioned: docs/qa/test-report-v1.1.md
74
- - Test code goes in tests/ directory.
74
+ - Test code goes in tests/e2e/ directory (Playwright tests).
75
75
  - Do NOT fix bugs — only report them clearly in your test report.
76
+ - Use the run_plugin MCP tool to execute Playwright tests and take screenshots. Prefer run_plugin over raw bash commands.
76
77
 
77
78
  Communication rules:
78
79
  - Only send [SEND:...] messages for BLOCKING issues that prevent the product from working.
@@ -83,11 +84,12 @@ Communication rules:
83
84
  agentId: 'claude',
84
85
  dependsOn: [],
85
86
  workDir: './',
87
+ plugins: ['playwright'],
86
88
  outputs: ['tests/', 'docs/qa/'],
87
89
  steps: [
88
90
  { id: 'plan', label: 'Test Planning', prompt: 'Read the latest PRD in docs/prd/ and existing test plans in docs/qa/. Write a NEW test plan file covering only the NEW/CHANGED features.' },
89
- { id: 'write-tests', label: 'Write Tests', prompt: 'Implement test cases in tests/ directory based on your test plan. Add new tests, do not rewrite existing passing tests. Do NOT send any messages to other agents in this step.' },
90
- { id: 'execute', label: 'Execute Tests', prompt: 'Run all tests. Write a test report documenting results. Only if you find BLOCKING bugs (app crashes, data loss, security holes), send ONE consolidated message: [SEND:Engineer:fix_request] followed by a brief list of blocking issues. Minor issues go in the report only.' },
91
+ { id: 'write-tests', label: 'Write Tests', prompt: 'Write Playwright e2e test cases in tests/e2e/ directory based on your test plan. If playwright.config.ts does not exist, create one with testDir: "./tests/e2e" and baseURL from the project. Add new tests, do not rewrite existing passing tests. Do NOT send any messages to other agents in this step.' },
92
+ { id: 'execute', label: 'Execute Tests', prompt: 'Run tests using run_plugin (plugin: "playwright", action: "test"). If run_plugin is not available, fall back to: npx playwright test tests/e2e/ --reporter=line. Write a test report documenting results. Only if you find BLOCKING bugs (app crashes, data loss, security holes), send ONE consolidated message: [SEND:Engineer:fix_request] followed by a brief list of blocking issues. Minor issues go in the report only.' },
91
93
  ],
92
94
  },
93
95
 
@@ -29,7 +29,7 @@ export interface SessionMonitorEvent {
29
29
 
30
30
  const POLL_INTERVAL = 3000; // check every 3s
31
31
  const IDLE_THRESHOLD = 3540000; // 59min of no file change → check for result entry
32
- const STABLE_THRESHOLD = 3600000; // 60min of no change → force done (fallback if hook missed)
32
+ const STABLE_THRESHOLD = 3600000; // 60min of no change → force done (fallback if hook missed) [59min check + 1min grace]
33
33
 
34
34
  export class SessionFileMonitor extends EventEmitter {
35
35
  private timers = new Map<string, NodeJS.Timeout>();
@@ -155,14 +155,14 @@ export class SessionFileMonitor extends EventEmitter {
155
155
  if (prevState === 'running') {
156
156
  if (stableFor >= IDLE_THRESHOLD) {
157
157
  // Check if session file has a 'result' entry at the end
158
- const resultInfo = this.checkForResult(filePath);
158
+ const resultInfo = this.checkForResult(filePath, size);
159
159
  if (resultInfo) {
160
160
  this.setState(agentId, 'done', filePath, resultInfo);
161
161
  return;
162
162
  }
163
163
  }
164
164
  if (stableFor >= STABLE_THRESHOLD) {
165
- // Force done after 30s even without result entry
165
+ // Force done after 60min even without result entry (ultimate fallback)
166
166
  this.setState(agentId, 'done', filePath, 'stable timeout');
167
167
  return;
168
168
  }
@@ -179,15 +179,19 @@ export class SessionFileMonitor extends EventEmitter {
179
179
  * Check the last few lines of the session file for a 'result' type entry.
180
180
  * Claude Code writes this when a turn completes.
181
181
  */
182
- private checkForResult(filePath: string): string | null {
182
+ private checkForResult(filePath: string, fileSize?: number): string | null {
183
183
  try {
184
- // Read last 4KB of the file
185
- const stat = statSync(filePath);
186
- const readSize = Math.min(4096, stat.size);
187
- const fd = require('node:fs').openSync(filePath, 'r');
184
+ // Read last 4KB of the file — fileSize passed from caller to avoid second statSync
185
+ const { openSync, readSync, closeSync } = require('node:fs') as typeof import('node:fs');
186
+ const size = fileSize ?? statSync(filePath).size;
187
+ const readSize = Math.min(4096, size);
188
+ const fd = openSync(filePath, 'r');
188
189
  const buf = Buffer.alloc(readSize);
189
- require('node:fs').readSync(fd, buf, 0, readSize, Math.max(0, stat.size - readSize));
190
- require('node:fs').closeSync(fd);
190
+ try {
191
+ readSync(fd, buf, 0, readSize, Math.max(0, size - readSize));
192
+ } finally {
193
+ closeSync(fd);
194
+ }
191
195
 
192
196
  const tail = buf.toString('utf-8');
193
197
  const lines = tail.split('\n').filter(l => l.trim());
@@ -40,6 +40,8 @@ export interface WorkspaceAgentConfig {
40
40
  boundSessionId?: string;
41
41
  // Skip dangerous permissions check (default true when persistentSession is enabled)
42
42
  skipPermissions?: boolean;
43
+ // Plugins: list of plugin IDs (or source IDs) this agent can use via MCP run_plugin
44
+ plugins?: string[];
43
45
  // Watch: autonomous periodic monitoring
44
46
  watch?: WatchConfig;
45
47
  }
@@ -82,7 +84,7 @@ export interface AgentStep {
82
84
  // ─── Agent State (Two-Layer Model) ───────────────────────
83
85
 
84
86
  /** Smith layer: daemon lifecycle */
85
- export type SmithStatus = 'down' | 'active';
87
+ export type SmithStatus = 'down' | 'starting' | 'active';
86
88
 
87
89
  /** Task layer: current work execution */
88
90
  export type TaskStatus = 'idle' | 'running' | 'done' | 'failed';
@@ -287,9 +287,18 @@ async function handleAgentsPost(id: string, body: any, res: ServerResponse): Pro
287
287
  launchInfo = resolveTerminalLaunch(agentConfig.agentId);
288
288
  } catch {}
289
289
 
290
- // resolveOnly: just return launch info without side effects
290
+ // resolveOnly: return launch info + current session ID (no side effects)
291
291
  if (body.resolveOnly) {
292
- return json(res, { ok: true, ...launchInfo });
292
+ let currentSessionId: string | null = null;
293
+ if (agentConfig.primary) {
294
+ try {
295
+ const { getFixedSession } = await import('./project-sessions.js');
296
+ currentSessionId = getFixedSession(orch.projectPath) || null;
297
+ } catch {}
298
+ } else {
299
+ currentSessionId = agentConfig.boundSessionId || null;
300
+ }
301
+ return json(res, { ok: true, ...launchInfo, currentSessionId });
293
302
  }
294
303
 
295
304
  // Primary agent: always return its fixed session, no selection
@@ -297,9 +306,12 @@ async function handleAgentsPost(id: string, body: any, res: ServerResponse): Pro
297
306
  return json(res, { ok: true, primary: true, tmuxSession: agentState.tmuxSession, fixedSession: true, ...launchInfo });
298
307
  }
299
308
 
300
- if (agentState.tmuxSession) {
301
- return json(res, { ok: true, alreadyOpen: true, tmuxSession: agentState.tmuxSession, ...launchInfo });
302
- }
309
+ // No tmux has-session liveness check — it adds latency with no benefit.
310
+ // If the session is dead, openTerminalSession will recreate it anyway.
311
+
312
+ // Ensure tmux session exists (creates if needed, no 3s startup delay)
313
+ // forceRestart: kill existing tmux so launch script is rewritten with current boundSessionId
314
+ const tmuxSession = await orch.openTerminalSession(agentId, body.forceRestart === true);
303
315
 
304
316
  orch.setManualMode(agentId);
305
317
  // Skills call Next.js API (/api/workspace/.../smith), so use FORGE_PORT not daemon PORT
@@ -312,6 +324,7 @@ async function handleAgentsPost(id: string, body: any, res: ServerResponse): Pro
312
324
  skillsInstalled: result.installed,
313
325
  agentId,
314
326
  label: agentConfig.label,
327
+ tmuxSession: tmuxSession || undefined,
315
328
  ...launchInfo,
316
329
  });
317
330
  }
@@ -412,8 +425,10 @@ async function handleAgentsPost(id: string, body: any, res: ServerResponse): Pro
412
425
  return json(res, { ok: true });
413
426
  }
414
427
  case 'start_daemon': {
415
- // Check active daemon count before starting
416
- const activeCount = Array.from(orchestrators.values()).filter(o => o.isDaemonActive()).length;
428
+ // Check active daemon count before starting (include 'starting' to prevent exceeding MAX_ACTIVE)
429
+ const activeCount = Array.from(orchestrators.values()).filter(o =>
430
+ o.isDaemonActive() || Object.values(o.getAllAgentStates()).some(s => s.smithStatus === 'starting')
431
+ ).length;
417
432
  if (activeCount >= MAX_ACTIVE && !orch.isDaemonActive()) {
418
433
  return jsonError(res, `Maximum ${MAX_ACTIVE} active daemons. Stop agents in another workspace first.`);
419
434
  }
@@ -509,14 +524,12 @@ async function handleSmith(id: string, body: any, res: ServerResponse): Promise<
509
524
  }).trim();
510
525
  } catch {}
511
526
 
512
- let gitDiffDetail = '';
513
- try {
514
- gitDiffDetail = execSync('git diff HEAD --name-only', {
515
- cwd: orch.projectPath, encoding: 'utf-8', timeout: 5000,
516
- }).trim();
517
- } catch {}
518
-
519
- const changedFiles = gitDiffDetail.split('\n').filter(Boolean);
527
+ // Parse file names from --stat output (lines with ' | ') instead of a second execSync
528
+ const changedFiles = gitDiff
529
+ .split('\n')
530
+ .filter(l => l.includes(' | '))
531
+ .map(l => l.split(' | ')[0].trim())
532
+ .filter(Boolean);
520
533
  const entry = (orch as any).agents?.get(agentId);
521
534
  const config = entry?.config;
522
535
 
@@ -584,10 +597,12 @@ async function handleSmith(id: string, body: any, res: ServerResponse): Promise<
584
597
  const target = snapshot.agents.find(a => a.label.toLowerCase() === to.toLowerCase() || a.id === to);
585
598
  if (!target) return jsonError(res, `Agent "${to}" not found. Available: ${snapshot.agents.map(a => a.label).join(', ')}`, 404);
586
599
 
587
- // Resolve sender: use agentId if valid, otherwise 'user'
588
- const senderId = (agentId && agentId !== 'unknown')
589
- ? agentId
590
- : 'user';
600
+ // Resolve sender: validate agentId exists in workspace, otherwise 'user'
601
+ const senderExists = agentId && agentId !== 'unknown' && snapshot.agents.some(a => a.id === agentId);
602
+ if (agentId && agentId !== 'unknown' && !senderExists) {
603
+ return jsonError(res, `Sender agent "${agentId}" not found in workspace`);
604
+ }
605
+ const senderId = senderExists ? agentId : 'user';
591
606
 
592
607
  // Block: if sender is currently processing a message FROM the target,
593
608
  // don't send — the result is already delivered via markMessageDone
@@ -824,7 +839,8 @@ const server = createServer(async (req, res) => {
824
839
  // Agent operations
825
840
  if (subPath === '/agents' && method === 'POST') {
826
841
  const bodyStr = await readBody(req);
827
- const body = JSON.parse(bodyStr);
842
+ let body: any;
843
+ try { body = JSON.parse(bodyStr); } catch { return jsonError(res, 'Invalid JSON', 400); }
828
844
  return handleAgentsPost(id, body, res);
829
845
  }
830
846
 
@@ -840,7 +856,8 @@ const server = createServer(async (req, res) => {
840
856
  // Smith API
841
857
  if (subPath === '/smith' && method === 'POST') {
842
858
  const bodyStr = await readBody(req);
843
- const body = JSON.parse(bodyStr);
859
+ let body: any;
860
+ try { body = JSON.parse(bodyStr); } catch { return jsonError(res, 'Invalid JSON', 400); }
844
861
  return handleSmith(id, body, res);
845
862
  }
846
863
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.5.21",
3
+ "version": "0.5.23",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -3,4 +3,4 @@
3
3
  "agentId": "qa-1774920510930",
4
4
  "agentLabel": "QA",
5
5
  "forgePort": 8403
6
- }
6
+ }