@aion0/forge 0.4.16 → 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 (92) hide show
  1. package/README.md +1 -1
  2. package/RELEASE_NOTES.md +170 -14
  3. package/app/api/agents/route.ts +17 -0
  4. package/app/api/delivery/[id]/route.ts +62 -0
  5. package/app/api/delivery/route.ts +40 -0
  6. package/app/api/mobile-chat/route.ts +13 -7
  7. package/app/api/monitor/route.ts +10 -6
  8. package/app/api/pipelines/[id]/route.ts +16 -3
  9. package/app/api/tasks/route.ts +2 -1
  10. package/app/api/workspace/[id]/agents/route.ts +35 -0
  11. package/app/api/workspace/[id]/memory/route.ts +23 -0
  12. package/app/api/workspace/[id]/smith/route.ts +22 -0
  13. package/app/api/workspace/[id]/stream/route.ts +28 -0
  14. package/app/api/workspace/route.ts +100 -0
  15. package/app/global-error.tsx +10 -4
  16. package/app/icon.ico +0 -0
  17. package/app/layout.tsx +2 -2
  18. package/app/login/LoginForm.tsx +96 -0
  19. package/app/login/page.tsx +7 -98
  20. package/app/page.tsx +2 -2
  21. package/bin/forge-server.mjs +13 -1
  22. package/check-forge-status.sh +9 -0
  23. package/components/ConversationEditor.tsx +411 -0
  24. package/components/ConversationGraphView.tsx +347 -0
  25. package/components/ConversationTerminalView.tsx +303 -0
  26. package/components/Dashboard.tsx +36 -39
  27. package/components/DashboardWrapper.tsx +9 -0
  28. package/components/DeliveryFlowEditor.tsx +491 -0
  29. package/components/DeliveryList.tsx +230 -0
  30. package/components/DeliveryWorkspace.tsx +589 -0
  31. package/components/DocTerminal.tsx +10 -2
  32. package/components/DocsViewer.tsx +10 -2
  33. package/components/HelpTerminal.tsx +11 -6
  34. package/components/InlinePipelineView.tsx +111 -0
  35. package/components/MobileView.tsx +20 -0
  36. package/components/MonitorPanel.tsx +9 -4
  37. package/components/NewTaskModal.tsx +32 -0
  38. package/components/PipelineEditor.tsx +49 -6
  39. package/components/PipelineView.tsx +482 -64
  40. package/components/ProjectDetail.tsx +314 -56
  41. package/components/ProjectManager.tsx +49 -4
  42. package/components/SessionView.tsx +27 -13
  43. package/components/SettingsModal.tsx +790 -124
  44. package/components/SkillsPanel.tsx +31 -8
  45. package/components/TaskBoard.tsx +3 -0
  46. package/components/WebTerminal.tsx +257 -43
  47. package/components/WorkspaceTree.tsx +221 -0
  48. package/components/WorkspaceView.tsx +2224 -0
  49. package/install.sh +2 -2
  50. package/lib/agents/claude-adapter.ts +104 -0
  51. package/lib/agents/generic-adapter.ts +64 -0
  52. package/lib/agents/index.ts +242 -0
  53. package/lib/agents/types.ts +70 -0
  54. package/lib/artifacts.ts +106 -0
  55. package/lib/delivery.ts +787 -0
  56. package/lib/forge-skills/forge-inbox.md +37 -0
  57. package/lib/forge-skills/forge-send.md +40 -0
  58. package/lib/forge-skills/forge-status.md +32 -0
  59. package/lib/forge-skills/forge-workspace-sync.md +37 -0
  60. package/lib/help-docs/00-overview.md +7 -1
  61. package/lib/help-docs/01-settings.md +159 -2
  62. package/lib/help-docs/05-pipelines.md +89 -0
  63. package/lib/help-docs/07-projects.md +35 -1
  64. package/lib/help-docs/11-workspace.md +204 -0
  65. package/lib/help-docs/CLAUDE.md +5 -2
  66. package/lib/init.ts +60 -10
  67. package/lib/pipeline.ts +537 -1
  68. package/lib/settings.ts +115 -22
  69. package/lib/skills.ts +249 -372
  70. package/lib/task-manager.ts +113 -33
  71. package/lib/telegram-bot.ts +33 -1
  72. package/lib/workspace/__tests__/state-machine.test.ts +388 -0
  73. package/lib/workspace/__tests__/workspace.test.ts +311 -0
  74. package/lib/workspace/agent-bus.ts +416 -0
  75. package/lib/workspace/agent-worker.ts +667 -0
  76. package/lib/workspace/backends/api-backend.ts +262 -0
  77. package/lib/workspace/backends/cli-backend.ts +479 -0
  78. package/lib/workspace/index.ts +82 -0
  79. package/lib/workspace/manager.ts +136 -0
  80. package/lib/workspace/orchestrator.ts +1804 -0
  81. package/lib/workspace/persistence.ts +310 -0
  82. package/lib/workspace/presets.ts +170 -0
  83. package/lib/workspace/skill-installer.ts +188 -0
  84. package/lib/workspace/smith-memory.ts +498 -0
  85. package/lib/workspace/types.ts +231 -0
  86. package/lib/workspace/watch-manager.ts +288 -0
  87. package/lib/workspace-standalone.ts +790 -0
  88. package/middleware.ts +1 -0
  89. package/package.json +4 -1
  90. package/src/config/index.ts +12 -1
  91. package/src/core/db/database.ts +1 -0
  92. package/start.sh +7 -0
@@ -0,0 +1,100 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { listWorkspaces, findWorkspaceByProject, loadWorkspace, saveWorkspace, deleteWorkspace } from '@/lib/workspace';
3
+ import type { WorkspaceState } from '@/lib/workspace';
4
+ import { randomUUID } from 'node:crypto';
5
+
6
+ // List workspaces, find by projectPath, or export template
7
+ export async function GET(req: Request) {
8
+ const url = new URL(req.url);
9
+ const projectPath = url.searchParams.get('projectPath');
10
+ const exportId = url.searchParams.get('export');
11
+
12
+ if (exportId) {
13
+ // Export workspace as template (agents + positions, no state/logs)
14
+ const ws = loadWorkspace(exportId);
15
+ if (!ws) return NextResponse.json({ error: 'not found' }, { status: 404 });
16
+ const template = {
17
+ name: ws.projectName + ' template',
18
+ agents: ws.agents.map(a => ({ ...a, entries: undefined })), // strip Input entries
19
+ nodePositions: ws.nodePositions,
20
+ exportedAt: Date.now(),
21
+ };
22
+ return NextResponse.json(template);
23
+ }
24
+
25
+ if (projectPath) {
26
+ const ws = findWorkspaceByProject(projectPath);
27
+ return NextResponse.json(ws || null);
28
+ }
29
+
30
+ return NextResponse.json(listWorkspaces());
31
+ }
32
+
33
+ // Create workspace or import template
34
+ export async function POST(req: Request) {
35
+ const body = await req.json();
36
+ const { projectPath, projectName, template } = body;
37
+
38
+ if (!projectPath || !projectName) {
39
+ return NextResponse.json({ error: 'projectPath and projectName are required' }, { status: 400 });
40
+ }
41
+
42
+ const existing = findWorkspaceByProject(projectPath);
43
+ if (existing && !template) {
44
+ return NextResponse.json(existing);
45
+ }
46
+
47
+ const state: WorkspaceState = {
48
+ id: existing?.id || randomUUID(),
49
+ projectPath,
50
+ projectName,
51
+ agents: [],
52
+ agentStates: {},
53
+ nodePositions: {},
54
+ busLog: [],
55
+ createdAt: existing?.createdAt || Date.now(),
56
+ updatedAt: Date.now(),
57
+ };
58
+
59
+ // Import template: create agents from template with new IDs
60
+ if (template?.agents) {
61
+ const idMap = new Map<string, string>(); // old ID → new ID
62
+ const ts = Date.now();
63
+ for (const agent of template.agents) {
64
+ const newId = `${agent.label.toLowerCase().replace(/\s+/g, '-')}-${ts}-${Math.random().toString(36).slice(2, 5)}`;
65
+ idMap.set(agent.id, newId);
66
+ }
67
+ for (const agent of template.agents) {
68
+ state.agents.push({
69
+ ...agent,
70
+ id: idMap.get(agent.id) || agent.id,
71
+ dependsOn: agent.dependsOn.map((d: string) => idMap.get(d) || d),
72
+ entries: agent.type === 'input' ? [] : undefined,
73
+ });
74
+ state.agentStates[idMap.get(agent.id) || agent.id] = { smithStatus: 'down', mode: 'auto', taskStatus: 'idle', history: [], artifacts: [] };
75
+ }
76
+ if (template.nodePositions) {
77
+ for (const [oldId, pos] of Object.entries(template.nodePositions)) {
78
+ const newId = idMap.get(oldId);
79
+ if (newId) state.nodePositions[newId] = pos as { x: number; y: number };
80
+ }
81
+ }
82
+ }
83
+
84
+ await saveWorkspace(state);
85
+ return NextResponse.json(state, { status: 201 });
86
+ }
87
+
88
+ // Delete a workspace
89
+ export async function DELETE(req: Request) {
90
+ const url = new URL(req.url);
91
+ const id = url.searchParams.get('id');
92
+ if (!id) return NextResponse.json({ error: 'id required' }, { status: 400 });
93
+
94
+ // Unload from daemon if active
95
+ const daemonUrl = `http://localhost:${Number(process.env.WORKSPACE_PORT) || 8405}`;
96
+ try { await fetch(`${daemonUrl}/workspace/${id}/unload`, { method: 'POST' }); } catch {}
97
+
98
+ deleteWorkspace(id);
99
+ return NextResponse.json({ ok: true });
100
+ }
@@ -1,12 +1,18 @@
1
1
  'use client';
2
2
 
3
- export default function GlobalError({ error, reset }: { error: Error; reset: () => void }) {
3
+ export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
4
4
  return (
5
- <html>
5
+ <html lang="en">
6
+ <head>
7
+ <meta charSet="utf-8" />
8
+ </head>
6
9
  <body style={{ background: '#0a0a0a', color: '#e5e5e5', fontFamily: 'monospace', padding: '2rem' }}>
7
10
  <h2>Something went wrong</h2>
8
- <p style={{ color: '#999' }}>{error.message}</p>
9
- <button onClick={reset} style={{ marginTop: '1rem', padding: '0.5rem 1rem', background: '#3b82f6', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
11
+ <p style={{ color: '#999' }}>{error?.message || 'Unknown error'}</p>
12
+ <button
13
+ onClick={() => reset()}
14
+ style={{ marginTop: '1rem', padding: '0.5rem 1rem', background: '#3b82f6', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer' }}
15
+ >
10
16
  Try again
11
17
  </button>
12
18
  </body>
package/app/icon.ico ADDED
Binary file
package/app/layout.tsx CHANGED
@@ -8,8 +8,8 @@ export const metadata: Metadata = {
8
8
 
9
9
  export default function RootLayout({ children }: { children: React.ReactNode }) {
10
10
  return (
11
- <html lang="en" className="dark">
12
- <body className="min-h-screen bg-[var(--bg-primary)]">
11
+ <html lang="en" className="dark" suppressHydrationWarning>
12
+ <body className="min-h-screen bg-[var(--bg-primary)]" suppressHydrationWarning>
13
13
  {children}
14
14
  </body>
15
15
  </html>
@@ -0,0 +1,96 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import { signIn } from 'next-auth/react';
5
+
6
+ export default function LoginForm({ isRemote }: { isRemote: boolean }) {
7
+ const [password, setPassword] = useState('');
8
+ const [sessionCode, setSessionCode] = useState('');
9
+ const [error, setError] = useState('');
10
+ const [showHelp, setShowHelp] = useState(false);
11
+
12
+ useEffect(() => {
13
+ const saved = localStorage.getItem('forge-theme');
14
+ if (saved === 'light') document.documentElement.setAttribute('data-theme', 'light');
15
+ }, []);
16
+
17
+ const handleSubmit = async (e: React.FormEvent) => {
18
+ e.preventDefault();
19
+ setError('');
20
+ const result = await signIn('credentials', {
21
+ password,
22
+ sessionCode: isRemote ? sessionCode : '',
23
+ isRemote: String(isRemote),
24
+ redirect: false,
25
+ }) as { error?: string; ok?: boolean } | undefined;
26
+ if (result?.error) {
27
+ setError(isRemote ? 'Wrong password or session code' : 'Wrong password');
28
+ } else if (result?.ok) {
29
+ window.location.href = window.location.origin + '/';
30
+ }
31
+ };
32
+
33
+ return (
34
+ <div className="min-h-screen flex items-center justify-center">
35
+ <div className="w-80 space-y-6">
36
+ <div className="text-center">
37
+ <img src="/icon.png" alt="Forge" width={48} height={48} className="rounded mx-auto mb-2" />
38
+ <h1 className="text-2xl font-bold text-[var(--text-primary)]">Forge</h1>
39
+ <p className="text-sm text-[var(--text-secondary)] mt-1">
40
+ {isRemote ? 'Remote Access' : 'Local Access'}
41
+ </p>
42
+ </div>
43
+
44
+ <form onSubmit={handleSubmit} className="space-y-3">
45
+ <input
46
+ type="password"
47
+ value={password}
48
+ onChange={e => { setPassword(e.target.value); setError(''); }}
49
+ placeholder="Admin Password"
50
+ autoFocus
51
+ className="w-full px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
52
+ />
53
+ {isRemote && (
54
+ <input
55
+ type="text"
56
+ inputMode="numeric"
57
+ pattern="[0-9]*"
58
+ maxLength={8}
59
+ value={sessionCode}
60
+ onChange={e => { setSessionCode(e.target.value.replace(/\D/g, '')); setError(''); }}
61
+ placeholder="Session Code (8 digits)"
62
+ className="w-full px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-sm text-[var(--text-primary)] font-mono tracking-widest text-center focus:outline-none focus:border-[var(--accent)]"
63
+ />
64
+ )}
65
+ {error && <p className="text-xs text-[var(--red)]">{error}</p>}
66
+ <button
67
+ type="submit"
68
+ className="w-full py-2 bg-[var(--accent)] text-white rounded text-sm hover:opacity-90"
69
+ >
70
+ Sign In
71
+ </button>
72
+ {isRemote && (
73
+ <p className="text-[10px] text-[var(--text-secondary)] text-center">
74
+ Session code is generated when tunnel starts. Use /tunnel_code in Telegram or <code>forge tcode</code> to get it.
75
+ </p>
76
+ )}
77
+ <div className="text-center">
78
+ <button
79
+ type="button"
80
+ onClick={() => setShowHelp(v => !v)}
81
+ className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
82
+ >
83
+ Forgot password?
84
+ </button>
85
+ {showHelp && (
86
+ <p className="text-[10px] text-[var(--text-secondary)] mt-1 bg-[var(--bg-tertiary)] rounded p-2">
87
+ Run in terminal:<br />
88
+ <code className="text-[var(--accent)]">forge --reset-password</code>
89
+ </p>
90
+ )}
91
+ </div>
92
+ </form>
93
+ </div>
94
+ </div>
95
+ );
96
+ }
@@ -1,101 +1,10 @@
1
- 'use client';
1
+ import { headers } from 'next/headers';
2
+ import LoginForm from './LoginForm';
2
3
 
3
- import { useState, useEffect } from 'react';
4
- import { signIn } from 'next-auth/react';
4
+ export default async function LoginPage() {
5
+ const headersList = await headers();
6
+ const host = headersList.get('host') || '';
7
+ const isRemote = host.endsWith('.trycloudflare.com');
5
8
 
6
- export default function LoginPage() {
7
- const [password, setPassword] = useState('');
8
- const [sessionCode, setSessionCode] = useState('');
9
- const [error, setError] = useState('');
10
- const [isRemote, setIsRemote] = useState(false);
11
- const [showHelp, setShowHelp] = useState(false);
12
-
13
- useEffect(() => {
14
- const host = window.location.hostname;
15
- setIsRemote(host.endsWith('.trycloudflare.com'));
16
- // Restore theme
17
- const saved = localStorage.getItem('forge-theme');
18
- if (saved === 'light') document.documentElement.setAttribute('data-theme', 'light');
19
- }, []);
20
-
21
- const handleLocal = async (e: React.FormEvent) => {
22
- e.preventDefault();
23
- setError('');
24
- const result = await signIn('credentials', {
25
- password,
26
- sessionCode: isRemote ? sessionCode : '',
27
- isRemote: String(isRemote),
28
- redirect: false,
29
- }) as { error?: string; ok?: boolean } | undefined;
30
- if (result?.error) {
31
- setError(isRemote ? 'Wrong password or session code' : 'Wrong password');
32
- } else if (result?.ok) {
33
- window.location.href = window.location.origin + '/';
34
- }
35
- };
36
-
37
- return (
38
- <div className="min-h-screen flex items-center justify-center">
39
- <div className="w-80 space-y-6">
40
- <div className="text-center">
41
- <img src="/icon.png" alt="Forge" width={48} height={48} className="rounded mx-auto mb-2" />
42
- <h1 className="text-2xl font-bold text-[var(--text-primary)]">Forge</h1>
43
- <p className="text-sm text-[var(--text-secondary)] mt-1">
44
- {isRemote ? 'Remote Access' : 'Local Access'}
45
- </p>
46
- </div>
47
-
48
- <form onSubmit={handleLocal} className="space-y-3">
49
- <input
50
- type="password"
51
- value={password}
52
- onChange={e => { setPassword(e.target.value); setError(''); }}
53
- placeholder="Admin Password"
54
- autoFocus
55
- className="w-full px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
56
- />
57
- {isRemote && (
58
- <input
59
- type="text"
60
- inputMode="numeric"
61
- pattern="[0-9]*"
62
- maxLength={8}
63
- value={sessionCode}
64
- onChange={e => { setSessionCode(e.target.value.replace(/\D/g, '')); setError(''); }}
65
- placeholder="Session Code (8 digits)"
66
- className="w-full px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-sm text-[var(--text-primary)] font-mono tracking-widest text-center focus:outline-none focus:border-[var(--accent)]"
67
- />
68
- )}
69
- {error && <p className="text-xs text-[var(--red)]">{error}</p>}
70
- <button
71
- type="submit"
72
- className="w-full py-2 bg-[var(--accent)] text-white rounded text-sm hover:opacity-90"
73
- >
74
- Sign In
75
- </button>
76
- {isRemote && (
77
- <p className="text-[10px] text-[var(--text-secondary)] text-center">
78
- Session code is generated when tunnel starts. Use /tunnel_code in Telegram to get it.
79
- </p>
80
- )}
81
- <div className="text-center">
82
- <button
83
- type="button"
84
- onClick={() => setShowHelp(v => !v)}
85
- className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
86
- >
87
- Forgot password?
88
- </button>
89
- {showHelp && (
90
- <p className="text-[10px] text-[var(--text-secondary)] mt-1 bg-[var(--bg-tertiary)] rounded p-2">
91
- Run in terminal:<br />
92
- <code className="text-[var(--accent)]">forge --reset-password</code>
93
- </p>
94
- )}
95
- </div>
96
- </form>
97
-
98
- </div>
99
- </div>
100
- );
9
+ return <LoginForm isRemote={isRemote} />;
101
10
  }
package/app/page.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import { auth } from '@/lib/auth';
2
2
  import { redirect } from 'next/navigation';
3
3
  import { headers } from 'next/headers';
4
- import Dashboard from '@/components/Dashboard';
4
+ import DashboardWrapper from '@/components/DashboardWrapper';
5
5
 
6
6
  export default async function Home({ searchParams }: { searchParams: Promise<{ force?: string }> }) {
7
7
  const session = await auth();
@@ -17,5 +17,5 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ f
17
17
  if (isMobile) redirect('/mobile');
18
18
  }
19
19
 
20
- return <Dashboard user={session.user} />;
20
+ return <DashboardWrapper user={session.user} />;
21
21
  }
@@ -64,6 +64,7 @@ const resetPassword = process.argv.includes('--reset-password');
64
64
 
65
65
  const webPort = parseInt(getArg('--port')) || 8403;
66
66
  const terminalPort = parseInt(getArg('--terminal-port')) || (webPort + 1);
67
+ const workspacePort = parseInt(getArg('--workspace-port')) || (webPort + 2);
67
68
  const DATA_DIR = getArg('--dir')?.replace(/^~/, homedir()) || join(homedir(), '.forge', 'data');
68
69
 
69
70
  const PID_FILE = join(DATA_DIR, 'forge.pid');
@@ -124,6 +125,7 @@ if (existsSync(envFile)) {
124
125
  // Set env vars for Next.js and terminal server
125
126
  process.env.PORT = String(webPort);
126
127
  process.env.TERMINAL_PORT = String(terminalPort);
128
+ process.env.WORKSPACE_PORT = String(workspacePort);
127
129
  process.env.FORGE_DATA_DIR = DATA_DIR;
128
130
 
129
131
  // ── Password setup (first run or --reset-password) ──
@@ -255,7 +257,7 @@ function cleanupOrphans() {
255
257
  }
256
258
  // Kill standalone processes: our instance's + orphans without any tag
257
259
  try {
258
- const out = execSync(`ps aux | grep -E 'telegram-standalone|terminal-standalone' | grep -v grep`, {
260
+ const out = execSync(`ps aux | grep -E 'telegram-standalone|terminal-standalone|workspace-standalone' | grep -v grep`, {
259
261
  encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
260
262
  }).trim();
261
263
  for (const line of out.split('\n').filter(Boolean)) {
@@ -298,6 +300,16 @@ function startServices() {
298
300
  services.push(telegramChild);
299
301
  console.log(`[forge] Telegram bot started (pid: ${telegramChild.pid})`);
300
302
 
303
+ // Workspace daemon
304
+ const workspaceScript = join(ROOT, 'lib', 'workspace-standalone.ts');
305
+ const workspaceChild = spawn('npx', ['tsx', workspaceScript, instanceTag], {
306
+ cwd: ROOT,
307
+ stdio: ['ignore', 'inherit', 'inherit'],
308
+ env: { ...process.env },
309
+ });
310
+ services.push(workspaceChild);
311
+ console.log(`[forge] Workspace daemon started (pid: ${workspaceChild.pid})`);
312
+
301
313
  // Track all child PIDs for clean shutdown
302
314
  const childPids = services.map(c => c.pid).filter(Boolean);
303
315
  savePids(childPids);
@@ -32,6 +32,15 @@ else
32
32
  echo " ○ Telegram stopped"
33
33
  fi
34
34
 
35
+ # Workspace Daemon
36
+ count=$(ps aux | grep 'workspace-standalone' | grep -v grep | grep -v 'npm exec' | grep -v 'cli.mjs' | wc -l | tr -d ' ')
37
+ pid=$(ps aux | grep 'workspace-standalone' | grep -v grep | grep -v 'npm exec' | grep -v 'cli.mjs' | awk '{print $2}' | head -1)
38
+ if [ "$count" -gt 0 ]; then
39
+ echo " ● Workspace running (pid: $pid)"
40
+ else
41
+ echo " ○ Workspace stopped"
42
+ fi
43
+
35
44
  # Cloudflare Tunnel
36
45
  count=$(ps aux | grep 'cloudflared tunnel' | grep -v grep | wc -l | tr -d ' ')
37
46
  pid=$(ps aux | grep 'cloudflared tunnel' | grep -v grep | awk '{print $2}' | head -1)