@aion0/forge 0.2.4 → 0.2.6
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/app/api/code/route.ts +7 -5
- package/app/api/git/route.ts +2 -2
- package/app/api/pipelines/route.ts +16 -0
- package/app/api/preview/route.ts +101 -87
- package/app/global-error.tsx +15 -0
- package/bin/forge-server.mjs +23 -0
- package/cli/mw.ts +13 -0
- package/components/CodeViewer.tsx +6 -6
- package/components/Dashboard.tsx +1 -1
- package/components/PipelineEditor.tsx +1 -1
- package/components/PipelineView.tsx +27 -7
- package/components/PreviewPanel.tsx +104 -91
- package/components/SettingsModal.tsx +57 -0
- package/components/WebTerminal.tsx +12 -2
- package/dev-test.sh +1 -1
- package/install.sh +29 -0
- package/instrumentation.ts +2 -3
- package/lib/init.ts +4 -3
- package/lib/notify.ts +8 -0
- package/lib/password.ts +1 -1
- package/lib/pipeline.ts +66 -3
- package/lib/settings.ts +6 -0
- package/lib/task-manager.ts +20 -1
- package/lib/telegram-bot.ts +161 -116
- package/package.json +1 -1
- package/tsconfig.json +2 -1
|
@@ -1,134 +1,154 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useEffect } from 'react';
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
interface PreviewEntry {
|
|
6
|
+
port: number;
|
|
7
|
+
url: string | null;
|
|
8
|
+
status: string;
|
|
9
|
+
label?: string;
|
|
10
|
+
}
|
|
4
11
|
|
|
5
12
|
export default function PreviewPanel() {
|
|
6
|
-
const [
|
|
13
|
+
const [previews, setPreviews] = useState<PreviewEntry[]>([]);
|
|
7
14
|
const [inputPort, setInputPort] = useState('');
|
|
8
|
-
const [
|
|
9
|
-
const [
|
|
15
|
+
const [inputLabel, setInputLabel] = useState('');
|
|
16
|
+
const [starting, setStarting] = useState(false);
|
|
10
17
|
const [error, setError] = useState('');
|
|
18
|
+
const [activePreview, setActivePreview] = useState<number | null>(null);
|
|
11
19
|
const [isRemote, setIsRemote] = useState(false);
|
|
12
20
|
|
|
13
21
|
useEffect(() => {
|
|
14
22
|
setIsRemote(!['localhost', '127.0.0.1'].includes(window.location.hostname));
|
|
15
|
-
fetch('/api/preview')
|
|
16
|
-
.then(r => r.json())
|
|
17
|
-
.then(d => {
|
|
18
|
-
if (d.port) {
|
|
19
|
-
setPort(d.port);
|
|
20
|
-
setInputPort(String(d.port));
|
|
21
|
-
setTunnelUrl(d.url || null);
|
|
22
|
-
setStatus(d.status || 'stopped');
|
|
23
|
-
}
|
|
24
|
-
})
|
|
25
|
-
.catch(() => {});
|
|
26
23
|
}, []);
|
|
27
24
|
|
|
25
|
+
const fetchPreviews = useCallback(async () => {
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetch('/api/preview');
|
|
28
|
+
const data = await res.json();
|
|
29
|
+
if (Array.isArray(data)) setPreviews(data);
|
|
30
|
+
} catch {}
|
|
31
|
+
}, []);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
fetchPreviews();
|
|
35
|
+
const timer = setInterval(fetchPreviews, 5000);
|
|
36
|
+
return () => clearInterval(timer);
|
|
37
|
+
}, [fetchPreviews]);
|
|
38
|
+
|
|
28
39
|
const handleStart = async () => {
|
|
29
40
|
const p = parseInt(inputPort);
|
|
30
|
-
if (!p || p < 1 || p > 65535) {
|
|
31
|
-
setError('Invalid port');
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
41
|
+
if (!p || p < 1 || p > 65535) { setError('Invalid port'); return; }
|
|
34
42
|
setError('');
|
|
35
|
-
|
|
43
|
+
setStarting(true);
|
|
36
44
|
try {
|
|
37
45
|
const res = await fetch('/api/preview', {
|
|
38
46
|
method: 'POST',
|
|
39
47
|
headers: { 'Content-Type': 'application/json' },
|
|
40
|
-
body: JSON.stringify({ port: p }),
|
|
48
|
+
body: JSON.stringify({ action: 'start', port: p, label: inputLabel || undefined }),
|
|
41
49
|
});
|
|
42
50
|
const data = await res.json();
|
|
43
|
-
if (data.error)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
setTunnelUrl(data.url || null);
|
|
49
|
-
setStatus(data.status || 'running');
|
|
51
|
+
if (data.error) setError(data.error);
|
|
52
|
+
else {
|
|
53
|
+
setInputPort('');
|
|
54
|
+
setInputLabel('');
|
|
55
|
+
setActivePreview(p);
|
|
50
56
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
57
|
+
fetchPreviews();
|
|
58
|
+
} catch { setError('Failed'); }
|
|
59
|
+
setStarting(false);
|
|
55
60
|
};
|
|
56
61
|
|
|
57
|
-
const handleStop = async () => {
|
|
62
|
+
const handleStop = async (port: number) => {
|
|
58
63
|
await fetch('/api/preview', {
|
|
59
64
|
method: 'POST',
|
|
60
65
|
headers: { 'Content-Type': 'application/json' },
|
|
61
|
-
body: JSON.stringify({ action: 'stop' }),
|
|
66
|
+
body: JSON.stringify({ action: 'stop', port }),
|
|
62
67
|
});
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
setStatus('stopped');
|
|
68
|
+
if (activePreview === port) setActivePreview(null);
|
|
69
|
+
fetchPreviews();
|
|
66
70
|
};
|
|
67
71
|
|
|
68
|
-
|
|
69
|
-
const previewSrc =
|
|
70
|
-
?
|
|
71
|
-
:
|
|
72
|
+
const active = previews.find(p => p.port === activePreview);
|
|
73
|
+
const previewSrc = active
|
|
74
|
+
? (isRemote ? active.url : `http://localhost:${active.port}`)
|
|
75
|
+
: null;
|
|
72
76
|
|
|
73
77
|
return (
|
|
74
78
|
<div className="flex-1 flex flex-col min-h-0">
|
|
75
|
-
{/*
|
|
76
|
-
<div className="px-4 py-2 border-b border-[var(--border)]
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
79
|
+
{/* Top bar */}
|
|
80
|
+
<div className="px-4 py-2 border-b border-[var(--border)] shrink-0 space-y-2">
|
|
81
|
+
{/* Preview list */}
|
|
82
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
83
|
+
<span className="text-[11px] font-semibold text-[var(--text-primary)]">Demo Preview</span>
|
|
84
|
+
{previews.map(p => (
|
|
85
|
+
<div key={p.port} className="flex items-center gap-1">
|
|
86
|
+
<button
|
|
87
|
+
onClick={() => setActivePreview(p.port)}
|
|
88
|
+
className={`text-[10px] px-2 py-0.5 rounded ${activePreview === p.port ? 'bg-[var(--accent)] text-white' : 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
|
|
89
|
+
>
|
|
90
|
+
<span className={`mr-1 ${p.status === 'running' ? 'text-green-400' : 'text-gray-500'}`}>●</span>
|
|
91
|
+
{p.label || `:${p.port}`}
|
|
92
|
+
</button>
|
|
93
|
+
{p.url && (
|
|
94
|
+
<button
|
|
95
|
+
onClick={() => navigator.clipboard.writeText(p.url!)}
|
|
96
|
+
className="text-[8px] text-green-400 hover:text-green-300 truncate max-w-[150px]"
|
|
97
|
+
title={`Copy: ${p.url}`}
|
|
98
|
+
>
|
|
99
|
+
{p.url.replace('https://', '').slice(0, 20)}...
|
|
100
|
+
</button>
|
|
101
|
+
)}
|
|
102
|
+
<button
|
|
103
|
+
onClick={() => handleStop(p.port)}
|
|
104
|
+
className="text-[9px] text-red-400 hover:text-red-300"
|
|
105
|
+
>
|
|
106
|
+
x
|
|
107
|
+
</button>
|
|
108
|
+
</div>
|
|
109
|
+
))}
|
|
110
|
+
</div>
|
|
87
111
|
|
|
88
|
-
{
|
|
112
|
+
{/* Add new */}
|
|
113
|
+
<div className="flex items-center gap-2">
|
|
114
|
+
<input
|
|
115
|
+
type="number"
|
|
116
|
+
value={inputPort}
|
|
117
|
+
onChange={e => setInputPort(e.target.value)}
|
|
118
|
+
onKeyDown={e => e.key === 'Enter' && handleStart()}
|
|
119
|
+
placeholder="Port"
|
|
120
|
+
className="w-20 text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] font-mono"
|
|
121
|
+
/>
|
|
122
|
+
<input
|
|
123
|
+
value={inputLabel}
|
|
124
|
+
onChange={e => setInputLabel(e.target.value)}
|
|
125
|
+
onKeyDown={e => e.key === 'Enter' && handleStart()}
|
|
126
|
+
placeholder="Label (optional)"
|
|
127
|
+
className="w-32 text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
|
128
|
+
/>
|
|
89
129
|
<button
|
|
90
130
|
onClick={handleStart}
|
|
91
|
-
disabled={!inputPort}
|
|
131
|
+
disabled={!inputPort || starting}
|
|
92
132
|
className="text-[10px] px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
|
|
93
133
|
>
|
|
94
|
-
|
|
134
|
+
{starting ? 'Starting...' : '+ Add'}
|
|
95
135
|
</button>
|
|
96
|
-
|
|
97
|
-
<span className="text-[10px] text-yellow-400">Starting tunnel...</span>
|
|
98
|
-
) : (
|
|
99
|
-
<>
|
|
100
|
-
<span className="text-[10px] text-green-400">● localhost:{port}</span>
|
|
101
|
-
{tunnelUrl && (
|
|
102
|
-
<button
|
|
103
|
-
onClick={() => { navigator.clipboard.writeText(tunnelUrl); }}
|
|
104
|
-
className="text-[10px] text-green-400 hover:text-green-300 truncate max-w-[250px]"
|
|
105
|
-
title={`Click to copy: ${tunnelUrl}`}
|
|
106
|
-
>
|
|
107
|
-
{tunnelUrl.replace('https://', '')}
|
|
108
|
-
</button>
|
|
109
|
-
)}
|
|
136
|
+
{active && (
|
|
110
137
|
<a
|
|
111
138
|
href={previewSrc || '#'}
|
|
112
139
|
target="_blank"
|
|
113
140
|
rel="noopener"
|
|
114
|
-
className="text-[10px] text-[var(--accent)] hover:underline"
|
|
141
|
+
className="text-[10px] text-[var(--accent)] hover:underline ml-auto"
|
|
115
142
|
>
|
|
116
143
|
Open ↗
|
|
117
144
|
</a>
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
>
|
|
122
|
-
Stop
|
|
123
|
-
</button>
|
|
124
|
-
</>
|
|
125
|
-
)}
|
|
126
|
-
|
|
127
|
-
{error && <span className="text-[10px] text-red-400">{error}</span>}
|
|
145
|
+
)}
|
|
146
|
+
{error && <span className="text-[10px] text-red-400">{error}</span>}
|
|
147
|
+
</div>
|
|
128
148
|
</div>
|
|
129
149
|
|
|
130
150
|
{/* Preview iframe */}
|
|
131
|
-
{previewSrc && status === 'running' ? (
|
|
151
|
+
{previewSrc && active?.status === 'running' ? (
|
|
132
152
|
<iframe
|
|
133
153
|
src={previewSrc}
|
|
134
154
|
className="flex-1 w-full border-0 bg-white"
|
|
@@ -137,15 +157,8 @@ export default function PreviewPanel() {
|
|
|
137
157
|
) : (
|
|
138
158
|
<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
|
|
139
159
|
<div className="text-center space-y-3 max-w-md">
|
|
140
|
-
<p className="text-sm">
|
|
141
|
-
<p className="text-xs">Enter
|
|
142
|
-
<p className="text-xs">A dedicated Cloudflare Tunnel will be created for that port, giving it its own public URL — no path prefix issues.</p>
|
|
143
|
-
<div className="text-[10px] text-left bg-[var(--bg-tertiary)] rounded p-3 space-y-1">
|
|
144
|
-
<p>1. Start your dev server: <code className="text-[var(--accent)]">npm run dev</code></p>
|
|
145
|
-
<p>2. Enter its port (e.g. <code className="text-[var(--accent)]">4321</code>)</p>
|
|
146
|
-
<p>3. Click <strong>Start Tunnel</strong></p>
|
|
147
|
-
<p>4. Share the generated URL — it maps directly to your dev server</p>
|
|
148
|
-
</div>
|
|
160
|
+
<p className="text-sm">{previews.length > 0 ? 'Select a preview to display' : 'Preview local dev servers'}</p>
|
|
161
|
+
<p className="text-xs">Enter a port, add a label, and click Add. Each preview gets its own Cloudflare Tunnel URL.</p>
|
|
149
162
|
</div>
|
|
150
163
|
</div>
|
|
151
164
|
)}
|
|
@@ -12,6 +12,9 @@ interface Settings {
|
|
|
12
12
|
notifyOnFailure: boolean;
|
|
13
13
|
tunnelAutoStart: boolean;
|
|
14
14
|
telegramTunnelPassword: string;
|
|
15
|
+
taskModel: string;
|
|
16
|
+
pipelineModel: string;
|
|
17
|
+
telegramModel: string;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
interface TunnelStatus {
|
|
@@ -33,6 +36,9 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
33
36
|
notifyOnFailure: true,
|
|
34
37
|
tunnelAutoStart: false,
|
|
35
38
|
telegramTunnelPassword: '',
|
|
39
|
+
taskModel: 'sonnet',
|
|
40
|
+
pipelineModel: 'sonnet',
|
|
41
|
+
telegramModel: 'sonnet',
|
|
36
42
|
});
|
|
37
43
|
const [newRoot, setNewRoot] = useState('');
|
|
38
44
|
const [newDocRoot, setNewDocRoot] = useState('');
|
|
@@ -263,6 +269,57 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
263
269
|
</div>
|
|
264
270
|
</div>
|
|
265
271
|
|
|
272
|
+
{/* Model Settings */}
|
|
273
|
+
<div className="space-y-2">
|
|
274
|
+
<label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">
|
|
275
|
+
Models
|
|
276
|
+
</label>
|
|
277
|
+
<p className="text-[10px] text-[var(--text-secondary)]">
|
|
278
|
+
Claude model for each feature. Uses your Claude Code subscription. Options: sonnet, opus, haiku, or default (subscription default).
|
|
279
|
+
</p>
|
|
280
|
+
<div className="grid grid-cols-3 gap-2">
|
|
281
|
+
<div>
|
|
282
|
+
<label className="text-[9px] text-[var(--text-secondary)] block mb-0.5">Tasks</label>
|
|
283
|
+
<select
|
|
284
|
+
value={settings.taskModel || 'sonnet'}
|
|
285
|
+
onChange={e => setSettings({ ...settings, taskModel: e.target.value })}
|
|
286
|
+
className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)]"
|
|
287
|
+
>
|
|
288
|
+
<option value="default">Default</option>
|
|
289
|
+
<option value="sonnet">Sonnet</option>
|
|
290
|
+
<option value="opus">Opus</option>
|
|
291
|
+
<option value="haiku">Haiku</option>
|
|
292
|
+
</select>
|
|
293
|
+
</div>
|
|
294
|
+
<div>
|
|
295
|
+
<label className="text-[9px] text-[var(--text-secondary)] block mb-0.5">Pipelines</label>
|
|
296
|
+
<select
|
|
297
|
+
value={settings.pipelineModel || 'sonnet'}
|
|
298
|
+
onChange={e => setSettings({ ...settings, pipelineModel: e.target.value })}
|
|
299
|
+
className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)]"
|
|
300
|
+
>
|
|
301
|
+
<option value="default">Default</option>
|
|
302
|
+
<option value="sonnet">Sonnet</option>
|
|
303
|
+
<option value="opus">Opus</option>
|
|
304
|
+
<option value="haiku">Haiku</option>
|
|
305
|
+
</select>
|
|
306
|
+
</div>
|
|
307
|
+
<div>
|
|
308
|
+
<label className="text-[9px] text-[var(--text-secondary)] block mb-0.5">Telegram</label>
|
|
309
|
+
<select
|
|
310
|
+
value={settings.telegramModel || 'sonnet'}
|
|
311
|
+
onChange={e => setSettings({ ...settings, telegramModel: e.target.value })}
|
|
312
|
+
className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)]"
|
|
313
|
+
>
|
|
314
|
+
<option value="default">Default</option>
|
|
315
|
+
<option value="sonnet">Sonnet</option>
|
|
316
|
+
<option value="opus">Opus</option>
|
|
317
|
+
<option value="haiku">Haiku</option>
|
|
318
|
+
</select>
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
|
|
266
323
|
{/* Remote Access (Cloudflare Tunnel) */}
|
|
267
324
|
<div className="space-y-2">
|
|
268
325
|
<label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">
|
|
@@ -429,7 +429,7 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
429
429
|
const detachedCount = tmuxSessions.filter(s => !usedSessions.includes(s.name)).length;
|
|
430
430
|
|
|
431
431
|
return (
|
|
432
|
-
<div className="h-full w-full flex-1 flex flex-col bg-[#1a1a2e]">
|
|
432
|
+
<div className="h-full w-full flex-1 flex flex-col bg-[#1a1a2e] overflow-hidden">
|
|
433
433
|
{/* Tab bar + toolbar */}
|
|
434
434
|
<div className="flex items-center bg-[#12122a] border-b border-[#2a2a4a] shrink-0">
|
|
435
435
|
{/* Tabs */}
|
|
@@ -1080,7 +1080,17 @@ const MemoTerminalPane = memo(function TerminalPane({
|
|
|
1080
1080
|
}, 500);
|
|
1081
1081
|
}
|
|
1082
1082
|
} else if (msg.type === 'error') {
|
|
1083
|
-
|
|
1083
|
+
// Session no longer exists — auto-create a new one
|
|
1084
|
+
if (!connectedSession && msg.message?.includes('no longer exists') && createRetries < MAX_CREATE_RETRIES) {
|
|
1085
|
+
createRetries++;
|
|
1086
|
+
isNewlyCreated = true;
|
|
1087
|
+
term.write(`\r\n\x1b[93m[${msg.message} — creating new session...]\x1b[0m\r\n`);
|
|
1088
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
1089
|
+
socket.send(JSON.stringify({ type: 'create', cols: term.cols, rows: term.rows }));
|
|
1090
|
+
}
|
|
1091
|
+
} else {
|
|
1092
|
+
term.write(`\r\n\x1b[93m[${msg.message || 'error'}]\x1b[0m\r\n`);
|
|
1093
|
+
}
|
|
1084
1094
|
} else if (msg.type === 'exit') {
|
|
1085
1095
|
term.write('\r\n\x1b[90m[session ended]\x1b[0m\r\n');
|
|
1086
1096
|
}
|
package/dev-test.sh
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
# dev-test.sh — Start Forge test instance (port 4000, data ~/.forge-test)
|
|
3
3
|
|
|
4
4
|
mkdir -p ~/.forge-test
|
|
5
|
-
PORT=4000 TERMINAL_PORT=4001 FORGE_DATA_DIR=~/.forge-test
|
|
5
|
+
PORT=4000 TERMINAL_PORT=4001 FORGE_DATA_DIR=~/.forge-test npx next dev --turbopack -p 4000
|
package/install.sh
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# install.sh — Install Forge globally, ready to run
|
|
3
|
+
#
|
|
4
|
+
# Usage:
|
|
5
|
+
# ./install.sh # from npm
|
|
6
|
+
# ./install.sh local # from local source
|
|
7
|
+
|
|
8
|
+
set -e
|
|
9
|
+
|
|
10
|
+
if [ "$1" = "local" ] || [ "$1" = "--local" ]; then
|
|
11
|
+
echo "[forge] Installing from local source..."
|
|
12
|
+
npm uninstall -g @aion0/forge 2>/dev/null || true
|
|
13
|
+
npm link
|
|
14
|
+
echo "[forge] Building..."
|
|
15
|
+
pnpm build
|
|
16
|
+
else
|
|
17
|
+
echo "[forge] Installing from npm..."
|
|
18
|
+
rm -rf "$(npm root -g)/@aion0/forge" 2>/dev/null || true
|
|
19
|
+
npm cache clean --force 2>/dev/null || true
|
|
20
|
+
# Install from /tmp to avoid pnpm node_modules conflict
|
|
21
|
+
(cd /tmp && npm install -g @aion0/forge)
|
|
22
|
+
echo "[forge] Building..."
|
|
23
|
+
cd "$(npm root -g)/@aion0/forge" && npx next build && cd -
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
echo ""
|
|
27
|
+
echo "[forge] Done."
|
|
28
|
+
forge-server --version
|
|
29
|
+
echo "Run: forge-server"
|
package/instrumentation.ts
CHANGED
|
@@ -9,7 +9,8 @@ export async function register() {
|
|
|
9
9
|
const { existsSync, readFileSync } = await import('node:fs');
|
|
10
10
|
const { join } = await import('node:path');
|
|
11
11
|
const { homedir } = await import('node:os');
|
|
12
|
-
const
|
|
12
|
+
const dataDir = process.env.FORGE_DATA_DIR || join(homedir(), '.forge');
|
|
13
|
+
const envFile = join(dataDir, '.env.local');
|
|
13
14
|
if (existsSync(envFile)) {
|
|
14
15
|
for (const line of readFileSync(envFile, 'utf-8').split('\n')) {
|
|
15
16
|
const trimmed = line.trim();
|
|
@@ -25,7 +26,5 @@ export async function register() {
|
|
|
25
26
|
const { getPassword } = await import('./lib/password');
|
|
26
27
|
const password = getPassword();
|
|
27
28
|
process.env.MW_PASSWORD = password;
|
|
28
|
-
console.log(`[init] Login password: ${password}`);
|
|
29
|
-
console.log('[init] Forgot password? Run: forge password');
|
|
30
29
|
}
|
|
31
30
|
}
|
package/lib/init.ts
CHANGED
|
@@ -22,7 +22,7 @@ export function ensureInitialized() {
|
|
|
22
22
|
// Display login password (auto-generated, rotates daily)
|
|
23
23
|
const password = getPassword();
|
|
24
24
|
console.log(`[init] Login password: ${password} (valid today)`);
|
|
25
|
-
console.log('[init] Forgot? Run:
|
|
25
|
+
console.log('[init] Forgot? Run: forge password');
|
|
26
26
|
|
|
27
27
|
// Start background task runner
|
|
28
28
|
ensureRunnerStarted();
|
|
@@ -61,11 +61,12 @@ function startTerminalProcess() {
|
|
|
61
61
|
|
|
62
62
|
const termPort = Number(process.env.TERMINAL_PORT) || 3001;
|
|
63
63
|
|
|
64
|
-
// Check if port is already in use
|
|
64
|
+
// Check if port is already in use — kill stale process if needed
|
|
65
65
|
const net = require('node:net');
|
|
66
66
|
const tester = net.createServer();
|
|
67
67
|
tester.once('error', () => {
|
|
68
|
-
|
|
68
|
+
// Port in use — terminal server already running, reuse it
|
|
69
|
+
console.log(`[terminal] Port ${termPort} already in use, reusing existing`);
|
|
69
70
|
});
|
|
70
71
|
tester.once('listening', () => {
|
|
71
72
|
tester.close();
|
package/lib/notify.ts
CHANGED
|
@@ -6,6 +6,9 @@ import { loadSettings } from './settings';
|
|
|
6
6
|
import type { Task } from '@/src/types';
|
|
7
7
|
|
|
8
8
|
export async function notifyTaskComplete(task: Task) {
|
|
9
|
+
// Skip pipeline tasks
|
|
10
|
+
try { const { pipelineTaskIds } = require('./pipeline'); if (pipelineTaskIds.has(task.id)) return; } catch {}
|
|
11
|
+
|
|
9
12
|
const settings = loadSettings();
|
|
10
13
|
if (!settings.notifyOnComplete) return;
|
|
11
14
|
|
|
@@ -13,11 +16,13 @@ export async function notifyTaskComplete(task: Task) {
|
|
|
13
16
|
const duration = task.startedAt && task.completedAt
|
|
14
17
|
? formatDuration(new Date(task.completedAt).getTime() - new Date(task.startedAt).getTime())
|
|
15
18
|
: 'unknown';
|
|
19
|
+
const model = task.log?.find(e => e.subtype === 'init' && e.content.startsWith('Model:'))?.content.replace('Model: ', '') || 'unknown';
|
|
16
20
|
|
|
17
21
|
await sendTelegram(
|
|
18
22
|
`✅ *Task Done*\n\n` +
|
|
19
23
|
`*Project:* ${esc(task.projectName)}\n` +
|
|
20
24
|
`*Task:* ${esc(task.prompt.slice(0, 200))}\n` +
|
|
25
|
+
`*Model:* ${esc(model)}\n` +
|
|
21
26
|
`*Duration:* ${duration}\n` +
|
|
22
27
|
`*Cost:* ${cost}\n\n` +
|
|
23
28
|
`${task.resultSummary ? `*Result:*\n${esc(task.resultSummary.slice(0, 500))}` : '_No summary_'}`
|
|
@@ -25,6 +30,9 @@ export async function notifyTaskComplete(task: Task) {
|
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
export async function notifyTaskFailed(task: Task) {
|
|
33
|
+
// Skip pipeline tasks
|
|
34
|
+
try { const { pipelineTaskIds } = require('./pipeline'); if (pipelineTaskIds.has(task.id)) return; } catch {}
|
|
35
|
+
|
|
28
36
|
const settings = loadSettings();
|
|
29
37
|
if (!settings.notifyOnFailure) return;
|
|
30
38
|
|
package/lib/password.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Auto-generated login password.
|
|
3
3
|
* Rotates daily. Saved to ~/.forge/password.json with date.
|
|
4
|
-
* CLI can read it via `
|
|
4
|
+
* CLI can read it via `forge password`.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
package/lib/pipeline.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from
|
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
12
|
import YAML from 'yaml';
|
|
13
|
-
import { createTask, getTask, onTaskEvent } from './task-manager';
|
|
13
|
+
import { createTask, getTask, onTaskEvent, taskModelOverrides } from './task-manager';
|
|
14
14
|
import { getProjectInfo } from './projects';
|
|
15
15
|
import { loadSettings } from './settings';
|
|
16
16
|
import type { Task } from '@/src/types';
|
|
@@ -18,6 +18,12 @@ import type { Task } from '@/src/types';
|
|
|
18
18
|
const PIPELINES_DIR = join(homedir(), '.forge', 'pipelines');
|
|
19
19
|
const WORKFLOWS_DIR = join(homedir(), '.forge', 'flows');
|
|
20
20
|
|
|
21
|
+
// Track pipeline task IDs so terminal notifications can skip them (persists across hot-reloads)
|
|
22
|
+
const pipelineTaskKey = Symbol.for('mw-pipeline-task-ids');
|
|
23
|
+
const gPipeline = globalThis as any;
|
|
24
|
+
if (!gPipeline[pipelineTaskKey]) gPipeline[pipelineTaskKey] = new Set<string>();
|
|
25
|
+
export const pipelineTaskIds: Set<string> = gPipeline[pipelineTaskKey];
|
|
26
|
+
|
|
21
27
|
// ─── Types ────────────────────────────────────────────────
|
|
22
28
|
|
|
23
29
|
export interface WorkflowNode {
|
|
@@ -226,6 +232,58 @@ export function startPipeline(workflowName: string, input: Record<string, string
|
|
|
226
232
|
return pipeline;
|
|
227
233
|
}
|
|
228
234
|
|
|
235
|
+
// ─── Recovery: check for stuck pipelines ──────────────────
|
|
236
|
+
|
|
237
|
+
function recoverStuckPipelines() {
|
|
238
|
+
const pipelines = listPipelines().filter(p => p.status === 'running');
|
|
239
|
+
for (const pipeline of pipelines) {
|
|
240
|
+
const workflow = getWorkflow(pipeline.workflowName);
|
|
241
|
+
if (!workflow) continue;
|
|
242
|
+
|
|
243
|
+
let changed = false;
|
|
244
|
+
for (const [nodeId, node] of Object.entries(pipeline.nodes)) {
|
|
245
|
+
if (node.status === 'running' && node.taskId) {
|
|
246
|
+
const task = getTask(node.taskId);
|
|
247
|
+
if (!task) {
|
|
248
|
+
// Task gone — mark node as done (task completed and was cleaned up)
|
|
249
|
+
node.status = 'done';
|
|
250
|
+
node.completedAt = new Date().toISOString();
|
|
251
|
+
changed = true;
|
|
252
|
+
} else if (task.status === 'done') {
|
|
253
|
+
// Extract outputs
|
|
254
|
+
const nodeDef = workflow.nodes[nodeId];
|
|
255
|
+
if (nodeDef) {
|
|
256
|
+
for (const outputDef of nodeDef.outputs) {
|
|
257
|
+
if (outputDef.extract === 'result') node.outputs[outputDef.name] = task.resultSummary || '';
|
|
258
|
+
else if (outputDef.extract === 'git_diff') node.outputs[outputDef.name] = task.gitDiff || '';
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
node.status = 'done';
|
|
262
|
+
node.completedAt = new Date().toISOString();
|
|
263
|
+
changed = true;
|
|
264
|
+
} else if (task.status === 'failed' || task.status === 'cancelled') {
|
|
265
|
+
node.status = 'failed';
|
|
266
|
+
node.error = task.error || 'Task failed';
|
|
267
|
+
node.completedAt = new Date().toISOString();
|
|
268
|
+
changed = true;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (changed) {
|
|
274
|
+
savePipeline(pipeline);
|
|
275
|
+
// Re-setup listener and schedule next nodes
|
|
276
|
+
setupTaskListener(pipeline.id);
|
|
277
|
+
scheduleReadyNodes(pipeline, workflow);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Run recovery every 30 seconds
|
|
283
|
+
setInterval(recoverStuckPipelines, 30_000);
|
|
284
|
+
// Also run once on load
|
|
285
|
+
setTimeout(recoverStuckPipelines, 5000);
|
|
286
|
+
|
|
229
287
|
export function cancelPipeline(id: string): boolean {
|
|
230
288
|
const pipeline = getPipeline(id);
|
|
231
289
|
if (!pipeline || pipeline.status !== 'running') return false;
|
|
@@ -291,12 +349,17 @@ function scheduleReadyNodes(pipeline: Pipeline, workflow: Workflow) {
|
|
|
291
349
|
continue;
|
|
292
350
|
}
|
|
293
351
|
|
|
294
|
-
// Create task
|
|
352
|
+
// Create task with pipeline model
|
|
295
353
|
const task = createTask({
|
|
296
354
|
projectName: projectInfo.name,
|
|
297
355
|
projectPath: projectInfo.path,
|
|
298
356
|
prompt,
|
|
299
357
|
});
|
|
358
|
+
pipelineTaskIds.add(task.id);
|
|
359
|
+
const pipelineModel = loadSettings().pipelineModel;
|
|
360
|
+
if (pipelineModel && pipelineModel !== 'default') {
|
|
361
|
+
taskModelOverrides.set(task.id, pipelineModel);
|
|
362
|
+
}
|
|
300
363
|
|
|
301
364
|
nodeState.status = 'running';
|
|
302
365
|
nodeState.taskId = task.id;
|
|
@@ -394,7 +457,7 @@ function setupTaskListener(pipelineId: string) {
|
|
|
394
457
|
}
|
|
395
458
|
|
|
396
459
|
savePipeline(pipeline);
|
|
397
|
-
|
|
460
|
+
// No per-step done notification — only notify on start and failure
|
|
398
461
|
} else if (data === 'failed') {
|
|
399
462
|
nodeState.status = 'failed';
|
|
400
463
|
nodeState.error = task?.error || 'Task failed';
|
package/lib/settings.ts
CHANGED
|
@@ -16,6 +16,9 @@ export interface Settings {
|
|
|
16
16
|
notifyOnFailure: boolean; // Notify when task fails
|
|
17
17
|
tunnelAutoStart: boolean; // Auto-start Cloudflare Tunnel on startup
|
|
18
18
|
telegramTunnelPassword: string; // Password for getting login password via Telegram
|
|
19
|
+
taskModel: string; // Model for tasks (default: sonnet)
|
|
20
|
+
pipelineModel: string; // Model for pipelines (default: sonnet)
|
|
21
|
+
telegramModel: string; // Model for Telegram AI features (default: sonnet)
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
const defaults: Settings = {
|
|
@@ -28,6 +31,9 @@ const defaults: Settings = {
|
|
|
28
31
|
notifyOnFailure: true,
|
|
29
32
|
tunnelAutoStart: false,
|
|
30
33
|
telegramTunnelPassword: '',
|
|
34
|
+
taskModel: 'default',
|
|
35
|
+
pipelineModel: 'default',
|
|
36
|
+
telegramModel: 'sonnet',
|
|
31
37
|
};
|
|
32
38
|
|
|
33
39
|
export function loadSettings(): Settings {
|