@aion0/forge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CLAUDE.md +4 -0
  2. package/README.md +264 -0
  3. package/app/api/auth/[...nextauth]/route.ts +3 -0
  4. package/app/api/claude/[id]/route.ts +31 -0
  5. package/app/api/claude/[id]/stream/route.ts +63 -0
  6. package/app/api/claude/route.ts +28 -0
  7. package/app/api/claude-sessions/[projectName]/live/route.ts +72 -0
  8. package/app/api/claude-sessions/[projectName]/route.ts +37 -0
  9. package/app/api/claude-sessions/sync/route.ts +17 -0
  10. package/app/api/flows/route.ts +6 -0
  11. package/app/api/flows/run/route.ts +19 -0
  12. package/app/api/notify/test/route.ts +33 -0
  13. package/app/api/projects/route.ts +7 -0
  14. package/app/api/sessions/[id]/chat/route.ts +64 -0
  15. package/app/api/sessions/[id]/messages/route.ts +9 -0
  16. package/app/api/sessions/[id]/route.ts +17 -0
  17. package/app/api/sessions/route.ts +20 -0
  18. package/app/api/settings/route.ts +15 -0
  19. package/app/api/status/route.ts +12 -0
  20. package/app/api/tasks/[id]/route.ts +36 -0
  21. package/app/api/tasks/[id]/stream/route.ts +77 -0
  22. package/app/api/tasks/link/route.ts +37 -0
  23. package/app/api/tasks/route.ts +43 -0
  24. package/app/api/tasks/session/route.ts +14 -0
  25. package/app/api/templates/route.ts +6 -0
  26. package/app/api/tunnel/route.ts +20 -0
  27. package/app/api/watchers/route.ts +33 -0
  28. package/app/globals.css +26 -0
  29. package/app/icon.svg +26 -0
  30. package/app/layout.tsx +17 -0
  31. package/app/login/page.tsx +61 -0
  32. package/app/page.tsx +9 -0
  33. package/cli/mw.ts +377 -0
  34. package/components/ChatPanel.tsx +191 -0
  35. package/components/ClaudeTerminal.tsx +267 -0
  36. package/components/Dashboard.tsx +270 -0
  37. package/components/MarkdownContent.tsx +57 -0
  38. package/components/NewSessionModal.tsx +93 -0
  39. package/components/NewTaskModal.tsx +456 -0
  40. package/components/ProjectList.tsx +108 -0
  41. package/components/SessionList.tsx +74 -0
  42. package/components/SessionView.tsx +655 -0
  43. package/components/SettingsModal.tsx +366 -0
  44. package/components/StatusBar.tsx +99 -0
  45. package/components/TaskBoard.tsx +110 -0
  46. package/components/TaskDetail.tsx +351 -0
  47. package/components/TunnelToggle.tsx +163 -0
  48. package/components/WebTerminal.tsx +1069 -0
  49. package/docs/LOCAL-DEPLOY.md +144 -0
  50. package/docs/roadmap-multi-agent-workflow.md +330 -0
  51. package/instrumentation.ts +14 -0
  52. package/lib/auth.ts +47 -0
  53. package/lib/claude-process.ts +352 -0
  54. package/lib/claude-sessions.ts +267 -0
  55. package/lib/cloudflared.ts +218 -0
  56. package/lib/flows.ts +86 -0
  57. package/lib/init.ts +82 -0
  58. package/lib/notify.ts +75 -0
  59. package/lib/password.ts +77 -0
  60. package/lib/projects.ts +86 -0
  61. package/lib/session-manager.ts +156 -0
  62. package/lib/session-watcher.ts +345 -0
  63. package/lib/settings.ts +44 -0
  64. package/lib/task-manager.ts +668 -0
  65. package/lib/telegram-bot.ts +912 -0
  66. package/lib/terminal-server.ts +70 -0
  67. package/lib/terminal-standalone.ts +363 -0
  68. package/middleware.ts +33 -0
  69. package/next-env.d.ts +6 -0
  70. package/next.config.ts +16 -0
  71. package/package.json +66 -0
  72. package/postcss.config.mjs +7 -0
  73. package/src/config/index.ts +119 -0
  74. package/src/core/db/database.ts +133 -0
  75. package/src/core/memory/strategy.ts +32 -0
  76. package/src/core/providers/chat.ts +65 -0
  77. package/src/core/providers/registry.ts +60 -0
  78. package/src/core/session/manager.ts +190 -0
  79. package/src/types/index.ts +128 -0
  80. package/tsconfig.json +41 -0
@@ -0,0 +1,912 @@
1
+ /**
2
+ * Telegram Bot — remote interface for My Workflow.
3
+ *
4
+ * Optimized for mobile:
5
+ * - /tasks shows numbered list, reply with number to see details
6
+ * - Reply to task messages to send follow-ups
7
+ * - Plain text "project: instructions" to create tasks
8
+ */
9
+
10
+ import { loadSettings } from './settings';
11
+ import { createTask, getTask, listTasks, cancelTask, retryTask, onTaskEvent } from './task-manager';
12
+ import { scanProjects } from './projects';
13
+ import { listClaudeSessions, getSessionFilePath, readSessionEntries } from './claude-sessions';
14
+ import { listWatchers, createWatcher, deleteWatcher, toggleWatcher } from './session-watcher';
15
+ import { startTunnel, stopTunnel, getTunnelStatus } from './cloudflared';
16
+ import { getPassword } from './password';
17
+ import type { Task, TaskLogEntry } from '@/src/types';
18
+
19
+ let polling = false;
20
+ let pollTimer: ReturnType<typeof setTimeout> | null = null;
21
+ let lastUpdateId = 0;
22
+
23
+ // Prevent duplicate polling across hot-reloads
24
+ const globalKey = Symbol.for('mw-telegram-polling');
25
+ const g = globalThis as any;
26
+
27
+ // Track which Telegram message maps to which task (for reply-based interaction)
28
+ const taskMessageMap = new Map<number, string>(); // messageId → taskId
29
+ const taskChatMap = new Map<string, number>(); // taskId → chatId
30
+
31
+ // Numbered lists — maps number (1-10) → id for quick selection
32
+ const chatNumberedTasks = new Map<number, Map<number, string>>();
33
+ // Session selection: two-tier — first pick project, then pick session
34
+ const chatNumberedSessions = new Map<number, Map<number, { projectName: string; sessionId: string }>>();
35
+ const chatNumberedProjects = new Map<number, Map<number, string>>();
36
+ // Track what the last numbered list was for
37
+ const chatListMode = new Map<number, 'tasks' | 'projects' | 'sessions'>();
38
+
39
+ // Buffer for streaming logs
40
+ const logBuffers = new Map<string, { entries: string[]; timer: ReturnType<typeof setTimeout> | null }>();
41
+
42
+ // ─── Start/Stop ──────────────────────────────────────────────
43
+
44
+ export function startTelegramBot() {
45
+ if (polling || g[globalKey]) return;
46
+ const settings = loadSettings();
47
+ if (!settings.telegramBotToken || !settings.telegramChatId) return;
48
+
49
+ polling = true;
50
+ g[globalKey] = true;
51
+ console.log('[telegram] Bot started');
52
+
53
+ // Set bot command menu
54
+ setBotCommands(settings.telegramBotToken);
55
+
56
+ // Listen for task events → stream to Telegram
57
+ onTaskEvent((taskId, event, data) => {
58
+ const settings = loadSettings();
59
+ if (!settings.telegramBotToken || !settings.telegramChatId) return;
60
+ const chatId = Number(settings.telegramChatId);
61
+
62
+ if (event === 'log') {
63
+ bufferLogEntry(taskId, chatId, data as TaskLogEntry);
64
+ } else if (event === 'status') {
65
+ handleStatusChange(taskId, chatId, data as string);
66
+ }
67
+ });
68
+
69
+ poll();
70
+ }
71
+
72
+ export function stopTelegramBot() {
73
+ polling = false;
74
+ g[globalKey] = false;
75
+ if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
76
+ }
77
+
78
+ // ─── Polling ─────────────────────────────────────────────────
79
+
80
+ async function poll() {
81
+ if (!polling) return;
82
+
83
+ try {
84
+ const settings = loadSettings();
85
+ const url = `https://api.telegram.org/bot${settings.telegramBotToken}/getUpdates?offset=${lastUpdateId + 1}&timeout=30`;
86
+ const res = await fetch(url);
87
+ const data = await res.json();
88
+
89
+ if (data.ok && data.result) {
90
+ for (const update of data.result) {
91
+ lastUpdateId = update.update_id;
92
+ if (update.message?.text) {
93
+ await handleMessage(update.message);
94
+ }
95
+ }
96
+ }
97
+ } catch (err) {
98
+ console.error('[telegram] Poll error:', err);
99
+ }
100
+
101
+ pollTimer = setTimeout(poll, 1000);
102
+ }
103
+
104
+ // ─── Message Handler ─────────────────────────────────────────
105
+
106
+ async function handleMessage(msg: any) {
107
+ const chatId = msg.chat.id;
108
+ console.log(`[telegram] Message from chat ID: ${chatId}, user: ${msg.from?.username || msg.from?.first_name || 'unknown'}`);
109
+ const text: string = msg.text.trim();
110
+ const replyTo = msg.reply_to_message?.message_id;
111
+
112
+ // Check if replying to a task message → follow-up
113
+ if (replyTo && taskMessageMap.has(replyTo)) {
114
+ const taskId = taskMessageMap.get(replyTo)!;
115
+ await handleFollowUp(chatId, taskId, text);
116
+ return;
117
+ }
118
+
119
+ // Quick number selection (1-10) → context-dependent
120
+ if (/^\d{1,2}$/.test(text)) {
121
+ const num = parseInt(text);
122
+ const mode = chatListMode.get(chatId);
123
+
124
+ if (mode === 'projects') {
125
+ const projMap = chatNumberedProjects.get(chatId);
126
+ if (projMap?.has(num)) {
127
+ await sendSessionList(chatId, projMap.get(num)!);
128
+ return;
129
+ }
130
+ } else if (mode === 'sessions') {
131
+ const sessMap = chatNumberedSessions.get(chatId);
132
+ if (sessMap?.has(num)) {
133
+ const { projectName, sessionId } = sessMap.get(num)!;
134
+ await sendSessionContent(chatId, projectName, sessionId);
135
+ return;
136
+ }
137
+ } else {
138
+ const taskMap = chatNumberedTasks.get(chatId);
139
+ if (taskMap?.has(num)) {
140
+ await sendTaskDetail(chatId, taskMap.get(num)!);
141
+ return;
142
+ }
143
+ }
144
+ }
145
+
146
+ // Commands
147
+ if (text.startsWith('/')) {
148
+ const [cmd, ...args] = text.split(/\s+/);
149
+ switch (cmd) {
150
+ case '/start':
151
+ case '/help':
152
+ await sendHelp(chatId);
153
+ break;
154
+ case '/tasks':
155
+ case '/t':
156
+ await sendNumberedTaskList(chatId, args[0]);
157
+ break;
158
+ case '/new':
159
+ case '/task':
160
+ await handleNewTask(chatId, args.join(' '));
161
+ break;
162
+ case '/sessions':
163
+ case '/s':
164
+ if (args[0]) {
165
+ await sendSessionList(chatId, args[0]);
166
+ } else {
167
+ await sendProjectListForSessions(chatId);
168
+ }
169
+ break;
170
+ case '/projects':
171
+ case '/p':
172
+ await sendProjectList(chatId);
173
+ break;
174
+ case '/watch':
175
+ await handleWatch(chatId, args[0], args[1]);
176
+ break;
177
+ case '/watchers':
178
+ case '/w':
179
+ await sendWatcherList(chatId);
180
+ break;
181
+ case '/unwatch':
182
+ await handleUnwatch(chatId, args[0]);
183
+ break;
184
+ case '/cancel':
185
+ await handleCancel(chatId, args[0]);
186
+ break;
187
+ case '/retry':
188
+ await handleRetry(chatId, args[0]);
189
+ break;
190
+ case '/tunnel':
191
+ await handleTunnel(chatId, args[0], args[1], msg.message_id);
192
+ break;
193
+ case '/tunnel_password':
194
+ await handleTunnelPassword(chatId, args[0], msg.message_id);
195
+ break;
196
+ default:
197
+ await send(chatId, `Unknown command: ${cmd}\nUse /help to see available commands.`);
198
+ }
199
+ return;
200
+ }
201
+
202
+ // Plain text — try to parse as "project: task" format
203
+ const colonIdx = text.indexOf(':');
204
+ if (colonIdx > 0 && colonIdx < 30) {
205
+ const projectName = text.slice(0, colonIdx).trim();
206
+ const prompt = text.slice(colonIdx + 1).trim();
207
+ if (prompt) {
208
+ await handleNewTask(chatId, `${projectName} ${prompt}`);
209
+ return;
210
+ }
211
+ }
212
+
213
+ await send(chatId,
214
+ `Send a task as:\nproject-name: your instructions\n\nOr use /help for all commands.`
215
+ );
216
+ }
217
+
218
+ // ─── Command Handlers ────────────────────────────────────────
219
+
220
+ async function sendHelp(chatId: number) {
221
+ await send(chatId,
222
+ `šŸ¤– Forge\n\n` +
223
+ `šŸ“‹ /tasks — numbered task list\n` +
224
+ `/tasks running — filter by status\n` +
225
+ `šŸ” /sessions — browse session content\n` +
226
+ `/sessions <project> — sessions for project\n\n` +
227
+ `šŸ‘ /watch <project> [sessionId] — monitor session\n` +
228
+ `/watchers — list active watchers\n` +
229
+ `/unwatch <id> — stop watching\n\n` +
230
+ `šŸ“ Submit task:\nproject-name: your instructions\n\n` +
231
+ `šŸ”§ /cancel <id> /retry <id>\n` +
232
+ `/projects — list projects\n\n` +
233
+ `🌐 /tunnel [start|stop] — remote access\n` +
234
+ `/tunnel_password <pw> — get login password\n\n` +
235
+ `Reply number to select`
236
+ );
237
+ }
238
+
239
+ async function sendNumberedTaskList(chatId: number, statusFilter?: string) {
240
+ // Get running/queued first, then recent done/failed
241
+ const allTasks = listTasks(statusFilter as any || undefined);
242
+
243
+ // Sort: running first, then queued, then by recency
244
+ const prioritized = [
245
+ ...allTasks.filter(t => t.status === 'running'),
246
+ ...allTasks.filter(t => t.status === 'queued'),
247
+ ...allTasks.filter(t => t.status !== 'running' && t.status !== 'queued'),
248
+ ].slice(0, 10);
249
+
250
+ if (prioritized.length === 0) {
251
+ await send(chatId, 'No tasks found.');
252
+ return;
253
+ }
254
+
255
+ // Build numbered map
256
+ const numMap = new Map<number, string>();
257
+ const lines: string[] = [];
258
+
259
+ prioritized.forEach((t, i) => {
260
+ const num = i + 1;
261
+ numMap.set(num, t.id);
262
+
263
+ const icon = t.status === 'running' ? 'šŸ”„' : t.status === 'queued' ? 'ā³' : t.status === 'done' ? 'āœ…' : t.status === 'failed' ? 'āŒ' : '⚪';
264
+ const cost = t.costUSD != null ? ` $${t.costUSD.toFixed(3)}` : '';
265
+ const prompt = t.prompt.length > 40 ? t.prompt.slice(0, 40) + '...' : t.prompt;
266
+
267
+ lines.push(`${num}. ${icon} ${t.projectName}\n ${prompt}${cost}`);
268
+ });
269
+
270
+ chatNumberedTasks.set(chatId, numMap);
271
+ chatListMode.set(chatId, 'tasks');
272
+
273
+ await send(chatId,
274
+ `šŸ“‹ Tasks — reply number to see details\n\n${lines.join('\n\n')}`
275
+ );
276
+ }
277
+
278
+ async function sendTaskDetail(chatId: number, taskId: string) {
279
+ const task = getTask(taskId);
280
+ if (!task) {
281
+ await send(chatId, `Task not found: ${taskId}`);
282
+ return;
283
+ }
284
+
285
+ const icon = task.status === 'done' ? 'āœ…' : task.status === 'running' ? 'šŸ”„' : task.status === 'failed' ? 'āŒ' : 'ā³';
286
+
287
+ let text = `${icon} ${task.projectName} [${task.id}]\n`;
288
+ text += `Status: ${task.status}\n`;
289
+ text += `Task: ${task.prompt}\n`;
290
+
291
+ if (task.startedAt) text += `Started: ${new Date(task.startedAt).toLocaleString()}\n`;
292
+ if (task.completedAt) text += `Done: ${new Date(task.completedAt).toLocaleString()}\n`;
293
+ if (task.costUSD != null) text += `Cost: $${task.costUSD.toFixed(4)}\n`;
294
+ if (task.error) text += `\nā— Error: ${task.error}\n`;
295
+
296
+ if (task.resultSummary) {
297
+ const result = task.resultSummary.length > 1500
298
+ ? task.resultSummary.slice(0, 1500) + '...'
299
+ : task.resultSummary;
300
+ text += `\n--- Result ---\n${result}`;
301
+ }
302
+
303
+ // Show recent log summary for running tasks
304
+ if (task.status === 'running' && task.log.length > 0) {
305
+ const recent = task.log
306
+ .filter(e => e.subtype === 'text' || e.subtype === 'tool_use')
307
+ .slice(-5)
308
+ .map(e => e.subtype === 'tool_use' ? `šŸ”§ ${e.tool}` : e.content.slice(0, 80))
309
+ .join('\n');
310
+ if (recent) text += `\n--- Recent ---\n${recent}`;
311
+ }
312
+
313
+ const msgId = await send(chatId, text);
314
+ if (msgId) {
315
+ taskMessageMap.set(msgId, taskId);
316
+ }
317
+
318
+ // Show action hints
319
+ if (task.status === 'done') {
320
+ await send(chatId, `šŸ’¬ Reply to the message above to send follow-up`);
321
+ } else if (task.status === 'failed') {
322
+ await send(chatId, `šŸ”„ /retry ${task.id}`);
323
+ } else if (task.status === 'running' || task.status === 'queued') {
324
+ await send(chatId, `šŸ›‘ /cancel ${task.id}`);
325
+ }
326
+ }
327
+
328
+ async function sendProjectListForSessions(chatId: number) {
329
+ const projects = scanProjects();
330
+ if (projects.length === 0) {
331
+ await send(chatId, 'No projects found.');
332
+ return;
333
+ }
334
+
335
+ const numMap = new Map<number, string>();
336
+ const lines: string[] = [];
337
+
338
+ projects.slice(0, 10).forEach((p, i) => {
339
+ const num = i + 1;
340
+ numMap.set(num, p.name);
341
+ lines.push(`${num}. ${p.name}${p.language ? ` (${p.language})` : ''}`);
342
+ });
343
+
344
+ chatNumberedProjects.set(chatId, numMap);
345
+ chatListMode.set(chatId, 'projects');
346
+
347
+ await send(chatId,
348
+ `šŸ“ Select project — reply number\n\n${lines.join('\n')}`
349
+ );
350
+ }
351
+
352
+ async function sendSessionList(chatId: number, projectName: string) {
353
+ const sessions = listClaudeSessions(projectName);
354
+ if (sessions.length === 0) {
355
+ await send(chatId, `No sessions for ${projectName}`);
356
+ return;
357
+ }
358
+
359
+ const numMap = new Map<number, { projectName: string; sessionId: string }>();
360
+ const lines: string[] = [];
361
+
362
+ sessions.slice(0, 10).forEach((s, i) => {
363
+ const num = i + 1;
364
+ numMap.set(num, { projectName, sessionId: s.sessionId });
365
+ const label = s.summary || s.firstPrompt || s.sessionId.slice(0, 8);
366
+ const msgs = s.messageCount != null ? ` (${s.messageCount} msgs)` : '';
367
+ const date = s.modified ? new Date(s.modified).toLocaleDateString() : '';
368
+ lines.push(`${num}. ${label}${msgs}\n ${date} ${s.gitBranch || ''}`);
369
+ });
370
+
371
+ chatNumberedSessions.set(chatId, numMap);
372
+ chatListMode.set(chatId, 'sessions');
373
+
374
+ await send(chatId,
375
+ `šŸ” ${projectName} sessions — reply number\n\n${lines.join('\n\n')}`
376
+ );
377
+ }
378
+
379
+ async function sendSessionContent(chatId: number, projectName: string, sessionId: string) {
380
+ const filePath = getSessionFilePath(projectName, sessionId);
381
+ if (!filePath) {
382
+ await send(chatId, 'Session file not found');
383
+ return;
384
+ }
385
+
386
+ const entries = readSessionEntries(filePath);
387
+ if (entries.length === 0) {
388
+ await send(chatId, 'Session is empty');
389
+ return;
390
+ }
391
+
392
+ // Build a readable summary — show user messages and assistant text, skip tool details
393
+ const parts: string[] = [];
394
+ let charCount = 0;
395
+ const MAX = 3500;
396
+
397
+ // Walk from end to get most recent content
398
+ for (let i = entries.length - 1; i >= 0 && charCount < MAX; i--) {
399
+ const e = entries[i];
400
+ let line = '';
401
+ if (e.type === 'user') {
402
+ line = `šŸ‘¤ ${e.content}`;
403
+ } else if (e.type === 'assistant_text') {
404
+ line = `šŸ¤– ${e.content.slice(0, 500)}`;
405
+ } else if (e.type === 'tool_use') {
406
+ line = `šŸ”§ ${e.toolName || 'tool'}`;
407
+ }
408
+ // Skip thinking, tool_result, system for brevity
409
+ if (!line) continue;
410
+
411
+ if (charCount + line.length > MAX) {
412
+ line = line.slice(0, MAX - charCount) + '...';
413
+ }
414
+ parts.unshift(line);
415
+ charCount += line.length;
416
+ }
417
+
418
+ const header = `šŸ” Session: ${sessionId.slice(0, 8)}\nProject: ${projectName}\n${entries.length} entries\n\n`;
419
+
420
+ // Split into chunks for Telegram's 4096 limit
421
+ const fullText = header + parts.join('\n\n');
422
+ const chunks = splitMessage(fullText, 4000);
423
+ for (const chunk of chunks) {
424
+ await send(chatId, chunk);
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Parse task creation input. Supports:
430
+ * project-name instructions
431
+ * project-name -s sessionId instructions
432
+ * project-name -in 30m instructions
433
+ * project-name -at 2024-01-01T10:00 instructions
434
+ */
435
+ async function handleNewTask(chatId: number, input: string) {
436
+ if (!input) {
437
+ await send(chatId,
438
+ 'Usage:\nproject: instructions\n\n' +
439
+ 'Options:\n' +
440
+ ' -s <sessionId> — resume specific session\n' +
441
+ ' -in 30m — delay (e.g. 10m, 2h, 1d)\n' +
442
+ ' -at 18:00 — schedule at time\n\n' +
443
+ 'Example:\nmy-app: Fix the login bug\nmy-app -s abc123 -in 1h: continue work'
444
+ );
445
+ return;
446
+ }
447
+
448
+ // Parse project name (before first space or colon)
449
+ const colonIdx = input.indexOf(':');
450
+ let projectPart: string;
451
+ let restPart: string;
452
+
453
+ if (colonIdx > 0 && colonIdx < 40) {
454
+ projectPart = input.slice(0, colonIdx).trim();
455
+ restPart = input.slice(colonIdx + 1).trim();
456
+ } else {
457
+ const spaceIdx = input.indexOf(' ');
458
+ if (spaceIdx < 0) {
459
+ await send(chatId, 'Please provide instructions after the project name.');
460
+ return;
461
+ }
462
+ projectPart = input.slice(0, spaceIdx).trim();
463
+ restPart = input.slice(spaceIdx + 1).trim();
464
+ }
465
+
466
+ const projects = scanProjects();
467
+ const project = projects.find(p => p.name === projectPart || p.name.toLowerCase() === projectPart.toLowerCase());
468
+
469
+ if (!project) {
470
+ const available = projects.slice(0, 10).map(p => ` ${p.name}`).join('\n');
471
+ await send(chatId, `Project not found: ${projectPart}\n\nAvailable:\n${available}`);
472
+ return;
473
+ }
474
+
475
+ // Parse flags
476
+ let sessionId: string | undefined;
477
+ let scheduledAt: string | undefined;
478
+ let tokens = restPart.split(/\s+/);
479
+ const promptTokens: string[] = [];
480
+
481
+ for (let i = 0; i < tokens.length; i++) {
482
+ if (tokens[i] === '-s' && i + 1 < tokens.length) {
483
+ sessionId = tokens[++i];
484
+ } else if (tokens[i] === '-in' && i + 1 < tokens.length) {
485
+ scheduledAt = parseDelay(tokens[++i]);
486
+ } else if (tokens[i] === '-at' && i + 1 < tokens.length) {
487
+ scheduledAt = parseTimeAt(tokens[++i]);
488
+ } else {
489
+ promptTokens.push(tokens[i]);
490
+ }
491
+ }
492
+
493
+ const prompt = promptTokens.join(' ');
494
+ if (!prompt) {
495
+ await send(chatId, 'Please provide instructions.');
496
+ return;
497
+ }
498
+
499
+ const task = createTask({
500
+ projectName: project.name,
501
+ projectPath: project.path,
502
+ prompt,
503
+ conversationId: sessionId,
504
+ scheduledAt,
505
+ });
506
+
507
+ let statusLine = 'Status: queued';
508
+ if (scheduledAt) {
509
+ statusLine = `Scheduled: ${new Date(scheduledAt).toLocaleString()}`;
510
+ }
511
+ if (sessionId) {
512
+ statusLine += `\nSession: ${sessionId.slice(0, 12)}`;
513
+ }
514
+
515
+ const msgId = await send(chatId,
516
+ `šŸ“‹ Task created: ${task.id}\n${task.projectName}: ${prompt}\n\n${statusLine}`
517
+ );
518
+
519
+ if (msgId) {
520
+ taskMessageMap.set(msgId, task.id);
521
+ taskChatMap.set(task.id, chatId);
522
+ }
523
+ }
524
+
525
+ function parseDelay(s: string): string | undefined {
526
+ const match = s.match(/^(\d+)(m|h|d)$/);
527
+ if (!match) return undefined;
528
+ const val = Number(match[1]);
529
+ const unit = match[2];
530
+ const ms = unit === 'm' ? val * 60_000 : unit === 'h' ? val * 3600_000 : val * 86400_000;
531
+ return new Date(Date.now() + ms).toISOString();
532
+ }
533
+
534
+ function parseTimeAt(s: string): string | undefined {
535
+ // Try HH:MM format (today)
536
+ const timeMatch = s.match(/^(\d{1,2}):(\d{2})$/);
537
+ if (timeMatch) {
538
+ const now = new Date();
539
+ now.setHours(Number(timeMatch[1]), Number(timeMatch[2]), 0, 0);
540
+ if (now.getTime() < Date.now()) now.setDate(now.getDate() + 1); // next day
541
+ return now.toISOString();
542
+ }
543
+ // Try ISO or date format
544
+ try {
545
+ const d = new Date(s);
546
+ if (!isNaN(d.getTime())) return d.toISOString();
547
+ } catch {}
548
+ return undefined;
549
+ }
550
+
551
+ async function handleFollowUp(chatId: number, taskId: string, message: string) {
552
+ const task = getTask(taskId);
553
+ if (!task) {
554
+ await send(chatId, 'Task not found.');
555
+ return;
556
+ }
557
+
558
+ if (task.status === 'running') {
559
+ await send(chatId, 'ā³ Task still running, wait for it to finish.');
560
+ return;
561
+ }
562
+
563
+ const newTask = createTask({
564
+ projectName: task.projectName,
565
+ projectPath: task.projectPath,
566
+ prompt: message,
567
+ conversationId: task.conversationId || undefined,
568
+ });
569
+
570
+ const msgId = await send(chatId,
571
+ `šŸ”„ Follow-up: ${newTask.id}\nContinuing ${task.projectName} session\n\n${message}`
572
+ );
573
+
574
+ if (msgId) {
575
+ taskMessageMap.set(msgId, newTask.id);
576
+ taskChatMap.set(newTask.id, chatId);
577
+ }
578
+ }
579
+
580
+ async function sendProjectList(chatId: number) {
581
+ const projects = scanProjects();
582
+ const lines = projects.slice(0, 20).map(p =>
583
+ `${p.name}${p.language ? ` (${p.language})` : ''}`
584
+ );
585
+ await send(chatId, `šŸ“ Projects\n\n${lines.join('\n')}\n\n${projects.length} total`);
586
+ }
587
+
588
+ async function handleCancel(chatId: number, taskId?: string) {
589
+ if (!taskId) { await send(chatId, 'Usage: /cancel <task-id>'); return; }
590
+ const ok = cancelTask(taskId);
591
+ await send(chatId, ok ? `šŸ›‘ Task ${taskId} cancelled` : `Cannot cancel task ${taskId}`);
592
+ }
593
+
594
+ async function handleRetry(chatId: number, taskId?: string) {
595
+ if (!taskId) { await send(chatId, 'Usage: /retry <task-id>'); return; }
596
+ const newTask = retryTask(taskId);
597
+ if (!newTask) {
598
+ await send(chatId, `Cannot retry task ${taskId}`);
599
+ return;
600
+ }
601
+ const msgId = await send(chatId, `šŸ”„ Retrying as ${newTask.id}`);
602
+ if (msgId) {
603
+ taskMessageMap.set(msgId, newTask.id);
604
+ taskChatMap.set(newTask.id, chatId);
605
+ }
606
+ }
607
+
608
+ // ─── Watcher Commands ────────────────────────────────────────
609
+
610
+ async function handleWatch(chatId: number, projectName?: string, sessionId?: string) {
611
+ if (!projectName) {
612
+ await send(chatId, 'Usage: /watch <project> [sessionId]\n\nMonitors a session and sends updates here.');
613
+ return;
614
+ }
615
+ const label = sessionId ? `${projectName}/${sessionId.slice(0, 8)}` : projectName;
616
+ const watcher = createWatcher({ projectName, sessionId, label });
617
+ await send(chatId, `šŸ‘ Watching: ${label}\nID: ${watcher.id}\nChecking every ${watcher.checkInterval}s`);
618
+ }
619
+
620
+ async function sendWatcherList(chatId: number) {
621
+ const all = listWatchers();
622
+ if (all.length === 0) {
623
+ await send(chatId, 'šŸ‘ No watchers.\n\nUse /watch <project> [sessionId] to add one.');
624
+ return;
625
+ }
626
+
627
+ const lines = all.map((w, i) => {
628
+ const status = w.active ? 'ā—' : 'ā—‹';
629
+ const target = w.sessionId ? `${w.projectName}/${w.sessionId.slice(0, 8)}` : w.projectName;
630
+ return `${status} ${w.id} — ${target} (${w.checkInterval}s)`;
631
+ });
632
+
633
+ await send(chatId, `šŸ‘ Watchers\n\n${lines.join('\n')}\n\nUse /unwatch <id> to remove`);
634
+ }
635
+
636
+ async function handleUnwatch(chatId: number, watcherId?: string) {
637
+ if (!watcherId) {
638
+ await send(chatId, 'Usage: /unwatch <watcher-id>');
639
+ return;
640
+ }
641
+ deleteWatcher(watcherId);
642
+ await send(chatId, `šŸ—‘ Watcher ${watcherId} removed`);
643
+ }
644
+
645
+ // ─── Tunnel Commands ─────────────────────────────────────────
646
+
647
+ async function handleTunnel(chatId: number, action?: string, password?: string, userMsgId?: number) {
648
+ const settings = loadSettings();
649
+ if (String(chatId) !== settings.telegramChatId) {
650
+ await send(chatId, 'ā›” Unauthorized');
651
+ return;
652
+ }
653
+
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);
666
+ }
667
+
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}`);
672
+ return;
673
+ }
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') {
683
+ stopTunnel();
684
+ await send(chatId, 'šŸ›‘ Tunnel stopped');
685
+ } 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
+ }
695
+ }
696
+ }
697
+
698
+ async function handleTunnelPassword(chatId: number, password?: string, userMsgId?: number) {
699
+ const settings = loadSettings();
700
+ if (String(chatId) !== settings.telegramChatId) {
701
+ await send(chatId, 'ā›” Unauthorized');
702
+ return;
703
+ }
704
+
705
+ if (!settings.telegramTunnelPassword) {
706
+ await send(chatId, 'āš ļø Telegram tunnel password not configured.\nSet it in Settings → Remote Access → Telegram tunnel password');
707
+ return;
708
+ }
709
+
710
+ if (!password) {
711
+ await send(chatId, 'Usage: /tunnel_password <your-password>');
712
+ return;
713
+ }
714
+
715
+ // Immediately delete user's message containing password
716
+ if (userMsgId) deleteMessageLater(chatId, userMsgId, 0);
717
+
718
+ if (password !== settings.telegramTunnelPassword) {
719
+ await send(chatId, 'ā›” Wrong password');
720
+ return;
721
+ }
722
+
723
+ const loginPassword = getPassword();
724
+ const status = getTunnelStatus();
725
+ // Send password as code block, auto-delete after 30s
726
+ const labelId = await send(chatId, 'šŸ”‘ Login password (auto-deletes in 30s):');
727
+ const pwId = await sendHtml(chatId, `<code>${loginPassword}</code>`);
728
+ if (labelId) deleteMessageLater(chatId, labelId);
729
+ if (pwId) deleteMessageLater(chatId, pwId);
730
+ if (status.status === 'running' && status.url) {
731
+ const urlLabelId = await send(chatId, '🌐 URL:');
732
+ const urlId = await sendHtml(chatId, `<a href="${status.url}">${status.url}</a>`);
733
+ if (urlLabelId) deleteMessageLater(chatId, urlLabelId);
734
+ if (urlId) deleteMessageLater(chatId, urlId);
735
+ }
736
+ }
737
+
738
+ // ─── Real-time Streaming ─────────────────────────────────────
739
+
740
+ function bufferLogEntry(taskId: string, chatId: number, entry: TaskLogEntry) {
741
+ taskChatMap.set(taskId, chatId);
742
+
743
+ let buf = logBuffers.get(taskId);
744
+ if (!buf) {
745
+ buf = { entries: [], timer: null };
746
+ logBuffers.set(taskId, buf);
747
+ }
748
+
749
+ let line = '';
750
+ if (entry.subtype === 'tool_use') {
751
+ line = `šŸ”§ ${entry.tool || 'tool'}: ${entry.content.slice(0, 80)}`;
752
+ } else if (entry.subtype === 'text') {
753
+ line = entry.content.slice(0, 200);
754
+ } else if (entry.type === 'result') {
755
+ line = `āœ… ${entry.content.slice(0, 200)}`;
756
+ } else if (entry.subtype === 'error') {
757
+ line = `ā— ${entry.content.slice(0, 200)}`;
758
+ }
759
+ if (!line) return;
760
+
761
+ buf.entries.push(line);
762
+
763
+ if (!buf.timer) {
764
+ buf.timer = setTimeout(() => flushLogBuffer(taskId, chatId), 3000);
765
+ }
766
+ }
767
+
768
+ async function flushLogBuffer(taskId: string, chatId: number) {
769
+ const buf = logBuffers.get(taskId);
770
+ if (!buf || buf.entries.length === 0) return;
771
+
772
+ const text = buf.entries.join('\n');
773
+ buf.entries = [];
774
+ buf.timer = null;
775
+
776
+ await send(chatId, text);
777
+ }
778
+
779
+ async function handleStatusChange(taskId: string, chatId: number, status: string) {
780
+ await flushLogBuffer(taskId, chatId);
781
+
782
+ const task = getTask(taskId);
783
+ if (!task) return;
784
+
785
+ const targetChat = taskChatMap.get(taskId) || chatId;
786
+
787
+ if (status === 'running') {
788
+ const msgId = await send(targetChat,
789
+ `šŸš€ Started: ${taskId}\n${task.projectName}: ${task.prompt.slice(0, 100)}`
790
+ );
791
+ if (msgId) taskMessageMap.set(msgId, taskId);
792
+ } else if (status === 'done') {
793
+ const cost = task.costUSD != null ? `Cost: $${task.costUSD.toFixed(4)}\n` : '';
794
+ const result = task.resultSummary ? task.resultSummary.slice(0, 800) : '';
795
+ const msgId = await send(targetChat,
796
+ `āœ… Done: ${taskId}\n${task.projectName}\n${cost}${result ? `\n${result}` : ''}\n\nšŸ’¬ Reply to continue`
797
+ );
798
+ if (msgId) taskMessageMap.set(msgId, taskId);
799
+ } else if (status === 'failed') {
800
+ const msgId = await send(targetChat,
801
+ `āŒ Failed: ${taskId}\n${task.error || 'Unknown error'}\n\n/retry ${taskId}`
802
+ );
803
+ if (msgId) taskMessageMap.set(msgId, taskId);
804
+ }
805
+ }
806
+
807
+ // ─── Telegram API ────────────────────────────────────────────
808
+
809
+ async function send(chatId: number, text: string): Promise<number | null> {
810
+ const settings = loadSettings();
811
+ if (!settings.telegramBotToken) return null;
812
+
813
+ try {
814
+ const url = `https://api.telegram.org/bot${settings.telegramBotToken}/sendMessage`;
815
+ const res = await fetch(url, {
816
+ method: 'POST',
817
+ headers: { 'Content-Type': 'application/json' },
818
+ body: JSON.stringify({
819
+ chat_id: chatId,
820
+ text,
821
+ disable_web_page_preview: true,
822
+ }),
823
+ });
824
+
825
+ const data = await res.json();
826
+ if (!data.ok) {
827
+ console.error('[telegram] Send error:', data.description);
828
+ return null;
829
+ }
830
+ return data.result?.message_id || null;
831
+ } catch (err) {
832
+ console.error('[telegram] Send failed:', err);
833
+ return null;
834
+ }
835
+ }
836
+
837
+ /** Delete a message after a delay (seconds) */
838
+ function deleteMessageLater(chatId: number, messageId: number, delaySec: number = 30) {
839
+ setTimeout(async () => {
840
+ const settings = loadSettings();
841
+ if (!settings.telegramBotToken) return;
842
+ try {
843
+ await fetch(`https://api.telegram.org/bot${settings.telegramBotToken}/deleteMessage`, {
844
+ method: 'POST',
845
+ headers: { 'Content-Type': 'application/json' },
846
+ body: JSON.stringify({ chat_id: chatId, message_id: messageId }),
847
+ });
848
+ } catch {}
849
+ }, delaySec * 1000);
850
+ }
851
+
852
+ /** Set bot command menu for quick access */
853
+ async function setBotCommands(token: string) {
854
+ try {
855
+ await fetch(`https://api.telegram.org/bot${token}/setMyCommands`, {
856
+ method: 'POST',
857
+ headers: { 'Content-Type': 'application/json' },
858
+ body: JSON.stringify({
859
+ commands: [
860
+ { command: 'tasks', description: 'List tasks' },
861
+ { command: 'task', description: 'Create task: /task project prompt' },
862
+ { command: 'sessions', description: 'Browse sessions' },
863
+ { command: 'projects', description: 'List projects' },
864
+ { command: 'tunnel', description: 'Tunnel status / start / stop' },
865
+ { command: 'tunnel_password', description: 'Get login password' },
866
+ { command: 'watch', description: 'Monitor session' },
867
+ { command: 'watchers', description: 'List watchers' },
868
+ { command: 'help', description: 'Show help' },
869
+ ],
870
+ }),
871
+ });
872
+ } catch {}
873
+ }
874
+
875
+ async function sendHtml(chatId: number, html: string): Promise<number | null> {
876
+ const settings = loadSettings();
877
+ if (!settings.telegramBotToken) return null;
878
+
879
+ try {
880
+ const url = `https://api.telegram.org/bot${settings.telegramBotToken}/sendMessage`;
881
+ const res = await fetch(url, {
882
+ method: 'POST',
883
+ headers: { 'Content-Type': 'application/json' },
884
+ body: JSON.stringify({
885
+ chat_id: chatId,
886
+ text: html,
887
+ parse_mode: 'HTML',
888
+ disable_web_page_preview: true,
889
+ }),
890
+ });
891
+
892
+ const data = await res.json();
893
+ if (!data.ok) {
894
+ return send(chatId, html.replace(/<[^>]+>/g, ''));
895
+ }
896
+ return data.result?.message_id || null;
897
+ } catch {
898
+ return send(chatId, html.replace(/<[^>]+>/g, ''));
899
+ }
900
+ }
901
+
902
+ function splitMessage(text: string, maxLen: number): string[] {
903
+ if (text.length <= maxLen) return [text];
904
+ const chunks: string[] = [];
905
+ while (text.length > 0) {
906
+ const cut = text.lastIndexOf('\n', maxLen);
907
+ const splitAt = cut > 0 ? cut : maxLen;
908
+ chunks.push(text.slice(0, splitAt));
909
+ text = text.slice(splitAt).trimStart();
910
+ }
911
+ return chunks;
912
+ }