@aion0/forge 0.5.34 → 0.5.35

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/RELEASE_NOTES.md CHANGED
@@ -1,8 +1,14 @@
1
- # Forge v0.5.34
1
+ # Forge v0.5.35
2
2
 
3
- Released: 2026-04-10
3
+ Released: 2026-04-11
4
4
 
5
- ## Changes since v0.5.33
5
+ ## Changes since v0.5.34
6
6
 
7
+ ### Features
8
+ - feat: workspace UX — refresh, bell, lock, mouse, terminal layout
7
9
 
8
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.33...v0.5.34
10
+ ### Other
11
+ - feat(telegram): /i inject command for direct terminal input
12
+
13
+
14
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.34...v0.5.35
@@ -290,6 +290,21 @@ function useWorkspaceStream(workspaceId: string | null, onEvent?: (event: any) =
290
290
  setAgents(event.agents || []);
291
291
  setStates(event.agentStates || {});
292
292
  setBusLog(event.busLog || []);
293
+ // Seed logPreview from each agent's history (last 3 entries)
294
+ const initPreview: Record<string, string[]> = {};
295
+ for (const [agentId, st] of Object.entries(event.agentStates || {}) as [string, any][]) {
296
+ const hist: any[] = st?.history || [];
297
+ if (hist.length > 0) {
298
+ // Prefer the most recent step_summary or final_summary if present
299
+ const summary = [...hist].reverse().find(h => h?.subtype === 'step_summary' || h?.subtype === 'final_summary');
300
+ if (summary?.content) {
301
+ initPreview[agentId] = String(summary.content).split('\n').filter(l => l.trim()).slice(0, 4);
302
+ } else {
303
+ initPreview[agentId] = hist.slice(-3).map(h => h?.content).filter(Boolean).map(String);
304
+ }
305
+ }
306
+ }
307
+ setLogPreview(initPreview);
293
308
  if (event.daemonActive !== undefined) setDaemonActive(event.daemonActive);
294
309
  return;
295
310
  }
@@ -1994,6 +2009,30 @@ function getWsUrl() {
1994
2009
  return `${p}//${h}:${port + 1}`;
1995
2010
  }
1996
2011
 
2012
+ // ─── Bell notification (smith taskStatus changes) ────────
2013
+
2014
+ const bellLastFired = new Map<string, number>();
2015
+ const BELL_COOLDOWN = 30000; // 30s cooldown per smith
2016
+ function fireSmithBell(label: string, status: 'done' | 'failed') {
2017
+ const key = `${label}-${status}`;
2018
+ const now = Date.now();
2019
+ const last = bellLastFired.get(key) || 0;
2020
+ if (now - last < BELL_COOLDOWN) return;
2021
+ bellLastFired.set(key, now);
2022
+ const title = status === 'done' ? 'Forge — Smith Done ✅' : 'Forge — Smith Failed ❌';
2023
+ const body = `"${label}" task ${status === 'done' ? 'completed' : 'failed'}.`;
2024
+ // Browser notification
2025
+ if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
2026
+ new Notification(title, { body, icon: '/icon.png' });
2027
+ }
2028
+ // Telegram + in-app via API (reuse terminal-bell endpoint)
2029
+ fetch('/api/terminal-bell', {
2030
+ method: 'POST',
2031
+ headers: { 'Content-Type': 'application/json' },
2032
+ body: JSON.stringify({ tabLabel: `${label} (${status})` }),
2033
+ }).catch(() => {});
2034
+ }
2035
+
1997
2036
  // ─── Terminal Dock (right side panel with tabs) ──────────
1998
2037
  type TerminalEntry = { agentId: string; label: string; icon: string; cliId: string; cliCmd?: string; cliType?: string; workDir?: string; tmuxSession?: string; sessionName: string; resumeMode?: boolean; resumeSessionId?: string; profileEnv?: Record<string, string> };
1999
2038
 
@@ -2245,6 +2284,20 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
2245
2284
  const [size, setSize] = useState({ w: 500, h: 300 });
2246
2285
  const [showCloseDialog, setShowCloseDialog] = useState(false);
2247
2286
  const [mouseOn, setMouseOn] = useState(true);
2287
+ // Per-terminal "lock" — when locked, × button suspends directly (no kill option)
2288
+ // Persisted by session name so it survives refresh
2289
+ const lockKey = `forge.term.locked.${preferredSessionName || agentLabel}`;
2290
+ const [locked, setLocked] = useState<boolean>(() => {
2291
+ if (typeof window === 'undefined') return true;
2292
+ // Default LOCKED — user must explicitly unlock to allow kill
2293
+ const stored = localStorage.getItem(lockKey);
2294
+ return stored === null ? true : stored === '1';
2295
+ });
2296
+ const toggleLock = () => {
2297
+ const next = !locked;
2298
+ setLocked(next);
2299
+ try { localStorage.setItem(lockKey, next ? '1' : '0'); } catch {}
2300
+ };
2248
2301
  const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
2249
2302
  const resizeRef = useRef<{ startX: number; startY: number; origW: number; origH: number } | null>(null);
2250
2303
 
@@ -2486,7 +2539,19 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
2486
2539
  >
2487
2540
  🖱️ {mouseOn ? 'ON' : 'OFF'}
2488
2541
  </button>
2489
- <button onClick={() => setShowCloseDialog(true)} className="text-gray-500 hover:text-white text-sm shrink-0">✕</button>
2542
+ <button
2543
+ onClick={(e) => { e.stopPropagation(); toggleLock(); }}
2544
+ onMouseDown={(e) => e.stopPropagation()}
2545
+ className={`text-[9px] px-1.5 py-0.5 rounded border transition-colors ${locked ? 'border-yellow-500/60 text-yellow-400 bg-yellow-500/10' : 'border-gray-600 text-gray-500 bg-gray-800/50'}`}
2546
+ title={locked ? 'Locked — × will only suspend (kill disabled). Click to unlock.' : 'Click to lock — prevents accidental kill, × always suspends'}
2547
+ >
2548
+ {locked ? '🔒' : '🔓'}
2549
+ </button>
2550
+ <button
2551
+ onClick={() => setShowCloseDialog(true)}
2552
+ className="text-gray-500 hover:text-white text-sm shrink-0"
2553
+ title="Close terminal"
2554
+ >✕</button>
2490
2555
  </div>
2491
2556
 
2492
2557
  {/* Terminal */}
@@ -2519,9 +2584,14 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
2519
2584
  {showCloseDialog && (
2520
2585
  <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50" onClick={() => setShowCloseDialog(false)}>
2521
2586
  <div className="bg-[#161b22] border border-[#30363d] rounded-lg p-4 shadow-xl max-w-sm" onClick={e => e.stopPropagation()}>
2522
- <h3 className="text-sm font-semibold text-white mb-2">Close Terminal — {agentLabel}</h3>
2587
+ <h3 className="text-sm font-semibold text-white mb-2">
2588
+ Close Terminal — {agentLabel}
2589
+ {locked && <span className="ml-2 text-[9px] text-yellow-400">🔒 Locked</span>}
2590
+ </h3>
2523
2591
  <p className="text-xs text-gray-400 mb-3">
2524
- This agent has an active terminal session.
2592
+ {locked
2593
+ ? 'Terminal is locked — kill is disabled. Click 🔒 in the header to unlock first.'
2594
+ : 'This agent has an active terminal session.'}
2525
2595
  </p>
2526
2596
  <div className="flex gap-2">
2527
2597
  <button onClick={() => { setShowCloseDialog(false); onClose(false); }}
@@ -2529,17 +2599,20 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
2529
2599
  Suspend
2530
2600
  <span className="block text-[9px] text-gray-500 mt-0.5">Hide panel, session keeps running</span>
2531
2601
  </button>
2532
- <button onClick={() => {
2533
- setShowCloseDialog(false);
2534
- if (wsRef.current?.readyState === WebSocket.OPEN && sessionNameRef.current) {
2535
- wsRef.current.send(JSON.stringify({ type: 'kill', sessionName: sessionNameRef.current }));
2536
- }
2537
- onClose(true);
2538
- }}
2539
- className={`flex-1 px-3 py-1.5 text-[11px] rounded ${persistentSession ? 'bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30' : 'bg-red-500/20 text-red-400 hover:bg-red-500/30'}`}>
2540
- {persistentSession ? 'Restart Session' : 'Kill Session'}
2541
- <span className={`block text-[9px] mt-0.5 ${persistentSession ? 'text-yellow-400/60' : 'text-red-400/60'}`}>
2542
- {persistentSession ? 'Kill and restart with fresh env' : 'End session permanently'}
2602
+ <button
2603
+ disabled={locked}
2604
+ onClick={() => {
2605
+ if (locked) return;
2606
+ setShowCloseDialog(false);
2607
+ if (wsRef.current?.readyState === WebSocket.OPEN && sessionNameRef.current) {
2608
+ wsRef.current.send(JSON.stringify({ type: 'kill', sessionName: sessionNameRef.current }));
2609
+ }
2610
+ onClose(true);
2611
+ }}
2612
+ className={`flex-1 px-3 py-1.5 text-[11px] rounded ${locked ? 'bg-gray-700/30 text-gray-500 cursor-not-allowed' : persistentSession ? 'bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30' : 'bg-red-500/20 text-red-400 hover:bg-red-500/30'}`}>
2613
+ {locked ? '🔒 Locked' : persistentSession ? 'Restart Session' : 'Kill Session'}
2614
+ <span className={`block text-[9px] mt-0.5 ${locked ? 'text-gray-500' : persistentSession ? 'text-yellow-400/60' : 'text-red-400/60'}`}>
2615
+ {locked ? 'Unlock first to allow kill' : persistentSession ? 'Kill and restart with fresh env' : 'End session permanently'}
2543
2616
  </span>
2544
2617
  </button>
2545
2618
  </div>
@@ -2650,6 +2723,8 @@ interface AgentNodeData {
2650
2723
  onSwitchSession: () => void;
2651
2724
  onSaveAsTemplate: () => void;
2652
2725
  mascotTheme: MascotTheme;
2726
+ bellOn?: boolean;
2727
+ onToggleBell?: () => void;
2653
2728
  onMarkIdle?: () => void;
2654
2729
  onMarkDone?: (notify: boolean) => void;
2655
2730
  onMarkFailed?: (notify: boolean) => void;
@@ -3148,7 +3223,7 @@ function WorkerMascot({ taskStatus, smithStatus, seed, accentColor, theme }: { t
3148
3223
  }
3149
3224
 
3150
3225
  function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
3151
- const { config, state, colorIdx, previewLines, projectPath, workspaceId, onRun, onPause, onStop, onRetry, onEdit, onRemove, onMessage, onApprove, onShowLog, onShowMemory, onShowInbox, onOpenTerminal, onSwitchSession, onSaveAsTemplate, mascotTheme, inboxPending = 0, inboxFailed = 0 } = data;
3226
+ const { config, state, colorIdx, previewLines, projectPath, workspaceId, onRun, onPause, onStop, onRetry, onEdit, onRemove, onMessage, onApprove, onShowLog, onShowMemory, onShowInbox, onOpenTerminal, onSwitchSession, onSaveAsTemplate, mascotTheme, bellOn = false, onToggleBell, inboxPending = 0, inboxFailed = 0 } = data;
3152
3227
  const c = COLORS[colorIdx % COLORS.length];
3153
3228
  const smithStatus = state?.smithStatus || 'down';
3154
3229
  const taskStatus = state?.taskStatus || 'idle';
@@ -3285,12 +3360,17 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
3285
3360
  </span>
3286
3361
  <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onShowInbox(); }}
3287
3362
  className="text-[9px] text-gray-600 hover:text-orange-400 px-1" title="Messages (inbox/outbox)">📨</button>
3288
- <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onShowMemory(); }}
3289
- className="text-[9px] text-gray-600 hover:text-purple-400 px-1" title="Memory">🧠</button>
3290
- <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onShowLog(); }}
3291
- className="text-[9px] text-gray-600 hover:text-gray-300 px-1" title="Logs">📋</button>
3292
- <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onSaveAsTemplate(); }}
3293
- className="text-[9px] text-gray-600 hover:text-yellow-400 px-1" title="Save as template">💾</button>
3363
+ <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onToggleBell?.(); }}
3364
+ className={`text-[9px] px-1 ${bellOn ? 'text-orange-400' : 'text-gray-600 hover:text-orange-400'}`}
3365
+ title={bellOn ? 'Bell ON notify when this smith finishes (click to disable)' : 'Bell OFF — click to enable task done/failed notifications'}>
3366
+ {bellOn ? '🔔' : '🔕'}
3367
+ </button>
3368
+ <SmithMoreMenu
3369
+ onShowMemory={onShowMemory}
3370
+ onShowLog={onShowLog}
3371
+ onSaveAsTemplate={onSaveAsTemplate}
3372
+ onRefreshBus={async () => { if (workspaceId) try { await wsApi(workspaceId, 'refresh_bus'); } catch {} }}
3373
+ />
3294
3374
  <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onEdit(); }}
3295
3375
  className="text-[9px] text-gray-600 hover:text-blue-400 px-1">✏️</button>
3296
3376
  <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onRemove(); }}
@@ -3300,6 +3380,63 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
3300
3380
  );
3301
3381
  }
3302
3382
 
3383
+ // ─── Smith Node "More" Menu (⋯) ─────────────────────────
3384
+
3385
+ function SmithMoreMenu({ onShowMemory, onShowLog, onSaveAsTemplate, onRefreshBus }: {
3386
+ onShowMemory: () => void;
3387
+ onShowLog: () => void;
3388
+ onSaveAsTemplate: () => void;
3389
+ onRefreshBus: () => void;
3390
+ }) {
3391
+ const [open, setOpen] = useState(false);
3392
+ const ref = useRef<HTMLDivElement>(null);
3393
+
3394
+ useEffect(() => {
3395
+ if (!open) return;
3396
+ const handler = (e: MouseEvent) => {
3397
+ const target = e.target as unknown as globalThis.Node;
3398
+ if (ref.current && !ref.current.contains(target)) setOpen(false);
3399
+ };
3400
+ window.addEventListener('mousedown', handler);
3401
+ return () => window.removeEventListener('mousedown', handler);
3402
+ }, [open]);
3403
+
3404
+ return (
3405
+ <div ref={ref} className="relative">
3406
+ <button
3407
+ onPointerDown={e => e.stopPropagation()}
3408
+ onClick={e => { e.stopPropagation(); setOpen(v => !v); }}
3409
+ className="text-[10px] text-gray-600 hover:text-white px-1"
3410
+ title="More actions"
3411
+ >⋯</button>
3412
+ {open && (
3413
+ <div className="absolute right-0 top-full mt-1 z-50 bg-[#0d1117] border border-[#30363d] rounded shadow-xl py-1 min-w-[120px]">
3414
+ <button
3415
+ onPointerDown={e => e.stopPropagation()}
3416
+ onClick={e => { e.stopPropagation(); setOpen(false); onShowMemory(); }}
3417
+ className="w-full text-left text-[10px] px-2 py-1 hover:bg-[#161b22] text-gray-300 flex items-center gap-2"
3418
+ >🧠 Memory</button>
3419
+ <button
3420
+ onPointerDown={e => e.stopPropagation()}
3421
+ onClick={e => { e.stopPropagation(); setOpen(false); onShowLog(); }}
3422
+ className="w-full text-left text-[10px] px-2 py-1 hover:bg-[#161b22] text-gray-300 flex items-center gap-2"
3423
+ >📋 Logs</button>
3424
+ <button
3425
+ onPointerDown={e => e.stopPropagation()}
3426
+ onClick={e => { e.stopPropagation(); setOpen(false); onRefreshBus(); }}
3427
+ className="w-full text-left text-[10px] px-2 py-1 hover:bg-[#161b22] text-gray-300 flex items-center gap-2"
3428
+ >🔄 Refresh state</button>
3429
+ <button
3430
+ onPointerDown={e => e.stopPropagation()}
3431
+ onClick={e => { e.stopPropagation(); setOpen(false); onSaveAsTemplate(); }}
3432
+ className="w-full text-left text-[10px] px-2 py-1 hover:bg-[#161b22] text-gray-300 flex items-center gap-2"
3433
+ >💾 Save as template</button>
3434
+ </div>
3435
+ )}
3436
+ </div>
3437
+ );
3438
+ }
3439
+
3303
3440
  const nodeTypes = { agent: AgentFlowNode, input: InputFlowNode };
3304
3441
 
3305
3442
  // ─── Main Workspace ──────────────────────────────────────
@@ -3417,6 +3554,52 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
3417
3554
  // Auto-open terminals removed — persistent sessions run in background tmux.
3418
3555
  // User opens terminal via ⌨️ button when needed.
3419
3556
 
3557
+ // ─── Smith bell notifications (per-agent, persisted) ──
3558
+ const [bellAgents, setBellAgents] = useState<Set<string>>(() => {
3559
+ if (typeof window === 'undefined') return new Set();
3560
+ try {
3561
+ const raw = localStorage.getItem('forge.workspace.bellAgents');
3562
+ return raw ? new Set(JSON.parse(raw)) : new Set();
3563
+ } catch { return new Set(); }
3564
+ });
3565
+ const toggleBell = useCallback((agentId: string) => {
3566
+ setBellAgents(prev => {
3567
+ const next = new Set(prev);
3568
+ if (next.has(agentId)) next.delete(agentId);
3569
+ else {
3570
+ next.add(agentId);
3571
+ // Request browser notification permission on first enable
3572
+ if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
3573
+ Notification.requestPermission().catch(() => {});
3574
+ }
3575
+ }
3576
+ try { localStorage.setItem('forge.workspace.bellAgents', JSON.stringify([...next])); } catch {}
3577
+ return next;
3578
+ });
3579
+ }, []);
3580
+
3581
+ // Watch taskStatus transitions and fire bell on running → done/failed
3582
+ const prevTaskStatusRef = useRef<Record<string, string>>({});
3583
+ useEffect(() => {
3584
+ const prev = prevTaskStatusRef.current;
3585
+ for (const agent of agents) {
3586
+ const cur = states[agent.id]?.taskStatus;
3587
+ const before = prev[agent.id];
3588
+ if (cur && before === 'running' && (cur === 'done' || cur === 'failed')) {
3589
+ if (bellAgents.has(agent.id)) {
3590
+ fireSmithBell(agent.label, cur);
3591
+ }
3592
+ }
3593
+ }
3594
+ // Update snapshot for next tick
3595
+ const snapshot: Record<string, string> = {};
3596
+ for (const agent of agents) {
3597
+ const s = states[agent.id]?.taskStatus;
3598
+ if (s) snapshot[agent.id] = s;
3599
+ }
3600
+ prevTaskStatusRef.current = snapshot;
3601
+ }, [states, agents, bellAgents]);
3602
+
3420
3603
  // Rebuild nodes when agents/states/preview change — preserve existing positions + dimensions
3421
3604
  useEffect(() => {
3422
3605
  setRfNodes(prev => {
@@ -3468,6 +3651,8 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
3468
3651
  onPause: () => wsApi(workspaceId!, 'pause', { agentId: agent.id }),
3469
3652
  onStop: () => wsApi(workspaceId!, 'stop', { agentId: agent.id }),
3470
3653
  mascotTheme,
3654
+ bellOn: bellAgents.has(agent.id),
3655
+ onToggleBell: () => toggleBell(agent.id),
3471
3656
  onMarkIdle: () => wsApi(workspaceId!, 'mark_done', { agentId: agent.id, notify: false }),
3472
3657
  onMarkDone: (notify: boolean) => wsApi(workspaceId!, 'mark_done', { agentId: agent.id, notify }),
3473
3658
  onMarkFailed: (notify: boolean) => wsApi(workspaceId!, 'mark_failed', { agentId: agent.id, notify }),
@@ -3535,7 +3720,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
3535
3720
  };
3536
3721
  });
3537
3722
  });
3538
- }, [agents, states, logPreview, workspaceId, mascotTheme, savedPositions]); // eslint-disable-line react-hooks/exhaustive-deps
3723
+ }, [agents, states, logPreview, workspaceId, mascotTheme, savedPositions, bellAgents]); // eslint-disable-line react-hooks/exhaustive-deps
3539
3724
 
3540
3725
  // Derive edges from dependsOn
3541
3726
  const rfEdges = useMemo(() => {
@@ -32,7 +32,12 @@ const chatNumberedTasks = new Map<number, Map<number, string>>();
32
32
  const chatNumberedSessions = new Map<number, Map<number, { projectName: string; sessionId: string }>>();
33
33
  const chatNumberedProjects = new Map<number, Map<number, string>>();
34
34
  // Track what the last numbered list was for
35
- const chatListMode = new Map<number, 'tasks' | 'projects' | 'sessions' | 'task-create' | 'peek'>();
35
+ const chatListMode = new Map<number, 'tasks' | 'projects' | 'sessions' | 'task-create' | 'peek' | 'inject-pick' | 'inject-typing'>();
36
+ // Inject mode state — picked tmux session per chat
37
+ const chatNumberedTmux = new Map<number, Map<number, string>>(); // num → tmux session name
38
+ const chatInjectTarget = new Map<number, string>(); // chatId → tmux session name (currently selected)
39
+ const chatInjectAutoClear = new Map<number, ReturnType<typeof setTimeout>>(); // auto-clear timers
40
+ const INJECT_AUTO_CLEAR_MS = 3 * 60 * 1000; // 3 min idle → auto clear
36
41
 
37
42
  // Pending task creation: waiting for prompt text
38
43
  const pendingTaskProject = new Map<number, { name: string; path: string }>(); // chatId → project
@@ -178,6 +183,12 @@ async function handleMessage(msg: any) {
178
183
  await sendSessionContent(chatId, projectName, sessionId);
179
184
  return;
180
185
  }
186
+ } else if (mode === 'inject-pick') {
187
+ const tmuxMap = chatNumberedTmux.get(chatId);
188
+ if (tmuxMap?.has(num)) {
189
+ await pickInjectTarget(chatId, String(num));
190
+ return;
191
+ }
181
192
  } else {
182
193
  const taskMap = chatNumberedTasks.get(chatId);
183
194
  if (taskMap?.has(num)) {
@@ -259,6 +270,32 @@ async function handleMessage(msg: any) {
259
270
  case '/retry':
260
271
  await handleRetry(chatId, args[0]);
261
272
  break;
273
+ case '/inject':
274
+ case '/i':
275
+ if (args.length === 0) {
276
+ await startInjectFlow(chatId);
277
+ } else if (args.length === 1 && /^\d+$/.test(args[0])) {
278
+ // /i 1 — pick session number, prompt for text next
279
+ await pickInjectTarget(chatId, args[0]);
280
+ } else {
281
+ // /i 1 hello world — pick + send in one shot
282
+ if (/^\d+$/.test(args[0])) {
283
+ await pickInjectTarget(chatId, args[0]);
284
+ await handleInjectSend(chatId, args.slice(1).join(' '));
285
+ } else {
286
+ // No number — use last picked target
287
+ await handleInjectSend(chatId, args.join(' '));
288
+ }
289
+ }
290
+ break;
291
+ case '/iclear': {
292
+ chatInjectTarget.delete(chatId);
293
+ chatListMode.delete(chatId);
294
+ const t = chatInjectAutoClear.get(chatId);
295
+ if (t) { clearTimeout(t); chatInjectAutoClear.delete(chatId); }
296
+ await send(chatId, 'Inject target cleared.');
297
+ break;
298
+ }
262
299
  case '/tunnel':
263
300
  await handleTunnelStatus(chatId);
264
301
  break;
@@ -277,6 +314,12 @@ async function handleMessage(msg: any) {
277
314
  return;
278
315
  }
279
316
 
317
+ // Inject mode: if user picked a target, plain text goes to that tmux session
318
+ if (chatListMode.get(chatId) === 'inject-typing' && chatInjectTarget.has(chatId)) {
319
+ await handleInjectSend(chatId, text);
320
+ return;
321
+ }
322
+
280
323
  // Plain text — try to parse as "project: task" format
281
324
  const colonIdx = text.indexOf(':');
282
325
  if (colonIdx > 0 && colonIdx < 30) {
@@ -298,22 +341,22 @@ async function handleMessage(msg: any) {
298
341
  async function sendHelp(chatId: number) {
299
342
  await send(chatId,
300
343
  `🤖 Forge\n\n` +
301
- `📋 /taskcreate task (interactive)\n` +
302
- `/taskstask list\n\n` +
344
+ `🎯 /i (or /inject) type into a terminal & submit\n` +
345
+ `/iclearclear inject target\n\n` +
303
346
  `👀 /sessions — session summary (select project)\n` +
304
347
  `📖 /docs — docs summary / view file\n` +
305
348
  `📝 /note — quick note to docs\n\n` +
306
349
  `👁 /watch <project> — monitor session\n` +
307
350
  `/watch — list watchers\n` +
308
351
  `/unwatch <id> — stop\n\n` +
352
+ `📋 /task — create background task\n` +
353
+ `/tasks — task list\n` +
309
354
  `🔧 /cancel <id> /retry <id>\n` +
310
- `/sessionsbrowse sessions\n` +
311
- `/projects — list projects\n\n` +
355
+ `/projectslist projects\n` +
356
+ `🤖 /agents — list available agents\n\n` +
312
357
  `🌐 /tunnel — status\n` +
313
358
  `/tunnel_start / /tunnel_stop\n` +
314
359
  `/tunnel_code <admin_pw> — get session code\n\n` +
315
- `🤖 /agents — list available agents\n` +
316
- `Use @agent in /task to select (e.g. /task app @codex: review)\n\n` +
317
360
  `Reply number to select`
318
361
  );
319
362
  }
@@ -908,6 +951,234 @@ async function handleUnwatch(chatId: number, watcherId?: string) {
908
951
  await send(chatId, `🗑 Watcher ${watcherId} removed`);
909
952
  }
910
953
 
954
+ // ─── Inject Commands ─────────────────────────────────────────
955
+
956
+ /** Build a map of tmux session name → friendly label from terminal-state.json + workspace state */
957
+ function getSessionLabels(): Record<string, string> {
958
+ const labels: Record<string, string> = {};
959
+ try {
960
+ const { join } = require('node:path');
961
+ const { homedir } = require('node:os');
962
+ const { readFileSync, existsSync, readdirSync } = require('node:fs');
963
+
964
+ // 1. terminal-state.json — vibecoding terminal tabs
965
+ const termState = join(homedir(), '.forge', 'data', 'terminal-state.json');
966
+ if (existsSync(termState)) {
967
+ try {
968
+ const state = JSON.parse(readFileSync(termState, 'utf-8'));
969
+ // sessionLabels (top-level map)
970
+ if (state.sessionLabels) {
971
+ for (const [name, label] of Object.entries(state.sessionLabels)) {
972
+ labels[name] = String(label);
973
+ }
974
+ }
975
+ // Walk tab trees and assign unique labels
976
+ // First collect all terminals per tab in order, so we can suffix duplicates
977
+ const collectTerminals = (node: any, acc: { name: string; proj: string | null }[]) => {
978
+ if (!node) return;
979
+ if (node.type === 'terminal' && node.sessionName) {
980
+ acc.push({ name: node.sessionName, proj: node.projectPath ? node.projectPath.split('/').pop() : null });
981
+ }
982
+ if (node.first) collectTerminals(node.first, acc);
983
+ if (node.second) collectTerminals(node.second, acc);
984
+ };
985
+ if (state.tabs) {
986
+ for (const tab of state.tabs) {
987
+ const terminals: { name: string; proj: string | null }[] = [];
988
+ collectTerminals(tab.tree, terminals);
989
+ const tabLabel = tab.label || 'Terminal';
990
+ terminals.forEach((t, idx) => {
991
+ if (labels[t.name]) return; // already labeled (e.g. workspace smith)
992
+ const base = t.proj || tabLabel;
993
+ // Disambiguate: append #N if multiple terminals share the same base in this tab
994
+ const sameBase = terminals.filter(o => (o.proj || tabLabel) === base);
995
+ if (sameBase.length > 1) {
996
+ const idxInBase = sameBase.findIndex(o => o.name === t.name) + 1;
997
+ labels[t.name] = `${base} #${idxInBase}`;
998
+ } else {
999
+ labels[t.name] = base;
1000
+ }
1001
+ });
1002
+ }
1003
+ }
1004
+ } catch {}
1005
+ }
1006
+
1007
+
1008
+ // 2. Workspace state files — smith tmuxSession + agent label
1009
+ const wsDir = join(homedir(), '.forge', 'workspaces');
1010
+ if (existsSync(wsDir)) {
1011
+ for (const wsId of readdirSync(wsDir)) {
1012
+ const stateFile = join(wsDir, wsId, 'state.json');
1013
+ if (!existsSync(stateFile)) continue;
1014
+ try {
1015
+ const ws = JSON.parse(readFileSync(stateFile, 'utf-8'));
1016
+ const projectName = ws.projectName || '';
1017
+ for (const [agentId, st] of Object.entries(ws.agentStates || {}) as any[]) {
1018
+ if (st?.tmuxSession) {
1019
+ const agent = (ws.agents || []).find((a: any) => a.id === agentId);
1020
+ const label = agent?.label || agentId;
1021
+ const icon = agent?.icon || '';
1022
+ labels[st.tmuxSession] = `${icon} ${projectName ? `[${projectName}] ` : ''}${label}`.trim();
1023
+ }
1024
+ }
1025
+ } catch {}
1026
+ }
1027
+ }
1028
+ } catch {}
1029
+ return labels;
1030
+ }
1031
+
1032
+ /** Capture the last non-empty line(s) of a tmux session for preview.
1033
+ * @param maxLines max number of lines to return (1 for single-line list, more for detail view) */
1034
+ function getSessionPreview(sessionName: string, maxLines: number = 1): string {
1035
+ try {
1036
+ const { execSync } = require('node:child_process');
1037
+ // Capture last screen, strip ANSI escapes, find last non-empty lines
1038
+ const out = execSync(`tmux capture-pane -t "${sessionName}" -p -S -50 2>/dev/null`, {
1039
+ encoding: 'utf-8',
1040
+ timeout: 2000,
1041
+ }) as string;
1042
+ if (!out) return '';
1043
+ // Strip ANSI escape codes
1044
+ // eslint-disable-next-line no-control-regex
1045
+ const clean = out.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
1046
+ const lines = clean.split('\n').map((l: string) => l.trim()).filter((l: string) => l.length > 0);
1047
+ if (lines.length === 0) return '';
1048
+ const tail = lines.slice(-maxLines);
1049
+ if (maxLines === 1) {
1050
+ const last = tail[0];
1051
+ return last.length > 30 ? last.slice(0, 30) + '…' : last;
1052
+ }
1053
+ // Multi-line: truncate each line to 80 chars
1054
+ return tail.map(l => l.length > 80 ? l.slice(0, 80) + '…' : l).join('\n');
1055
+ } catch {
1056
+ return '';
1057
+ }
1058
+ }
1059
+
1060
+ /** Schedule auto-clear of inject target after idle timeout */
1061
+ function scheduleInjectAutoClear(chatId: number) {
1062
+ const existing = chatInjectAutoClear.get(chatId);
1063
+ if (existing) clearTimeout(existing);
1064
+ const timer = setTimeout(async () => {
1065
+ chatInjectTarget.delete(chatId);
1066
+ chatListMode.delete(chatId);
1067
+ chatInjectAutoClear.delete(chatId);
1068
+ try { await send(chatId, '⏰ Inject target auto-cleared (idle 3 min). Use /i to pick again.'); } catch {}
1069
+ }, INJECT_AUTO_CLEAR_MS);
1070
+ chatInjectAutoClear.set(chatId, timer);
1071
+ }
1072
+
1073
+ /** List active tmux sessions and let user pick one to inject text into */
1074
+ async function startInjectFlow(chatId: number) {
1075
+ const settings = loadSettings();
1076
+ if (String(chatId) !== settings.telegramChatId) { await send(chatId, '⛔ Unauthorized'); return; }
1077
+
1078
+ try {
1079
+ const { execSync } = require('node:child_process');
1080
+ const out = execSync('tmux list-sessions -F "#{session_name}|#{session_attached}" 2>/dev/null', { encoding: 'utf-8', timeout: 3000 }).trim();
1081
+ if (!out) {
1082
+ await send(chatId, 'No active tmux sessions. Open a terminal in the browser first.');
1083
+ return;
1084
+ }
1085
+ const sessions = (out as string).split('\n').map((line: string) => {
1086
+ const [name, attached] = line.split('|');
1087
+ return { name, attached: attached !== '0' };
1088
+ }).filter((s: { name: string }) => s.name.startsWith('mw'));
1089
+
1090
+ if (sessions.length === 0) {
1091
+ await send(chatId, 'No active Forge terminal sessions.');
1092
+ return;
1093
+ }
1094
+
1095
+ // Resolve friendly labels
1096
+ const labelMap = getSessionLabels();
1097
+
1098
+ const tmuxMap = new Map<number, string>();
1099
+ const lines = sessions.slice(0, 20).map((s: { name: string; attached: boolean }, i: number) => {
1100
+ const num = i + 1;
1101
+ tmuxMap.set(num, s.name);
1102
+ const friendly = labelMap[s.name];
1103
+ const display = friendly || s.name.replace(/^mw-?/, '');
1104
+ const marker = s.attached ? '👁' : '⚫';
1105
+ const preview = getSessionPreview(s.name);
1106
+ const previewLine = preview ? `\n └ ${preview}` : '';
1107
+ return `${num}. ${marker} ${display}${previewLine}`;
1108
+ });
1109
+ chatNumberedTmux.set(chatId, tmuxMap);
1110
+ chatListMode.set(chatId, 'inject-pick');
1111
+
1112
+ await send(chatId,
1113
+ `🎯 Pick a terminal to inject text:\n\n${lines.join('\n')}\n\n` +
1114
+ `👁 = attached, ⚫ = detached\n` +
1115
+ `Reply with a number, or use /i <num> <text> for one-shot.\n` +
1116
+ `Use /iclear to cancel.`
1117
+ );
1118
+ } catch (e: any) {
1119
+ await send(chatId, `Error listing sessions: ${e.message}`);
1120
+ }
1121
+ }
1122
+
1123
+ /** After picking a session number, set it as the target and prompt for text */
1124
+ async function pickInjectTarget(chatId: number, numStr: string) {
1125
+ const num = parseInt(numStr);
1126
+ const tmuxMap = chatNumberedTmux.get(chatId);
1127
+ if (!tmuxMap?.has(num)) {
1128
+ await send(chatId, 'Invalid number. Use /i to refresh the list.');
1129
+ return;
1130
+ }
1131
+ const sessionName = tmuxMap.get(num)!;
1132
+ chatInjectTarget.set(chatId, sessionName);
1133
+ chatListMode.set(chatId, 'inject-typing');
1134
+ scheduleInjectAutoClear(chatId);
1135
+ const labelMap = getSessionLabels();
1136
+ const display = labelMap[sessionName] || sessionName.replace(/^mw-?/, '');
1137
+ // Show last 8 lines of context so user knows what's in the terminal
1138
+ const context = getSessionPreview(sessionName, 8);
1139
+ const contextBlock = context ? `\n\n📺 Last output:\n\`\`\`\n${context}\n\`\`\`` : '';
1140
+ await send(chatId,
1141
+ `🎯 Target: ${display}${contextBlock}\n\n` +
1142
+ `Send any text → typed + submitted in the terminal.\n` +
1143
+ `Auto-clears after 3 min idle. Use /iclear to cancel.`
1144
+ );
1145
+ }
1146
+
1147
+ /** Send text to the currently selected tmux session */
1148
+ async function handleInjectSend(chatId: number, text: string) {
1149
+ const settings = loadSettings();
1150
+ if (String(chatId) !== settings.telegramChatId) { await send(chatId, '⛔ Unauthorized'); return; }
1151
+
1152
+ const sessionName = chatInjectTarget.get(chatId);
1153
+ if (!sessionName) {
1154
+ await send(chatId, 'No target selected. Use /i to pick a terminal first.');
1155
+ return;
1156
+ }
1157
+ if (!text || !text.trim()) {
1158
+ await send(chatId, 'Empty text. Send something to inject.');
1159
+ return;
1160
+ }
1161
+
1162
+ try {
1163
+ const { execSync } = require('node:child_process');
1164
+ // Paste text into the input then submit with Enter (like the user typed it and hit return)
1165
+ const buf = require('node:os').tmpdir() + `/forge-inject-${Date.now()}.txt`;
1166
+ require('node:fs').writeFileSync(buf, text);
1167
+ execSync(`tmux load-buffer -t "${sessionName}" "${buf}" && tmux paste-buffer -t "${sessionName}" && sleep 0.2 && tmux send-keys -t "${sessionName}" Enter`, { timeout: 5000 });
1168
+ require('node:fs').unlinkSync(buf);
1169
+ const preview = text.length > 60 ? text.slice(0, 60) + '...' : text;
1170
+ const labelMap = getSessionLabels();
1171
+ const display = labelMap[sessionName] || sessionName.replace(/^mw-?/, '');
1172
+ // Reset auto-clear timer on activity
1173
+ scheduleInjectAutoClear(chatId);
1174
+ await send(chatId, `✅ Sent to ${display}\n> ${preview}`);
1175
+ } catch (e: any) {
1176
+ await send(chatId, `❌ Inject failed: ${e.message}\nThe session may have closed. Use /i to pick another.`);
1177
+ chatInjectTarget.delete(chatId);
1178
+ chatListMode.delete(chatId);
1179
+ }
1180
+ }
1181
+
911
1182
  // ─── Tunnel Commands ─────────────────────────────────────────
912
1183
 
913
1184
  async function handleTunnelStatus(chatId: number) {
@@ -1420,12 +1691,14 @@ async function setBotCommands(token: string) {
1420
1691
  headers: { 'Content-Type': 'application/json' },
1421
1692
  body: JSON.stringify({
1422
1693
  commands: [
1423
- { command: 'task', description: 'Create task' },
1424
- { command: 'tasks', description: 'List tasks' },
1694
+ { command: 'i', description: '🎯 Inject text into a terminal' },
1695
+ { command: 'iclear', description: 'Clear inject target' },
1425
1696
  { command: 'sessions', description: 'Session summary (AI)' },
1426
1697
  { command: 'docs', description: 'Docs summary / view file' },
1427
1698
  { command: 'note', description: 'Quick note to docs' },
1428
1699
  { command: 'watch', description: 'Monitor session / list watchers' },
1700
+ { command: 'task', description: 'Create task (background)' },
1701
+ { command: 'tasks', description: 'List tasks' },
1429
1702
  { command: 'tunnel', description: 'Tunnel status' },
1430
1703
  { command: 'tunnel_start', description: 'Start tunnel' },
1431
1704
  { command: 'tunnel_stop', description: 'Stop tunnel' },
@@ -3360,7 +3360,7 @@ Silently ingest this context. Do NOT respond — await an actual task.`;
3360
3360
  }
3361
3361
 
3362
3362
  /** Emit agents_changed so SSE pushes the updated list to frontend */
3363
- private emitAgentsChanged(): void {
3363
+ emitAgentsChanged(): void {
3364
3364
  // Refresh topology cache so MCP queries always return current state
3365
3365
  this.rebuildTopo();
3366
3366
  const agents = Array.from(this.agents.values()).map(e => e.config);
@@ -203,6 +203,14 @@ async function handleAgentsPost(id: string, body: any, res: ServerResponse): Pro
203
203
  case 'get_positions': {
204
204
  return json(res, { positions: orch.getNodePositions() });
205
205
  }
206
+ case 'refresh_bus': {
207
+ // Re-broadcast current bus log state — used by inbox refresh button
208
+ // when client state appears stale
209
+ orch.emit('event', { type: 'bus_log_updated', log: orch.getBusLog() } as any);
210
+ // Also re-emit agents_changed to refresh agent states
211
+ orch.emitAgentsChanged();
212
+ return json(res, { ok: true });
213
+ }
206
214
  case 'agent_done': {
207
215
  // Called by Claude Code Stop hook — agent finished a turn
208
216
  if (!agentId) return jsonError(res, 'agentId required');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.5.34",
3
+ "version": "0.5.35",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {