@aion0/forge 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -1,3 +1,46 @@
1
+ ## Project: Forge (@aion0/forge)
2
+
3
+ ### Dev Commands
4
+ ```bash
5
+ # Development (hot-reload)
6
+ pnpm dev
7
+
8
+ # Production (local)
9
+ pnpm build && pnpm start
10
+
11
+ # Publish to npm (bump version in package.json first)
12
+ npm publish --access public --otp=<code>
13
+
14
+ # Install globally from local source (for testing)
15
+ npm install -g /Users/zliu/IdeaProjects/my-workflow
16
+
17
+ # Install from npm
18
+ npm install -g @aion0/forge
19
+
20
+ # Run via npm global install
21
+ forge-server # foreground (auto-builds if needed)
22
+ forge-server --dev # dev mode
23
+ forge-server --background # background, logs to ~/.forge/forge.log
24
+ forge-server --stop # stop background server
25
+ forge-server --rebuild # force rebuild
26
+
27
+ # CLI
28
+ forge # help
29
+ forge password # show today's login password
30
+ forge tasks # list tasks
31
+ forge task <project> "prompt" # submit task
32
+
33
+ # Terminal server runs on port 3001 (auto-started by Next.js)
34
+ # Data directory: ~/.forge/
35
+ # Config: ~/.forge/settings.yaml
36
+ # Env: ~/.forge/.env.local
37
+ ```
38
+
39
+ ### Key Paths
40
+ - Data: `~/.forge/` (settings, db, password, terminal-state, flows, bin)
41
+ - npm package: `@aion0/forge`
42
+ - GitHub: `github.com/aiwatching/forge`
43
+
1
44
  ## Obsidian Vault
2
45
  Location: /Users/zliu/MyDocuments/obsidian-project/Projects/Bastion
3
46
  When I ask about my notes, use bash to search and read files from this directory.
@@ -1,5 +1,5 @@
1
1
  import { NextResponse } from 'next/server';
2
- import { getTask, cancelTask, deleteTask, retryTask } from '@/lib/task-manager';
2
+ import { getTask, cancelTask, deleteTask, retryTask, updateTask } from '@/lib/task-manager';
3
3
 
4
4
  // Get task details (including full log)
5
5
  export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
@@ -28,6 +28,15 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
28
28
  return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
29
29
  }
30
30
 
31
+ // Edit a queued task
32
+ export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
33
+ const { id } = await params;
34
+ const body = await req.json();
35
+ const updated = updateTask(id, body);
36
+ if (!updated) return NextResponse.json({ error: 'Cannot edit (only queued tasks)' }, { status: 400 });
37
+ return NextResponse.json(updated);
38
+ }
39
+
31
40
  // Delete a task
32
41
  export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
33
42
  const { id } = await params;
@@ -17,29 +17,33 @@ interface SessionInfo {
17
17
  gitBranch?: string;
18
18
  }
19
19
 
20
+ interface TaskData {
21
+ projectName: string;
22
+ prompt: string;
23
+ priority?: number;
24
+ conversationId?: string;
25
+ newSession?: boolean;
26
+ scheduledAt?: string;
27
+ mode?: TaskMode;
28
+ watchConfig?: WatchConfig;
29
+ }
30
+
20
31
  export default function NewTaskModal({
21
32
  onClose,
22
33
  onCreate,
34
+ editTask,
23
35
  }: {
24
36
  onClose: () => void;
25
- onCreate: (data: {
26
- projectName: string;
27
- prompt: string;
28
- priority?: number;
29
- conversationId?: string;
30
- newSession?: boolean;
31
- scheduledAt?: string;
32
- mode?: TaskMode;
33
- watchConfig?: WatchConfig;
34
- }) => void;
37
+ onCreate: (data: TaskData) => void;
38
+ editTask?: { id: string; projectName: string; prompt: string; priority: number; mode: TaskMode };
35
39
  }) {
36
40
  const [projects, setProjects] = useState<Project[]>([]);
37
- const [selectedProject, setSelectedProject] = useState('');
38
- const [prompt, setPrompt] = useState('');
39
- const [priority, setPriority] = useState(0);
41
+ const [selectedProject, setSelectedProject] = useState(editTask?.projectName || '');
42
+ const [prompt, setPrompt] = useState(editTask?.prompt || '');
43
+ const [priority, setPriority] = useState(editTask?.priority || 0);
40
44
 
41
45
  // Task mode
42
- const [taskMode, setTaskMode] = useState<TaskMode>('prompt');
46
+ const [taskMode, setTaskMode] = useState<TaskMode>(editTask?.mode || 'prompt');
43
47
 
44
48
  // Monitor config
45
49
  const [watchCondition, setWatchCondition] = useState<WatchConfig['condition']>('change');
@@ -63,7 +67,7 @@ export default function NewTaskModal({
63
67
  useEffect(() => {
64
68
  fetch('/api/projects').then(r => r.json()).then((p: Project[]) => {
65
69
  setProjects(p);
66
- if (p.length > 0) setSelectedProject(p[0].name);
70
+ if (!selectedProject && p.length > 0) setSelectedProject(p[0].name);
67
71
  });
68
72
  }, []);
69
73
 
@@ -135,7 +139,7 @@ export default function NewTaskModal({
135
139
  <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
136
140
  <div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg w-[560px] max-h-[80vh] overflow-auto" onClick={e => e.stopPropagation()}>
137
141
  <div className="p-4 border-b border-[var(--border)]">
138
- <h2 className="text-sm font-semibold">New Task</h2>
142
+ <h2 className="text-sm font-semibold">{editTask ? 'Edit Task' : 'New Task'}</h2>
139
143
  <p className="text-[11px] text-[var(--text-secondary)] mt-0.5">
140
144
  Submit a task for Claude Code to work on autonomously
141
145
  </p>
@@ -446,7 +450,7 @@ export default function NewTaskModal({
446
450
  disabled={!selectedProject || (taskMode === 'prompt' && !prompt.trim()) || (taskMode === 'monitor' && !selectedSessionId)}
447
451
  className="text-xs px-4 py-1.5 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
448
452
  >
449
- {taskMode === 'monitor' ? 'Start Monitor' : scheduleMode === 'now' ? 'Submit Task' : 'Schedule Task'}
453
+ {editTask ? 'Save & Restart' : taskMode === 'monitor' ? 'Start Monitor' : scheduleMode === 'now' ? 'Submit Task' : 'Schedule Task'}
450
454
  </button>
451
455
  </div>
452
456
  </form>
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useState, useEffect, useRef } from 'react';
4
4
  import MarkdownContent from './MarkdownContent';
5
+ import NewTaskModal from './NewTaskModal';
5
6
  import type { Task, TaskLogEntry } from '@/src/types';
6
7
 
7
8
  export default function TaskDetail({
@@ -18,6 +19,7 @@ export default function TaskDetail({
18
19
  const [tab, setTab] = useState<'log' | 'diff' | 'result'>('log');
19
20
  const [expandedTools, setExpandedTools] = useState<Set<number>>(new Set());
20
21
  const [followUpText, setFollowUpText] = useState('');
22
+ const [editing, setEditing] = useState(false);
21
23
  const logEndRef = useRef<HTMLDivElement>(null);
22
24
 
23
25
  // SSE stream for running tasks
@@ -90,6 +92,9 @@ export default function TaskDetail({
90
92
  <span className="text-[10px] text-[var(--text-secondary)] font-mono">{task.id}</span>
91
93
  </div>
92
94
  <div className="flex items-center gap-2">
95
+ <button onClick={() => setEditing(true)} className="text-[10px] px-2 py-0.5 text-[var(--accent)] border border-[var(--accent)]/30 rounded hover:bg-[var(--accent)] hover:text-white">
96
+ Edit
97
+ </button>
93
98
  {(liveStatus === 'running' || liveStatus === 'queued') && (
94
99
  <button onClick={() => handleAction('cancel')} className="text-[10px] px-2 py-0.5 text-[var(--red)] border border-[var(--red)]/30 rounded hover:bg-[var(--red)] hover:text-white">
95
100
  Cancel
@@ -223,6 +228,22 @@ export default function TaskDetail({
223
228
  </p>
224
229
  </div>
225
230
  )}
231
+
232
+ {editing && (
233
+ <NewTaskModal
234
+ editTask={{ id: task.id, projectName: task.projectName, prompt: task.prompt, priority: task.priority, mode: task.mode }}
235
+ onClose={() => setEditing(false)}
236
+ onCreate={async (data) => {
237
+ await fetch(`/api/tasks/${task.id}`, {
238
+ method: 'PATCH',
239
+ headers: { 'Content-Type': 'application/json' },
240
+ body: JSON.stringify({ ...data, restart: true }),
241
+ });
242
+ setEditing(false);
243
+ onRefresh();
244
+ }}
245
+ />
246
+ )}
226
247
  </div>
227
248
  );
228
249
  }
@@ -478,6 +478,7 @@ const WebTerminal = forwardRef<WebTerminalHandle>(function WebTerminal(_props, r
478
478
 
479
479
  {/* Toolbar */}
480
480
  <div className="flex items-center gap-1 px-2 ml-auto">
481
+ <span className="text-[9px] text-gray-600 mr-2">Shift+drag to copy</span>
481
482
  <button onClick={() => onSplit('vertical')} className="text-[10px] px-2 py-0.5 text-gray-400 hover:text-white hover:bg-[#2a2a4a] rounded">
482
483
  Split Right
483
484
  </button>
@@ -828,7 +829,7 @@ const MemoTerminalPane = memo(function TerminalPane({
828
829
  background: '#1a1a2e',
829
830
  foreground: '#e0e0e0',
830
831
  cursor: '#7c5bf0',
831
- selectionBackground: '#7c5bf044',
832
+ selectionBackground: '#7c5bf066',
832
833
  black: '#1a1a2e',
833
834
  red: '#ff6b6b',
834
835
  green: '#69db7c',
@@ -150,6 +150,7 @@ export async function startTunnel(localPort: number = 3000): Promise<{ url?: str
150
150
  state.url = urlMatch[1];
151
151
  state.status = 'running';
152
152
  console.log(`[cloudflared] Tunnel URL: ${state.url}`);
153
+ startHealthCheck();
153
154
  if (!resolved) {
154
155
  resolved = true;
155
156
  resolve({ url: state.url });
@@ -198,6 +199,7 @@ export async function startTunnel(localPort: number = 3000): Promise<{ url?: str
198
199
  }
199
200
 
200
201
  export function stopTunnel() {
202
+ stopHealthCheck();
201
203
  if (state.process) {
202
204
  state.process.kill('SIGTERM');
203
205
  state.process = null;
@@ -216,3 +218,92 @@ export function getTunnelStatus() {
216
218
  log: state.log.slice(-20),
217
219
  };
218
220
  }
221
+
222
+ // ─── Tunnel health check ──────────────────────────────────────
223
+
224
+ let healthCheckTimer: ReturnType<typeof setInterval> | null = null;
225
+ let consecutiveFailures = 0;
226
+ const MAX_FAILURES = 3;
227
+ const HEALTH_CHECK_INTERVAL = 60_000; // 60s
228
+
229
+ async function checkTunnelHealth() {
230
+ if (state.status !== 'running' || !state.url) return;
231
+
232
+ try {
233
+ const controller = new AbortController();
234
+ const timeout = setTimeout(() => controller.abort(), 10_000);
235
+ const res = await fetch(state.url, {
236
+ method: 'HEAD',
237
+ signal: controller.signal,
238
+ redirect: 'manual',
239
+ });
240
+ clearTimeout(timeout);
241
+
242
+ // Any response (including 302 to login) means tunnel is alive
243
+ if (res.status > 0) {
244
+ if (consecutiveFailures > 0) {
245
+ pushLog(`[health] Tunnel recovered after ${consecutiveFailures} failures`);
246
+ }
247
+ consecutiveFailures = 0;
248
+ return;
249
+ }
250
+ } catch {
251
+ // fetch failed — tunnel likely down
252
+ }
253
+
254
+ consecutiveFailures++;
255
+ pushLog(`[health] Tunnel unreachable (${consecutiveFailures}/${MAX_FAILURES})`);
256
+
257
+ if (consecutiveFailures >= MAX_FAILURES) {
258
+ pushLog('[health] Tunnel appears dead — restarting...');
259
+ state.status = 'error';
260
+ state.error = 'Tunnel unreachable — restarting';
261
+
262
+ // Kill old process and restart
263
+ if (state.process) {
264
+ state.process.kill('SIGTERM');
265
+ state.process = null;
266
+ }
267
+ state.url = null;
268
+ consecutiveFailures = 0;
269
+
270
+ // Restart after a short delay
271
+ setTimeout(async () => {
272
+ const result = await startTunnel();
273
+ if (result.url) {
274
+ pushLog(`[health] Tunnel restarted: ${result.url}`);
275
+ // Notify via Telegram if configured
276
+ try {
277
+ const { loadSettings } = await import('./settings');
278
+ const settings = loadSettings();
279
+ if (settings.telegramBotToken && settings.telegramChatId) {
280
+ await fetch(`https://api.telegram.org/bot${settings.telegramBotToken}/sendMessage`, {
281
+ method: 'POST',
282
+ headers: { 'Content-Type': 'application/json' },
283
+ body: JSON.stringify({
284
+ chat_id: settings.telegramChatId,
285
+ text: `🔄 Tunnel restarted\n\nNew URL: ${result.url}`,
286
+ disable_web_page_preview: true,
287
+ }),
288
+ });
289
+ }
290
+ } catch {}
291
+ } else {
292
+ pushLog(`[health] Tunnel restart failed: ${result.error}`);
293
+ }
294
+ }, 3000);
295
+ }
296
+ }
297
+
298
+ export function startHealthCheck() {
299
+ if (healthCheckTimer) return;
300
+ healthCheckTimer = setInterval(checkTunnelHealth, HEALTH_CHECK_INTERVAL);
301
+ }
302
+
303
+ export function stopHealthCheck() {
304
+ if (healthCheckTimer) {
305
+ clearInterval(healthCheckTimer);
306
+ healthCheckTimer = null;
307
+ }
308
+ consecutiveFailures = 0;
309
+ }
@@ -133,6 +133,35 @@ export function deleteTask(id: string): boolean {
133
133
  return true;
134
134
  }
135
135
 
136
+ export function updateTask(id: string, updates: { prompt?: string; projectName?: string; projectPath?: string; priority?: number; restart?: boolean }): Task | null {
137
+ const task = getTask(id);
138
+ if (!task) return null;
139
+
140
+ // If running, cancel first
141
+ if (task.status === 'running') cancelTask(id);
142
+
143
+ const fields: string[] = [];
144
+ const values: any[] = [];
145
+ if (updates.prompt !== undefined) { fields.push('prompt = ?'); values.push(updates.prompt); }
146
+ if (updates.projectName !== undefined) { fields.push('project_name = ?'); values.push(updates.projectName); }
147
+ if (updates.projectPath !== undefined) { fields.push('project_path = ?'); values.push(updates.projectPath); }
148
+ if (updates.priority !== undefined) { fields.push('priority = ?'); values.push(updates.priority); }
149
+
150
+ // Reset to queued so it runs again
151
+ if (updates.restart) {
152
+ fields.push("status = 'queued'", 'started_at = NULL', 'completed_at = NULL', 'error = NULL', "log = '[]'", 'result_summary = NULL', 'git_diff = NULL', 'cost_usd = NULL');
153
+ }
154
+
155
+ if (fields.length === 0) return task;
156
+
157
+ values.push(id);
158
+ db().prepare(`UPDATE tasks SET ${fields.join(', ')} WHERE id = ?`).run(...values);
159
+
160
+ if (updates.restart) ensureRunnerStarted();
161
+
162
+ return getTask(id);
163
+ }
164
+
136
165
  export function retryTask(id: string): Task | null {
137
166
  const task = getTask(id);
138
167
  if (!task) return null;
@@ -506,21 +535,43 @@ function startMonitorTask(task: Task) {
506
535
  }, 30_000);
507
536
  }
508
537
 
538
+ // Notification throttling: batch updates and send at most once per interval
539
+ const notifyInterval = (config.notifyIntervalSeconds || 60) * 1000;
540
+ let lastNotifyTime = 0;
541
+ let pendingContext: string[] = [];
542
+ let notifyTimer: ReturnType<typeof setTimeout> | null = null;
543
+
544
+ function scheduleNotify(context: string, immediate?: boolean) {
545
+ pendingContext.push(context);
546
+ if (immediate) {
547
+ flushNotify();
548
+ return;
549
+ }
550
+ if (notifyTimer) return; // already scheduled
551
+ const elapsed = Date.now() - lastNotifyTime;
552
+ const delay = Math.max(0, notifyInterval - elapsed);
553
+ notifyTimer = setTimeout(flushNotify, delay);
554
+ }
555
+
556
+ function flushNotify() {
557
+ if (notifyTimer) { clearTimeout(notifyTimer); notifyTimer = null; }
558
+ if (pendingContext.length === 0) return;
559
+ const summary = pendingContext.length === 1
560
+ ? pendingContext[0]
561
+ : `${pendingContext.length} updates:\n\n${pendingContext.slice(-5).join('\n\n')}`;
562
+ pendingContext = [];
563
+ lastNotifyTime = Date.now();
564
+ triggerMonitorAction(task, summary);
565
+ }
566
+
509
567
  // Tail the file for changes (uses fs.watch + 5s polling fallback)
510
568
  const stopTail = tailSessionFile(fp, (newEntries) => {
511
569
  lastActivityTime = Date.now();
512
570
  lastEntryCount += newEntries.length;
513
- // Monitor entries tracked in task log only
514
-
515
- appendLog(task.id, {
516
- type: 'system', subtype: 'text',
517
- content: `+${newEntries.length} entries (${lastEntryCount} total)`,
518
- timestamp: new Date().toISOString(),
519
- });
520
571
 
521
572
  // Check conditions
522
573
  if (config.condition === 'change') {
523
- triggerMonitorAction(task, summarizeNewEntries(newEntries));
574
+ scheduleNotify(summarizeNewEntries(newEntries));
524
575
  if (!config.repeat) stopMonitor(task.id);
525
576
  }
526
577
 
@@ -528,7 +579,7 @@ function startMonitorTask(task: Task) {
528
579
  const kw = config.keyword.toLowerCase();
529
580
  const matched = newEntries.find(e => e.content.toLowerCase().includes(kw));
530
581
  if (matched) {
531
- triggerMonitorAction(task, `Keyword "${config.keyword}" found: ${matched.content.slice(0, 200)}`);
582
+ scheduleNotify(`Keyword "${config.keyword}" found: ${matched.content.slice(0, 200)}`, true);
532
583
  if (!config.repeat) stopMonitor(task.id);
533
584
  }
534
585
  }
@@ -538,7 +589,7 @@ function startMonitorTask(task: Task) {
538
589
  e.type === 'system' && e.content.toLowerCase().includes('error')
539
590
  );
540
591
  if (errors.length > 0) {
541
- triggerMonitorAction(task, `Error detected: ${errors[0].content.slice(0, 200)}`);
592
+ scheduleNotify(`Error detected: ${errors[0].content.slice(0, 200)}`, true);
542
593
  if (!config.repeat) stopMonitor(task.id);
543
594
  }
544
595
  }
@@ -554,7 +605,7 @@ function startMonitorTask(task: Task) {
554
605
  // Wait a bit to see if more entries come
555
606
  setTimeout(() => {
556
607
  if (Date.now() - lastActivityTime > 30_000) {
557
- triggerMonitorAction(task, `Session appears complete.\n\nLast: ${lastAssistant.content.slice(0, 300)}`);
608
+ scheduleNotify(`Session appears complete.\n\nLast: ${lastAssistant.content.slice(0, 300)}`, true);
558
609
  if (!config.repeat) stopMonitor(task.id);
559
610
  }
560
611
  }, 35_000);
@@ -573,6 +624,7 @@ function startMonitorTask(task: Task) {
573
624
  const cleanup = () => {
574
625
  stopTail();
575
626
  if (idleTimer) clearInterval(idleTimer);
627
+ flushNotify(); // send any remaining batched notifications
576
628
  };
577
629
 
578
630
  activeMonitors.set(task.id, cleanup);
@@ -94,8 +94,12 @@ async function poll() {
94
94
  }
95
95
  }
96
96
  }
97
- } catch (err) {
98
- console.error('[telegram] Poll error:', err);
97
+ } catch (err: any) {
98
+ // Network errors (ECONNRESET, fetch failed) are normal during sleep/wake — silent retry
99
+ const isNetworkError = err?.cause?.code === 'ECONNRESET' || err?.message?.includes('fetch failed');
100
+ if (!isNetworkError) {
101
+ console.error('[telegram] Poll error:', err);
102
+ }
99
103
  }
100
104
 
101
105
  pollTimer = setTimeout(poll, 1000);
@@ -188,7 +192,13 @@ async function handleMessage(msg: any) {
188
192
  await handleRetry(chatId, args[0]);
189
193
  break;
190
194
  case '/tunnel':
191
- await handleTunnel(chatId, args[0], args[1], msg.message_id);
195
+ await handleTunnelStatus(chatId);
196
+ break;
197
+ case '/tunnel_start':
198
+ await handleTunnelStart(chatId);
199
+ break;
200
+ case '/tunnel_stop':
201
+ await handleTunnelStop(chatId);
192
202
  break;
193
203
  case '/tunnel_password':
194
204
  await handleTunnelPassword(chatId, args[0], msg.message_id);
@@ -230,7 +240,9 @@ async function sendHelp(chatId: number) {
230
240
  `📝 Submit task:\nproject-name: your instructions\n\n` +
231
241
  `🔧 /cancel <id> /retry <id>\n` +
232
242
  `/projects — list projects\n\n` +
233
- `🌐 /tunnel [start|stop] remote access\n` +
243
+ `🌐 /tunnel — tunnel status\n` +
244
+ `/tunnel_start — start tunnel\n` +
245
+ `/tunnel_stop — stop tunnel\n` +
234
246
  `/tunnel_password <pw> — get login password\n\n` +
235
247
  `Reply number to select`
236
248
  );
@@ -644,57 +656,64 @@ async function handleUnwatch(chatId: number, watcherId?: string) {
644
656
 
645
657
  // ─── Tunnel Commands ─────────────────────────────────────────
646
658
 
647
- async function handleTunnel(chatId: number, action?: string, password?: string, userMsgId?: number) {
659
+ async function handleTunnelStatus(chatId: number) {
648
660
  const settings = loadSettings();
649
- if (String(chatId) !== settings.telegramChatId) {
650
- await send(chatId, '⛔ Unauthorized');
651
- return;
652
- }
661
+ if (String(chatId) !== settings.telegramChatId) { await send(chatId, '⛔ Unauthorized'); return; }
653
662
 
654
- // start/stop require password
655
- if (action === 'start' || action === 'stop') {
656
- if (!settings.telegramTunnelPassword) {
657
- await send(chatId, '⚠️ Set telegram tunnel password in Settings first.');
658
- return;
659
- }
660
- if (!password || password !== settings.telegramTunnelPassword) {
661
- await send(chatId, `⛔ Password required\nUsage: /tunnel ${action} <password>`);
662
- return;
663
- }
664
- // Delete user's message containing password
665
- if (userMsgId) deleteMessageLater(chatId, userMsgId, 0);
663
+ const status = getTunnelStatus();
664
+ if (status.status === 'running' && status.url) {
665
+ await sendHtml(chatId, `🌐 Tunnel running:\n<a href="${status.url}">${status.url}</a>\n\n/tunnel_stop — stop tunnel`);
666
+ } else if (status.status === 'starting') {
667
+ await send(chatId, '⏳ Tunnel is starting...');
668
+ } else {
669
+ await send(chatId, `🌐 Tunnel is ${status.status}\n\n/tunnel_start — start tunnel`);
666
670
  }
671
+ }
667
672
 
668
- if (action === 'start') {
669
- const status = getTunnelStatus();
670
- if (status.status === 'running' && status.url) {
671
- await send(chatId, `🌐 Tunnel already running:\n${status.url}`);
673
+ async function handleTunnelStart(chatId: number) {
674
+ const settings = loadSettings();
675
+ if (String(chatId) !== settings.telegramChatId) { await send(chatId, '⛔ Unauthorized'); return; }
676
+
677
+ // Check if tunnel is already running and still reachable
678
+ const status = getTunnelStatus();
679
+ if (status.status === 'running' && status.url) {
680
+ // Verify it's actually alive
681
+ let alive = false;
682
+ try {
683
+ const controller = new AbortController();
684
+ const timeout = setTimeout(() => controller.abort(), 8000);
685
+ const res = await fetch(status.url, { method: 'HEAD', signal: controller.signal, redirect: 'manual' });
686
+ clearTimeout(timeout);
687
+ alive = res.status > 0;
688
+ } catch {}
689
+
690
+ if (alive) {
691
+ await sendHtml(chatId, `🌐 Tunnel already running:\n<a href="${status.url}">${status.url}</a>`);
672
692
  return;
673
693
  }
674
- await send(chatId, '🌐 Starting tunnel...');
675
- const result = await startTunnel();
676
- if (result.url) {
677
- await send(chatId, '✅ Tunnel started:');
678
- await sendHtml(chatId, `<a href="${result.url}">${result.url}</a>`);
679
- } else {
680
- await send(chatId, `❌ Failed: ${result.error}`);
681
- }
682
- } else if (action === 'stop') {
694
+ // Tunnel process alive but URL unreachable — kill and restart
695
+ await send(chatId, '🌐 Tunnel URL unreachable, restarting...');
683
696
  stopTunnel();
684
- await send(chatId, '🛑 Tunnel stopped');
697
+ }
698
+
699
+ await send(chatId, '🌐 Starting tunnel...');
700
+ const result = await startTunnel();
701
+ if (result.url) {
702
+ await send(chatId, '✅ Tunnel started:');
703
+ await sendHtml(chatId, `<a href="${result.url}">${result.url}</a>`);
685
704
  } else {
686
- // Status (no password needed)
687
- const status = getTunnelStatus();
688
- if (status.status === 'running' && status.url) {
689
- await send(chatId, `🌐 Tunnel running:\n${status.url}\n\n/tunnel stop <pw> — stop tunnel`);
690
- } else if (status.status === 'starting') {
691
- await send(chatId, '⏳ Tunnel is starting...');
692
- } else {
693
- await send(chatId, `🌐 Tunnel is ${status.status}\n\n/tunnel start <pw> — start tunnel`);
694
- }
705
+ await send(chatId, `❌ Failed: ${result.error}`);
695
706
  }
696
707
  }
697
708
 
709
+ async function handleTunnelStop(chatId: number) {
710
+ const settings = loadSettings();
711
+ if (String(chatId) !== settings.telegramChatId) { await send(chatId, '⛔ Unauthorized'); return; }
712
+
713
+ stopTunnel();
714
+ await send(chatId, '🛑 Tunnel stopped');
715
+ }
716
+
698
717
  async function handleTunnelPassword(chatId: number, password?: string, userMsgId?: number) {
699
718
  const settings = loadSettings();
700
719
  if (String(chatId) !== settings.telegramChatId) {
@@ -861,7 +880,9 @@ async function setBotCommands(token: string) {
861
880
  { command: 'task', description: 'Create task: /task project prompt' },
862
881
  { command: 'sessions', description: 'Browse sessions' },
863
882
  { command: 'projects', description: 'List projects' },
864
- { command: 'tunnel', description: 'Tunnel status / start / stop' },
883
+ { command: 'tunnel', description: 'Tunnel status' },
884
+ { command: 'tunnel_start', description: 'Start tunnel' },
885
+ { command: 'tunnel_stop', description: 'Stop tunnel' },
865
886
  { command: 'tunnel_password', description: 'Get login password' },
866
887
  { command: 'watch', description: 'Monitor session' },
867
888
  { command: 'watchers', description: 'List watchers' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -86,6 +86,7 @@ export interface WatchConfig {
86
86
  actionPrompt?: string; // message to send or task prompt
87
87
  actionProject?: string; // for 'task' action
88
88
  repeat?: boolean; // keep watching after trigger (default false)
89
+ notifyIntervalSeconds?: number; // min seconds between notifications (default 60)
89
90
  }
90
91
 
91
92
  export interface Task {