@aion0/forge 0.5.29 → 0.5.31
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 +4 -7
- package/components/ProjectDetail.tsx +23 -20
- package/components/ProjectManager.tsx +141 -82
- package/components/UsagePanel.tsx +431 -49
- package/components/WorkspaceView.tsx +195 -33
- package/lib/usage-scanner.ts +14 -5
- package/lib/workspace/orchestrator.ts +16 -13
- package/lib/workspace/presets.ts +52 -5
- package/lib/workspace-standalone.ts +13 -0
- package/package.json +1 -1
|
@@ -2211,7 +2211,7 @@ function FloatingTerminalInline({ agentLabel, agentIcon, projectPath, agentCliId
|
|
|
2211
2211
|
return <div ref={containerRef} className="w-full h-full" style={{ background: '#0d1117' }} />;
|
|
2212
2212
|
}
|
|
2213
2213
|
|
|
2214
|
-
function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliCmd: cliCmdProp, cliType, workDir, preferredSessionName, existingSession, resumeMode, resumeSessionId, profileEnv, isPrimary, skipPermissions, persistentSession, boundSessionId, initialPos, onSessionReady, onClose }: {
|
|
2214
|
+
function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliCmd: cliCmdProp, cliType, workDir, preferredSessionName, existingSession, resumeMode, resumeSessionId, profileEnv, isPrimary, skipPermissions, persistentSession, boundSessionId, initialPos, docked, onSessionReady, onClose }: {
|
|
2215
2215
|
agentLabel: string;
|
|
2216
2216
|
agentIcon: string;
|
|
2217
2217
|
projectPath: string;
|
|
@@ -2229,6 +2229,7 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
|
|
|
2229
2229
|
persistentSession?: boolean;
|
|
2230
2230
|
boundSessionId?: string;
|
|
2231
2231
|
initialPos?: { x: number; y: number };
|
|
2232
|
+
docked?: boolean; // when true, render as grid cell instead of fixed floating window
|
|
2232
2233
|
onSessionReady?: (name: string) => void;
|
|
2233
2234
|
onClose: (killSession: boolean) => void;
|
|
2234
2235
|
}) {
|
|
@@ -2243,9 +2244,24 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
|
|
|
2243
2244
|
}, [initialPos?.x, initialPos?.y]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
2244
2245
|
const [size, setSize] = useState({ w: 500, h: 300 });
|
|
2245
2246
|
const [showCloseDialog, setShowCloseDialog] = useState(false);
|
|
2247
|
+
const [mouseOn, setMouseOn] = useState(true);
|
|
2246
2248
|
const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
|
|
2247
2249
|
const resizeRef = useRef<{ startX: number; startY: number; origW: number; origH: number } | null>(null);
|
|
2248
2250
|
|
|
2251
|
+
const toggleMouse = () => {
|
|
2252
|
+
const next = !mouseOn;
|
|
2253
|
+
setMouseOn(next);
|
|
2254
|
+
// Send via current WebSocket (shared for all workspace terminals)
|
|
2255
|
+
try {
|
|
2256
|
+
const ws = new WebSocket(getWsUrl());
|
|
2257
|
+
ws.onopen = () => {
|
|
2258
|
+
ws.send(JSON.stringify({ type: 'tmux-mouse', mouse: next }));
|
|
2259
|
+
setTimeout(() => ws.close(), 300);
|
|
2260
|
+
};
|
|
2261
|
+
ws.onerror = () => ws.close();
|
|
2262
|
+
} catch {}
|
|
2263
|
+
};
|
|
2264
|
+
|
|
2249
2265
|
useEffect(() => {
|
|
2250
2266
|
const el = containerRef.current;
|
|
2251
2267
|
if (!el) return;
|
|
@@ -2437,13 +2453,16 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
|
|
|
2437
2453
|
|
|
2438
2454
|
return (
|
|
2439
2455
|
<div
|
|
2440
|
-
className=
|
|
2441
|
-
|
|
2456
|
+
className={docked
|
|
2457
|
+
? "relative bg-[#0d1117] border border-[#30363d] rounded-lg flex flex-col overflow-hidden w-full h-full"
|
|
2458
|
+
: "fixed z-50 bg-[#0d1117] border border-[#30363d] rounded-lg shadow-2xl flex flex-col overflow-hidden"
|
|
2459
|
+
}
|
|
2460
|
+
style={docked ? undefined : { left: pos.x, top: pos.y, width: size.w, height: size.h }}
|
|
2442
2461
|
>
|
|
2443
|
-
{/*
|
|
2462
|
+
{/* Header — draggable in floating mode, static in docked mode */}
|
|
2444
2463
|
<div
|
|
2445
|
-
className=
|
|
2446
|
-
onMouseDown={(e) => {
|
|
2464
|
+
className={`flex items-center gap-2 px-3 py-1.5 bg-[#161b22] border-b border-[#30363d] shrink-0 select-none ${docked ? '' : 'cursor-move'}`}
|
|
2465
|
+
onMouseDown={docked ? undefined : (e) => {
|
|
2447
2466
|
e.preventDefault();
|
|
2448
2467
|
dragRef.current = { startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y };
|
|
2449
2468
|
setUserDragged(true);
|
|
@@ -2457,34 +2476,44 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
|
|
|
2457
2476
|
}}
|
|
2458
2477
|
>
|
|
2459
2478
|
<span className="text-sm">{agentIcon}</span>
|
|
2460
|
-
<span className="text-[11px] font-semibold text-white">{agentLabel}</span>
|
|
2461
|
-
<span className="text-[8px] text-gray-500">⌨️ manual terminal</span>
|
|
2462
|
-
<button
|
|
2479
|
+
<span className="text-[11px] font-semibold text-white truncate">{agentLabel}</span>
|
|
2480
|
+
{!docked && <span className="text-[8px] text-gray-500">⌨️ manual terminal</span>}
|
|
2481
|
+
<button
|
|
2482
|
+
onClick={(e) => { e.stopPropagation(); toggleMouse(); }}
|
|
2483
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
2484
|
+
className={`ml-auto text-[9px] px-1.5 py-0.5 rounded border transition-colors ${mouseOn ? 'border-green-600/40 text-green-400 bg-green-500/10' : 'border-gray-600 text-gray-500 bg-gray-800/50'}`}
|
|
2485
|
+
title={mouseOn ? 'Mouse ON (trackpad scroll, Shift+drag to select text)' : 'Mouse OFF (drag to select text, Ctrl+B [ to scroll)'}
|
|
2486
|
+
>
|
|
2487
|
+
🖱️ {mouseOn ? 'ON' : 'OFF'}
|
|
2488
|
+
</button>
|
|
2489
|
+
<button onClick={() => setShowCloseDialog(true)} className="text-gray-500 hover:text-white text-sm shrink-0">✕</button>
|
|
2463
2490
|
</div>
|
|
2464
2491
|
|
|
2465
2492
|
{/* Terminal */}
|
|
2466
2493
|
<div ref={containerRef} className="flex-1 min-h-0" style={{ background: '#0d1117' }} />
|
|
2467
2494
|
|
|
2468
|
-
{/* Resize handle */}
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
e
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
<
|
|
2486
|
-
|
|
2487
|
-
|
|
2495
|
+
{/* Resize handle — floating mode only */}
|
|
2496
|
+
{!docked && (
|
|
2497
|
+
<div
|
|
2498
|
+
className="absolute bottom-0 right-0 w-4 h-4 cursor-se-resize"
|
|
2499
|
+
onMouseDown={(e) => {
|
|
2500
|
+
e.preventDefault();
|
|
2501
|
+
e.stopPropagation();
|
|
2502
|
+
resizeRef.current = { startX: e.clientX, startY: e.clientY, origW: size.w, origH: size.h };
|
|
2503
|
+
const onMove = (ev: MouseEvent) => {
|
|
2504
|
+
if (!resizeRef.current) return;
|
|
2505
|
+
setSize({ w: Math.max(400, resizeRef.current.origW + ev.clientX - resizeRef.current.startX), h: Math.max(250, resizeRef.current.origH + ev.clientY - resizeRef.current.startY) });
|
|
2506
|
+
};
|
|
2507
|
+
const onUp = () => { resizeRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
|
|
2508
|
+
window.addEventListener('mousemove', onMove);
|
|
2509
|
+
window.addEventListener('mouseup', onUp);
|
|
2510
|
+
}}
|
|
2511
|
+
>
|
|
2512
|
+
<svg viewBox="0 0 16 16" className="w-3 h-3 absolute bottom-0.5 right-0.5 text-gray-600">
|
|
2513
|
+
<path d="M14 14L8 14L14 8Z" fill="currentColor" />
|
|
2514
|
+
</svg>
|
|
2515
|
+
</div>
|
|
2516
|
+
)}
|
|
2488
2517
|
|
|
2489
2518
|
{/* Close confirmation dialog */}
|
|
2490
2519
|
{showCloseDialog && (
|
|
@@ -3445,6 +3474,27 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
3445
3474
|
};
|
|
3446
3475
|
const [floatingTerminals, setFloatingTerminals] = useState<{ 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>; isPrimary?: boolean; skipPermissions?: boolean; persistentSession?: boolean; boundSessionId?: string; initialPos?: { x: number; y: number } }[]>([]);
|
|
3447
3476
|
const [termPicker, setTermPicker] = useState<{ agent: AgentConfig; sessName: string; workDir?: string; supportsSession?: boolean; currentSessionId: string | null; initialPos?: { x: number; y: number } } | null>(null);
|
|
3477
|
+
// Terminal layout: floating (draggable windows) or docked (fixed grid at bottom)
|
|
3478
|
+
const [terminalLayout, setTerminalLayout] = useState<'floating' | 'docked'>(() => {
|
|
3479
|
+
if (typeof window === 'undefined') return 'floating';
|
|
3480
|
+
return (localStorage.getItem('forge.termLayout') as 'floating' | 'docked') || 'floating';
|
|
3481
|
+
});
|
|
3482
|
+
const [dockColumns, setDockColumns] = useState<number>(() => {
|
|
3483
|
+
if (typeof window === 'undefined') return 2;
|
|
3484
|
+
return parseInt(localStorage.getItem('forge.termDockCols') || '2');
|
|
3485
|
+
});
|
|
3486
|
+
const [dockHeight, setDockHeight] = useState<number>(() => {
|
|
3487
|
+
if (typeof window === 'undefined') return 320;
|
|
3488
|
+
return parseInt(localStorage.getItem('forge.termDockHeight') || '320');
|
|
3489
|
+
});
|
|
3490
|
+
const updateTerminalLayout = (l: 'floating' | 'docked') => {
|
|
3491
|
+
setTerminalLayout(l);
|
|
3492
|
+
if (typeof window !== 'undefined') localStorage.setItem('forge.termLayout', l);
|
|
3493
|
+
};
|
|
3494
|
+
const updateDockColumns = (n: number) => {
|
|
3495
|
+
setDockColumns(n);
|
|
3496
|
+
if (typeof window !== 'undefined') localStorage.setItem('forge.termDockCols', String(n));
|
|
3497
|
+
};
|
|
3448
3498
|
|
|
3449
3499
|
// Expose focusAgent to parent
|
|
3450
3500
|
useImperativeHandle(ref, () => ({
|
|
@@ -3470,6 +3520,29 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
3470
3520
|
ensureWorkspace(projectPath, projectName).then(setWorkspaceId).catch(() => {});
|
|
3471
3521
|
}, [projectPath, projectName]);
|
|
3472
3522
|
|
|
3523
|
+
// Saved node positions from server (loaded once on workspace init)
|
|
3524
|
+
const [savedPositions, setSavedPositions] = useState<Record<string, { x: number; y: number }>>({});
|
|
3525
|
+
useEffect(() => {
|
|
3526
|
+
if (!workspaceId) return;
|
|
3527
|
+
wsApi(workspaceId, 'get_positions').then((res: any) => {
|
|
3528
|
+
if (res?.positions) setSavedPositions(res.positions);
|
|
3529
|
+
}).catch(() => {});
|
|
3530
|
+
}, [workspaceId]);
|
|
3531
|
+
|
|
3532
|
+
// Save positions (debounced) when nodes are dragged
|
|
3533
|
+
const savePositionsDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
3534
|
+
const saveNodePositions = useCallback(() => {
|
|
3535
|
+
if (!workspaceId) return;
|
|
3536
|
+
if (savePositionsDebounceRef.current) clearTimeout(savePositionsDebounceRef.current);
|
|
3537
|
+
savePositionsDebounceRef.current = setTimeout(() => {
|
|
3538
|
+
const positions: Record<string, { x: number; y: number }> = {};
|
|
3539
|
+
for (const n of rfNodes) {
|
|
3540
|
+
positions[n.id] = { x: n.position.x, y: n.position.y };
|
|
3541
|
+
}
|
|
3542
|
+
wsApi(workspaceId, 'set_positions', { positions }).catch(() => {});
|
|
3543
|
+
}, 500);
|
|
3544
|
+
}, [workspaceId, rfNodes]);
|
|
3545
|
+
|
|
3473
3546
|
// SSE stream — server is the single source of truth
|
|
3474
3547
|
const { agents, states, logPreview, busLog, daemonActive: daemonActiveFromStream, setDaemonActive: setDaemonActiveFromStream } = useWorkspaceStream(workspaceId, (event) => {
|
|
3475
3548
|
if (event.type === 'user_input_request') {
|
|
@@ -3488,7 +3561,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
3488
3561
|
const existing = prevMap.get(agent.id);
|
|
3489
3562
|
const base = {
|
|
3490
3563
|
id: agent.id,
|
|
3491
|
-
position: existing?.position ?? { x: i * 260, y: 60 },
|
|
3564
|
+
position: existing?.position ?? savedPositions[agent.id] ?? { x: i * 260, y: 60 },
|
|
3492
3565
|
...(existing?.measured ? { measured: existing.measured } : {}),
|
|
3493
3566
|
...(existing?.width ? { width: existing.width, height: existing.height } : {}),
|
|
3494
3567
|
};
|
|
@@ -3598,7 +3671,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
3598
3671
|
};
|
|
3599
3672
|
});
|
|
3600
3673
|
});
|
|
3601
|
-
}, [agents, states, logPreview, workspaceId, mascotTheme]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
3674
|
+
}, [agents, states, logPreview, workspaceId, mascotTheme, savedPositions]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
3602
3675
|
|
|
3603
3676
|
// Derive edges from dependsOn
|
|
3604
3677
|
const rfEdges = useMemo(() => {
|
|
@@ -3770,6 +3843,32 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
3770
3843
|
</>
|
|
3771
3844
|
)}
|
|
3772
3845
|
<div className="ml-auto flex items-center gap-2">
|
|
3846
|
+
{/* Terminal layout switcher */}
|
|
3847
|
+
<div className="flex items-center gap-0.5 px-1 py-0.5 rounded border border-[#30363d] bg-[#0d1117]">
|
|
3848
|
+
<button
|
|
3849
|
+
onClick={() => updateTerminalLayout('floating')}
|
|
3850
|
+
className={`text-[8px] px-1.5 py-0.5 rounded ${terminalLayout === 'floating' ? 'bg-[#58a6ff]/20 text-[#58a6ff]' : 'text-gray-500 hover:text-white'}`}
|
|
3851
|
+
title="Floating terminals (draggable windows)"
|
|
3852
|
+
>⧉ Float</button>
|
|
3853
|
+
<button
|
|
3854
|
+
onClick={() => updateTerminalLayout('docked')}
|
|
3855
|
+
className={`text-[8px] px-1.5 py-0.5 rounded ${terminalLayout === 'docked' ? 'bg-[#58a6ff]/20 text-[#58a6ff]' : 'text-gray-500 hover:text-white'}`}
|
|
3856
|
+
title="Docked terminals (bottom grid)"
|
|
3857
|
+
>▤ Dock</button>
|
|
3858
|
+
{terminalLayout === 'docked' && (
|
|
3859
|
+
<>
|
|
3860
|
+
<span className="w-px h-3 bg-[#30363d] mx-0.5" />
|
|
3861
|
+
{[1, 2, 3, 4].map(n => (
|
|
3862
|
+
<button
|
|
3863
|
+
key={n}
|
|
3864
|
+
onClick={() => updateDockColumns(n)}
|
|
3865
|
+
className={`text-[8px] px-1 py-0.5 rounded ${dockColumns === n ? 'bg-[#58a6ff]/20 text-[#58a6ff]' : 'text-gray-500 hover:text-white'}`}
|
|
3866
|
+
title={`${n} column${n > 1 ? 's' : ''}`}
|
|
3867
|
+
>{n}</button>
|
|
3868
|
+
))}
|
|
3869
|
+
</>
|
|
3870
|
+
)}
|
|
3871
|
+
</div>
|
|
3773
3872
|
<select value={mascotTheme} onChange={e => updateMascotTheme(e.target.value as MascotTheme)}
|
|
3774
3873
|
className="text-[8px] px-1.5 py-0.5 rounded border border-[#30363d] bg-[#0d1117] text-gray-500 hover:text-white hover:border-[#58a6ff]/60 cursor-pointer focus:outline-none"
|
|
3775
3874
|
title="Mascot theme">
|
|
@@ -3867,6 +3966,8 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
3867
3966
|
edges={rfEdges}
|
|
3868
3967
|
onNodesChange={onNodesChange}
|
|
3869
3968
|
onNodeDragStop={() => {
|
|
3969
|
+
// Persist positions
|
|
3970
|
+
saveNodePositions();
|
|
3870
3971
|
// Reposition terminals to follow their nodes
|
|
3871
3972
|
setFloatingTerminals(prev => prev.map(ft => {
|
|
3872
3973
|
const nodeEl = document.querySelector(`[data-id="${ft.agentId}"]`);
|
|
@@ -4011,8 +4112,8 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
4011
4112
|
/>
|
|
4012
4113
|
)}
|
|
4013
4114
|
|
|
4014
|
-
{/*
|
|
4015
|
-
{floatingTerminals.map(ft => (
|
|
4115
|
+
{/* Terminals — floating (draggable windows) or docked (bottom grid) */}
|
|
4116
|
+
{terminalLayout === 'floating' && floatingTerminals.map(ft => (
|
|
4016
4117
|
<FloatingTerminal
|
|
4017
4118
|
key={ft.agentId}
|
|
4018
4119
|
agentLabel={ft.label}
|
|
@@ -4043,6 +4144,67 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
4043
4144
|
/>
|
|
4044
4145
|
))}
|
|
4045
4146
|
|
|
4147
|
+
{/* Docked terminals — bottom panel with grid layout */}
|
|
4148
|
+
{terminalLayout === 'docked' && floatingTerminals.length > 0 && (
|
|
4149
|
+
<div
|
|
4150
|
+
className="fixed bottom-0 left-0 right-0 z-40 bg-[#0a0e14] border-t border-[#30363d] flex flex-col"
|
|
4151
|
+
style={{ height: dockHeight }}
|
|
4152
|
+
>
|
|
4153
|
+
{/* Resize handle */}
|
|
4154
|
+
<div
|
|
4155
|
+
className="h-1 bg-[#30363d] hover:bg-[var(--accent)] cursor-ns-resize shrink-0"
|
|
4156
|
+
onMouseDown={(e) => {
|
|
4157
|
+
e.preventDefault();
|
|
4158
|
+
const startY = e.clientY;
|
|
4159
|
+
const startH = dockHeight;
|
|
4160
|
+
const onMove = (ev: MouseEvent) => {
|
|
4161
|
+
const newH = Math.max(200, Math.min(window.innerHeight - 100, startH - (ev.clientY - startY)));
|
|
4162
|
+
setDockHeight(newH);
|
|
4163
|
+
};
|
|
4164
|
+
const onUp = () => {
|
|
4165
|
+
window.removeEventListener('mousemove', onMove);
|
|
4166
|
+
window.removeEventListener('mouseup', onUp);
|
|
4167
|
+
if (typeof window !== 'undefined') localStorage.setItem('forge.termDockHeight', String(dockHeight));
|
|
4168
|
+
};
|
|
4169
|
+
window.addEventListener('mousemove', onMove);
|
|
4170
|
+
window.addEventListener('mouseup', onUp);
|
|
4171
|
+
}}
|
|
4172
|
+
/>
|
|
4173
|
+
<div className="grid gap-1 p-1 flex-1 min-h-0" style={{ gridTemplateColumns: `repeat(${Math.min(floatingTerminals.length, dockColumns)}, minmax(0, 1fr))` }}>
|
|
4174
|
+
{floatingTerminals.map(ft => (
|
|
4175
|
+
<FloatingTerminal
|
|
4176
|
+
key={ft.agentId}
|
|
4177
|
+
agentLabel={ft.label}
|
|
4178
|
+
agentIcon={ft.icon}
|
|
4179
|
+
projectPath={projectPath}
|
|
4180
|
+
agentCliId={ft.cliId}
|
|
4181
|
+
cliCmd={ft.cliCmd}
|
|
4182
|
+
cliType={ft.cliType}
|
|
4183
|
+
workDir={ft.workDir}
|
|
4184
|
+
preferredSessionName={ft.sessionName}
|
|
4185
|
+
existingSession={ft.tmuxSession}
|
|
4186
|
+
resumeMode={ft.resumeMode}
|
|
4187
|
+
resumeSessionId={ft.resumeSessionId}
|
|
4188
|
+
profileEnv={ft.profileEnv}
|
|
4189
|
+
isPrimary={ft.isPrimary}
|
|
4190
|
+
skipPermissions={ft.skipPermissions}
|
|
4191
|
+
persistentSession={ft.persistentSession}
|
|
4192
|
+
boundSessionId={ft.boundSessionId}
|
|
4193
|
+
docked
|
|
4194
|
+
onSessionReady={(name) => {
|
|
4195
|
+
if (workspaceId) wsApi(workspaceId, 'set_tmux_session', { agentId: ft.agentId, sessionName: name });
|
|
4196
|
+
setFloatingTerminals(prev => prev.map(t => t.agentId === ft.agentId ? { ...t, tmuxSession: name } : t));
|
|
4197
|
+
}}
|
|
4198
|
+
onClose={(killSession) => {
|
|
4199
|
+
setFloatingTerminals(prev => prev.filter(t => t.agentId !== ft.agentId));
|
|
4200
|
+
if (workspaceId) wsApi(workspaceId, 'close_terminal', { agentId: ft.agentId, kill: killSession });
|
|
4201
|
+
}}
|
|
4202
|
+
/>
|
|
4203
|
+
))}
|
|
4204
|
+
</div>
|
|
4205
|
+
</div>
|
|
4206
|
+
)}
|
|
4207
|
+
|
|
4046
4208
|
{/* User input request from agent (via bus) */}
|
|
4047
4209
|
{userInputRequest && workspaceId && (
|
|
4048
4210
|
<RunPromptDialog
|
package/lib/usage-scanner.ts
CHANGED
|
@@ -180,10 +180,10 @@ export function queryUsage(opts: {
|
|
|
180
180
|
source?: string;
|
|
181
181
|
model?: string;
|
|
182
182
|
}): {
|
|
183
|
-
total: { input: number; output: number; cost: number; sessions: number; messages: number };
|
|
183
|
+
total: { input: number; output: number; cacheRead: number; cacheCreate: number; cost: number; sessions: number; messages: number };
|
|
184
184
|
byProject: { name: string; input: number; output: number; cost: number; sessions: number }[];
|
|
185
185
|
byModel: { model: string; input: number; output: number; cost: number; messages: number }[];
|
|
186
|
-
byDay: { date: string; input: number; output: number; cost: number }[];
|
|
186
|
+
byDay: { date: string; input: number; output: number; cacheRead: number; cacheCreate: number; cost: number; messages: number }[];
|
|
187
187
|
bySource: { source: string; input: number; output: number; cost: number; messages: number }[];
|
|
188
188
|
} {
|
|
189
189
|
let where = '1=1';
|
|
@@ -202,6 +202,7 @@ export function queryUsage(opts: {
|
|
|
202
202
|
|
|
203
203
|
const totalRow = db().prepare(`
|
|
204
204
|
SELECT COALESCE(SUM(input_tokens), 0) as input, COALESCE(SUM(output_tokens), 0) as output,
|
|
205
|
+
COALESCE(SUM(cache_read_tokens), 0) as cacheRead, COALESCE(SUM(cache_create_tokens), 0) as cacheCreate,
|
|
205
206
|
COALESCE(SUM(cost_usd), 0) as cost, COUNT(DISTINCT session_id) as sessions,
|
|
206
207
|
COALESCE(SUM(message_count), 0) as messages
|
|
207
208
|
FROM token_usage WHERE ${where}
|
|
@@ -226,11 +227,15 @@ export function queryUsage(opts: {
|
|
|
226
227
|
}));
|
|
227
228
|
|
|
228
229
|
const byDay = (db().prepare(`
|
|
229
|
-
SELECT day as date, SUM(input_tokens) as input, SUM(output_tokens) as output,
|
|
230
|
+
SELECT day as date, SUM(input_tokens) as input, SUM(output_tokens) as output,
|
|
231
|
+
SUM(cache_read_tokens) as cacheRead, SUM(cache_create_tokens) as cacheCreate,
|
|
232
|
+
SUM(cost_usd) as cost, SUM(message_count) as messages
|
|
230
233
|
FROM token_usage WHERE ${where} AND day != 'unknown'
|
|
231
234
|
GROUP BY day ORDER BY day DESC LIMIT 30
|
|
232
235
|
`).all(...params) as any[]).map(r => ({
|
|
233
|
-
date: r.date, input: r.input, output: r.output,
|
|
236
|
+
date: r.date, input: r.input, output: r.output,
|
|
237
|
+
cacheRead: r.cacheRead || 0, cacheCreate: r.cacheCreate || 0,
|
|
238
|
+
cost: +r.cost.toFixed(4), messages: r.messages || 0,
|
|
234
239
|
}));
|
|
235
240
|
|
|
236
241
|
const bySource = (db().prepare(`
|
|
@@ -243,7 +248,11 @@ export function queryUsage(opts: {
|
|
|
243
248
|
}));
|
|
244
249
|
|
|
245
250
|
return {
|
|
246
|
-
total: {
|
|
251
|
+
total: {
|
|
252
|
+
input: totalRow.input, output: totalRow.output,
|
|
253
|
+
cacheRead: totalRow.cacheRead || 0, cacheCreate: totalRow.cacheCreate || 0,
|
|
254
|
+
cost: +totalRow.cost.toFixed(4), sessions: totalRow.sessions, messages: totalRow.messages,
|
|
255
|
+
},
|
|
247
256
|
byProject, byModel, byDay, bySource,
|
|
248
257
|
};
|
|
249
258
|
}
|
|
@@ -96,6 +96,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
96
96
|
private reconcileTick = 0; // counts health check ticks for 60s reconcile
|
|
97
97
|
private _topoCache: WorkspaceTopo | null = null; // cached workspace topology
|
|
98
98
|
private roleInjectState = new Map<string, { lastInjectAt: number; msgsSinceInject: number }>(); // per-agent role reminder tracking
|
|
99
|
+
private nodePositions: Record<string, { x: number; y: number }> = {}; // persisted smith positions in ReactFlow graph
|
|
99
100
|
|
|
100
101
|
/** Emit a log event (auto-persisted via constructor listener) */
|
|
101
102
|
emitLog(agentId: string, entry: any): void {
|
|
@@ -1175,18 +1176,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1175
1176
|
entry.worker = null;
|
|
1176
1177
|
}
|
|
1177
1178
|
|
|
1178
|
-
//
|
|
1179
|
-
// This is a Claude Code slash command — no LLM call, just a local context reset.
|
|
1180
|
-
if (entry.state.tmuxSession && entry.config.role?.trim()) {
|
|
1181
|
-
let isAttached = false;
|
|
1182
|
-
try {
|
|
1183
|
-
const info = execSync(`tmux display-message -t "${entry.state.tmuxSession}" -p "#{session_attached}" 2>/dev/null`, { timeout: 3000, encoding: 'utf-8' }).trim();
|
|
1184
|
-
isAttached = info !== '0';
|
|
1185
|
-
} catch {}
|
|
1186
|
-
if (isAttached) {
|
|
1187
|
-
try { this.injectIntoSession(id, '/clear'); } catch {}
|
|
1188
|
-
}
|
|
1189
|
-
}
|
|
1179
|
+
// Do NOT send /clear — preserves user's conversation context in attached terminals
|
|
1190
1180
|
this.roleInjectState.delete(id);
|
|
1191
1181
|
|
|
1192
1182
|
// 3. Kill tmux session (skip if user is attached to it)
|
|
@@ -1858,6 +1848,17 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1858
1848
|
return this.bus.getLog();
|
|
1859
1849
|
}
|
|
1860
1850
|
|
|
1851
|
+
// ─── Node positions (ReactFlow layout) ─────────────────
|
|
1852
|
+
|
|
1853
|
+
getNodePositions(): Record<string, { x: number; y: number }> {
|
|
1854
|
+
return { ...this.nodePositions };
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
setNodePositions(positions: Record<string, { x: number; y: number }>): void {
|
|
1858
|
+
this.nodePositions = { ...this.nodePositions, ...positions };
|
|
1859
|
+
this.saveNow();
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1861
1862
|
// ─── State Snapshot (for persistence) ──────────────────
|
|
1862
1863
|
|
|
1863
1864
|
/** Get full workspace state for auto-save */
|
|
@@ -1868,7 +1869,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1868
1869
|
projectName: this.projectName,
|
|
1869
1870
|
agents: Array.from(this.agents.values()).map(e => e.config),
|
|
1870
1871
|
agentStates: this.getAllAgentStates(),
|
|
1871
|
-
nodePositions:
|
|
1872
|
+
nodePositions: this.nodePositions,
|
|
1872
1873
|
busLog: [...this.bus.getLog()],
|
|
1873
1874
|
busOutbox: this.bus.getAllOutbox(),
|
|
1874
1875
|
createdAt: this.createdAt,
|
|
@@ -1896,7 +1897,9 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1896
1897
|
agentStates: Record<string, AgentState>;
|
|
1897
1898
|
busLog: BusMessage[];
|
|
1898
1899
|
busOutbox?: Record<string, BusMessage[]>;
|
|
1900
|
+
nodePositions?: Record<string, { x: number; y: number }>;
|
|
1899
1901
|
}): void {
|
|
1902
|
+
if (data.nodePositions) this.nodePositions = { ...data.nodePositions };
|
|
1900
1903
|
this.agents.clear();
|
|
1901
1904
|
this.daemonActive = false; // Reset daemon — user must click Start Daemon again after restart
|
|
1902
1905
|
for (const config of data.agents) {
|
package/lib/workspace/presets.ts
CHANGED
|
@@ -22,6 +22,43 @@ import type { WorkspaceAgentConfig } from './types';
|
|
|
22
22
|
|
|
23
23
|
type PresetTemplate = Omit<WorkspaceAgentConfig, 'id'>;
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Shared decision rule for all smiths: when to use request documents
|
|
27
|
+
* vs inbox messages. This is the most common source of confusion.
|
|
28
|
+
*/
|
|
29
|
+
const REQUEST_VS_INBOX_RULE = `## Rule: Request vs Inbox
|
|
30
|
+
|
|
31
|
+
Use **request document** (create_request / claim_request / update_response) when:
|
|
32
|
+
- Delegating substantive work to another smith (implement feature, write tests, do review)
|
|
33
|
+
- Work has concrete deliverables and acceptance criteria
|
|
34
|
+
- Work should flow through a pipeline (engineer → qa → reviewer)
|
|
35
|
+
- The task needs to be tracked, claimed, and its status visible to everyone
|
|
36
|
+
|
|
37
|
+
Use **inbox message** (send_message) when:
|
|
38
|
+
- Asking a clarifying question ("what format should X be?")
|
|
39
|
+
- Quick status update ("I'm starting on this")
|
|
40
|
+
- Reporting a bug back to upstream (after review fails)
|
|
41
|
+
- Coordinating without a concrete deliverable
|
|
42
|
+
|
|
43
|
+
**Decision tree when user or another smith asks you to coordinate work:**
|
|
44
|
+
\`\`\`
|
|
45
|
+
Is it substantive implementation/testing/review work with clear acceptance criteria?
|
|
46
|
+
├─ YES → create_request (then notify via inbox if needed)
|
|
47
|
+
└─ NO → send_message only
|
|
48
|
+
|
|
49
|
+
Is it a question or quick coordination (no deliverable)?
|
|
50
|
+
├─ YES → send_message only
|
|
51
|
+
└─ NO → create_request
|
|
52
|
+
|
|
53
|
+
Am I being asked to do work that would result in code/tests/docs changes?
|
|
54
|
+
├─ YES and I'm executing it → claim_request if one exists, or tell user to create one
|
|
55
|
+
└─ NO → just respond via inbox
|
|
56
|
+
\`\`\`
|
|
57
|
+
|
|
58
|
+
**When unsure, prefer create_request** — having a tracked artifact beats losing context in chat.
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
|
|
25
62
|
export const AGENT_PRESETS: Record<string, PresetTemplate> = {
|
|
26
63
|
pm: {
|
|
27
64
|
label: 'PM',
|
|
@@ -162,7 +199,9 @@ IF request stuck open (no one claimed):
|
|
|
162
199
|
- Do NOT write code — your output is request documents only
|
|
163
200
|
- Each acceptance_criterion must be verifiable by QA
|
|
164
201
|
- Group related requests in a batch for tracking
|
|
165
|
-
- Downstream agents are auto-notified via DAG when you create requests
|
|
202
|
+
- Downstream agents are auto-notified via DAG when you create requests
|
|
203
|
+
|
|
204
|
+
${REQUEST_VS_INBOX_RULE}`,
|
|
166
205
|
backend: 'cli',
|
|
167
206
|
agentId: 'claude',
|
|
168
207
|
dependsOn: [],
|
|
@@ -241,7 +280,9 @@ IF you encounter a blocking issue (unclear requirement, impossible constraint):
|
|
|
241
280
|
- ALWAYS update_response when done — triggers downstream pipeline
|
|
242
281
|
- Only implement what the request asks — don't scope-creep
|
|
243
282
|
- Architecture docs are versioned — never overwrite
|
|
244
|
-
- Existing working code stays unless request explicitly requires changes
|
|
283
|
+
- Existing working code stays unless request explicitly requires changes
|
|
284
|
+
|
|
285
|
+
${REQUEST_VS_INBOX_RULE}`,
|
|
245
286
|
backend: 'cli',
|
|
246
287
|
agentId: 'claude',
|
|
247
288
|
dependsOn: [],
|
|
@@ -331,7 +372,9 @@ IF result = "failed":
|
|
|
331
372
|
- Do NOT fix bugs — only report them
|
|
332
373
|
- Each test must trace back to an acceptance_criterion
|
|
333
374
|
- One consolidated message max — never spam Engineers
|
|
334
|
-
- Never send messages during planning/writing — only after execution
|
|
375
|
+
- Never send messages during planning/writing — only after execution
|
|
376
|
+
|
|
377
|
+
${REQUEST_VS_INBOX_RULE}`,
|
|
335
378
|
backend: 'cli',
|
|
336
379
|
agentId: 'claude',
|
|
337
380
|
dependsOn: [],
|
|
@@ -436,7 +479,9 @@ IF result = "rejected":
|
|
|
436
479
|
- Review ONLY files_changed from the request, not the entire codebase
|
|
437
480
|
- Actionable feedback: not "this is bad" but "change X to Y because Z"
|
|
438
481
|
- One consolidated message max per verdict
|
|
439
|
-
- MINOR findings go in report only — never message about style
|
|
482
|
+
- MINOR findings go in report only — never message about style
|
|
483
|
+
|
|
484
|
+
${REQUEST_VS_INBOX_RULE}`,
|
|
440
485
|
backend: 'cli',
|
|
441
486
|
agentId: 'claude',
|
|
442
487
|
dependsOn: [],
|
|
@@ -549,7 +594,9 @@ IF multiple Engineers exist and request unclaimed:
|
|
|
549
594
|
- Every delegated task MUST go through request documents (create_request)
|
|
550
595
|
- Each request needs concrete acceptance_criteria that QA can verify
|
|
551
596
|
- Do NOT duplicate work an active agent is already doing — check status first
|
|
552
|
-
- When covering a gap, be thorough — don't half-do it just because it's not your "main" role
|
|
597
|
+
- When covering a gap, be thorough — don't half-do it just because it's not your "main" role
|
|
598
|
+
|
|
599
|
+
${REQUEST_VS_INBOX_RULE}`,
|
|
553
600
|
backend: 'cli',
|
|
554
601
|
agentId: 'claude',
|
|
555
602
|
dependsOn: [],
|
|
@@ -58,7 +58,11 @@ function loadOrchestrator(id: string): WorkspaceOrchestrator {
|
|
|
58
58
|
agentStates: state.agentStates,
|
|
59
59
|
busLog: state.busLog,
|
|
60
60
|
busOutbox: state.busOutbox,
|
|
61
|
+
nodePositions: state.nodePositions,
|
|
61
62
|
});
|
|
63
|
+
} else if (state.nodePositions) {
|
|
64
|
+
// Load positions even when no agents yet (rare, but safe)
|
|
65
|
+
orch.loadSnapshot({ agents: [], agentStates: {}, busLog: [], nodePositions: state.nodePositions });
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
// Wire up SSE broadcasting
|
|
@@ -190,6 +194,15 @@ async function handleAgentsPost(id: string, body: any, res: ServerResponse): Pro
|
|
|
190
194
|
return jsonError(res, err.message);
|
|
191
195
|
}
|
|
192
196
|
}
|
|
197
|
+
case 'set_positions': {
|
|
198
|
+
const positions = body.positions as Record<string, { x: number; y: number }> | undefined;
|
|
199
|
+
if (!positions) return jsonError(res, 'positions required');
|
|
200
|
+
orch.setNodePositions(positions);
|
|
201
|
+
return json(res, { ok: true });
|
|
202
|
+
}
|
|
203
|
+
case 'get_positions': {
|
|
204
|
+
return json(res, { positions: orch.getNodePositions() });
|
|
205
|
+
}
|
|
193
206
|
case 'agent_done': {
|
|
194
207
|
// Called by Claude Code Stop hook — agent finished a turn
|
|
195
208
|
if (!agentId) return jsonError(res, 'agentId required');
|
package/package.json
CHANGED