@aion0/forge 0.5.20 → 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/RELEASE_NOTES.md +32 -6
- package/app/api/code/route.ts +10 -4
- 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 +371 -87
- 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/next-env.d.ts +1 -1
- package/package.json +1 -1
- package/qa/.forge/agent-context.json +1 -1
package/lib/workspace/presets.ts
CHANGED
|
@@ -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: '
|
|
90
|
-
{ id: 'execute', label: 'Execute Tests', prompt: 'Run
|
|
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
|
|
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
|
|
186
|
-
const
|
|
187
|
-
const
|
|
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
|
-
|
|
190
|
-
|
|
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());
|
package/lib/workspace/types.ts
CHANGED
|
@@ -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:
|
|
290
|
+
// resolveOnly: return launch info + current session ID (no side effects)
|
|
291
291
|
if (body.resolveOnly) {
|
|
292
|
-
|
|
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
|
-
|
|
301
|
-
|
|
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 =>
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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:
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/next-env.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference types="next" />
|
|
2
2
|
/// <reference types="next/image-types/global" />
|
|
3
|
-
import "./.next/types/routes.d.ts";
|
|
3
|
+
import "./.next/dev/types/routes.d.ts";
|
|
4
4
|
|
|
5
5
|
// NOTE: This file should not be edited
|
|
6
6
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
package/package.json
CHANGED