@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.
Files changed (100) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +2 -2
  3. package/RELEASE_NOTES.md +170 -13
  4. package/app/api/agents/route.ts +17 -0
  5. package/app/api/delivery/[id]/route.ts +62 -0
  6. package/app/api/delivery/route.ts +40 -0
  7. package/app/api/mobile-chat/route.ts +13 -7
  8. package/app/api/monitor/route.ts +10 -6
  9. package/app/api/pipelines/[id]/route.ts +16 -3
  10. package/app/api/tasks/route.ts +2 -1
  11. package/app/api/workspace/[id]/agents/route.ts +35 -0
  12. package/app/api/workspace/[id]/memory/route.ts +23 -0
  13. package/app/api/workspace/[id]/smith/route.ts +22 -0
  14. package/app/api/workspace/[id]/stream/route.ts +28 -0
  15. package/app/api/workspace/route.ts +100 -0
  16. package/app/global-error.tsx +10 -4
  17. package/app/icon.ico +0 -0
  18. package/app/layout.tsx +2 -2
  19. package/app/login/LoginForm.tsx +96 -0
  20. package/app/login/page.tsx +7 -98
  21. package/app/page.tsx +2 -2
  22. package/bin/forge-server.mjs +23 -4
  23. package/check-forge-status.sh +9 -0
  24. package/cli/mw.ts +2 -2
  25. package/components/ConversationEditor.tsx +411 -0
  26. package/components/ConversationGraphView.tsx +347 -0
  27. package/components/ConversationTerminalView.tsx +303 -0
  28. package/components/Dashboard.tsx +36 -39
  29. package/components/DashboardWrapper.tsx +9 -0
  30. package/components/DeliveryFlowEditor.tsx +491 -0
  31. package/components/DeliveryList.tsx +230 -0
  32. package/components/DeliveryWorkspace.tsx +589 -0
  33. package/components/DocTerminal.tsx +12 -4
  34. package/components/DocsViewer.tsx +10 -2
  35. package/components/HelpTerminal.tsx +13 -8
  36. package/components/InlinePipelineView.tsx +111 -0
  37. package/components/MobileView.tsx +20 -0
  38. package/components/MonitorPanel.tsx +9 -4
  39. package/components/NewTaskModal.tsx +32 -0
  40. package/components/PipelineEditor.tsx +49 -6
  41. package/components/PipelineView.tsx +482 -64
  42. package/components/ProjectDetail.tsx +314 -56
  43. package/components/ProjectManager.tsx +49 -4
  44. package/components/SessionView.tsx +27 -13
  45. package/components/SettingsModal.tsx +790 -124
  46. package/components/SkillsPanel.tsx +34 -8
  47. package/components/TaskBoard.tsx +3 -0
  48. package/components/WebTerminal.tsx +259 -45
  49. package/components/WorkspaceTree.tsx +221 -0
  50. package/components/WorkspaceView.tsx +2224 -0
  51. package/docs/LOCAL-DEPLOY.md +15 -15
  52. package/install.sh +2 -2
  53. package/lib/agents/claude-adapter.ts +104 -0
  54. package/lib/agents/generic-adapter.ts +64 -0
  55. package/lib/agents/index.ts +242 -0
  56. package/lib/agents/types.ts +70 -0
  57. package/lib/artifacts.ts +106 -0
  58. package/lib/cloudflared.ts +1 -1
  59. package/lib/delivery.ts +787 -0
  60. package/lib/forge-skills/forge-inbox.md +37 -0
  61. package/lib/forge-skills/forge-send.md +40 -0
  62. package/lib/forge-skills/forge-status.md +32 -0
  63. package/lib/forge-skills/forge-workspace-sync.md +37 -0
  64. package/lib/help-docs/00-overview.md +8 -2
  65. package/lib/help-docs/01-settings.md +159 -2
  66. package/lib/help-docs/05-pipelines.md +95 -6
  67. package/lib/help-docs/07-projects.md +35 -1
  68. package/lib/help-docs/11-workspace.md +204 -0
  69. package/lib/help-docs/CLAUDE.md +5 -2
  70. package/lib/init.ts +62 -12
  71. package/lib/pipeline.ts +537 -1
  72. package/lib/settings.ts +115 -22
  73. package/lib/skills.ts +249 -372
  74. package/lib/task-manager.ts +113 -33
  75. package/lib/telegram-bot.ts +33 -1
  76. package/lib/telegram-standalone.ts +1 -1
  77. package/lib/terminal-server.ts +2 -2
  78. package/lib/terminal-standalone.ts +1 -1
  79. package/lib/workspace/__tests__/state-machine.test.ts +388 -0
  80. package/lib/workspace/__tests__/workspace.test.ts +311 -0
  81. package/lib/workspace/agent-bus.ts +416 -0
  82. package/lib/workspace/agent-worker.ts +667 -0
  83. package/lib/workspace/backends/api-backend.ts +262 -0
  84. package/lib/workspace/backends/cli-backend.ts +479 -0
  85. package/lib/workspace/index.ts +82 -0
  86. package/lib/workspace/manager.ts +136 -0
  87. package/lib/workspace/orchestrator.ts +1804 -0
  88. package/lib/workspace/persistence.ts +310 -0
  89. package/lib/workspace/presets.ts +170 -0
  90. package/lib/workspace/skill-installer.ts +188 -0
  91. package/lib/workspace/smith-memory.ts +498 -0
  92. package/lib/workspace/types.ts +231 -0
  93. package/lib/workspace/watch-manager.ts +288 -0
  94. package/lib/workspace-standalone.ts +790 -0
  95. package/middleware.ts +1 -0
  96. package/next-env.d.ts +1 -1
  97. package/package.json +5 -2
  98. package/src/config/index.ts +13 -2
  99. package/src/core/db/database.ts +1 -0
  100. package/start.sh +10 -0
@@ -8,13 +8,13 @@
8
8
 
9
9
  ### 方案对比
10
10
 
11
- | 方案 | 原理 | 优点 | 缺点 |
12
- |------|------|------|------|
13
- | **同一 WiFi 局域网** | Mac 开 Web 服务,手机直接访问 `192.168.x.x:3000` | 零配置、零成本 | 只能在家用 |
14
- | **Tailscale** | 虚拟局域网,任何网络下设备互通 | 免费、安全、无需公网 IP | 需要装客户端 |
15
- | **Cloudflare Tunnel** | 免费内网穿透,给你一个公网域名 | 免费、HTTPS、不开端口 | 依赖 Cloudflare |
16
- | **ngrok** | 临时隧道 | 一行命令搞定 | 免费版地址每次变 |
17
- | **frp** | 自建内网穿透 | 完全自控 | 需要一台有公网 IP 的服务器 |
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 :3000
27
- │ ├── WebSocket :3000/ws │
28
- │ └── Dashboard :3000
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:3000 │ ← 任何网络下都能访问
40
- │ 或 Safari → 192.168.x.x:3000 │ ← 同一 WiFi 下
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:3000
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:3000
136
+ 手机 → Tailscale → Mac:8403
137
137
 
138
138
  迁移到云端:
139
- 手机 → Tailscale → VPS:3000 (只是 IP 变了)
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
+ }
@@ -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
+ }
@@ -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 || '3000')): Promise<{ url?: string; error?: string }> {
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).