@aion0/forge 0.2.5 → 0.2.7

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.
@@ -16,6 +16,22 @@ export async function GET(req: Request) {
16
16
  return NextResponse.json(listWorkflows());
17
17
  }
18
18
 
19
+ if (type === 'workflow-yaml') {
20
+ const name = searchParams.get('name');
21
+ if (!name) return NextResponse.json({ error: 'name required' }, { status: 400 });
22
+ try {
23
+ const { readFileSync, existsSync } = await import('node:fs');
24
+ const filePath = join(FLOWS_DIR, `${name}.yaml`);
25
+ const altPath = join(FLOWS_DIR, `${name}.yml`);
26
+ const path = existsSync(filePath) ? filePath : existsSync(altPath) ? altPath : null;
27
+ if (!path) return NextResponse.json({ error: 'Not found' }, { status: 404 });
28
+ const yaml = readFileSync(path, 'utf-8');
29
+ return NextResponse.json({ yaml });
30
+ } catch {
31
+ return NextResponse.json({ error: 'Failed to read' }, { status: 500 });
32
+ }
33
+ }
34
+
19
35
  return NextResponse.json(listPipelines().sort((a, b) => b.createdAt.localeCompare(a.createdAt)));
20
36
  }
21
37
 
@@ -180,7 +180,7 @@ if (isDev) {
180
180
  });
181
181
  child.on('exit', (code) => process.exit(code || 0));
182
182
  } else {
183
- if (!existsSync(join(ROOT, '.next'))) {
183
+ if (!existsSync(join(ROOT, '.next', 'BUILD_ID'))) {
184
184
  console.log('[forge] Building...');
185
185
  execSync('npx next build', { cwd: ROOT, stdio: 'inherit', env: { ...process.env } });
186
186
  }
@@ -324,7 +324,7 @@ export default function Dashboard({ user }: { user: any }) {
324
324
  {/* Pipelines */}
325
325
  {viewMode === 'pipelines' && (
326
326
  <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
327
- <PipelineView />
327
+ <PipelineView onViewTask={(taskId) => { setViewMode('tasks'); setActiveTaskId(taskId); }} />
328
328
  </Suspense>
329
329
  )}
330
330
 
@@ -168,7 +168,7 @@ export default function PipelineEditor({ onSave, onClose, initialYaml }: {
168
168
  const [nodes, setNodes, onNodesChange] = useNodesState<Node<NodeData>>([]);
169
169
  const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
170
170
  const [editingNode, setEditingNode] = useState<{ id: string; project: string; prompt: string; outputs: { name: string; extract: string }[] } | null>(null);
171
- const [workflowName, setWorkflowName] = useState('my-workflow');
171
+ const [workflowName, setWorkflowName] = useState('');
172
172
  const [workflowDesc, setWorkflowDesc] = useState('');
173
173
  const [varsProject, setVarsProject] = useState('');
174
174
  const [projects, setProjects] = useState<{ name: string; root: string }[]>([]);
@@ -60,7 +60,7 @@ const STATUS_COLOR: Record<string, string> = {
60
60
  skipped: 'text-gray-500',
61
61
  };
62
62
 
63
- export default function PipelineView() {
63
+ export default function PipelineView({ onViewTask }: { onViewTask?: (taskId: string) => void }) {
64
64
  const [pipelines, setPipelines] = useState<Pipeline[]>([]);
65
65
  const [workflows, setWorkflows] = useState<Workflow[]>([]);
66
66
  const [selectedPipeline, setSelectedPipeline] = useState<Pipeline | null>(null);
@@ -69,6 +69,7 @@ export default function PipelineView() {
69
69
  const [inputValues, setInputValues] = useState<Record<string, string>>({});
70
70
  const [creating, setCreating] = useState(false);
71
71
  const [showEditor, setShowEditor] = useState(false);
72
+ const [editorYaml, setEditorYaml] = useState<string | undefined>(undefined);
72
73
 
73
74
  const fetchData = useCallback(async () => {
74
75
  const [pRes, wRes] = await Promise.all([
@@ -150,12 +151,25 @@ export default function PipelineView() {
150
151
  <aside className="w-72 border-r border-[var(--border)] flex flex-col shrink-0">
151
152
  <div className="px-3 py-2 border-b border-[var(--border)] flex items-center justify-between">
152
153
  <span className="text-[11px] font-semibold text-[var(--text-primary)]">Pipelines</span>
153
- <button
154
- onClick={() => setShowEditor(true)}
155
- className="text-[10px] px-2 py-0.5 rounded text-green-400 hover:bg-green-400/10"
154
+ <select
155
+ onChange={async (e) => {
156
+ const name = e.target.value;
157
+ if (!name) { setEditorYaml(undefined); setShowEditor(true); return; }
158
+ try {
159
+ const res = await fetch(`/api/pipelines?type=workflow-yaml&name=${encodeURIComponent(name)}`);
160
+ const data = await res.json();
161
+ setEditorYaml(data.yaml || undefined);
162
+ } catch { setEditorYaml(undefined); }
163
+ setShowEditor(true);
164
+ e.target.value = '';
165
+ }}
166
+ className="text-[10px] px-1 py-0.5 rounded text-green-400 bg-transparent hover:bg-green-400/10 cursor-pointer"
167
+ defaultValue=""
156
168
  >
157
- Editor
158
- </button>
169
+ <option value="">Editor ā–¾</option>
170
+ <option value="">+ New workflow</option>
171
+ {workflows.map(w => <option key={w.name} value={w.name}>{w.name}</option>)}
172
+ </select>
159
173
  <button
160
174
  onClick={() => setShowCreate(v => !v)}
161
175
  className={`text-[10px] px-2 py-0.5 rounded ${showCreate ? 'text-white bg-[var(--accent)]' : 'text-[var(--accent)] hover:bg-[var(--accent)]/10'}`}
@@ -322,7 +336,12 @@ export default function PipelineView() {
322
336
  <span className={STATUS_COLOR[node.status]}>{STATUS_ICON[node.status]}</span>
323
337
  <span className="text-xs font-semibold text-[var(--text-primary)]">{nodeId}</span>
324
338
  {node.taskId && (
325
- <span className="text-[9px] text-[var(--text-secondary)] font-mono">task:{node.taskId}</span>
339
+ <button
340
+ onClick={() => onViewTask?.(node.taskId!)}
341
+ className="text-[9px] text-[var(--accent)] font-mono hover:underline"
342
+ >
343
+ task:{node.taskId}
344
+ </button>
326
345
  )}
327
346
  {node.iterations > 1 && (
328
347
  <span className="text-[9px] text-yellow-400">iter {node.iterations}</span>
@@ -416,6 +435,7 @@ nodes:
416
435
  {showEditor && (
417
436
  <Suspense fallback={null}>
418
437
  <PipelineEditor
438
+ initialYaml={editorYaml}
419
439
  onSave={async (yaml) => {
420
440
  // Save YAML to ~/.forge/flows/
421
441
  await fetch('/api/pipelines', {
@@ -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">
package/install.sh CHANGED
@@ -17,7 +17,8 @@ else
17
17
  echo "[forge] Installing from npm..."
18
18
  rm -rf "$(npm root -g)/@aion0/forge" 2>/dev/null || true
19
19
  npm cache clean --force 2>/dev/null || true
20
- npm install -g @aion0/forge
20
+ # Install from /tmp to avoid pnpm node_modules conflict
21
+ (cd /tmp && npm install -g @aion0/forge)
21
22
  echo "[forge] Building..."
22
23
  cd "$(npm root -g)/@aion0/forge" && npx next build && cd -
23
24
  fi
@@ -26,7 +26,5 @@ export async function register() {
26
26
  const { getPassword } = await import('./lib/password');
27
27
  const password = getPassword();
28
28
  process.env.MW_PASSWORD = password;
29
- console.log(`[init] Login password: ${password}`);
30
- console.log('[init] Forgot password? Run: forge password');
31
29
  }
32
30
  }
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 {
@@ -39,6 +39,9 @@ function db() {
39
39
  return getDb(getDbPath());
40
40
  }
41
41
 
42
+ // Per-task model overrides (used by pipeline to set pipelineModel)
43
+ export const taskModelOverrides = new Map<string, string>();
44
+
42
45
  // ─── CRUD ────────────────────────────────────────────────────
43
46
 
44
47
  export function createTask(opts: {
@@ -239,6 +242,12 @@ function executeTask(task: Task): Promise<void> {
239
242
 
240
243
  const args = ['-p', '--verbose', '--output-format', 'stream-json', '--dangerously-skip-permissions'];
241
244
 
245
+ // Use model override if set, otherwise fall back to taskModel setting
246
+ const model = taskModelOverrides.get(task.id) || settings.taskModel;
247
+ if (model && model !== 'default') {
248
+ args.push('--model', model);
249
+ }
250
+
242
251
  // Resume specific session to continue the conversation
243
252
  if (task.conversationId) {
244
253
  args.push('--resume', task.conversationId);
@@ -254,7 +263,7 @@ function executeTask(task: Task): Promise<void> {
254
263
 
255
264
  // Resolve the actual claude CLI script path (claude is a symlink to a .js file)
256
265
  const resolvedClaude = resolveClaudePath(claudePath);
257
- console.log(`[task] ${task.projectName}: "${task.prompt.slice(0, 60)}..."`);
266
+ console.log(`[task] ${task.projectName} [${model || 'default'}]: "${task.prompt.slice(0, 60)}..."`);
258
267
 
259
268
  const child = spawn(resolvedClaude.cmd, [...resolvedClaude.prefix, ...args], {
260
269
  cwd: task.projectPath,
@@ -266,6 +275,7 @@ function executeTask(task: Task): Promise<void> {
266
275
  let resultText = '';
267
276
  let totalCost = 0;
268
277
  let sessionId = '';
278
+ let modelUsed = '';
269
279
 
270
280
  child.on('error', (err) => {
271
281
  console.error(`[task-runner] Spawn error:`, err.message);
@@ -296,6 +306,9 @@ function executeTask(task: Task): Promise<void> {
296
306
  }
297
307
 
298
308
  if (parsed.session_id) sessionId = parsed.session_id;
309
+ if (parsed.type === 'system' && parsed.subtype === 'init' && parsed.model) {
310
+ modelUsed = parsed.model;
311
+ }
299
312
  if (parsed.type === 'result') {
300
313
  resultText = typeof parsed.result === 'string' ? parsed.result : JSON.stringify(parsed.result);
301
314
  totalCost = parsed.total_cost_usd || 0;
@@ -381,6 +394,12 @@ function executeTask(task: Task): Promise<void> {
381
394
  * Sends a visible bell character so the user knows to resume.
382
395
  */
383
396
  function notifyTerminalSession(task: Task, status: 'done' | 'failed', sessionId?: string) {
397
+ // Skip pipeline tasks — they have their own notification system
398
+ try {
399
+ const { pipelineTaskIds } = require('./pipeline');
400
+ if (pipelineTaskIds.has(task.id)) return;
401
+ } catch {}
402
+
384
403
  try {
385
404
  const out = execSync(
386
405
  `tmux list-sessions -F "#{session_name}" 2>/dev/null`,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Telegram Bot — remote interface for My Workflow.
2
+ * Telegram Bot — remote interface for Forge.
3
3
  *
4
4
  * Optimized for mobile:
5
5
  * - /tasks shows numbered list, reply with number to see details
@@ -19,8 +19,8 @@ import type { Task, TaskLogEntry } from '@/src/types';
19
19
  // Prevent duplicate polling and state loss across hot-reloads
20
20
  const globalKey = Symbol.for('mw-telegram-state');
21
21
  const g = globalThis as any;
22
- if (!g[globalKey]) g[globalKey] = { polling: false, pollTimer: null, lastUpdateId: 0, taskListenerAttached: false, pollActive: false };
23
- const botState: { polling: boolean; pollTimer: ReturnType<typeof setTimeout> | null; lastUpdateId: number; taskListenerAttached: boolean; pollActive: boolean } = g[globalKey];
22
+ if (!g[globalKey]) g[globalKey] = { polling: false, pollTimer: null, lastUpdateId: 0, taskListenerAttached: false, pollActive: false, processedMsgIds: new Set<number>(), pollGeneration: 0 };
23
+ const botState: { polling: boolean; pollTimer: ReturnType<typeof setTimeout> | null; lastUpdateId: number; taskListenerAttached: boolean; pollActive: boolean; processedMsgIds: Set<number>; pollGeneration: number } = g[globalKey];
24
24
 
25
25
  // Track which Telegram message maps to which task (for reply-based interaction)
26
26
  const taskMessageMap = new Map<number, string>(); // messageId → taskId
@@ -46,10 +46,16 @@ const logBuffers = new Map<string, { entries: string[]; timer: ReturnType<typeof
46
46
  // ─── Start/Stop ──────────────────────────────────────────────
47
47
 
48
48
  export function startTelegramBot() {
49
- if (botState.polling) return;
50
49
  const settings = loadSettings();
51
50
  if (!settings.telegramBotToken || !settings.telegramChatId) return;
52
51
 
52
+ // Kill any existing poll loop (handles hot-reload creating duplicates)
53
+ if (botState.polling) {
54
+ botState.pollGeneration++;
55
+ if (botState.pollTimer) { clearTimeout(botState.pollTimer); botState.pollTimer = null; }
56
+ botState.pollActive = false;
57
+ }
58
+
53
59
  botState.polling = true;
54
60
  console.log('[telegram] Bot started');
55
61
 
@@ -62,6 +68,13 @@ export function startTelegramBot() {
62
68
  onTaskEvent((taskId, event, data) => {
63
69
  const settings = loadSettings();
64
70
  if (!settings.telegramBotToken || !settings.telegramChatId) return;
71
+
72
+ // Skip pipeline tasks — they have their own notification
73
+ try {
74
+ const { pipelineTaskIds } = require('./pipeline');
75
+ if (pipelineTaskIds.has(taskId)) return;
76
+ } catch {}
77
+
65
78
  const chatId = Number(settings.telegramChatId.split(',')[0].trim());
66
79
 
67
80
  if (event === 'log') {
@@ -102,7 +115,9 @@ function schedulePoll(delay: number = 1000) {
102
115
  }
103
116
 
104
117
  async function poll() {
105
- // Prevent concurrent polls — main cause of duplicate messages after sleep/wake
118
+ const myGeneration = botState.pollGeneration;
119
+
120
+ // Prevent concurrent polls
106
121
  if (!botState.polling || botState.pollActive) return;
107
122
  botState.pollActive = true;
108
123
 
@@ -117,11 +132,13 @@ async function poll() {
117
132
 
118
133
  const data = await res.json();
119
134
 
120
- if (data.ok && data.result) {
135
+ if (data.ok && data.result && data.result.length > 0) {
136
+ console.log(`[telegram] Poll got ${data.result.length} updates, lastId=${botState.lastUpdateId}`);
121
137
  for (const update of data.result) {
122
138
  if (update.update_id <= botState.lastUpdateId) continue;
123
139
  botState.lastUpdateId = update.update_id;
124
140
  if (update.message?.text) {
141
+ console.log(`[telegram] Processing msg ${update.message.message_id}: ${update.message.text.slice(0, 30)}`);
125
142
  await handleMessage(update.message);
126
143
  }
127
144
  }
@@ -131,7 +148,8 @@ async function poll() {
131
148
  }
132
149
 
133
150
  botState.pollActive = false;
134
- if (botState.polling) schedulePoll(1000);
151
+ // Only continue polling if this is still the current generation
152
+ if (botState.polling && myGeneration === botState.pollGeneration) schedulePoll(1000);
135
153
  }
136
154
 
137
155
  // ─── Message Handler ─────────────────────────────────────────
@@ -147,6 +165,16 @@ async function handleMessage(msg: any) {
147
165
  }
148
166
 
149
167
  // Message received (logged silently)
168
+ // Dedup: skip if we already processed this message
169
+ const msgId = msg.message_id;
170
+ if (botState.processedMsgIds.has(msgId)) return;
171
+ botState.processedMsgIds.add(msgId);
172
+ // Keep set size bounded
173
+ if (botState.processedMsgIds.size > 200) {
174
+ const oldest = [...botState.processedMsgIds].slice(0, 100);
175
+ oldest.forEach(id => botState.processedMsgIds.delete(id));
176
+ }
177
+
150
178
  const text: string = msg.text.trim();
151
179
  const replyTo = msg.reply_to_message?.message_id;
152
180
 
@@ -260,28 +288,30 @@ async function handleMessage(msg: any) {
260
288
  await sendProjectList(chatId);
261
289
  break;
262
290
  case '/watch':
263
- await handleWatch(chatId, args[0], args[1]);
264
- break;
265
- case '/watchers':
266
291
  case '/w':
267
- await sendWatcherList(chatId);
292
+ if (args.length > 0) {
293
+ await handleWatch(chatId, args[0], args[1]);
294
+ } else {
295
+ await sendWatcherList(chatId);
296
+ }
268
297
  break;
269
298
  case '/unwatch':
270
299
  await handleUnwatch(chatId, args[0]);
271
300
  break;
301
+ case '/docs':
302
+ case '/doc':
303
+ await handleDocs(chatId, args.join(' '));
304
+ break;
272
305
  case '/peek':
306
+ case '/sessions':
307
+ case '/s':
273
308
  if (args.length > 0) {
274
309
  await handlePeek(chatId, args[0], args[1]);
275
310
  } else {
276
311
  await startPeekSelection(chatId);
277
312
  }
278
313
  break;
279
- case '/docs':
280
- case '/doc':
281
- await handleDocs(chatId, args.join(' '));
282
- break;
283
314
  case '/note':
284
- case '/docs_write':
285
315
  await handleDocsWrite(chatId, args.join(' '));
286
316
  break;
287
317
  case '/cancel':
@@ -329,23 +359,19 @@ async function handleMessage(msg: any) {
329
359
  async function sendHelp(chatId: number) {
330
360
  await send(chatId,
331
361
  `šŸ¤– Forge\n\n` +
332
- `šŸ“‹ /tasks — numbered task list\n` +
333
- `/tasks running — filter by status\n` +
334
- `šŸ” /sessions — browse session content\n` +
335
- `/sessions <project> — sessions for project\n\n` +
336
- `šŸ‘ /watch <project> [sessionId] — monitor session\n` +
337
- `/watchers — list active watchers\n` +
338
- `/unwatch <id> — stop watching\n\n` +
339
- `šŸ“ Submit task:\nproject-name: your instructions\n\n` +
340
- `šŸ‘€ /peek [project] [sessionId] — session summary\n` +
341
- `šŸ“– /docs — docs session summary\n` +
342
- `/docs <filename> — view doc file\n` +
343
- `šŸ“ /note — quick note to docs claude\n\n` +
362
+ `šŸ“‹ /task — create task (interactive)\n` +
363
+ `/tasks — task list\n\n` +
364
+ `šŸ‘€ /sessions — session summary (select project)\n` +
365
+ `šŸ“– /docs — docs summary / view file\n` +
366
+ `šŸ“ /note — quick note to docs\n\n` +
367
+ `šŸ‘ /watch <project> — monitor session\n` +
368
+ `/watch — list watchers\n` +
369
+ `/unwatch <id> — stop\n\n` +
344
370
  `šŸ”§ /cancel <id> /retry <id>\n` +
371
+ `/sessions — browse sessions\n` +
345
372
  `/projects — list projects\n\n` +
346
- `🌐 /tunnel — tunnel status\n` +
347
- `/tunnel_start — start tunnel\n` +
348
- `/tunnel_stop — stop tunnel\n` +
373
+ `🌐 /tunnel — status\n` +
374
+ `/tunnel_start / /tunnel_stop\n` +
349
375
  `/tunnel_password <pw> — get login password\n\n` +
350
376
  `Reply number to select`
351
377
  );
@@ -657,39 +683,16 @@ async function handlePeek(chatId: number, projectArg?: string, sessionArg?: stri
657
683
  contextLen += line.length;
658
684
  }
659
685
 
660
- // AI summary
661
- let summary = '';
662
- try {
663
- const apiKey = process.env.ANTHROPIC_API_KEY;
664
- if (apiKey) {
665
- const res = await fetch('https://api.anthropic.com/v1/messages', {
666
- method: 'POST',
667
- headers: {
668
- 'Content-Type': 'application/json',
669
- 'x-api-key': apiKey,
670
- 'anthropic-version': '2023-06-01',
671
- },
672
- body: JSON.stringify({
673
- model: 'claude-haiku-4-5-20251001',
674
- max_tokens: 500,
675
- messages: [{
676
- role: 'user',
677
- content: `Summarize this Claude Code session in 2-3 sentences. What was the user trying to do? What's the current status? Answer in the same language as the session content.\n\n${contextEntries.join('\n')}`,
678
- }],
679
- }),
680
- });
681
- if (res.ok) {
682
- const data = await res.json();
683
- summary = data.content?.[0]?.text || '';
684
- }
685
- }
686
- } catch {}
686
+ const telegramModel = loadSettings().telegramModel || 'sonnet';
687
+ const summary = contextEntries.length > 3
688
+ ? await aiSummarize(contextEntries.join('\n'), 'Summarize this Claude Code session in 2-3 sentences. What was the user working on? What is the current status? Answer in the same language as the content.')
689
+ : '';
687
690
 
688
691
  // Format output
689
- const header = `šŸ“‹ ${project.name} / ${session.sessionId.slice(0, 8)}\n${entries.length} entries${session.gitBranch ? ` • ${session.gitBranch}` : ''}`;
692
+ const header = `šŸ“‹ ${project.name} / ${session.sessionId.slice(0, 8)}\n${entries.length} entries${session.gitBranch ? ` • ${session.gitBranch}` : ''}${summary ? ` • AI: ${telegramModel}` : ''}`;
690
693
 
691
694
  const summaryBlock = summary
692
- ? `\n\nšŸ“ Summary:\n${summary}`
695
+ ? `\n\nšŸ“ Summary (${telegramModel}):\n${summary}`
693
696
  : '';
694
697
 
695
698
  const rawBlock = `\n\n--- Recent ---\n${recentRaw.join('\n\n')}`;
@@ -1037,6 +1040,47 @@ async function handleTunnelPassword(chatId: number, password?: string, userMsgId
1037
1040
  }
1038
1041
  }
1039
1042
 
1043
+ // ─── AI Summarize (using Claude Code subscription) ───────────
1044
+
1045
+ async function aiSummarize(content: string, instruction: string): Promise<string> {
1046
+ try {
1047
+ const settings = loadSettings();
1048
+ const claudePath = settings.claudePath || process.env.CLAUDE_PATH || 'claude';
1049
+ const model = settings.telegramModel || 'sonnet';
1050
+ const { execSync } = require('child_process');
1051
+ const { realpathSync } = require('fs');
1052
+
1053
+ // Resolve claude path
1054
+ let cmd = claudePath;
1055
+ try {
1056
+ const which = execSync(`which ${claudePath}`, { encoding: 'utf-8' }).trim();
1057
+ cmd = realpathSync(which);
1058
+ } catch {}
1059
+
1060
+ const args = ['-p', '--model', model, '--max-turns', '1'];
1061
+ const prompt = `${instruction}\n\nContent:\n${content.slice(0, 8000)}`;
1062
+
1063
+ let execCmd: string;
1064
+ if (cmd.endsWith('.js') || cmd.endsWith('.mjs')) {
1065
+ execCmd = `${process.execPath} ${cmd} ${args.join(' ')}`;
1066
+ } else {
1067
+ execCmd = `${cmd} ${args.join(' ')}`;
1068
+ }
1069
+
1070
+ const result = execSync(execCmd, {
1071
+ input: prompt,
1072
+ encoding: 'utf-8',
1073
+ timeout: 30000,
1074
+ stdio: ['pipe', 'pipe', 'pipe'],
1075
+ env: { ...process.env, CLAUDECODE: undefined },
1076
+ }).trim();
1077
+
1078
+ return result.slice(0, 1000);
1079
+ } catch {
1080
+ return '';
1081
+ }
1082
+ }
1083
+
1040
1084
  // ─── Docs ────────────────────────────────────────────────────
1041
1085
 
1042
1086
  async function handleDocs(chatId: number, input: string) {
@@ -1158,38 +1202,13 @@ async function handleDocs(chatId: number, input: string) {
1158
1202
  } catch {}
1159
1203
 
1160
1204
  const recent = entries.slice(-8).join('\n\n');
1161
- const header = `šŸ“– Docs: ${docRoot.split('/').pop()}\nšŸ“‹ Session: ${sessionId.slice(0, 12)}\n`;
1162
-
1163
- // Try AI summary if available
1164
- let summary = '';
1165
- try {
1166
- const apiKey = process.env.ANTHROPIC_API_KEY;
1167
- if (apiKey && entries.length > 2) {
1168
- const contextText = entries.slice(-15).join('\n');
1169
- const res = await fetch('https://api.anthropic.com/v1/messages', {
1170
- method: 'POST',
1171
- headers: {
1172
- 'Content-Type': 'application/json',
1173
- 'x-api-key': apiKey,
1174
- 'anthropic-version': '2023-06-01',
1175
- },
1176
- body: JSON.stringify({
1177
- model: 'claude-haiku-4-5-20251001',
1178
- max_tokens: 300,
1179
- messages: [{
1180
- role: 'user',
1181
- content: `Summarize this Claude Code session in 2-3 sentences. What was the user working on? What's the current status? Answer in the same language as the content.\n\n${contextText}`,
1182
- }],
1183
- }),
1184
- });
1185
- if (res.ok) {
1186
- const data = await res.json();
1187
- summary = data.content?.[0]?.text || '';
1188
- }
1189
- }
1190
- } catch {}
1205
+ const tModel = loadSettings().telegramModel || 'sonnet';
1206
+ const summary = entries.length > 3
1207
+ ? await aiSummarize(entries.slice(-15).join('\n'), 'Summarize this Claude Code session in 2-3 sentences. What was the user working on? What is the current status? Answer in the same language as the content.')
1208
+ : '';
1209
+ const header = `šŸ“– Docs: ${docRoot.split('/').pop()}\nšŸ“‹ Session: ${sessionId.slice(0, 12)}${summary ? ` • AI: ${tModel}` : ''}\n`;
1210
+ const summaryBlock = summary ? `\nšŸ“ (${tModel}) ${summary}\n` : '';
1191
1211
 
1192
- const summaryBlock = summary ? `\nšŸ“ ${summary}\n` : '';
1193
1212
  const fullText = header + summaryBlock + '\n--- Recent ---\n' + recent;
1194
1213
 
1195
1214
  const chunks = splitMessage(fullText, 4000);
@@ -1393,19 +1412,16 @@ async function setBotCommands(token: string) {
1393
1412
  headers: { 'Content-Type': 'application/json' },
1394
1413
  body: JSON.stringify({
1395
1414
  commands: [
1415
+ { command: 'task', description: 'Create task' },
1396
1416
  { command: 'tasks', description: 'List tasks' },
1397
- { command: 'task', description: 'Create task (interactive or /task project prompt)' },
1398
- { command: 'sessions', description: 'Browse sessions' },
1399
- { command: 'projects', description: 'List projects' },
1417
+ { command: 'sessions', description: 'Session summary (AI)' },
1418
+ { command: 'docs', description: 'Docs summary / view file' },
1419
+ { command: 'note', description: 'Quick note to docs' },
1420
+ { command: 'watch', description: 'Monitor session / list watchers' },
1400
1421
  { command: 'tunnel', description: 'Tunnel status' },
1401
1422
  { command: 'tunnel_start', description: 'Start tunnel' },
1402
1423
  { command: 'tunnel_stop', description: 'Stop tunnel' },
1403
1424
  { command: 'tunnel_password', description: 'Get login password' },
1404
- { command: 'peek', description: 'Session summary (AI + recent)' },
1405
- { command: 'docs', description: 'Docs session summary / view file' },
1406
- { command: 'note', description: 'Quick note to docs Claude' },
1407
- { command: 'watch', description: 'Monitor session' },
1408
- { command: 'watchers', description: 'List watchers' },
1409
1425
  { command: 'help', description: 'Show help' },
1410
1426
  ],
1411
1427
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {