@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.
@@ -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 [port, setPort] = useState(0);
13
+ const [previews, setPreviews] = useState<PreviewEntry[]>([]);
7
14
  const [inputPort, setInputPort] = useState('');
8
- const [tunnelUrl, setTunnelUrl] = useState<string | null>(null);
9
- const [status, setStatus] = useState<string>('stopped');
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
- setStatus('starting');
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
- setError(data.error);
45
- setStatus('error');
46
- } else {
47
- setPort(data.port);
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
- } catch {
52
- setError('Failed to start tunnel');
53
- setStatus('error');
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
- setPort(0);
64
- setTunnelUrl(null);
65
- setStatus('stopped');
68
+ if (activePreview === port) setActivePreview(null);
69
+ fetchPreviews();
66
70
  };
67
71
 
68
- // What to show in iframe: tunnel URL for remote, localhost for local
69
- const previewSrc = isRemote
70
- ? tunnelUrl
71
- : port ? `http://localhost:${port}` : null;
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
- {/* Control bar */}
76
- <div className="px-4 py-2 border-b border-[var(--border)] flex items-center gap-3 shrink-0 flex-wrap">
77
- <span className="text-[11px] font-semibold text-[var(--text-primary)]">Preview</span>
78
-
79
- <input
80
- type="number"
81
- value={inputPort}
82
- onChange={e => setInputPort(e.target.value)}
83
- onKeyDown={e => e.key === 'Enter' && handleStart()}
84
- placeholder="Port"
85
- className="w-24 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"
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
- {status === 'stopped' || status === 'error' ? (
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
- Start Tunnel
134
+ {starting ? 'Starting...' : '+ Add'}
95
135
  </button>
96
- ) : status === 'starting' ? (
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
- <button
119
- onClick={handleStop}
120
- className="text-[10px] text-red-400 hover:text-red-300"
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">Preview a local dev server</p>
141
- <p className="text-xs">Enter the port of your running dev server and click Start Tunnel.</p>
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
- term.write(`\r\n\x1b[93m[${msg.message || 'error'}]\x1b[0m\r\n`);
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 pnpm dev -- -p 4000
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"
@@ -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 envFile = join(homedir(), '.forge', '.env.local');
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: mw password');
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
- console.log(`[terminal] Port ${termPort} already in use, skipping`);
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 `mw password`.
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
- notifyStep(pipeline, nodeId, 'done');
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 {