@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 +43 -0
- package/app/api/tasks/[id]/route.ts +10 -1
- package/components/NewTaskModal.tsx +21 -17
- package/components/TaskDetail.tsx +21 -0
- package/components/WebTerminal.tsx +2 -1
- package/lib/cloudflared.ts +91 -0
- package/lib/task-manager.ts +63 -11
- package/lib/telegram-bot.ts +66 -45
- package/package.json +1 -1
- package/src/types/index.ts +1 -0
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
|
-
|
|
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: '#
|
|
832
|
+
selectionBackground: '#7c5bf066',
|
|
832
833
|
black: '#1a1a2e',
|
|
833
834
|
red: '#ff6b6b',
|
|
834
835
|
green: '#69db7c',
|
package/lib/cloudflared.ts
CHANGED
|
@@ -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
|
+
}
|
package/lib/task-manager.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/lib/telegram-bot.ts
CHANGED
|
@@ -94,8 +94,12 @@ async function poll() {
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
|
-
} catch (err) {
|
|
98
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
655
|
-
if (
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
675
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
package/src/types/index.ts
CHANGED
|
@@ -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 {
|