@aion0/forge 0.5.33 → 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/CLAUDE.md +2 -0
- package/RELEASE_NOTES.md +10 -4
- package/components/WorkspaceView.tsx +207 -22
- package/lib/help-docs/00-overview.md +16 -0
- package/lib/help-docs/07-projects.md +20 -0
- package/lib/help-docs/10-troubleshooting.md +12 -0
- package/lib/help-docs/11-workspace.md +45 -2
- package/lib/help-docs/12-usage.md +103 -0
- package/lib/help-docs/CLAUDE.md +4 -0
- package/lib/telegram-bot.ts +282 -9
- package/lib/workspace/orchestrator.ts +1 -1
- package/lib/workspace-standalone.ts +8 -0
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -60,6 +60,8 @@ When adding or changing a feature, check if `lib/help-docs/` needs updating. Eac
|
|
|
60
60
|
- `08-rules.md` — CLAUDE.md templates
|
|
61
61
|
- `09-issue-autofix.md` — GitHub issue scanner
|
|
62
62
|
- `10-troubleshooting.md` — common issues
|
|
63
|
+
- `11-workspace.md` — multi-agent workspace (smiths, daemon, request docs)
|
|
64
|
+
- `12-usage.md` — token usage analytics and cost tracking
|
|
63
65
|
If a feature change affects user-facing behavior, update the corresponding help doc in the same commit.
|
|
64
66
|
|
|
65
67
|
### Architecture
|
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
# Forge v0.5.
|
|
1
|
+
# Forge v0.5.35
|
|
2
2
|
|
|
3
|
-
Released: 2026-04-
|
|
3
|
+
Released: 2026-04-11
|
|
4
4
|
|
|
5
|
-
## Changes since v0.5.
|
|
5
|
+
## Changes since v0.5.34
|
|
6
6
|
|
|
7
|
+
### Features
|
|
8
|
+
- feat: workspace UX — refresh, bell, lock, mouse, terminal layout
|
|
7
9
|
|
|
8
|
-
|
|
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
|
|
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">
|
|
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
|
-
|
|
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
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
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();
|
|
3289
|
-
className=
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
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(() => {
|
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
Forge is a self-hosted Vibe Coding platform for Claude Code. It provides a browser-based terminal, multi-agent workspace orchestration, AI task management, remote access, and mobile control via Telegram.
|
|
4
4
|
|
|
5
|
+
## Main Features
|
|
6
|
+
|
|
7
|
+
| Feature | Description |
|
|
8
|
+
|---|---|
|
|
9
|
+
| **Browser Terminal** | xterm.js + tmux persistence, split panes, mouse on/off toggle, auto-reconnect on disconnect |
|
|
10
|
+
| **Projects** | File tree, Git operations, code search, per-project tabs, favorites |
|
|
11
|
+
| **Workspace (Smiths)** | Multi-agent orchestration with DAG dependencies, message bus, request documents |
|
|
12
|
+
| **Tasks** | Background AI task queue with hook-based completion notifications |
|
|
13
|
+
| **Pipelines** | YAML-driven DAG workflows with scheduling and routing |
|
|
14
|
+
| **Skills Marketplace** | Install/update Claude Code skills and slash commands per project |
|
|
15
|
+
| **Usage Analytics** | Token/cost tracking by project, model, source with charts and heatmaps |
|
|
16
|
+
| **Telegram Bot** | Mobile control — submit tasks, receive notifications |
|
|
17
|
+
| **Remote Access** | One-click Cloudflare tunnel for remote browsing |
|
|
18
|
+
| **GitHub Issue Auto-fix** | Scan issues, auto-fix, create PRs |
|
|
19
|
+
| **Memory (optional)** | Code graph + knowledge via `@aion0/temper` MCP server |
|
|
20
|
+
|
|
5
21
|
## Quick Start
|
|
6
22
|
|
|
7
23
|
```bash
|
|
@@ -40,6 +40,26 @@ Add project directories in Settings → **Project Roots** (e.g. `~/Projects`). F
|
|
|
40
40
|
|
|
41
41
|
Click ★ next to a project to favorite it. Favorites appear at the top of the sidebar.
|
|
42
42
|
|
|
43
|
+
## Sidebar & Navigation
|
|
44
|
+
|
|
45
|
+
- **Collapse sidebar** (▶/◀ button): Narrow strip shows opened tabs at top (marked with green dot) and all other projects as initials below. Hover to show close (×) button on open tabs.
|
|
46
|
+
- **Sidebar state is persisted** across browser refreshes via `localStorage`.
|
|
47
|
+
- **Open projects** are marked with a green dot in the expanded sidebar; hover to reveal a close button.
|
|
48
|
+
- **Close confirmation** prompts before closing a project tab to prevent accidental closes.
|
|
49
|
+
- Up to **20 project tabs** stay mounted simultaneously (LRU eviction beyond that) — switching between recent projects is instant, terminal state is preserved.
|
|
50
|
+
|
|
51
|
+
## Tree Views — Collapse All
|
|
52
|
+
|
|
53
|
+
All hierarchical tree views have a `⇱` "Collapse all" button to quickly fold every folder:
|
|
54
|
+
|
|
55
|
+
| Location | Button position |
|
|
56
|
+
|---|---|
|
|
57
|
+
| Project **Code** tab file tree | Right of "Files" label |
|
|
58
|
+
| **Docs viewer** tree | Right of search box |
|
|
59
|
+
| **Skills** panel file tree | Top of file tree |
|
|
60
|
+
| **Workspace** sidebar | Header next to `+` |
|
|
61
|
+
| **Session** list | Next to Batch button |
|
|
62
|
+
|
|
43
63
|
## Terminal
|
|
44
64
|
|
|
45
65
|
### Opening a Terminal
|
|
@@ -39,6 +39,18 @@ rm ~/.forge/data/terminal-state.json
|
|
|
39
39
|
# Restart server — tabs will be empty but tmux sessions survive
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
+
### Terminal input is laggy
|
|
43
|
+
Usually caused by high system load. Check:
|
|
44
|
+
- System memory — if heavily swapping, kill some processes
|
|
45
|
+
- Clean up old tmux sessions: `tmux list-sessions` then `tmux kill-session -t <name>`
|
|
46
|
+
- Reduce polling: open tabs are limited to 20 by LRU eviction
|
|
47
|
+
- Workspace terminals auto-reconnect on disconnect; no need to manually reopen
|
|
48
|
+
|
|
49
|
+
### "Connection error" in workspace terminal
|
|
50
|
+
The WebSocket dropped (system suspend, network blip). Forge auto-reconnects after 2s and re-attaches to the same tmux session. If it keeps happening:
|
|
51
|
+
- Check `~/.forge/data/forge.log` for terminal-standalone errors
|
|
52
|
+
- Restart: `forge server restart`
|
|
53
|
+
|
|
42
54
|
### gh CLI not authenticated (Issue Scanner)
|
|
43
55
|
```bash
|
|
44
56
|
gh auth login
|
|
@@ -207,6 +207,24 @@ Agents use these MCP tools (via forge-mcp-server):
|
|
|
207
207
|
| `mark_message_done` | Mark a processed message as done |
|
|
208
208
|
| `check_outbox` | Check delivery status of sent messages |
|
|
209
209
|
|
|
210
|
+
### Request vs Inbox — When to use which
|
|
211
|
+
|
|
212
|
+
Every preset smith's role prompt includes a decision rule for this:
|
|
213
|
+
|
|
214
|
+
**Use `create_request`** when:
|
|
215
|
+
- Delegating substantive work (implement feature, write tests, do review)
|
|
216
|
+
- Work has concrete deliverables and acceptance criteria
|
|
217
|
+
- Work should flow through a pipeline (engineer → qa → reviewer)
|
|
218
|
+
- The task needs to be tracked, claimed, and its status visible
|
|
219
|
+
|
|
220
|
+
**Use `send_message`** when:
|
|
221
|
+
- Asking a clarifying question
|
|
222
|
+
- Quick status update or coordination
|
|
223
|
+
- Reporting a bug back after review fails
|
|
224
|
+
- No concrete deliverable
|
|
225
|
+
|
|
226
|
+
**When unsure, prefer `create_request`** — a tracked artifact beats losing context in chat.
|
|
227
|
+
|
|
210
228
|
### Other
|
|
211
229
|
|
|
212
230
|
| Tool | Description |
|
|
@@ -317,6 +335,31 @@ Click **⌨️** on any smith to open a terminal session:
|
|
|
317
335
|
- Forge Skills available: `/forge-send`, `/forge-inbox`, `/forge-status`, `/forge-workspace-sync`
|
|
318
336
|
- Session Picker: choose new session, continue existing, or browse all Claude sessions
|
|
319
337
|
- Close terminal → smith returns to auto mode, pending messages resume
|
|
338
|
+
- **Auto-reconnect**: If the WebSocket drops (e.g. system suspend, network blip), the terminal automatically reconnects after 2s and re-attaches to the same tmux session — conversation context preserved
|
|
339
|
+
- **Mouse ON/OFF toggle** (🖱️ button in header): Toggle tmux mouse mode globally for all sessions
|
|
340
|
+
- **ON**: trackpad scroll, `Shift+drag` to select text
|
|
341
|
+
- **OFF**: drag to select text directly, `Ctrl+B [` to enter scroll mode
|
|
342
|
+
- Click to apply instantly (no restart needed)
|
|
343
|
+
|
|
344
|
+
### Terminal Layout: Float vs Dock
|
|
345
|
+
|
|
346
|
+
The workspace toolbar has a layout switcher: `⧉ Float` or `▤ Dock`.
|
|
347
|
+
|
|
348
|
+
| Layout | Behavior |
|
|
349
|
+
|---|---|
|
|
350
|
+
| **Float** (default) | Each terminal is a draggable/resizable floating window positioned near its smith node |
|
|
351
|
+
| **Dock** | All open terminals arranged in a fixed grid at the bottom of the workspace |
|
|
352
|
+
|
|
353
|
+
Dock mode features:
|
|
354
|
+
- Grid columns selector (1/2/3/4) — auto-expands based on open terminal count
|
|
355
|
+
- 1 terminal with 4 columns → fills full width
|
|
356
|
+
- 2 → half-half, 3 → thirds, 4 → quarters, 5+ → wraps to second row
|
|
357
|
+
- Drag the top border to resize dock height
|
|
358
|
+
- Layout preference persisted to localStorage
|
|
359
|
+
|
|
360
|
+
### Smith Node Positions
|
|
361
|
+
|
|
362
|
+
Drag smith nodes to reorganize the graph. Positions are **persisted to workspace state** and restored on reload. Auto-save debounces writes (500ms after drag stops).
|
|
320
363
|
|
|
321
364
|
## Watch (Autonomous Monitoring)
|
|
322
365
|
|
|
@@ -356,7 +399,7 @@ Agents can monitor file/git/command changes without message-driven triggers.
|
|
|
356
399
|
|
|
357
400
|
Each smith can display an animated companion character next to its node.
|
|
358
401
|
|
|
359
|
-
**Themes**: Stick figure, Cat,
|
|
402
|
+
**Themes**: Stick figure, Cat, Pixel (8-bit RPG hero), Emoji, Off (default)
|
|
360
403
|
|
|
361
404
|
- Theme picker in workspace header
|
|
362
405
|
- Animates based on smith state (idle/running/done/failed/sleeping)
|
|
@@ -368,7 +411,7 @@ Each smith can display an animated companion character next to its node.
|
|
|
368
411
|
| Action | Description |
|
|
369
412
|
|--------|-------------|
|
|
370
413
|
| **Start Daemon** | Launch all smiths, begin consuming messages |
|
|
371
|
-
| **Stop Daemon** | Stop all smiths, kill workers |
|
|
414
|
+
| **Stop Daemon** | Stop all smiths, kill workers. Preserves user's terminal conversation context (no `/clear` is sent). Tmux sessions attached to by a user are kept alive. |
|
|
372
415
|
| **Run All** | Trigger all runnable agents once |
|
|
373
416
|
| **Run** | Trigger specific agent |
|
|
374
417
|
| **Pause/Resume** | Pause/resume message consumption for one agent |
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Usage Analytics
|
|
2
|
+
|
|
3
|
+
Forge tracks Claude API token usage and estimated costs across all your projects.
|
|
4
|
+
|
|
5
|
+
## Access
|
|
6
|
+
|
|
7
|
+
Click **Usage** in the Dashboard top navigation.
|
|
8
|
+
|
|
9
|
+
## Data Source
|
|
10
|
+
|
|
11
|
+
Forge scans these sources on-demand (via **Scan Now** button) or automatically on startup:
|
|
12
|
+
|
|
13
|
+
| Source | Location | What it tracks |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| `claude-code` | `~/.claude/projects/*/` | Interactive Claude Code sessions |
|
|
16
|
+
| `forge-task` | `~/.forge/data/tasks.db` | Background tasks submitted through Forge |
|
|
17
|
+
| `api-direct` | SDK/API calls logged by Forge | Direct API calls (rare) |
|
|
18
|
+
|
|
19
|
+
Stored in `~/.forge/data/usage.db` (SQLite). Each row records: session_id, source, project, model, day, input/output/cache tokens, cost_usd, message_count.
|
|
20
|
+
|
|
21
|
+
## Time Range Filter
|
|
22
|
+
|
|
23
|
+
Buttons in the header: **7d / 30d / 90d / All** — filter all charts and tables.
|
|
24
|
+
|
|
25
|
+
## Summary Cards
|
|
26
|
+
|
|
27
|
+
| Card | Shows |
|
|
28
|
+
|---|---|
|
|
29
|
+
| **Total Cost** | Sum of `cost_usd` in the selected range + trend (↑/↓ vs previous half) |
|
|
30
|
+
| **Daily Avg** | Total cost divided by days with activity |
|
|
31
|
+
| **Tokens** | Total tokens (input + output + cache read), broken down below |
|
|
32
|
+
| **Cache Hit** | `cacheRead / (input + cacheRead) × 100%` + cached tokens count |
|
|
33
|
+
|
|
34
|
+
## Visualizations
|
|
35
|
+
|
|
36
|
+
### Token Mix (stacked bar)
|
|
37
|
+
A single horizontal bar showing the proportion of:
|
|
38
|
+
- 🔵 Input tokens
|
|
39
|
+
- 🟢 Output tokens
|
|
40
|
+
- 🟣 Cache read tokens
|
|
41
|
+
- 🟠 Cache create tokens
|
|
42
|
+
|
|
43
|
+
Hover for tooltip.
|
|
44
|
+
|
|
45
|
+
### Cost Trend (line chart)
|
|
46
|
+
Line chart of daily cost over the selected range. Y-axis auto-scales to max cost. X-axis labels shown for ~7 date points.
|
|
47
|
+
|
|
48
|
+
### Activity Heatmap
|
|
49
|
+
GitHub-style 90-day grid: rows are weekdays (S/M/T/W/T/F/S), columns are weeks. Darker blue = higher cost. Hover a cell for exact date and cost.
|
|
50
|
+
|
|
51
|
+
### Avg by Weekday
|
|
52
|
+
Bar chart showing the average daily cost per weekday. Weekends highlighted in orange, weekdays in blue.
|
|
53
|
+
|
|
54
|
+
### By Model / By Source (donut charts)
|
|
55
|
+
Two side-by-side donut charts splitting total cost by model (Opus, Sonnet, Haiku) and by source (claude-code, forge-task, api-direct). Shows percentage + absolute cost per slice.
|
|
56
|
+
|
|
57
|
+
### By Project (bar list)
|
|
58
|
+
Top 20 projects ranked by cost. Each row shows project name, relative bar, cost, and session count.
|
|
59
|
+
|
|
60
|
+
### Model Details (table)
|
|
61
|
+
Per-model breakdown:
|
|
62
|
+
- Input / Output tokens
|
|
63
|
+
- Cost
|
|
64
|
+
- Message count
|
|
65
|
+
- Avg cost per message
|
|
66
|
+
|
|
67
|
+
### Summary Stats (bottom 3 cards)
|
|
68
|
+
- **Avg per session** — total cost / session count
|
|
69
|
+
- **Avg per message** — total cost / message count
|
|
70
|
+
- **Sessions per day** — session count / days with activity
|
|
71
|
+
|
|
72
|
+
## Cost Calculation
|
|
73
|
+
|
|
74
|
+
Estimates based on public Anthropic API pricing:
|
|
75
|
+
|
|
76
|
+
| Model | Input | Output |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| Claude Opus 4 | $15/M | $75/M |
|
|
79
|
+
| Claude Sonnet 4 | $3/M | $15/M |
|
|
80
|
+
| Claude Haiku 4 | ~$0.80/M | ~$4/M |
|
|
81
|
+
|
|
82
|
+
Cache reads are ~90% cheaper than regular inputs (~$0.30/M for Opus).
|
|
83
|
+
|
|
84
|
+
> Actual cost may differ if you're on Claude Max/Pro subscription (fixed monthly), or using Bedrock/Vertex with different pricing.
|
|
85
|
+
|
|
86
|
+
## Actions
|
|
87
|
+
|
|
88
|
+
- **Scan Now** — Re-scans all JSONL session files and tasks.db, updates the database
|
|
89
|
+
- **Time range** — 7/30/90/All days
|
|
90
|
+
|
|
91
|
+
## Troubleshooting
|
|
92
|
+
|
|
93
|
+
### Usage shows $0
|
|
94
|
+
- No data yet — click **Scan Now**
|
|
95
|
+
- Check `~/.claude/projects/` has JSONL session files
|
|
96
|
+
- Check `~/.forge/data/tasks.db` exists and has rows
|
|
97
|
+
- Check `~/.forge/data/usage.db` has data: `sqlite3 ~/.forge/data/usage.db 'SELECT COUNT(*) FROM token_usage'`
|
|
98
|
+
|
|
99
|
+
### Wrong project name
|
|
100
|
+
Usage scanner derives project name from directory path. Rename via scan refresh after moving projects.
|
|
101
|
+
|
|
102
|
+
### Missing recent sessions
|
|
103
|
+
Sessions in progress aren't tracked until the file is flushed. Click **Scan Now** to force a refresh.
|
package/lib/help-docs/CLAUDE.md
CHANGED
|
@@ -42,6 +42,7 @@ The token is valid for 24 hours. Store it in a variable and reuse for all API ca
|
|
|
42
42
|
| `09-issue-autofix.md` | GitHub issue auto-fix pipeline |
|
|
43
43
|
| `10-troubleshooting.md` | Common issues and solutions |
|
|
44
44
|
| `11-workspace.md` | Workspace (Forge Smiths) — multi-agent orchestration, daemon, message bus, profiles |
|
|
45
|
+
| `12-usage.md` | Token usage analytics — charts, heatmap, cost estimation, by model/project/source |
|
|
45
46
|
|
|
46
47
|
## Matching questions to docs
|
|
47
48
|
|
|
@@ -60,3 +61,6 @@ The token is valid for 24 hours. Store it in a variable and reuse for all API ca
|
|
|
60
61
|
- Watch/monitor/detect/file changes/autonomous → `11-workspace.md`
|
|
61
62
|
- Agent profile/env/model/cliType → `01-settings.md` + `11-workspace.md`
|
|
62
63
|
- Agent log/logs/history/clear logs → `11-workspace.md`
|
|
64
|
+
- Usage/cost/tokens/spending/billing/analytics → `12-usage.md`
|
|
65
|
+
- Terminal dock/float/mouse toggle/reconnect → `07-projects.md` + `11-workspace.md`
|
|
66
|
+
- Sidebar collapse/project tabs/favorites → `07-projects.md`
|
package/lib/telegram-bot.ts
CHANGED
|
@@ -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
|
-
|
|
302
|
-
`/
|
|
344
|
+
`🎯 /i (or /inject) — type into a terminal & submit\n` +
|
|
345
|
+
`/iclear — clear 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
|
-
`/
|
|
311
|
-
|
|
355
|
+
`/projects — list 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: '
|
|
1424
|
-
{ command: '
|
|
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
|
-
|
|
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