@aion0/forge 0.1.6 → 0.1.7

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.
@@ -0,0 +1,15 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { NextResponse } from 'next/server';
5
+
6
+ const STATE_FILE = join(homedir(), '.forge', 'terminal-state.json');
7
+
8
+ export async function GET() {
9
+ try {
10
+ const data = JSON.parse(readFileSync(STATE_FILE, 'utf-8'));
11
+ return NextResponse.json(data);
12
+ } catch {
13
+ return NextResponse.json(null);
14
+ }
15
+ }
@@ -46,35 +46,19 @@ function getWsUrl() {
46
46
  return `${wsProtocol}//${wsHost}:3001`;
47
47
  }
48
48
 
49
- /** Load shared terminal state from server */
50
- function loadSharedState(): Promise<{ tabs: TabState[]; activeTabId: number; sessionLabels: Record<string, string> } | null> {
51
- return new Promise((resolve) => {
52
- const timeout = setTimeout(() => resolve(null), 3000);
53
- try {
54
- const ws = new WebSocket(getWsUrl());
55
- ws.onopen = () => ws.send(JSON.stringify({ type: 'load-state' }));
56
- ws.onmessage = (e) => {
57
- clearTimeout(timeout);
58
- try {
59
- const msg = JSON.parse(e.data);
60
- if (msg.type === 'terminal-state' && msg.data) {
61
- const d = msg.data;
62
- if (Array.isArray(d.tabs) && d.tabs.length > 0 && typeof d.activeTabId === 'number') {
63
- resolve({ tabs: d.tabs, activeTabId: d.activeTabId, sessionLabels: d.sessionLabels || {} });
64
- ws.close();
65
- return;
66
- }
67
- }
68
- } catch {}
69
- resolve(null);
70
- ws.close();
71
- };
72
- ws.onerror = () => { clearTimeout(timeout); resolve(null); };
73
- } catch {
74
- clearTimeout(timeout);
75
- resolve(null);
49
+ /** Load shared terminal state via API (always available, doesn't depend on terminal WebSocket server) */
50
+ async function loadSharedState(): Promise<{ tabs: TabState[]; activeTabId: number; sessionLabels: Record<string, string> } | null> {
51
+ try {
52
+ const res = await fetch('/api/terminal-state');
53
+ if (!res.ok) return null;
54
+ const d = await res.json();
55
+ if (d && Array.isArray(d.tabs) && d.tabs.length > 0 && typeof d.activeTabId === 'number') {
56
+ return { tabs: d.tabs, activeTabId: d.activeTabId, sessionLabels: d.sessionLabels || {} };
76
57
  }
77
- });
58
+ return null;
59
+ } catch {
60
+ return null;
61
+ }
78
62
  }
79
63
 
80
64
  /** Save shared terminal state to server (fire-and-forget) */
@@ -179,6 +163,7 @@ const WebTerminal = forwardRef<WebTerminalHandle>(function WebTerminal(_props, r
179
163
  });
180
164
  const [activeTabId, setActiveTabId] = useState(() => tabs[0]?.id || 1);
181
165
  const [hydrated, setHydrated] = useState(false);
166
+ const stateLoadedRef = useRef(false);
182
167
  const [tmuxSessions, setTmuxSessions] = useState<TmuxSession[]>([]);
183
168
  const [showSessionPicker, setShowSessionPicker] = useState(false);
184
169
  const [editingTabId, setEditingTabId] = useState<number | null>(null);
@@ -196,6 +181,7 @@ const WebTerminal = forwardRef<WebTerminalHandle>(function WebTerminal(_props, r
196
181
  setTabs(saved.tabs);
197
182
  setActiveTabId(saved.activeTabId);
198
183
  sessionLabelsRef.current = saved.sessionLabels || {};
184
+ stateLoadedRef.current = true;
199
185
  }
200
186
  setHydrated(true);
201
187
  });
@@ -345,6 +331,7 @@ const WebTerminal = forwardRef<WebTerminalHandle>(function WebTerminal(_props, r
345
331
  }, [activeTabId]);
346
332
 
347
333
  const onSessionConnected = useCallback((paneId: number, sessionName: string) => {
334
+ stateLoadedRef.current = true; // Allow saving once a session is connected
348
335
  setTabs(prev => prev.map(t => ({
349
336
  ...t,
350
337
  tree: updateSessionName(t.tree, paneId, sessionName),
@@ -182,23 +182,51 @@ function trackDetach(ws: WebSocket, sessionName: string) {
182
182
 
183
183
  // ─── Periodic orphan cleanup ─────────────────────────────────
184
184
 
185
- /** Clean up detached tmux sessions that no WS client is connected to (skip renamed ones) */
185
+ /** Clean up detached tmux sessions that are not tracked in terminal-state.json */
186
186
  function cleanupOrphanedSessions() {
187
- const renamed = getRenamedSessions();
187
+ const knownSessions = getKnownSessions();
188
188
  const sessions = listTmuxSessions();
189
189
  for (const s of sessions) {
190
190
  if (s.attached) continue;
191
- if (renamed.has(s.name)) continue; // user renamed — preserve
191
+ if (knownSessions.has(s.name)) continue; // saved in terminal state — preserve
192
192
  const clients = sessionClients.get(s.name)?.size ?? 0;
193
193
  if (clients === 0) {
194
- console.log(`[terminal] Cleanup: killing orphaned session "${s.name}" (no clients, not renamed)`);
195
194
  killTmuxSession(s.name);
196
195
  }
197
196
  }
198
197
  }
199
198
 
200
- // Run cleanup every 30 seconds
201
- setInterval(cleanupOrphanedSessions, 30_000);
199
+ /** Get all session names referenced in terminal-state.json (tabs + labels) */
200
+ function getKnownSessions(): Set<string> {
201
+ try {
202
+ const state = loadTerminalState() as any;
203
+ if (!state) return new Set();
204
+ const known = new Set<string>();
205
+ // From sessionLabels
206
+ if (state.sessionLabels) {
207
+ for (const name of Object.keys(state.sessionLabels)) known.add(name);
208
+ }
209
+ // From tab trees
210
+ if (state.tabs) {
211
+ for (const tab of state.tabs) {
212
+ collectTreeSessions(tab.tree, known);
213
+ }
214
+ }
215
+ return known;
216
+ } catch {
217
+ return new Set();
218
+ }
219
+ }
220
+
221
+ function collectTreeSessions(node: any, set: Set<string>) {
222
+ if (!node) return;
223
+ if (node.type === 'terminal' && node.sessionName) set.add(node.sessionName);
224
+ if (node.first) collectTreeSessions(node.first, set);
225
+ if (node.second) collectTreeSessions(node.second, set);
226
+ }
227
+
228
+ // Run cleanup every 60 seconds, with a 60s initial delay to let clients reconnect after restart
229
+ setTimeout(() => setInterval(cleanupOrphanedSessions, 60_000), 60_000);
202
230
 
203
231
  // ─── WebSocket server ──────────────────────────────────────────
204
232
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {