@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.
@@ -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="fixed z-50 bg-[#0d1117] border border-[#30363d] rounded-lg shadow-2xl flex flex-col overflow-hidden"
2441
- style={{ left: pos.x, top: pos.y, width: size.w, height: size.h }}
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
- {/* Draggable header */}
2462
+ {/* Header draggable in floating mode, static in docked mode */}
2444
2463
  <div
2445
- className="flex items-center gap-2 px-3 py-1.5 bg-[#161b22] border-b border-[#30363d] cursor-move shrink-0 select-none"
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 onClick={() => setShowCloseDialog(true)} className="ml-auto text-gray-500 hover:text-white text-sm">✕</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
- <div
2470
- className="absolute bottom-0 right-0 w-4 h-4 cursor-se-resize"
2471
- onMouseDown={(e) => {
2472
- e.preventDefault();
2473
- e.stopPropagation();
2474
- resizeRef.current = { startX: e.clientX, startY: e.clientY, origW: size.w, origH: size.h };
2475
- const onMove = (ev: MouseEvent) => {
2476
- if (!resizeRef.current) return;
2477
- 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) });
2478
- };
2479
- const onUp = () => { resizeRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
2480
- window.addEventListener('mousemove', onMove);
2481
- window.addEventListener('mouseup', onUp);
2482
- }}
2483
- >
2484
- <svg viewBox="0 0 16 16" className="w-3 h-3 absolute bottom-0.5 right-0.5 text-gray-600">
2485
- <path d="M14 14L8 14L14 8Z" fill="currentColor" />
2486
- </svg>
2487
- </div>
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
- {/* Floating terminals positioned near their agent node */}
4015
- {floatingTerminals.map(ft => (
4115
+ {/* Terminalsfloating (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
@@ -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, SUM(cost_usd) as cost
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, cost: +r.cost.toFixed(4),
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: { input: totalRow.input, output: totalRow.output, cost: +totalRow.cost.toFixed(4), sessions: totalRow.sessions, messages: totalRow.messages },
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
- // 2b. For persistent sessions: send /clear to reset Claude's context if user is attached.
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) {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.5.29",
3
+ "version": "0.5.31",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {