@aion0/forge 0.5.8 → 0.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/RELEASE_NOTES.md CHANGED
@@ -1,134 +1,15 @@
1
- # Forge v0.5.8
1
+ # Forge v0.5.9
2
2
 
3
3
  Released: 2026-03-30
4
4
 
5
- ## Changes since v0.5.7
5
+ ## Changes since v0.5.8
6
6
 
7
7
  ### Features
8
- - feat: Restart Session button for persistentSession agents
9
- - feat: boundSessionId drives non-primary terminal open flow
10
- - feat: boundSessionId per agent — each agent has its own last session
11
- - feat: execution mode determined by config, not runtime tmux state
12
- - feat: /resolve API for workspace lookup + skills use env var → API fallback
13
- - feat: switch session button (▾) for non-primary agents with active terminal
14
- - feat: auto-fallback to headless when terminal startup fails
15
- - feat: Forge agent monitors all inbox states
16
- - feat: Forge agent — autonomous bus monitor with periodic scanning
17
- - feat: Forge agent auto-requests summary when agent completes without reply
18
- - feat: nudge sender when target agent completes — inject hint into terminal
19
- - feat: restore auto-reply on completion + keep check_outbox as supplement
20
- - feat: auto-reply to message sender on completion + check_outbox tool
21
- - feat: add MCP Server to monitor panel and status script
22
- - feat: get_agents tool + fuzzy send_message matching
23
- - feat: inject FORGE env vars at all terminal launch points
24
- - feat: --mcp-config injected at all claude terminal launch points
25
- - feat: Forge MCP Server — replace HTTP skills with native tool calls
26
- - feat: open terminal button on session list items
27
- - feat: detect CLI startup failures in persistent sessions
28
- - feat: all terminal entry points use fixedSessionId via --resume
29
- - feat: bind button on sessions list to set fixedSessionId
30
- - feat: click-to-change session binding in project header
31
- - feat: show bound session in project header and session list
32
- - feat: session picker dropdown for fixedSessionId binding
33
- - feat: session list shows full ID on expand with copy button
34
- - feat: show and edit fixedSessionId in agent config modal
35
- - feat: primary agent setup prompt for new and existing workspaces
36
- - feat: primary agent checkbox in agent config modal
37
- - feat: terminal dock — inline right panel with tabs and resizable width
38
- - feat: VibeCoding uses workspace primary agent's fixed session
39
- - feat: fixed CLI session binding for primary agent
40
- - feat: primary agent — terminal-only, fixed session, root directory
41
- - feat: persistent terminal session per agent
42
- - feat: watch send_message auto-detects tmux sessions (workspace + VibeCoding)
43
- - feat: watch send_message injects directly into terminal session
8
+ - feat: terminal floats below agent node, follows on drag/zoom
44
9
 
45
10
  ### Bug Fixes
46
- - fix: unset profile env vars before setting new ones on agent switch
47
- - fix: normalize workDir paths before encoding session directory
48
- - fix: session list uses agent's workDir, not project root
49
- - fix: clear boundSessionId on agent change even without tmux session
50
- - fix: kill terminal and clear boundSessionId when agent CLI changes
51
- - fix: rename "Keep terminal session alive" to "Terminal mode"
52
- - fix: codex skipPermissionsFlag back to --full-auto
53
- - fix: codex skip flag + -c only when sessions exist
54
- - fix: set FORGE env vars as separate tmux command before CLI start
55
- - fix: set FORGE env vars in tmux, skills use env vars not curl lookup
56
- - fix: mark forge skills as FALLBACK so MCP tools take priority
57
- - fix: nudge explicitly says use MCP tool, not forge-send skill
58
- - fix: skills prefer MCP tools, fix subdirectory workspace lookup
59
- - fix: show headless after Kill, terminal (pending) only before daemon start
60
- - fix: Suspend vs Kill terminal behavior
61
- - fix: Forge agent nudge uses stronger language to trigger send_message
62
- - fix: always resolve agent launch info before opening terminal
63
- - fix: bus log updates immediately after message delete (no refresh needed)
64
- - fix: stop daemon kills tmux sessions + Forge agent only scans new messages
65
- - fix: enlarge session switch button (▾) for easier clicking
66
- - fix: terminal button visible during running state (was hidden)
67
- - fix: daemon persistent session uses -c for non-primary agents
68
- - fix: restore -c flag for non-primary agents to resume latest session
69
- - fix: Forge agent processes historical pending/running, skips done/failed
70
- - fix: Forge agent only scans messages after daemon start, ignores history
71
- - fix: Forge agent restarts message loop for stuck pending messages
72
- - fix: add skipPermissions to FloatingTerminalInline (was missing)
73
- - fix: pass skipPermissions to FloatingTerminal, add --dangerously-skip-permissions
74
- - fix: block terminal open when daemon not started
75
- - fix: re-open terminal when agent config changes (close old first)
76
- - fix: don't use claude -c for workspace agents (subdirs may have no session)
77
- - fix: MCP monitor shows port not pid, add separator after Tunnel
78
- - fix: add type:'sse' to MCP config (required by Claude Code schema)
79
- - fix: MCP server port 7830 → 8406 (follows 8403/8404/8405 sequence)
80
- - fix: MCP agentId resolved by server, not hardcoded in all URLs
81
- - fix: MAX_ACTIVE limits daemon start, not workspace loading
82
- - fix: increase MAX_ACTIVE workspaces from 2 to 5
83
- - fix: move all session action buttons to second row (right-aligned)
84
- - fix: skip message loop when persistent session fails to start
85
- - fix: only primary agent uses fixedSession, others use -c or session dialog
86
- - fix: refresh fixed session display after bind via window event
87
- - fix: resolveFixedSession auto-binds latest session when not set
88
- - fix: add error logging to session bind button for debugging
89
- - fix: session list uses disk files as source of truth, not stale index
90
- - fix: bind button always visible, set session when none bound
91
- - fix: limit fixedSessionId auto-bind to only 3 entry points
92
- - fix: show bind button even when no session is bound yet
93
- - fix: auto-bind fixedSessionId at Next.js API layer
94
- - fix: ensure primary session binding at all entry points
95
- - fix: auto-bind fixedSessionId on agent add and config update
96
- - fix: show terminal (pending) for persistentSession agents without tmux yet
97
- - fix: replace nested button with div+span in terminal dock tabs
98
- - fix: don't auto-open terminal UI on workspace load
99
- - fix: session binding edge cases
100
- - fix: auto-bind latest CLI session on upgrade instead of creating new
101
- - fix: persistent session starts claude with -c (resume last session)
102
- - fix: verify stored tmuxSession is alive before using, fallback to find
103
- - fix: findTmuxSession reads terminal-state.json for VibeCoding sessions
104
- - fix: session watch detects file switch (new session created)
105
- - fix: getAllAgentStates preserves tmuxSession + currentMessageId
106
- - fix: persistent session emits state update so frontend knows tmuxSession
107
- - fix: watch send_message sends configured prompt only + auto-Enter
108
- - fix: open terminal attaches to existing tmux session regardless of mode
109
- - fix: session watch only sends last matching entry, not all content
110
- - fix: remove .js extension from dynamic import for Next.js compat
111
- - fix: workspace terminal defaults to resume last session (-c)
112
- - fix: updateAgentConfig rebuilds worker + emits status update
113
- - fix: CLI backend auto-finds latest session when no sessionId
11
+ - fix: revise dedup to not block legitimate messages
12
+ - fix: reduce duplicate messages in workspace agent communication
114
13
 
115
- ### Performance
116
- - perf: cache session binding check, defer SessionView loading
117
14
 
118
- ### Refactoring
119
- - refactor: MCP context via URL params, remove FORGE env var injection
120
- - refactor: fixedSessionId is project-level, not workspace/agent-level
121
- - refactor: remove manual/auto mode, unify message execution via tmuxSession
122
-
123
- ### Documentation
124
- - revert auto-resume + document persistent terminal session plan
125
-
126
- ### Other
127
- - revert: remove orchestrator auto-reply, let agent decide via MCP
128
- - cleanup: remove fixedSessionId from agent config and workspace state
129
- - simplify: remove auto-detect session binding complexity
130
- - revert: restore floating terminal windows, remove dock panel
131
- - revert auto-resume + document persistent terminal session plan
132
-
133
-
134
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.7...v0.5.8
15
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.8...v0.5.9
@@ -1779,7 +1779,7 @@ function FloatingTerminalInline({ agentLabel, agentIcon, projectPath, agentCliId
1779
1779
  return <div ref={containerRef} className="w-full h-full" style={{ background: '#0d1117' }} />;
1780
1780
  }
1781
1781
 
1782
- function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliCmd: cliCmdProp, cliType, workDir, preferredSessionName, existingSession, resumeMode, resumeSessionId, profileEnv, isPrimary, skipPermissions, persistentSession, boundSessionId, onSessionReady, onClose }: {
1782
+ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliCmd: cliCmdProp, cliType, workDir, preferredSessionName, existingSession, resumeMode, resumeSessionId, profileEnv, isPrimary, skipPermissions, persistentSession, boundSessionId, initialPos, onSessionReady, onClose }: {
1783
1783
  agentLabel: string;
1784
1784
  agentIcon: string;
1785
1785
  projectPath: string;
@@ -1796,14 +1796,20 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
1796
1796
  skipPermissions?: boolean;
1797
1797
  persistentSession?: boolean;
1798
1798
  boundSessionId?: string;
1799
+ initialPos?: { x: number; y: number };
1799
1800
  onSessionReady?: (name: string) => void;
1800
1801
  onClose: (killSession: boolean) => void;
1801
1802
  }) {
1802
1803
  const containerRef = useRef<HTMLDivElement>(null);
1803
1804
  const wsRef = useRef<WebSocket | null>(null);
1804
1805
  const sessionNameRef = useRef('');
1805
- const [pos, setPos] = useState({ x: 80, y: 60 });
1806
- const [size, setSize] = useState({ w: 750, h: 450 });
1806
+ const [pos, setPos] = useState(initialPos || { x: 80, y: 60 });
1807
+ const [userDragged, setUserDragged] = useState(false);
1808
+ // Follow node position unless user manually dragged the terminal
1809
+ useEffect(() => {
1810
+ if (initialPos && !userDragged) setPos(initialPos);
1811
+ }, [initialPos?.x, initialPos?.y]); // eslint-disable-line react-hooks/exhaustive-deps
1812
+ const [size, setSize] = useState({ w: 500, h: 300 });
1807
1813
  const [showCloseDialog, setShowCloseDialog] = useState(false);
1808
1814
  const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
1809
1815
  const resizeRef = useRef<{ startX: number; startY: number; origW: number; origH: number } | null>(null);
@@ -1821,7 +1827,7 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
1821
1827
  if (disposed) return;
1822
1828
 
1823
1829
  const term = new Terminal({
1824
- cursorBlink: true, fontSize: 13,
1830
+ cursorBlink: true, fontSize: 10,
1825
1831
  fontFamily: 'Menlo, Monaco, "Courier New", monospace',
1826
1832
  scrollback: 5000,
1827
1833
  theme: { background: '#0d1117', foreground: '#c9d1d9', cursor: '#58a6ff' },
@@ -1831,7 +1837,15 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
1831
1837
  term.open(el);
1832
1838
  setTimeout(() => { try { fitAddon.fit(); } catch {} }, 100);
1833
1839
 
1834
- const ro = new ResizeObserver(() => { try { fitAddon.fit(); } catch {} });
1840
+ // Scale font: min 10 at small size, max 13 at large size
1841
+ const ro = new ResizeObserver(() => {
1842
+ try {
1843
+ const w = el.clientWidth;
1844
+ const newSize = Math.min(13, Math.max(10, Math.floor(w / 60)));
1845
+ if (term.options.fontSize !== newSize) term.options.fontSize = newSize;
1846
+ fitAddon.fit();
1847
+ } catch {}
1848
+ });
1835
1849
  ro.observe(el);
1836
1850
 
1837
1851
  // Connect WebSocket — attach to existing or create new
@@ -1956,6 +1970,7 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
1956
1970
  onMouseDown={(e) => {
1957
1971
  e.preventDefault();
1958
1972
  dragRef.current = { startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y };
1973
+ setUserDragged(true);
1959
1974
  const onMove = (ev: MouseEvent) => {
1960
1975
  if (!dragRef.current) return;
1961
1976
  setPos({ x: Math.max(0, dragRef.current.origX + ev.clientX - dragRef.current.startX), y: Math.max(0, dragRef.current.origY + ev.clientY - dragRef.current.startY) });
@@ -2113,6 +2128,8 @@ interface AgentNodeData {
2113
2128
  state: AgentState;
2114
2129
  colorIdx: number;
2115
2130
  previewLines: string[];
2131
+ projectPath: string;
2132
+ workspaceId: string | null;
2116
2133
  onRun: () => void;
2117
2134
  onPause: () => void;
2118
2135
  onStop: () => void;
@@ -2131,8 +2148,11 @@ interface AgentNodeData {
2131
2148
  [key: string]: unknown;
2132
2149
  }
2133
2150
 
2151
+ // PortalTerminal/NodeTerminal removed — xterm cannot render inside React Flow nodes
2152
+ // and createPortal causes event routing issues. Using FloatingTerminal instead.
2153
+
2134
2154
  function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
2135
- const { config, state, colorIdx, previewLines, onRun, onPause, onStop, onRetry, onEdit, onRemove, onMessage, onApprove, onShowLog, onShowMemory, onShowInbox, onOpenTerminal, onSwitchSession, inboxPending = 0, inboxFailed = 0 } = data;
2155
+ const { config, state, colorIdx, previewLines, projectPath, workspaceId, onRun, onPause, onStop, onRetry, onEdit, onRemove, onMessage, onApprove, onShowLog, onShowMemory, onShowInbox, onOpenTerminal, onSwitchSession, inboxPending = 0, inboxFailed = 0 } = data;
2136
2156
  const c = COLORS[colorIdx % COLORS.length];
2137
2157
  const smithStatus = state?.smithStatus || 'down';
2138
2158
  const taskStatus = state?.taskStatus || 'idle';
@@ -2141,7 +2161,7 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
2141
2161
  const taskInfo = TASK_STATUS[taskStatus] || TASK_STATUS.idle;
2142
2162
  const currentStep = state?.currentStep;
2143
2163
  const step = currentStep !== undefined ? config.steps[currentStep] : undefined;
2144
- const isApprovalPending = taskStatus === 'idle' && smithStatus === 'active'; // approximation, actual check would use approvalQueue
2164
+ const isApprovalPending = taskStatus === 'idle' && smithStatus === 'active';
2145
2165
 
2146
2166
  return (
2147
2167
  <div className="w-52 flex flex-col rounded-lg select-none"
@@ -2242,7 +2262,8 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
2242
2262
  <div className="flex-1" />
2243
2263
  <span className="flex items-center">
2244
2264
  <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onOpenTerminal(); }}
2245
- className={`text-[9px] px-1 ${hasTmux && taskStatus === 'running' ? 'text-green-400 animate-pulse' : 'text-gray-600 hover:text-green-400'}`} title="Open terminal">⌨️</button>
2265
+ className={`text-[9px] px-1 ${hasTmux && taskStatus === 'running' ? 'text-green-400 animate-pulse' : 'text-gray-600 hover:text-green-400'}`}
2266
+ title="Open terminal">⌨️</button>
2246
2267
  {hasTmux && !config.primary && (
2247
2268
  <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onSwitchSession(); }}
2248
2269
  className="text-[10px] text-gray-600 hover:text-yellow-400 px-0.5 py-0.5" title="Switch session">▾</button>
@@ -2287,8 +2308,8 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2287
2308
  const [memoryTarget, setMemoryTarget] = useState<{ id: string; label: string } | null>(null);
2288
2309
  const [inboxTarget, setInboxTarget] = useState<{ id: string; label: string } | null>(null);
2289
2310
  const [showBusPanel, setShowBusPanel] = useState(false);
2290
- 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 }[]>([]);
2291
- const [termLaunchDialog, setTermLaunchDialog] = useState<{ agent: AgentConfig; sessName: string; workDir?: string; sessions: string[]; supportsSession?: boolean } | null>(null);
2311
+ 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 } }[]>([]);
2312
+ const [termLaunchDialog, setTermLaunchDialog] = useState<{ agent: AgentConfig; sessName: string; workDir?: string; sessions: string[]; supportsSession?: boolean; initialPos?: { x: number; y: number } } | null>(null);
2292
2313
 
2293
2314
  // Expose focusAgent to parent
2294
2315
  useImperativeHandle(ref, () => ({
@@ -2367,6 +2388,8 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2367
2388
  state: states[agent.id] || { smithStatus: 'down', taskStatus: 'idle', artifacts: [] },
2368
2389
  colorIdx: i,
2369
2390
  previewLines: logPreview[agent.id] || [],
2391
+ projectPath,
2392
+ workspaceId,
2370
2393
  onRun: () => {
2371
2394
  wsApi(workspaceId!, 'run', { agentId: agent.id });
2372
2395
  },
@@ -2394,6 +2417,13 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2394
2417
  // Close existing terminal (config may have changed)
2395
2418
  setFloatingTerminals(prev => prev.filter(t => t.agentId !== agent.id));
2396
2419
 
2420
+ // Get node screen position for initial terminal placement
2421
+ const nodeEl = document.querySelector(`[data-id="${agent.id}"]`);
2422
+ const nodeRect = nodeEl?.getBoundingClientRect();
2423
+ const initialPos = nodeRect
2424
+ ? { x: nodeRect.left, y: nodeRect.bottom + 4 }
2425
+ : { x: 80, y: 60 };
2426
+
2397
2427
  const agentState = states[agent.id];
2398
2428
  const existingTmux = agentState?.tmuxSession;
2399
2429
  const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
@@ -2421,7 +2451,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2421
2451
  agentId: agent.id, label: agent.label, icon: agent.icon,
2422
2452
  cliId: agent.agentId || 'claude', ...launchInfo, workDir,
2423
2453
  tmuxSession: existingTmux, sessionName: sessName,
2424
- isPrimary: agent.primary, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId,
2454
+ isPrimary: agent.primary, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId, initialPos,
2425
2455
  }]);
2426
2456
  return;
2427
2457
  }
@@ -2433,7 +2463,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2433
2463
  agentId: agent.id, label: agent.label, icon: agent.icon,
2434
2464
  cliId: agent.agentId || 'claude', ...launchInfo, workDir,
2435
2465
  tmuxSession: res?.tmuxSession || sessName, sessionName: sessName,
2436
- isPrimary: true, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId,
2466
+ isPrimary: true, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId, initialPos,
2437
2467
  }]);
2438
2468
  return;
2439
2469
  }
@@ -2446,24 +2476,25 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2446
2476
  cliId: agent.agentId || 'claude', ...launchInfo, workDir,
2447
2477
  tmuxSession: res?.tmuxSession || sessName, sessionName: sessName,
2448
2478
  resumeSessionId: agent.boundSessionId,
2449
- isPrimary: false, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId,
2479
+ isPrimary: false, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId, initialPos,
2450
2480
  }]);
2451
2481
  return;
2452
2482
  }
2453
2483
  // No bound session → show launch dialog (New / Resume / Select)
2454
- setTermLaunchDialog({ agent, sessName, workDir, sessions: [], supportsSession: resolveRes?.supportsSession ?? true });
2484
+ setTermLaunchDialog({ agent, sessName, workDir, sessions: [], supportsSession: resolveRes?.supportsSession ?? true, initialPos });
2455
2485
  },
2456
2486
  onSwitchSession: async () => {
2457
2487
  if (!workspaceId) return;
2458
- // Close existing terminal
2459
2488
  setFloatingTerminals(prev => prev.filter(t => t.agentId !== agent.id));
2460
2489
  if (agent.id) wsApi(workspaceId, 'close_terminal', { agentId: agent.id });
2461
- // Show launch dialog
2490
+ const nodeEl = document.querySelector(`[data-id="${agent.id}"]`);
2491
+ const nodeRect = nodeEl?.getBoundingClientRect();
2492
+ const switchPos = nodeRect ? { x: nodeRect.left, y: nodeRect.bottom + 4 } : { x: 80, y: 60 };
2462
2493
  const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
2463
2494
  const sessName = `mw-forge-${safeName(projectName)}-${safeName(agent.label)}`;
2464
2495
  const workDir = agent.workDir && agent.workDir !== './' && agent.workDir !== '.' ? agent.workDir : undefined;
2465
2496
  const resolveRes = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id, resolveOnly: true }).catch(() => ({})) as any;
2466
- setTermLaunchDialog({ agent, sessName, workDir, sessions: [], supportsSession: resolveRes?.supportsSession ?? true });
2497
+ setTermLaunchDialog({ agent, sessName, workDir, sessions: [], supportsSession: resolveRes?.supportsSession ?? true, initialPos: switchPos });
2467
2498
  },
2468
2499
  } satisfies AgentNodeData,
2469
2500
  };
@@ -2716,6 +2747,22 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2716
2747
  nodes={rfNodes}
2717
2748
  edges={rfEdges}
2718
2749
  onNodesChange={onNodesChange}
2750
+ onNodeDragStop={() => {
2751
+ // Reposition terminals to follow their nodes
2752
+ setFloatingTerminals(prev => prev.map(ft => {
2753
+ const nodeEl = document.querySelector(`[data-id="${ft.agentId}"]`);
2754
+ const rect = nodeEl?.getBoundingClientRect();
2755
+ return rect ? { ...ft, initialPos: { x: rect.left, y: rect.bottom + 4 } } : ft;
2756
+ }));
2757
+ }}
2758
+ onMoveEnd={() => {
2759
+ // Reposition after pan/zoom
2760
+ setFloatingTerminals(prev => prev.map(ft => {
2761
+ const nodeEl = document.querySelector(`[data-id="${ft.agentId}"]`);
2762
+ const rect = nodeEl?.getBoundingClientRect();
2763
+ return rect ? { ...ft, initialPos: { x: rect.left, y: rect.bottom + 4 } } : ft;
2764
+ }));
2765
+ }}
2719
2766
  nodeTypes={nodeTypes}
2720
2767
  fitView
2721
2768
  fitViewOptions={{ padding: 0.3 }}
@@ -2826,7 +2873,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2826
2873
  cliCmd: res.cliCmd || 'claude',
2827
2874
  cliType: res.cliType || 'claude-code',
2828
2875
  workDir,
2829
- sessionName: sessName, resumeMode, resumeSessionId: sessionId, isPrimary: false, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: sessionId || agent.boundSessionId,
2876
+ sessionName: sessName, resumeMode, resumeSessionId: sessionId, isPrimary: false, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: sessionId || agent.boundSessionId, initialPos: termLaunchDialog.initialPos,
2830
2877
  profileEnv: {
2831
2878
  ...(res.env || {}),
2832
2879
  ...(res.model ? { CLAUDE_MODEL: res.model } : {}),
@@ -2861,6 +2908,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2861
2908
  skipPermissions={ft.skipPermissions}
2862
2909
  persistentSession={ft.persistentSession}
2863
2910
  boundSessionId={ft.boundSessionId}
2911
+ initialPos={ft.initialPos}
2864
2912
  onSessionReady={(name) => {
2865
2913
  if (workspaceId) wsApi(workspaceId, 'set_tmux_session', { agentId: ft.agentId, sessionName: name });
2866
2914
  setFloatingTerminals(prev => prev.map(t => t.agentId === ft.agentId ? { ...t, tmuxSession: name } : t));
@@ -1171,10 +1171,14 @@ export class WorkspaceOrchestrator extends EventEmitter {
1171
1171
  if (msg.type === 'ack' || msg.from === '_forge') continue;
1172
1172
  if (this.forgeActedMessages.has(msg.id)) continue;
1173
1173
 
1174
- // Case 1: Message done but no reply from target → ask target to send summary
1174
+ // Case 1: Message done but no reply from target → ask target to send summary (once per pair)
1175
1175
  if (msg.status === 'done') {
1176
1176
  const age = now - msg.timestamp;
1177
- if (age < 30_000) continue; // give 30s grace period
1177
+ if (age < 30_000) continue;
1178
+
1179
+ // Dedup by target→sender pair (only nudge once per relationship)
1180
+ const nudgeKey = `nudge-${msg.to}->${msg.from}`;
1181
+ if (this.forgeActedMessages.has(nudgeKey)) { this.forgeActedMessages.add(msg.id); continue; }
1178
1182
 
1179
1183
  const hasReply = log.some(r =>
1180
1184
  r.from === msg.to && r.to === msg.from &&
@@ -1189,7 +1193,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
1189
1193
  content: `[IMPORTANT] You finished a task requested by ${senderLabel} but did not send them the results. You MUST call the MCP tool "send_message" (NOT the forge-send skill) with to="${senderLabel}" and include a summary of what you did and the outcome. Do not do any other work until you have sent this reply.`,
1190
1194
  });
1191
1195
  this.forgeActedMessages.add(msg.id);
1192
- console.log(`[forge-agent] Nudged ${targetEntry.config.label} to reply to ${senderLabel}`);
1196
+ this.forgeActedMessages.add(nudgeKey);
1197
+ console.log(`[forge-agent] Nudged ${targetEntry.config.label} to reply to ${senderLabel} (once)`);
1193
1198
  }
1194
1199
  }
1195
1200
  }
@@ -1755,13 +1760,25 @@ export class WorkspaceOrchestrator extends EventEmitter {
1755
1760
  ? `${completedLabel} completed: ${files.length} files changed. ${summary.slice(0, 200)}`
1756
1761
  : `${completedLabel} completed. ${summary.slice(0, 300) || 'Check upstream outputs for updates.'}`;
1757
1762
 
1758
- // Find all downstream agents that depend on this one
1763
+ // Find all downstream agents skip if already sent upstream_complete recently (60s)
1764
+ const now = Date.now();
1759
1765
  let sent = 0;
1760
1766
  for (const [id, entry] of this.agents) {
1761
1767
  if (id === completedAgentId) continue;
1762
1768
  if (entry.config.type === 'input') continue;
1763
1769
  if (!entry.config.dependsOn.includes(completedAgentId)) continue;
1764
1770
 
1771
+ // Dedup: skip if upstream_complete was sent to this target within last 60s
1772
+ const recentDup = this.bus.getLog().some(m =>
1773
+ m.from === completedAgentId && m.to === id &&
1774
+ m.payload?.action === 'upstream_complete' &&
1775
+ now - m.timestamp < 60_000
1776
+ );
1777
+ if (recentDup) {
1778
+ console.log(`[bus] ${completedLabel} → ${entry.config.label}: upstream_complete skipped (sent <60s ago)`);
1779
+ continue;
1780
+ }
1781
+
1765
1782
  this.bus.send(completedAgentId, id, 'notify', {
1766
1783
  action: 'upstream_complete',
1767
1784
  content,
@@ -2209,8 +2226,23 @@ export class WorkspaceOrchestrator extends EventEmitter {
2209
2226
  // requiresApproval is handled at message arrival time (routeMessageToAgent),
2210
2227
  // not in the message loop. Approved messages come through as normal 'pending'.
2211
2228
 
2229
+ // Dedup: if multiple upstream_complete from same sender are pending, keep only latest
2230
+ const allRaw = this.bus.getPendingMessagesFor(agentId).filter(m => m.from !== agentId && m.type !== 'ack');
2231
+ const upstreamSeen = new Set<string>();
2232
+ for (let i = allRaw.length - 1; i >= 0; i--) {
2233
+ const m = allRaw[i];
2234
+ if (m.payload?.action === 'upstream_complete') {
2235
+ const key = `upstream-${m.from}`;
2236
+ if (upstreamSeen.has(key)) {
2237
+ m.status = 'done' as any;
2238
+ this.emit('event', { type: 'bus_message_status', messageId: m.id, status: 'done' } as any);
2239
+ }
2240
+ upstreamSeen.add(key);
2241
+ }
2242
+ }
2243
+
2212
2244
  // Find next pending message, applying causedBy rules
2213
- const allPending = this.bus.getPendingMessagesFor(agentId).filter(m => m.from !== agentId && m.type !== 'ack');
2245
+ const allPending = allRaw.filter(m => m.status === 'pending');
2214
2246
  const pending = allPending.filter(m => {
2215
2247
  // Tickets: accepted but check retry limit
2216
2248
  if (m.category === 'ticket') {
package/next-env.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /// <reference types="next" />
2
2
  /// <reference types="next/image-types/global" />
3
- import "./.next/dev/types/routes.d.ts";
3
+ import "./.next/types/routes.d.ts";
4
4
 
5
5
  // NOTE: This file should not be edited
6
6
  // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.5.8",
3
+ "version": "0.5.9",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "forge": {
4
+ "type": "sse",
5
+ "url": "http://localhost:8406/sse?workspaceId=656c9e65-9d73-4cb6-a065-60d966e1fc78&agentId=qa-1774920510930"
6
+ }
7
+ }
8
+ }