@aion0/forge 0.3.5 → 0.3.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.
package/CLAUDE.md CHANGED
@@ -47,6 +47,21 @@ forge watch <id> # live stream task output
47
47
  - npm package: `@aion0/forge`
48
48
  - GitHub: `github.com/aiwatching/forge`
49
49
 
50
+ ### Help Docs Rule
51
+ When adding or changing a feature, check if `lib/help-docs/` needs updating. Each file covers one module:
52
+ - `00-overview.md` — install, start, data paths
53
+ - `01-settings.md` — all settings fields
54
+ - `02-telegram.md` — bot setup and commands
55
+ - `03-tunnel.md` — remote access
56
+ - `04-tasks.md` — background tasks
57
+ - `05-pipelines.md` — DAG workflows
58
+ - `06-skills.md` — marketplace
59
+ - `07-projects.md` — project management
60
+ - `08-rules.md` — CLAUDE.md templates
61
+ - `09-issue-autofix.md` — GitHub issue scanner
62
+ - `10-troubleshooting.md` — common issues
63
+ If a feature change affects user-facing behavior, update the corresponding help doc in the same commit.
64
+
50
65
  ### Architecture
51
66
  - `forge-server.mjs` starts: Next.js + terminal-standalone + telegram-standalone
52
67
  - `pnpm dev` / `start.sh dev` starts: Next.js (init.ts spawns terminal + telegram)
@@ -0,0 +1,78 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { getConfigDir } from '@/lib/dirs';
5
+ import { loadSettings } from '@/lib/settings';
6
+ import { execSync } from 'node:child_process';
7
+
8
+ const HELP_DIR = join(getConfigDir(), 'help');
9
+ const SOURCE_HELP_DIR = join(process.cwd(), 'lib', 'help-docs');
10
+
11
+ /** Ensure help docs are copied to ~/.forge/help/ */
12
+ function ensureHelpDocs() {
13
+ if (!existsSync(HELP_DIR)) mkdirSync(HELP_DIR, { recursive: true });
14
+ if (existsSync(SOURCE_HELP_DIR)) {
15
+ for (const file of readdirSync(SOURCE_HELP_DIR)) {
16
+ if (!file.endsWith('.md')) continue;
17
+ const src = join(SOURCE_HELP_DIR, file);
18
+ const dest = join(HELP_DIR, file);
19
+ // Always overwrite to keep docs up to date
20
+ writeFileSync(dest, readFileSync(src));
21
+ }
22
+ }
23
+ }
24
+
25
+ /** Check if any agent CLI is available */
26
+ function detectAgent(): { name: string; path: string } | null {
27
+ const settings = loadSettings();
28
+ if (settings.claudePath) {
29
+ try {
30
+ execSync(`"${settings.claudePath}" --version`, { timeout: 5000, stdio: 'pipe' });
31
+ return { name: 'claude', path: settings.claudePath };
32
+ } catch {}
33
+ }
34
+ for (const agent of ['claude', 'codex', 'aider']) {
35
+ try {
36
+ const path = execSync(`which ${agent}`, { encoding: 'utf-8', timeout: 3000, stdio: 'pipe' }).trim();
37
+ if (path) return { name: agent, path };
38
+ } catch {}
39
+ }
40
+ return null;
41
+ }
42
+
43
+ // GET /api/help
44
+ export async function GET(req: Request) {
45
+ const { searchParams } = new URL(req.url);
46
+ const action = searchParams.get('action') || 'status';
47
+
48
+ if (action === 'status') {
49
+ const agent = detectAgent();
50
+ ensureHelpDocs();
51
+ const docs = existsSync(HELP_DIR)
52
+ ? readdirSync(HELP_DIR).filter(f => f.endsWith('.md')).sort()
53
+ : [];
54
+ return NextResponse.json({ agent, docsCount: docs.length, helpDir: HELP_DIR });
55
+ }
56
+
57
+ if (action === 'docs') {
58
+ ensureHelpDocs();
59
+ const docs = existsSync(HELP_DIR)
60
+ ? readdirSync(HELP_DIR).filter(f => f.endsWith('.md')).sort().map(f => ({
61
+ name: f,
62
+ title: f.replace(/^\d+-/, '').replace(/\.md$/, '').replace(/-/g, ' '),
63
+ }))
64
+ : [];
65
+ return NextResponse.json({ docs });
66
+ }
67
+
68
+ if (action === 'doc') {
69
+ const name = searchParams.get('name');
70
+ if (!name) return NextResponse.json({ error: 'name required' }, { status: 400 });
71
+ ensureHelpDocs();
72
+ const file = join(HELP_DIR, name);
73
+ if (!existsSync(file)) return NextResponse.json({ error: 'Not found' }, { status: 404 });
74
+ return NextResponse.json({ content: readFileSync(file, 'utf-8') });
75
+ }
76
+
77
+ return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
78
+ }
@@ -0,0 +1,100 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { existsSync, statSync, openSync, readSync, closeSync, writeFileSync, renameSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { getDataDir } from '@/lib/dirs';
5
+ import { execSync } from 'node:child_process';
6
+
7
+ const LOG_FILE = join(getDataDir(), 'forge.log');
8
+ const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5MB — auto-rotate above this
9
+
10
+ /** Read last N bytes from file (efficient tail) */
11
+ function tailFile(filePath: string, maxBytes: number): string {
12
+ const stat = statSync(filePath);
13
+ const size = stat.size;
14
+ const readSize = Math.min(size, maxBytes);
15
+ const buf = Buffer.alloc(readSize);
16
+ const fd = openSync(filePath, 'r');
17
+ readSync(fd, buf, 0, readSize, size - readSize);
18
+ closeSync(fd);
19
+ // Skip partial first line
20
+ const str = buf.toString('utf-8');
21
+ const firstNewline = str.indexOf('\n');
22
+ return firstNewline > 0 ? str.slice(firstNewline + 1) : str;
23
+ }
24
+
25
+ /** Rotate log if too large: forge.log → forge.log.old, start fresh */
26
+ function rotateIfNeeded() {
27
+ if (!existsSync(LOG_FILE)) return;
28
+ const stat = statSync(LOG_FILE);
29
+ if (stat.size > MAX_LOG_SIZE) {
30
+ const oldFile = LOG_FILE + '.old';
31
+ try { renameSync(LOG_FILE, oldFile); } catch {}
32
+ writeFileSync(LOG_FILE, `[forge] Log rotated at ${new Date().toISOString()} (previous: ${(stat.size / 1024 / 1024).toFixed(1)}MB)\n`, 'utf-8');
33
+ }
34
+ }
35
+
36
+ // GET /api/logs?lines=200&search=keyword
37
+ export async function GET(req: Request) {
38
+ const { searchParams } = new URL(req.url);
39
+ const lines = Math.min(parseInt(searchParams.get('lines') || '200'), 1000);
40
+ const search = searchParams.get('search') || '';
41
+
42
+ rotateIfNeeded();
43
+
44
+ if (!existsSync(LOG_FILE)) {
45
+ return NextResponse.json({ lines: [], total: 0, size: 0 });
46
+ }
47
+
48
+ try {
49
+ const stat = statSync(LOG_FILE);
50
+ // Read last 512KB max (enough for ~5000 lines)
51
+ const raw = tailFile(LOG_FILE, 512 * 1024);
52
+ let allLines = raw.split('\n').filter(Boolean);
53
+
54
+ if (search) {
55
+ allLines = allLines.filter(l => l.toLowerCase().includes(search.toLowerCase()));
56
+ }
57
+
58
+ const result = allLines.slice(-lines);
59
+
60
+ return NextResponse.json({
61
+ lines: result,
62
+ total: allLines.length,
63
+ size: stat.size,
64
+ file: LOG_FILE,
65
+ });
66
+ } catch (e: any) {
67
+ return NextResponse.json({ error: e.message }, { status: 500 });
68
+ }
69
+ }
70
+
71
+ // POST /api/logs — actions
72
+ export async function POST(req: Request) {
73
+ const body = await req.json();
74
+
75
+ if (body.action === 'clear') {
76
+ try {
77
+ writeFileSync(LOG_FILE, `[forge] Log cleared at ${new Date().toISOString()}\n`, 'utf-8');
78
+ return NextResponse.json({ ok: true });
79
+ } catch (e: any) {
80
+ return NextResponse.json({ error: e.message }, { status: 500 });
81
+ }
82
+ }
83
+
84
+ if (body.action === 'processes') {
85
+ try {
86
+ const out = execSync("ps aux | grep -E 'next-server|telegram-standalone|terminal-standalone|cloudflared' | grep -v grep", {
87
+ encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
88
+ }).trim();
89
+ const processes = out.split('\n').filter(Boolean).map(line => {
90
+ const parts = line.trim().split(/\s+/);
91
+ return { pid: parts[1], cpu: parts[2], mem: parts[3], cmd: parts.slice(10).join(' ').slice(0, 80) };
92
+ });
93
+ return NextResponse.json({ processes });
94
+ } catch {
95
+ return NextResponse.json({ processes: [] });
96
+ }
97
+ }
98
+
99
+ return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
100
+ }
@@ -56,6 +56,8 @@ export async function PUT(req: Request) {
56
56
  // Remove internal fields
57
57
  delete (updated as any)._secretStatus;
58
58
 
59
+ const changed = Object.keys(updated).filter(k => JSON.stringify((updated as any)[k]) !== JSON.stringify((settings as any)[k]) && !['telegramTunnelPassword', 'telegramBotToken'].includes(k));
60
+ if (changed.length > 0) console.log(`[settings] Updated: ${changed.join(', ')}`);
59
61
  saveSettings(updated);
60
62
  restartTelegramBot();
61
63
  return NextResponse.json({ ok: true });
@@ -72,6 +72,15 @@ const LOG_FILE = join(DATA_DIR, 'forge.log');
72
72
 
73
73
  process.chdir(ROOT);
74
74
 
75
+ // ── Add timestamps to all console output ──
76
+ const origLog = console.log;
77
+ const origError = console.error;
78
+ const origWarn = console.warn;
79
+ const ts = () => new Date().toISOString().replace('T', ' ').slice(0, 19);
80
+ console.log = (...args) => origLog(`[${ts()}]`, ...args);
81
+ console.error = (...args) => origError(`[${ts()}]`, ...args);
82
+ console.warn = (...args) => origWarn(`[${ts()}]`, ...args);
83
+
75
84
  // ── Migrate old layout (~/.forge/*) to new (~/.forge/data/*) ──
76
85
  if (!getArg('--dir')) {
77
86
  const oldSettings = join(homedir(), '.forge', 'settings.yaml');
@@ -18,6 +18,8 @@ const CodeViewer = lazy(() => import('./CodeViewer'));
18
18
  const ProjectManager = lazy(() => import('./ProjectManager'));
19
19
  const PreviewPanel = lazy(() => import('./PreviewPanel'));
20
20
  const PipelineView = lazy(() => import('./PipelineView'));
21
+ const HelpDialog = lazy(() => import('./HelpDialog'));
22
+ const LogViewer = lazy(() => import('./LogViewer'));
21
23
  const SkillsPanel = lazy(() => import('./SkillsPanel'));
22
24
 
23
25
  interface UsageSummary {
@@ -41,12 +43,13 @@ interface ProjectInfo {
41
43
  }
42
44
 
43
45
  export default function Dashboard({ user }: { user: any }) {
44
- const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'preview' | 'pipelines' | 'skills'>('terminal');
46
+ const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'preview' | 'pipelines' | 'skills' | 'logs'>('terminal');
45
47
  const [tasks, setTasks] = useState<Task[]>([]);
46
48
  const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
47
49
  const [showNewTask, setShowNewTask] = useState(false);
48
50
  const [showSettings, setShowSettings] = useState(false);
49
51
  const [showMonitor, setShowMonitor] = useState(false);
52
+ const [showHelp, setShowHelp] = useState(false);
50
53
  const [usage, setUsage] = useState<UsageSummary[]>([]);
51
54
  const [providers, setProviders] = useState<ProviderInfo[]>([]);
52
55
  const [projects, setProjects] = useState<ProjectInfo[]>([]);
@@ -262,6 +265,15 @@ export default function Dashboard({ user }: { user: any }) {
262
265
  + New Task
263
266
  </button>
264
267
  )}
268
+ {/* Help */}
269
+ <button
270
+ onClick={() => setShowHelp(v => !v)}
271
+ className={`text-[10px] px-2 py-0.5 border rounded transition-colors ${
272
+ showHelp
273
+ ? 'border-[var(--accent)] text-[var(--accent)]'
274
+ : 'border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)]'
275
+ }`}
276
+ >?</button>
265
277
  {/* Preview + Tunnel */}
266
278
  <button
267
279
  onClick={() => setViewMode('preview')}
@@ -416,6 +428,12 @@ export default function Dashboard({ user }: { user: any }) {
416
428
  >
417
429
  Settings
418
430
  </button>
431
+ <button
432
+ onClick={() => { setViewMode('logs'); setShowUserMenu(false); }}
433
+ className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
434
+ >
435
+ Logs
436
+ </button>
419
437
  <div className="border-t border-[var(--border)] my-1" />
420
438
  <button
421
439
  onClick={() => signOut({ callbackUrl: '/login' })}
@@ -562,6 +580,13 @@ export default function Dashboard({ user }: { user: any }) {
562
580
  </Suspense>
563
581
  )}
564
582
 
583
+ {/* Logs */}
584
+ {viewMode === 'logs' && (
585
+ <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
586
+ <LogViewer />
587
+ </Suspense>
588
+ )}
589
+
565
590
  {/* Docs — always mounted to keep terminal session alive */}
566
591
  <div className={viewMode === 'docs' ? 'flex-1 min-h-0 flex' : 'hidden'}>
567
592
  <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
@@ -597,6 +622,11 @@ export default function Dashboard({ user }: { user: any }) {
597
622
  {showSettings && (
598
623
  <SettingsModal onClose={() => { setShowSettings(false); fetchData(); refreshDisplayName(); }} />
599
624
  )}
625
+ {showHelp && (
626
+ <Suspense fallback={null}>
627
+ <HelpDialog onClose={() => setShowHelp(false)} />
628
+ </Suspense>
629
+ )}
600
630
  </div>
601
631
  );
602
632
  }
@@ -0,0 +1,169 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef, lazy, Suspense } from 'react';
4
+
5
+ interface DocItem {
6
+ name: string;
7
+ title: string;
8
+ }
9
+
10
+ const HelpTerminal = lazy(() => import('./HelpTerminal'));
11
+
12
+ export default function HelpDialog({ onClose }: { onClose: () => void }) {
13
+ const [docs, setDocs] = useState<DocItem[]>([]);
14
+ const [agent, setAgent] = useState<{ name: string } | null | undefined>(undefined); // undefined = loading
15
+ const [viewDoc, setViewDoc] = useState<string | null>(null);
16
+ const [docContent, setDocContent] = useState('');
17
+ const [search, setSearch] = useState('');
18
+ const [tab, setTab] = useState<'docs' | 'chat'>('docs');
19
+ const [position, setPosition] = useState({ x: Math.max(0, window.innerWidth - 520), y: 50 });
20
+ const [size, setSize] = useState({ w: 500, h: 560 });
21
+ const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
22
+ const resizeRef = useRef<{ startX: number; startY: number; origW: number; origH: number } | null>(null);
23
+
24
+ useEffect(() => {
25
+ fetch('/api/help?action=status').then(r => r.json())
26
+ .then(data => setAgent(data.agent || null)).catch(() => setAgent(null));
27
+ fetch('/api/help?action=docs').then(r => r.json())
28
+ .then(data => setDocs(data.docs || [])).catch(() => {});
29
+ }, []);
30
+
31
+ const loadDoc = async (name: string) => {
32
+ setViewDoc(name);
33
+ try {
34
+ const res = await fetch(`/api/help?action=doc&name=${encodeURIComponent(name)}`);
35
+ const data = await res.json();
36
+ setDocContent(data.content || '');
37
+ } catch { setDocContent('Failed to load'); }
38
+ };
39
+
40
+ // Drag
41
+ const onDragStart = (e: React.MouseEvent) => {
42
+ e.preventDefault();
43
+ dragRef.current = { startX: e.clientX, startY: e.clientY, origX: position.x, origY: position.y };
44
+ const onMove = (ev: MouseEvent) => {
45
+ if (!dragRef.current) return;
46
+ setPosition({
47
+ x: Math.max(0, dragRef.current.origX + ev.clientX - dragRef.current.startX),
48
+ y: Math.max(0, dragRef.current.origY + ev.clientY - dragRef.current.startY),
49
+ });
50
+ };
51
+ const onUp = () => { dragRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
52
+ window.addEventListener('mousemove', onMove);
53
+ window.addEventListener('mouseup', onUp);
54
+ };
55
+
56
+ // Resize
57
+ const onResizeStart = (e: React.MouseEvent) => {
58
+ e.preventDefault();
59
+ e.stopPropagation();
60
+ resizeRef.current = { startX: e.clientX, startY: e.clientY, origW: size.w, origH: size.h };
61
+ const onMove = (ev: MouseEvent) => {
62
+ if (!resizeRef.current) return;
63
+ setSize({
64
+ w: Math.max(350, resizeRef.current.origW + ev.clientX - resizeRef.current.startX),
65
+ h: Math.max(300, resizeRef.current.origH + ev.clientY - resizeRef.current.startY),
66
+ });
67
+ };
68
+ const onUp = () => { resizeRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
69
+ window.addEventListener('mousemove', onMove);
70
+ window.addEventListener('mouseup', onUp);
71
+ };
72
+
73
+ const filtered = search ? docs.filter(d => d.title.toLowerCase().includes(search.toLowerCase())) : docs;
74
+
75
+ return (
76
+ <div
77
+ className="fixed z-50 bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-2xl flex flex-col overflow-hidden"
78
+ style={{ left: position.x, top: position.y, width: size.w, height: size.h }}
79
+ >
80
+ {/* Title bar */}
81
+ <div
82
+ className="flex items-center gap-2 px-3 py-2 bg-[var(--bg-tertiary)] border-b border-[var(--border)] cursor-move shrink-0 select-none"
83
+ onMouseDown={onDragStart}
84
+ >
85
+ <span className="text-[11px] font-semibold text-[var(--text-primary)]">Forge Help</span>
86
+ <div className="ml-auto flex items-center gap-1">
87
+ <button
88
+ onClick={() => { setTab('docs'); setViewDoc(null); }}
89
+ className={`text-[9px] px-2 py-0.5 rounded ${tab === 'docs' ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'text-[var(--text-secondary)]'}`}
90
+ >Docs</button>
91
+ {agent && (
92
+ <button
93
+ onClick={() => setTab('chat')}
94
+ className={`text-[9px] px-2 py-0.5 rounded ${tab === 'chat' ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'text-[var(--text-secondary)]'}`}
95
+ >AI Chat</button>
96
+ )}
97
+ <button onClick={onClose} className="text-[var(--text-secondary)] hover:text-[var(--red)] ml-1 text-sm leading-none">✕</button>
98
+ </div>
99
+ </div>
100
+
101
+ {tab === 'chat' ? (
102
+ /* Embedded terminal */
103
+ <div className="flex-1 min-h-0">
104
+ <Suspense fallback={<div className="flex-1 flex items-center justify-center text-xs text-[var(--text-secondary)]">Loading terminal...</div>}>
105
+ <HelpTerminal />
106
+ </Suspense>
107
+ </div>
108
+ ) : viewDoc ? (
109
+ /* Doc view */
110
+ <>
111
+ <div className="px-3 py-1.5 border-b border-[var(--border)] flex items-center gap-2 shrink-0">
112
+ <button onClick={() => setViewDoc(null)} className="text-[10px] text-[var(--accent)]">← Back</button>
113
+ <span className="text-[10px] text-[var(--text-primary)] font-semibold truncate">
114
+ {docs.find(d => d.name === viewDoc)?.title || viewDoc}
115
+ </span>
116
+ </div>
117
+ <div className="flex-1 overflow-y-auto p-3">
118
+ <pre className="text-[11px] text-[var(--text-primary)] whitespace-pre-wrap break-words font-mono leading-relaxed">
119
+ {docContent}
120
+ </pre>
121
+ </div>
122
+ </>
123
+ ) : (
124
+ /* Doc list */
125
+ <>
126
+ <div className="px-3 py-2 border-b border-[var(--border)] shrink-0">
127
+ <input
128
+ type="text"
129
+ value={search}
130
+ onChange={e => setSearch(e.target.value)}
131
+ placeholder="Search help topics..."
132
+ className="w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
133
+ autoFocus
134
+ />
135
+ </div>
136
+ {!agent && agent !== undefined && (
137
+ <div className="px-3 py-2 bg-[var(--yellow)]/10 border-b border-[var(--border)] shrink-0">
138
+ <p className="text-[9px] text-[var(--text-secondary)]">
139
+ Install Claude Code for AI help: <code className="text-[var(--accent)]">npm i -g @anthropic-ai/claude-code</code>
140
+ </p>
141
+ </div>
142
+ )}
143
+ <div className="flex-1 overflow-y-auto">
144
+ {filtered.map(doc => (
145
+ <button
146
+ key={doc.name}
147
+ onClick={() => loadDoc(doc.name)}
148
+ className="w-full text-left px-3 py-2.5 border-b border-[var(--border)]/30 hover:bg-[var(--bg-tertiary)] text-[11px] text-[var(--text-primary)] capitalize"
149
+ >
150
+ {doc.title}
151
+ </button>
152
+ ))}
153
+ </div>
154
+ <div className="px-3 py-2 border-t border-[var(--border)] shrink-0">
155
+ <a href="https://github.com/aiwatching/forge" target="_blank" rel="noopener noreferrer"
156
+ className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--accent)]">GitHub →</a>
157
+ </div>
158
+ </>
159
+ )}
160
+
161
+ {/* Resize handle */}
162
+ <div
163
+ onMouseDown={onResizeStart}
164
+ className="absolute bottom-0 right-0 w-4 h-4 cursor-se-resize"
165
+ style={{ background: 'linear-gradient(135deg, transparent 50%, var(--border) 50%)' }}
166
+ />
167
+ </div>
168
+ );
169
+ }
@@ -0,0 +1,130 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { Terminal } from '@xterm/xterm';
5
+ import { FitAddon } from '@xterm/addon-fit';
6
+ import '@xterm/xterm/css/xterm.css';
7
+
8
+ const SESSION_NAME = 'mw-forge-help';
9
+
10
+ function getWsUrl() {
11
+ if (typeof window === 'undefined') return `ws://localhost:${parseInt(process.env.TERMINAL_PORT || '3001')}`;
12
+ const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
13
+ const wsHost = window.location.hostname;
14
+ if (wsHost !== 'localhost' && wsHost !== '127.0.0.1') {
15
+ return `${wsProtocol}//${window.location.host}/terminal-ws`;
16
+ }
17
+ const webPort = parseInt(window.location.port) || 3000;
18
+ return `${wsProtocol}//${wsHost}:${webPort + 1}`;
19
+ }
20
+
21
+ export default function HelpTerminal() {
22
+ const containerRef = useRef<HTMLDivElement>(null);
23
+ const [connected, setConnected] = useState(false);
24
+
25
+ useEffect(() => {
26
+ if (!containerRef.current) return;
27
+
28
+ let disposed = false;
29
+ const cs = getComputedStyle(document.documentElement);
30
+ const tv = (name: string) => cs.getPropertyValue(name).trim();
31
+ const term = new Terminal({
32
+ cursorBlink: true,
33
+ fontSize: 12,
34
+ fontFamily: 'Menlo, Monaco, "Courier New", monospace',
35
+ scrollback: 3000,
36
+ logger: { trace: () => {}, debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
37
+ theme: {
38
+ background: tv('--term-bg') || '#1a1a2e',
39
+ foreground: tv('--term-fg') || '#e0e0e0',
40
+ cursor: tv('--term-cursor') || '#7c5bf0',
41
+ selectionBackground: (tv('--term-cursor') || '#7c5bf0') + '44',
42
+ },
43
+ });
44
+ const fit = new FitAddon();
45
+ term.loadAddon(fit);
46
+ term.open(containerRef.current);
47
+ try { fit.fit(); } catch {}
48
+
49
+ const wsUrl = getWsUrl();
50
+ let ws: WebSocket | null = null;
51
+ let reconnectTimer = 0;
52
+ let isNewSession = false;
53
+
54
+ function connect() {
55
+ if (disposed) return;
56
+ const socket = new WebSocket(wsUrl);
57
+ ws = socket;
58
+
59
+ socket.onopen = () => {
60
+ if (disposed) { socket.close(); return; }
61
+ socket.send(JSON.stringify({ type: 'attach', sessionName: SESSION_NAME, cols: term.cols, rows: term.rows }));
62
+ };
63
+
64
+ socket.onmessage = (event) => {
65
+ if (disposed) return;
66
+ try {
67
+ const msg = JSON.parse(event.data);
68
+ if (msg.type === 'output') {
69
+ try { term.write(msg.data); } catch {}
70
+ } else if (msg.type === 'connected') {
71
+ setConnected(true);
72
+ if (isNewSession) {
73
+ isNewSession = false;
74
+ setTimeout(() => {
75
+ if (socket.readyState === WebSocket.OPEN) {
76
+ socket.send(JSON.stringify({ type: 'input', data: `cd ~/.forge/help 2>/dev/null && claude\n` }));
77
+ }
78
+ }, 300);
79
+ }
80
+ } else if (msg.type === 'error') {
81
+ isNewSession = true;
82
+ if (socket.readyState === WebSocket.OPEN) {
83
+ socket.send(JSON.stringify({ type: 'create', cols: term.cols, rows: term.rows, sessionName: SESSION_NAME }));
84
+ }
85
+ }
86
+ } catch {}
87
+ };
88
+
89
+ socket.onclose = () => {
90
+ if (disposed) return;
91
+ setConnected(false);
92
+ reconnectTimer = window.setTimeout(connect, 3000);
93
+ };
94
+ socket.onerror = () => {};
95
+ }
96
+
97
+ connect();
98
+
99
+ term.onData((data) => {
100
+ if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data }));
101
+ });
102
+
103
+ const resizeObserver = new ResizeObserver(() => {
104
+ const el = containerRef.current;
105
+ if (!el || el.offsetWidth < 50 || el.offsetHeight < 30) return;
106
+ try {
107
+ fit.fit();
108
+ if (term.cols < 2 || term.rows < 2) return;
109
+ if (ws?.readyState === WebSocket.OPEN) {
110
+ ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
111
+ }
112
+ } catch {}
113
+ });
114
+ resizeObserver.observe(containerRef.current);
115
+
116
+ return () => {
117
+ disposed = true;
118
+ clearTimeout(reconnectTimer);
119
+ ws?.close();
120
+ resizeObserver.disconnect();
121
+ term.dispose();
122
+ };
123
+ }, []);
124
+
125
+ return (
126
+ <div className="h-full flex flex-col">
127
+ <div ref={containerRef} className="flex-1" />
128
+ </div>
129
+ );
130
+ }