@aion0/forge 0.4.15 → 0.5.0
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/CLAUDE.md +1 -1
- package/README.md +2 -2
- package/RELEASE_NOTES.md +170 -13
- package/app/api/agents/route.ts +17 -0
- package/app/api/delivery/[id]/route.ts +62 -0
- package/app/api/delivery/route.ts +40 -0
- package/app/api/mobile-chat/route.ts +13 -7
- package/app/api/monitor/route.ts +10 -6
- package/app/api/pipelines/[id]/route.ts +16 -3
- package/app/api/tasks/route.ts +2 -1
- package/app/api/workspace/[id]/agents/route.ts +35 -0
- package/app/api/workspace/[id]/memory/route.ts +23 -0
- package/app/api/workspace/[id]/smith/route.ts +22 -0
- package/app/api/workspace/[id]/stream/route.ts +28 -0
- package/app/api/workspace/route.ts +100 -0
- package/app/global-error.tsx +10 -4
- package/app/icon.ico +0 -0
- package/app/layout.tsx +2 -2
- package/app/login/LoginForm.tsx +96 -0
- package/app/login/page.tsx +7 -98
- package/app/page.tsx +2 -2
- package/bin/forge-server.mjs +23 -4
- package/check-forge-status.sh +9 -0
- package/cli/mw.ts +2 -2
- package/components/ConversationEditor.tsx +411 -0
- package/components/ConversationGraphView.tsx +347 -0
- package/components/ConversationTerminalView.tsx +303 -0
- package/components/Dashboard.tsx +36 -39
- package/components/DashboardWrapper.tsx +9 -0
- package/components/DeliveryFlowEditor.tsx +491 -0
- package/components/DeliveryList.tsx +230 -0
- package/components/DeliveryWorkspace.tsx +589 -0
- package/components/DocTerminal.tsx +12 -4
- package/components/DocsViewer.tsx +10 -2
- package/components/HelpTerminal.tsx +13 -8
- package/components/InlinePipelineView.tsx +111 -0
- package/components/MobileView.tsx +20 -0
- package/components/MonitorPanel.tsx +9 -4
- package/components/NewTaskModal.tsx +32 -0
- package/components/PipelineEditor.tsx +49 -6
- package/components/PipelineView.tsx +482 -64
- package/components/ProjectDetail.tsx +314 -56
- package/components/ProjectManager.tsx +49 -4
- package/components/SessionView.tsx +27 -13
- package/components/SettingsModal.tsx +790 -124
- package/components/SkillsPanel.tsx +34 -8
- package/components/TaskBoard.tsx +3 -0
- package/components/WebTerminal.tsx +259 -45
- package/components/WorkspaceTree.tsx +221 -0
- package/components/WorkspaceView.tsx +2224 -0
- package/docs/LOCAL-DEPLOY.md +15 -15
- package/install.sh +2 -2
- package/lib/agents/claude-adapter.ts +104 -0
- package/lib/agents/generic-adapter.ts +64 -0
- package/lib/agents/index.ts +242 -0
- package/lib/agents/types.ts +70 -0
- package/lib/artifacts.ts +106 -0
- package/lib/cloudflared.ts +1 -1
- package/lib/delivery.ts +787 -0
- package/lib/forge-skills/forge-inbox.md +37 -0
- package/lib/forge-skills/forge-send.md +40 -0
- package/lib/forge-skills/forge-status.md +32 -0
- package/lib/forge-skills/forge-workspace-sync.md +37 -0
- package/lib/help-docs/00-overview.md +8 -2
- package/lib/help-docs/01-settings.md +159 -2
- package/lib/help-docs/05-pipelines.md +95 -6
- package/lib/help-docs/07-projects.md +35 -1
- package/lib/help-docs/11-workspace.md +204 -0
- package/lib/help-docs/CLAUDE.md +5 -2
- package/lib/init.ts +62 -12
- package/lib/pipeline.ts +537 -1
- package/lib/settings.ts +115 -22
- package/lib/skills.ts +249 -372
- package/lib/task-manager.ts +113 -33
- package/lib/telegram-bot.ts +33 -1
- package/lib/telegram-standalone.ts +1 -1
- package/lib/terminal-server.ts +2 -2
- package/lib/terminal-standalone.ts +1 -1
- package/lib/workspace/__tests__/state-machine.test.ts +388 -0
- package/lib/workspace/__tests__/workspace.test.ts +311 -0
- package/lib/workspace/agent-bus.ts +416 -0
- package/lib/workspace/agent-worker.ts +667 -0
- package/lib/workspace/backends/api-backend.ts +262 -0
- package/lib/workspace/backends/cli-backend.ts +479 -0
- package/lib/workspace/index.ts +82 -0
- package/lib/workspace/manager.ts +136 -0
- package/lib/workspace/orchestrator.ts +1804 -0
- package/lib/workspace/persistence.ts +310 -0
- package/lib/workspace/presets.ts +170 -0
- package/lib/workspace/skill-installer.ts +188 -0
- package/lib/workspace/smith-memory.ts +498 -0
- package/lib/workspace/types.ts +231 -0
- package/lib/workspace/watch-manager.ts +288 -0
- package/lib/workspace-standalone.ts +790 -0
- package/middleware.ts +1 -0
- package/next-env.d.ts +1 -1
- package/package.json +5 -2
- package/src/config/index.ts +13 -2
- package/src/core/db/database.ts +1 -0
- package/start.sh +10 -0
package/docs/LOCAL-DEPLOY.md
CHANGED
|
@@ -8,13 +8,13 @@
|
|
|
8
8
|
|
|
9
9
|
### 方案对比
|
|
10
10
|
|
|
11
|
-
| 方案 | 原理
|
|
12
|
-
|
|
13
|
-
| **同一 WiFi 局域网** | Mac 开 Web 服务,手机直接访问 `192.168.x.x:
|
|
14
|
-
| **Tailscale** | 虚拟局域网,任何网络下设备互通
|
|
15
|
-
| **Cloudflare Tunnel** | 免费内网穿透,给你一个公网域名
|
|
16
|
-
| **ngrok** | 临时隧道
|
|
17
|
-
| **frp** | 自建内网穿透
|
|
11
|
+
| 方案 | 原理 | 优点 | 缺点 |
|
|
12
|
+
|------|----------------------------------------|------|------|
|
|
13
|
+
| **同一 WiFi 局域网** | Mac 开 Web 服务,手机直接访问 `192.168.x.x:8403` | 零配置、零成本 | 只能在家用 |
|
|
14
|
+
| **Tailscale** | 虚拟局域网,任何网络下设备互通 | 免费、安全、无需公网 IP | 需要装客户端 |
|
|
15
|
+
| **Cloudflare Tunnel** | 免费内网穿透,给你一个公网域名 | 免费、HTTPS、不开端口 | 依赖 Cloudflare |
|
|
16
|
+
| **ngrok** | 临时隧道 | 一行命令搞定 | 免费版地址每次变 |
|
|
17
|
+
| **frp** | 自建内网穿透 | 完全自控 | 需要一台有公网 IP 的服务器 |
|
|
18
18
|
|
|
19
19
|
### 推荐组合:Tailscale(推荐) + 局域网(备选)
|
|
20
20
|
|
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
│ 你的 Mac │
|
|
24
24
|
│ │
|
|
25
25
|
│ my-workflow server │
|
|
26
|
-
│ ├── REST API :
|
|
27
|
-
│ ├── WebSocket :
|
|
28
|
-
│ └── Dashboard :
|
|
26
|
+
│ ├── REST API :8403 │
|
|
27
|
+
│ ├── WebSocket :8403/ws │
|
|
28
|
+
│ └── Dashboard :8403 │
|
|
29
29
|
│ │
|
|
30
30
|
│ Tailscale IP: 100.x.x.x │
|
|
31
31
|
│ 局域网 IP: 192.168.x.x │
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
┌──────────┴──────────────────────┐
|
|
37
37
|
│ 你的手机 │
|
|
38
38
|
│ │
|
|
39
|
-
│ 浏览器 → 100.x.x.x:
|
|
40
|
-
│ 或 Safari → 192.168.x.x:
|
|
39
|
+
│ 浏览器 → 100.x.x.x:8403 │ ← 任何网络下都能访问
|
|
40
|
+
│ 或 Safari → 192.168.x.x:8403 │ ← 同一 WiFi 下
|
|
41
41
|
│ │
|
|
42
42
|
└─────────────────────────────────┘
|
|
43
43
|
```
|
|
@@ -67,7 +67,7 @@ tailscale ip -4
|
|
|
67
67
|
|
|
68
68
|
# 5. 启动 my-workflow
|
|
69
69
|
mw server start
|
|
70
|
-
# → Dashboard: http://100.64.x.x:
|
|
70
|
+
# → Dashboard: http://100.64.x.x:8403
|
|
71
71
|
|
|
72
72
|
# 手机浏览器打开这个地址即可
|
|
73
73
|
```
|
|
@@ -133,10 +133,10 @@ launchctl list | grep my-workflow
|
|
|
133
133
|
|
|
134
134
|
```
|
|
135
135
|
本地开发阶段:
|
|
136
|
-
手机 → Tailscale → Mac:
|
|
136
|
+
手机 → Tailscale → Mac:8403
|
|
137
137
|
|
|
138
138
|
迁移到云端:
|
|
139
|
-
手机 → Tailscale → VPS:
|
|
139
|
+
手机 → Tailscale → VPS:8403 (只是 IP 变了)
|
|
140
140
|
或
|
|
141
141
|
手机 → https://workflow.yourdomain.com (Cloudflare Tunnel)
|
|
142
142
|
```
|
package/install.sh
CHANGED
|
@@ -12,7 +12,7 @@ if [ "$1" = "local" ] || [ "$1" = "--local" ]; then
|
|
|
12
12
|
npm uninstall -g @aion0/forge 2>/dev/null || true
|
|
13
13
|
npm link
|
|
14
14
|
echo "[forge] Building..."
|
|
15
|
-
pnpm build
|
|
15
|
+
pnpm build || echo "[forge] Build completed with warnings (non-critical)"
|
|
16
16
|
else
|
|
17
17
|
echo "[forge] Installing from npm..."
|
|
18
18
|
rm -rf "$(npm root -g)/@aion0/forge" 2>/dev/null || true
|
|
@@ -20,7 +20,7 @@ else
|
|
|
20
20
|
# Install from /tmp to avoid pnpm node_modules conflict
|
|
21
21
|
(cd /tmp && npm install -g @aion0/forge)
|
|
22
22
|
echo "[forge] Building..."
|
|
23
|
-
cd "$(npm root -g)/@aion0/forge" && npx next build && cd -
|
|
23
|
+
cd "$(npm root -g)/@aion0/forge" && (npx next build || echo "[forge] Build completed with warnings") && cd -
|
|
24
24
|
fi
|
|
25
25
|
|
|
26
26
|
echo ""
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code adapter — handles Claude CLI specifics.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { execSync } from 'node:child_process';
|
|
6
|
+
import { realpathSync } from 'node:fs';
|
|
7
|
+
import type { AgentAdapter, AgentConfig, AgentSpawnOptions, AgentSpawnResult } from './types';
|
|
8
|
+
|
|
9
|
+
const CAPABILITIES = {
|
|
10
|
+
supportsResume: true,
|
|
11
|
+
supportsStreamJson: true,
|
|
12
|
+
supportsModel: true,
|
|
13
|
+
supportsSkipPermissions: true,
|
|
14
|
+
hasSessionFiles: true,
|
|
15
|
+
requiresTTY: false,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Resolve claude binary path (symlink → real .js → node) */
|
|
19
|
+
function resolveClaudePath(claudePath: string): { cmd: string; prefix: string[] } {
|
|
20
|
+
try {
|
|
21
|
+
let resolved = claudePath;
|
|
22
|
+
try {
|
|
23
|
+
const which = execSync(`which ${claudePath}`, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
24
|
+
resolved = realpathSync(which);
|
|
25
|
+
} catch {
|
|
26
|
+
resolved = realpathSync(claudePath);
|
|
27
|
+
}
|
|
28
|
+
if (resolved.endsWith('.js') || resolved.endsWith('.mjs')) {
|
|
29
|
+
return { cmd: process.execPath, prefix: [resolved] };
|
|
30
|
+
}
|
|
31
|
+
return { cmd: resolved, prefix: [] };
|
|
32
|
+
} catch {
|
|
33
|
+
return { cmd: process.execPath, prefix: [claudePath] };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createClaudeAdapter(config: AgentConfig): AgentAdapter {
|
|
38
|
+
return {
|
|
39
|
+
id: 'claude',
|
|
40
|
+
config: { ...config, capabilities: CAPABILITIES },
|
|
41
|
+
|
|
42
|
+
buildTaskSpawn(opts: AgentSpawnOptions): AgentSpawnResult {
|
|
43
|
+
const resolved = resolveClaudePath(config.path);
|
|
44
|
+
const args = [...resolved.prefix, '-p', '--verbose'];
|
|
45
|
+
|
|
46
|
+
if (opts.outputFormat === 'stream-json' || opts.outputFormat === undefined) {
|
|
47
|
+
args.push('--output-format', 'stream-json');
|
|
48
|
+
} else if (opts.outputFormat === 'json') {
|
|
49
|
+
args.push('--output-format', 'json');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (opts.skipPermissions !== false) {
|
|
53
|
+
const flag = config.skipPermissionsFlag || '--dangerously-skip-permissions';
|
|
54
|
+
if (flag) args.push(...flag.split(/\s+/));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (opts.model && opts.model !== 'default') {
|
|
58
|
+
args.push('--model', opts.model);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (opts.conversationId) {
|
|
62
|
+
args.push('--resume', opts.conversationId);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (opts.extraFlags) {
|
|
66
|
+
args.push(...opts.extraFlags);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
args.push(opts.prompt);
|
|
70
|
+
|
|
71
|
+
return { cmd: resolved.cmd, args };
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
buildTerminalCommand(opts) {
|
|
75
|
+
const flag = config.skipPermissionsFlag || '--dangerously-skip-permissions';
|
|
76
|
+
const skipFlag = opts.skipPermissions && flag ? ` ${flag}` : '';
|
|
77
|
+
if (opts.sessionId) {
|
|
78
|
+
return `cd "${opts.projectPath}" && claude --resume ${opts.sessionId}${skipFlag}\n`;
|
|
79
|
+
}
|
|
80
|
+
const resumeFlag = opts.resume ? ' -c' : '';
|
|
81
|
+
return `cd "${opts.projectPath}" && claude${resumeFlag}${skipFlag}\n`;
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Detect if claude is installed and return config */
|
|
87
|
+
export function detectClaude(customPath?: string): AgentConfig | null {
|
|
88
|
+
const paths = customPath ? [customPath] : ['claude'];
|
|
89
|
+
for (const p of paths) {
|
|
90
|
+
try {
|
|
91
|
+
execSync(`which ${p}`, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
92
|
+
return {
|
|
93
|
+
id: 'claude',
|
|
94
|
+
name: 'Claude Code',
|
|
95
|
+
path: p,
|
|
96
|
+
enabled: true,
|
|
97
|
+
type: 'claude-code',
|
|
98
|
+
capabilities: CAPABILITIES,
|
|
99
|
+
skipPermissionsFlag: '--dangerously-skip-permissions',
|
|
100
|
+
};
|
|
101
|
+
} catch {}
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic agent adapter — works with any CLI agent that takes a prompt via args or stdin.
|
|
3
|
+
* Supports: codex, aider, or any custom agent binary.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execSync } from 'node:child_process';
|
|
7
|
+
import type { AgentAdapter, AgentConfig, AgentSpawnOptions, AgentSpawnResult } from './types';
|
|
8
|
+
|
|
9
|
+
export function createGenericAdapter(config: AgentConfig): AgentAdapter {
|
|
10
|
+
return {
|
|
11
|
+
id: config.id,
|
|
12
|
+
config,
|
|
13
|
+
|
|
14
|
+
buildTaskSpawn(opts: AgentSpawnOptions): AgentSpawnResult {
|
|
15
|
+
const args: string[] = [];
|
|
16
|
+
|
|
17
|
+
// Add configured flags (e.g., ['--message'] for aider)
|
|
18
|
+
if (config.flags) {
|
|
19
|
+
args.push(...config.flags);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Add skip permissions flag if configured
|
|
23
|
+
if (opts.skipPermissions !== false && config.skipPermissionsFlag) {
|
|
24
|
+
args.push(...config.skipPermissionsFlag.split(/\s+/));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Add prompt
|
|
28
|
+
args.push(opts.prompt);
|
|
29
|
+
|
|
30
|
+
if (opts.extraFlags) {
|
|
31
|
+
args.push(...opts.extraFlags);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { cmd: config.path, args };
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
buildTerminalCommand(opts) {
|
|
38
|
+
return `cd "${opts.projectPath}" && ${config.path}\n`;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Detect known agents */
|
|
44
|
+
export function detectAgent(id: string, name: string, binaryName: string, flags?: string[]): AgentConfig | null {
|
|
45
|
+
try {
|
|
46
|
+
execSync(`which ${binaryName}`, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
47
|
+
return {
|
|
48
|
+
id,
|
|
49
|
+
name,
|
|
50
|
+
path: binaryName,
|
|
51
|
+
enabled: true,
|
|
52
|
+
type: 'generic',
|
|
53
|
+
flags,
|
|
54
|
+
capabilities: {
|
|
55
|
+
supportsResume: false,
|
|
56
|
+
supportsStreamJson: false,
|
|
57
|
+
supportsModel: false,
|
|
58
|
+
supportsSkipPermissions: false,
|
|
59
|
+
hasSessionFiles: false,
|
|
60
|
+
requiresTTY: false,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
} catch { return null; }
|
|
64
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Registry — manages available agents and provides adapters.
|
|
3
|
+
* Agents coexist (not mutually exclusive). Each entry point can select any agent.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { loadSettings } from '../settings';
|
|
7
|
+
import type { AgentAdapter, AgentConfig, AgentId } from './types';
|
|
8
|
+
import { createClaudeAdapter, detectClaude } from './claude-adapter';
|
|
9
|
+
import { createGenericAdapter, detectAgent } from './generic-adapter';
|
|
10
|
+
|
|
11
|
+
export type { AgentAdapter, AgentConfig, AgentId } from './types';
|
|
12
|
+
|
|
13
|
+
// Module-level cache
|
|
14
|
+
const adapterCache = new Map<AgentId, AgentAdapter>();
|
|
15
|
+
|
|
16
|
+
/** Get all configured agents */
|
|
17
|
+
export function listAgents(): AgentConfig[] {
|
|
18
|
+
const settings = loadSettings();
|
|
19
|
+
const agents: AgentConfig[] = [];
|
|
20
|
+
|
|
21
|
+
// Claude (always check — primary agent)
|
|
22
|
+
const claudeConfig = settings.agents?.claude;
|
|
23
|
+
const claude = detectClaude(claudeConfig?.path || settings.claudePath);
|
|
24
|
+
if (claude) {
|
|
25
|
+
agents.push({ ...claude, enabled: claudeConfig?.enabled !== false, detected: true, skipPermissionsFlag: claudeConfig?.skipPermissionsFlag || '--dangerously-skip-permissions', cliType: 'claude-code' } as any);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Codex
|
|
29
|
+
const codexConfig = settings.agents?.codex;
|
|
30
|
+
const codex = detectAgent('codex', 'OpenAI Codex', codexConfig?.path || 'codex');
|
|
31
|
+
if (codex) {
|
|
32
|
+
codex.capabilities.requiresTTY = true;
|
|
33
|
+
agents.push({ ...codex, enabled: codexConfig?.enabled !== false, detected: true, skipPermissionsFlag: codexConfig?.skipPermissionsFlag || '--full-auto', cliType: 'codex' } as any);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Aider
|
|
37
|
+
const aiderConfig = settings.agents?.aider;
|
|
38
|
+
const aider = detectAgent('aider', 'Aider', aiderConfig?.path || 'aider', ['--message']);
|
|
39
|
+
if (aider) {
|
|
40
|
+
agents.push({ ...aider, enabled: aiderConfig?.enabled !== false, detected: true, skipPermissionsFlag: aiderConfig?.skipPermissionsFlag || '--yes', cliType: 'aider' } as any);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Custom agents + profiles from settings
|
|
44
|
+
if (settings.agents) {
|
|
45
|
+
for (const [id, cfg] of Object.entries(settings.agents)) {
|
|
46
|
+
if (['claude', 'codex', 'aider'].includes(id)) continue;
|
|
47
|
+
|
|
48
|
+
// API profile — no CLI detection needed
|
|
49
|
+
if (cfg.type === 'api') {
|
|
50
|
+
agents.push({
|
|
51
|
+
id,
|
|
52
|
+
name: cfg.name || id,
|
|
53
|
+
path: '',
|
|
54
|
+
enabled: cfg.enabled !== false,
|
|
55
|
+
type: 'generic' as const,
|
|
56
|
+
capabilities: { supportsResume: false, supportsStreamJson: false, supportsModel: false, supportsSkipPermissions: false, hasSessionFiles: false, requiresTTY: false },
|
|
57
|
+
isProfile: true,
|
|
58
|
+
backendType: 'api',
|
|
59
|
+
provider: cfg.provider,
|
|
60
|
+
model: cfg.model,
|
|
61
|
+
apiKey: cfg.apiKey,
|
|
62
|
+
} as any);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// CLI profile (has base) — inherit from base agent
|
|
67
|
+
if (cfg.base) {
|
|
68
|
+
const baseAgent = agents.find(a => a.id === cfg.base);
|
|
69
|
+
agents.push({
|
|
70
|
+
...(baseAgent || { type: 'generic' as const, capabilities: { supportsResume: false, supportsStreamJson: false, supportsModel: false, supportsSkipPermissions: false, hasSessionFiles: false, requiresTTY: false } }),
|
|
71
|
+
id,
|
|
72
|
+
name: cfg.name || id,
|
|
73
|
+
path: baseAgent?.path || '',
|
|
74
|
+
enabled: cfg.enabled !== false,
|
|
75
|
+
base: cfg.base,
|
|
76
|
+
isProfile: true,
|
|
77
|
+
backendType: 'cli',
|
|
78
|
+
model: cfg.model || cfg.models?.task,
|
|
79
|
+
skipPermissionsFlag: cfg.skipPermissionsFlag || baseAgent?.skipPermissionsFlag,
|
|
80
|
+
env: cfg.env,
|
|
81
|
+
cliType: cfg.cliType || (baseAgent as any)?.cliType || 'generic',
|
|
82
|
+
} as any);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Custom agent (not a profile) — detect binary
|
|
87
|
+
if (!cfg.path) continue;
|
|
88
|
+
const flags = cfg.taskFlags ? cfg.taskFlags.split(/\s+/).filter(Boolean) : cfg.flags;
|
|
89
|
+
const detected = detectAgent(id, cfg.name || id, cfg.path, flags);
|
|
90
|
+
agents.push({
|
|
91
|
+
...(detected || {
|
|
92
|
+
id, name: cfg.name || id, path: cfg.path, type: 'generic' as const, flags,
|
|
93
|
+
capabilities: { supportsResume: false, supportsStreamJson: false, supportsModel: false, supportsSkipPermissions: false, hasSessionFiles: false, requiresTTY: !!cfg.requiresTTY },
|
|
94
|
+
}),
|
|
95
|
+
flags,
|
|
96
|
+
enabled: cfg.enabled !== false,
|
|
97
|
+
detected: !!detected,
|
|
98
|
+
} as any);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return agents;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Get the default agent ID */
|
|
106
|
+
export function getDefaultAgentId(): AgentId {
|
|
107
|
+
const settings = loadSettings();
|
|
108
|
+
return settings.defaultAgent || 'claude';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Get an agent adapter by ID (falls back to default). For profiles, returns base agent's adapter. */
|
|
112
|
+
export function getAgent(id?: AgentId): AgentAdapter {
|
|
113
|
+
const agentId = id || getDefaultAgentId();
|
|
114
|
+
|
|
115
|
+
// Return cached adapter
|
|
116
|
+
if (adapterCache.has(agentId)) return adapterCache.get(agentId)!;
|
|
117
|
+
|
|
118
|
+
const agents = listAgents();
|
|
119
|
+
const config = agents.find(a => a.id === agentId && a.enabled);
|
|
120
|
+
|
|
121
|
+
// Profile with base → get base agent's adapter
|
|
122
|
+
if (config?.base) {
|
|
123
|
+
const baseAdapter = getAgent(config.base);
|
|
124
|
+
// Wrap adapter with profile's model override
|
|
125
|
+
const profileAdapter: AgentAdapter = {
|
|
126
|
+
...baseAdapter,
|
|
127
|
+
id: agentId,
|
|
128
|
+
config: { ...baseAdapter.config, ...config, id: agentId },
|
|
129
|
+
};
|
|
130
|
+
adapterCache.set(agentId, profileAdapter);
|
|
131
|
+
return profileAdapter;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!config) {
|
|
135
|
+
// If specifically requested agent not found, only fallback for 'claude' (default)
|
|
136
|
+
if (agentId === 'claude' || agentId === getDefaultAgentId()) {
|
|
137
|
+
const fallback = detectClaude() || {
|
|
138
|
+
id: 'claude', name: 'Claude Code', path: 'claude', enabled: true,
|
|
139
|
+
type: 'claude-code' as const,
|
|
140
|
+
capabilities: { supportsResume: true, supportsStreamJson: true, supportsModel: true, supportsSkipPermissions: true, hasSessionFiles: true, requiresTTY: false },
|
|
141
|
+
};
|
|
142
|
+
const adapter = createClaudeAdapter(fallback);
|
|
143
|
+
adapterCache.set(agentId, adapter);
|
|
144
|
+
return adapter;
|
|
145
|
+
}
|
|
146
|
+
// Non-default agent not found — create generic with the ID as path (will fail if not installed)
|
|
147
|
+
const notFound: AgentConfig = {
|
|
148
|
+
id: agentId, name: agentId, path: agentId, enabled: true, type: 'generic',
|
|
149
|
+
capabilities: { supportsResume: false, supportsStreamJson: false, supportsModel: false, supportsSkipPermissions: false, hasSessionFiles: false, requiresTTY: false },
|
|
150
|
+
};
|
|
151
|
+
const adapter = createGenericAdapter(notFound);
|
|
152
|
+
adapterCache.set(agentId, adapter);
|
|
153
|
+
return adapter;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const adapter = config.type === 'claude-code'
|
|
157
|
+
? createClaudeAdapter(config)
|
|
158
|
+
: createGenericAdapter(config);
|
|
159
|
+
|
|
160
|
+
adapterCache.set(agentId, adapter);
|
|
161
|
+
return adapter;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Clear adapter cache (call after settings change) */
|
|
165
|
+
export function clearAgentCache(): void {
|
|
166
|
+
adapterCache.clear();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Auto-detect all available agents (called on startup) */
|
|
170
|
+
export function autoDetectAgents(): AgentConfig[] {
|
|
171
|
+
const detected: AgentConfig[] = [];
|
|
172
|
+
|
|
173
|
+
const claude = detectClaude();
|
|
174
|
+
if (claude) detected.push(claude);
|
|
175
|
+
|
|
176
|
+
const codex = detectAgent('codex', 'OpenAI Codex', 'codex');
|
|
177
|
+
if (codex) detected.push(codex);
|
|
178
|
+
|
|
179
|
+
const aider = detectAgent('aider', 'Aider', 'aider', ['--message']);
|
|
180
|
+
if (aider) detected.push(aider);
|
|
181
|
+
|
|
182
|
+
if (detected.length > 0) {
|
|
183
|
+
console.log(`[agents] Detected: ${detected.map(a => a.name).join(', ')}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return detected;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Resolve terminal launch info for an agent — used by both VibeCoding and Workspace */
|
|
190
|
+
export interface TerminalLaunchInfo {
|
|
191
|
+
cliCmd: string; // actual binary: claude, codex, aider
|
|
192
|
+
cliType: string; // claude-code, codex, aider, generic
|
|
193
|
+
supportsSession: boolean; // has session files to resume
|
|
194
|
+
resumeFlag: string; // -c, --resume, etc.
|
|
195
|
+
env?: Record<string, string>; // profile env vars to export
|
|
196
|
+
model?: string; // profile model override (--model flag)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function resolveTerminalLaunch(agentId?: string): TerminalLaunchInfo {
|
|
200
|
+
const settings = loadSettings();
|
|
201
|
+
const agentCfg = settings.agents?.[agentId || 'claude'] || {};
|
|
202
|
+
// Resolve cliType: own cliType → base agent's cliType → base agent name guessing → agentId name guessing
|
|
203
|
+
const baseId = agentCfg.base;
|
|
204
|
+
const baseCfg = baseId ? (settings.agents?.[baseId] || {}) : {};
|
|
205
|
+
const cliType = agentCfg.cliType || baseCfg.cliType
|
|
206
|
+
|| (baseId === 'codex' ? 'codex' : baseId === 'aider' ? 'aider' : undefined)
|
|
207
|
+
|| (agentId === 'codex' ? 'codex' : agentId === 'aider' ? 'aider' : 'claude-code');
|
|
208
|
+
|
|
209
|
+
// Determine CLI command and capabilities from cliType
|
|
210
|
+
const cliMap: Record<string, { cmd: string; session: boolean; resume: string }> = {
|
|
211
|
+
'claude-code': { cmd: 'claude', session: true, resume: '-c' },
|
|
212
|
+
'codex': { cmd: 'codex', session: false, resume: '' },
|
|
213
|
+
'aider': { cmd: 'aider', session: false, resume: '' },
|
|
214
|
+
'generic': { cmd: agentCfg.path || agentId || 'claude', session: false, resume: '' },
|
|
215
|
+
};
|
|
216
|
+
const cli = cliMap[cliType] || cliMap['claude-code'];
|
|
217
|
+
|
|
218
|
+
// Resolve env/model: either from this agent's own profile fields, or from linked profile
|
|
219
|
+
let env: Record<string, string> | undefined;
|
|
220
|
+
let model: string | undefined;
|
|
221
|
+
if (agentCfg.base || agentCfg.env || agentCfg.model) {
|
|
222
|
+
// This agent IS a profile — read env/model directly
|
|
223
|
+
if (agentCfg.env) env = { ...agentCfg.env };
|
|
224
|
+
if (agentCfg.model) model = agentCfg.model;
|
|
225
|
+
} else if (agentCfg.profile) {
|
|
226
|
+
// Agent links to a separate profile — read from that
|
|
227
|
+
const profileCfg = settings.agents?.[agentCfg.profile];
|
|
228
|
+
if (profileCfg) {
|
|
229
|
+
if (profileCfg.env) env = { ...profileCfg.env };
|
|
230
|
+
if (profileCfg.model) model = profileCfg.model;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
cliCmd: cli.cmd,
|
|
236
|
+
cliType,
|
|
237
|
+
supportsSession: cli.session,
|
|
238
|
+
resumeFlag: agentCfg.resumeFlag || cli.resume,
|
|
239
|
+
env,
|
|
240
|
+
model,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent types — shared interfaces for multi-agent support.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type AgentId = string; // 'claude' | 'codex' | 'aider' | custom
|
|
6
|
+
|
|
7
|
+
export interface AgentCapabilities {
|
|
8
|
+
supportsResume: boolean; // -c / --resume (continue session)
|
|
9
|
+
supportsStreamJson: boolean; // structured output parsing
|
|
10
|
+
supportsModel: boolean; // --model flag
|
|
11
|
+
supportsSkipPermissions: boolean; // --dangerously-skip-permissions or equivalent
|
|
12
|
+
hasSessionFiles: boolean; // on-disk session files (JSONL etc.)
|
|
13
|
+
requiresTTY: boolean; // needs pseudo-terminal (e.g., codex)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AgentConfig {
|
|
17
|
+
id: AgentId;
|
|
18
|
+
name: string; // display name: "Claude Code", "OpenAI Codex", "Aider"
|
|
19
|
+
path: string; // binary path
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
type: 'claude-code' | 'generic'; // adapter type
|
|
22
|
+
flags?: string[]; // extra CLI flags
|
|
23
|
+
capabilities: AgentCapabilities;
|
|
24
|
+
version?: string;
|
|
25
|
+
skipPermissionsFlag?: string; // e.g., "--dangerously-skip-permissions", "--full-auto"
|
|
26
|
+
// Profile fields
|
|
27
|
+
base?: string; // base agent ID — makes this a profile
|
|
28
|
+
isProfile?: boolean; // true if this is a profile (not a base agent)
|
|
29
|
+
backendType?: 'cli' | 'api'; // 'api' for API profiles
|
|
30
|
+
provider?: string; // API provider (anthropic, google, openai, grok)
|
|
31
|
+
model?: string; // model override for profiles
|
|
32
|
+
apiKey?: string; // per-profile API key
|
|
33
|
+
env?: Record<string, string>; // env vars injected on spawn
|
|
34
|
+
cliType?: 'claude-code' | 'codex' | 'aider' | 'generic'; // CLI tool type
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AgentSpawnOptions {
|
|
38
|
+
projectPath: string;
|
|
39
|
+
prompt: string;
|
|
40
|
+
model?: string;
|
|
41
|
+
conversationId?: string; // for resume
|
|
42
|
+
skipPermissions?: boolean;
|
|
43
|
+
outputFormat?: 'stream-json' | 'json' | 'text';
|
|
44
|
+
extraFlags?: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface AgentSpawnResult {
|
|
48
|
+
cmd: string;
|
|
49
|
+
args: string[];
|
|
50
|
+
env?: Record<string, string>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface AgentAdapter {
|
|
54
|
+
id: AgentId;
|
|
55
|
+
config: AgentConfig;
|
|
56
|
+
|
|
57
|
+
/** Build spawn command + args for non-interactive task execution */
|
|
58
|
+
buildTaskSpawn(opts: AgentSpawnOptions): AgentSpawnResult;
|
|
59
|
+
|
|
60
|
+
/** Build the terminal command string (e.g., "cd /path && claude -c") */
|
|
61
|
+
buildTerminalCommand(opts: {
|
|
62
|
+
projectPath: string;
|
|
63
|
+
resume?: boolean;
|
|
64
|
+
sessionId?: string;
|
|
65
|
+
skipPermissions?: boolean;
|
|
66
|
+
}): string;
|
|
67
|
+
|
|
68
|
+
/** Parse a line of output into normalized events (for stream-json agents) */
|
|
69
|
+
parseOutputLine?(line: string): any[];
|
|
70
|
+
}
|
package/lib/artifacts.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact system — structured data passing between delivery agents.
|
|
3
|
+
* Each artifact is a named document (requirements, architecture, test-plan, etc.)
|
|
4
|
+
* stored as a separate JSON file for lazy loading.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { getDataDir } from './dirs';
|
|
11
|
+
|
|
12
|
+
export type ArtifactType = 'requirements' | 'architecture' | 'test-plan' | 'code-diff' | 'review-report' | 'custom';
|
|
13
|
+
|
|
14
|
+
export interface Artifact {
|
|
15
|
+
id: string;
|
|
16
|
+
deliveryId: string;
|
|
17
|
+
type: ArtifactType;
|
|
18
|
+
name: string; // e.g., "requirements.md"
|
|
19
|
+
content: string;
|
|
20
|
+
producedBy: string; // phase name or 'user'
|
|
21
|
+
consumedBy: string[]; // phases that consumed this
|
|
22
|
+
createdAt: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function artifactsDir(deliveryId: string): string {
|
|
26
|
+
const dir = join(getDataDir(), 'deliveries', deliveryId, 'artifacts');
|
|
27
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
28
|
+
return dir;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createArtifact(deliveryId: string, opts: {
|
|
32
|
+
type: ArtifactType;
|
|
33
|
+
name: string;
|
|
34
|
+
content: string;
|
|
35
|
+
producedBy: string;
|
|
36
|
+
}): Artifact {
|
|
37
|
+
const id = randomUUID().slice(0, 8);
|
|
38
|
+
const artifact: Artifact = {
|
|
39
|
+
id,
|
|
40
|
+
deliveryId,
|
|
41
|
+
type: opts.type,
|
|
42
|
+
name: opts.name,
|
|
43
|
+
content: opts.content,
|
|
44
|
+
producedBy: opts.producedBy,
|
|
45
|
+
consumedBy: [],
|
|
46
|
+
createdAt: new Date().toISOString(),
|
|
47
|
+
};
|
|
48
|
+
writeFileSync(join(artifactsDir(deliveryId), `${id}.json`), JSON.stringify(artifact, null, 2));
|
|
49
|
+
return artifact;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getArtifact(deliveryId: string, id: string): Artifact | null {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(readFileSync(join(artifactsDir(deliveryId), `${id}.json`), 'utf-8'));
|
|
55
|
+
} catch { return null; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function listArtifacts(deliveryId: string): Artifact[] {
|
|
59
|
+
const dir = artifactsDir(deliveryId);
|
|
60
|
+
if (!existsSync(dir)) return [];
|
|
61
|
+
return readdirSync(dir)
|
|
62
|
+
.filter(f => f.endsWith('.json'))
|
|
63
|
+
.map(f => {
|
|
64
|
+
try { return JSON.parse(readFileSync(join(dir, f), 'utf-8')) as Artifact; } catch { return null; }
|
|
65
|
+
})
|
|
66
|
+
.filter(Boolean) as Artifact[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function deleteArtifact(deliveryId: string, id: string): void {
|
|
70
|
+
try { unlinkSync(join(artifactsDir(deliveryId), `${id}.json`)); } catch {}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Extract artifacts from agent output using ===ARTIFACT:name=== markers */
|
|
74
|
+
export function extractArtifacts(output: string, deliveryId: string, producedBy: string): Artifact[] {
|
|
75
|
+
const artifacts: Artifact[] = [];
|
|
76
|
+
const regex = /===ARTIFACT:([\w.-]+)===\n([\s\S]*?)(?=\n===ARTIFACT:|$)/g;
|
|
77
|
+
let match;
|
|
78
|
+
|
|
79
|
+
while ((match = regex.exec(output)) !== null) {
|
|
80
|
+
const name = match[1];
|
|
81
|
+
const content = match[2].trim();
|
|
82
|
+
if (!content) continue;
|
|
83
|
+
|
|
84
|
+
// Infer type from name
|
|
85
|
+
let type: ArtifactType = 'custom';
|
|
86
|
+
if (name.includes('requirement')) type = 'requirements';
|
|
87
|
+
else if (name.includes('architect') || name.includes('design')) type = 'architecture';
|
|
88
|
+
else if (name.includes('test')) type = 'test-plan';
|
|
89
|
+
else if (name.includes('review')) type = 'review-report';
|
|
90
|
+
else if (name.includes('diff')) type = 'code-diff';
|
|
91
|
+
|
|
92
|
+
artifacts.push(createArtifact(deliveryId, { type, name, content, producedBy }));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Fallback: if no markers found, don't create any artifact — let the engine decide
|
|
96
|
+
return artifacts;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Write artifact content to the project directory */
|
|
100
|
+
export function writeArtifactToProject(artifact: Artifact, projectPath: string, subDir = '.forge-delivery'): string {
|
|
101
|
+
const dir = join(projectPath, subDir);
|
|
102
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
103
|
+
const filePath = join(dir, artifact.name);
|
|
104
|
+
writeFileSync(filePath, artifact.content, 'utf-8');
|
|
105
|
+
return filePath;
|
|
106
|
+
}
|
package/lib/cloudflared.ts
CHANGED
|
@@ -169,7 +169,7 @@ function pushLog(line: string) {
|
|
|
169
169
|
if (state.log.length > MAX_LOG_LINES) state.log.shift();
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
export async function startTunnel(localPort: number = parseInt(process.env.PORT || '
|
|
172
|
+
export async function startTunnel(localPort: number = parseInt(process.env.PORT || '8403')): Promise<{ url?: string; error?: string }> {
|
|
173
173
|
console.log(`[tunnel] Starting tunnel on port ${localPort}...`);
|
|
174
174
|
// Prevent concurrent starts: state.process is already spawned, or another call is
|
|
175
175
|
// mid-flight between the guard and spawn (the async download window).
|