@aion0/forge 0.2.16 → 0.2.18

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.
@@ -1,5 +1,5 @@
1
1
  import { NextResponse } from 'next/server';
2
- import { readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { join, relative, extname } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { execSync } from 'node:child_process';
@@ -221,3 +221,39 @@ export async function GET(req: Request) {
221
221
 
222
222
  return NextResponse.json({ tree, dirName, dirPath: resolvedDir, gitBranch, gitChanges, gitRepos });
223
223
  }
224
+
225
+ // PUT /api/code — save file content
226
+ export async function PUT(req: Request) {
227
+ const { dir, file, content } = await req.json() as {
228
+ dir: string;
229
+ file: string;
230
+ content: string;
231
+ };
232
+
233
+ if (!dir || !file) {
234
+ return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
235
+ }
236
+
237
+ const resolvedDir = dir.replace(/^~/, homedir());
238
+ const fullPath = join(resolvedDir, file);
239
+
240
+ // Security: ensure path is within the directory
241
+ if (!fullPath.startsWith(resolvedDir)) {
242
+ return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
243
+ }
244
+
245
+ // Verify dir is under a configured project root
246
+ const settings = loadSettings();
247
+ const roots = (settings.projectRoots || []).map(r => r.replace(/^~/, homedir()));
248
+ const allowed = roots.some(r => resolvedDir.startsWith(r));
249
+ if (!allowed) {
250
+ return NextResponse.json({ error: 'Directory not in project roots' }, { status: 403 });
251
+ }
252
+
253
+ try {
254
+ writeFileSync(fullPath, content, 'utf-8');
255
+ return NextResponse.json({ ok: true });
256
+ } catch {
257
+ return NextResponse.json({ error: 'Failed to save' }, { status: 500 });
258
+ }
259
+ }
@@ -0,0 +1,46 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { execSync } from 'node:child_process';
3
+ import { platform } from 'node:os';
4
+
5
+ interface CliInfo {
6
+ name: string;
7
+ path: string;
8
+ version: string;
9
+ installHint: string;
10
+ }
11
+
12
+ function detect(name: string, installHint: string): CliInfo {
13
+ try {
14
+ const path = execSync(`which ${name}`, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
15
+ let version = '';
16
+ try {
17
+ const out = execSync(`${path} --version`, { encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
18
+ // Extract version number from output (e.g. "@anthropic-ai/claude-code v1.2.3" or "codex 0.1.0")
19
+ const match = out.match(/v?(\d+\.\d+\.\d+)/);
20
+ version = match ? match[1] : out.slice(0, 50);
21
+ } catch {}
22
+ return { name, path, version, installHint };
23
+ } catch {
24
+ return { name, path: '', version: '', installHint };
25
+ }
26
+ }
27
+
28
+ export async function GET() {
29
+ const os = platform();
30
+ const isLinux = os === 'linux';
31
+ const isMac = os === 'darwin';
32
+
33
+ const results = [
34
+ detect('claude', isMac
35
+ ? 'npm install -g @anthropic-ai/claude-code'
36
+ : isLinux
37
+ ? 'npm install -g @anthropic-ai/claude-code'
38
+ : 'npm install -g @anthropic-ai/claude-code'),
39
+ detect('codex', 'npm install -g @openai/codex'),
40
+ detect('aider', isMac
41
+ ? 'brew install aider or pip install aider-chat'
42
+ : 'pip install aider-chat'),
43
+ ];
44
+
45
+ return NextResponse.json({ os, tools: results });
46
+ }
@@ -1,5 +1,5 @@
1
1
  import { NextResponse } from 'next/server';
2
- import { readdirSync, statSync, readFileSync } from 'node:fs';
2
+ import { readdirSync, statSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { join, relative, extname } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { loadSettings } from '@/lib/settings';
@@ -128,3 +128,34 @@ export async function GET(req: Request) {
128
128
 
129
129
  return NextResponse.json({ roots: rootNames, rootPaths: docRoots, tree });
130
130
  }
131
+
132
+ // PUT /api/docs — save file content
133
+ export async function PUT(req: Request) {
134
+ const { root: rootIdx, file: filePath, content } = await req.json() as {
135
+ root: number;
136
+ file: string;
137
+ content: string;
138
+ };
139
+
140
+ const settings = loadSettings();
141
+ const docRoots = (settings.docRoots || []).map(r => r.replace(/^~/, homedir()));
142
+
143
+ if (rootIdx >= docRoots.length || !filePath) {
144
+ return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
145
+ }
146
+
147
+ const root = docRoots[rootIdx];
148
+ const fullPath = join(root, filePath);
149
+
150
+ // Security: ensure path is within root
151
+ if (!fullPath.startsWith(root)) {
152
+ return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
153
+ }
154
+
155
+ try {
156
+ writeFileSync(fullPath, content, 'utf-8');
157
+ return NextResponse.json({ ok: true });
158
+ } catch (e) {
159
+ return NextResponse.json({ error: 'Failed to save' }, { status: 500 });
160
+ }
161
+ }
@@ -16,8 +16,16 @@ export async function POST() {
16
16
  });
17
17
  }
18
18
 
19
+ // Upgrade from npm
19
20
  execSync('cd /tmp && npm install -g @aion0/forge', { timeout: 120000 });
20
21
 
22
+ // Install devDependencies for build (npm -g doesn't install them)
23
+ const pkgRoot = execSync('npm root -g', { encoding: 'utf-8', timeout: 5000 }).trim();
24
+ const forgeRoot = join(pkgRoot, '@aion0', 'forge');
25
+ try {
26
+ execSync('npm install --include=dev', { cwd: forgeRoot, timeout: 120000 });
27
+ } catch {}
28
+
21
29
  return NextResponse.json({
22
30
  ok: true,
23
31
  message: 'Upgraded. Restart server to apply.',
@@ -16,8 +16,8 @@ const CURRENT_VERSION = (() => {
16
16
  let cachedLatest: { version: string; checkedAt: number } | null = null;
17
17
  const CACHE_TTL = 10 * 60 * 1000; // 10 minutes
18
18
 
19
- async function getLatestVersion(): Promise<string> {
20
- if (cachedLatest && Date.now() - cachedLatest.checkedAt < CACHE_TTL) {
19
+ async function getLatestVersion(force = false): Promise<string> {
20
+ if (!force && cachedLatest && Date.now() - cachedLatest.checkedAt < CACHE_TTL) {
21
21
  return cachedLatest.version;
22
22
  }
23
23
  try {
@@ -47,9 +47,11 @@ function compareVersions(a: string, b: string): number {
47
47
  return 0;
48
48
  }
49
49
 
50
- export async function GET() {
50
+ export async function GET(req: Request) {
51
+ const { searchParams } = new URL(req.url);
52
+ const force = searchParams.has('force');
51
53
  const current = CURRENT_VERSION;
52
- const latest = await getLatestVersion();
54
+ const latest = await getLatestVersion(force);
53
55
  const hasUpdate = latest && compareVersions(current, latest) < 0;
54
56
 
55
57
  return NextResponse.json({
@@ -28,6 +28,16 @@ import { homedir } from 'node:os';
28
28
  const __dirname = dirname(fileURLToPath(import.meta.url));
29
29
  const ROOT = join(__dirname, '..');
30
30
 
31
+ /** Build Next.js — install devDependencies first if missing */
32
+ function buildNext() {
33
+ // Check if devDependencies are installed (e.g. @tailwindcss/postcss)
34
+ if (!existsSync(join(ROOT, 'node_modules', '@tailwindcss', 'postcss'))) {
35
+ console.log('[forge] Installing dependencies...');
36
+ execSync('npm install --include=dev', { cwd: ROOT, stdio: 'inherit' });
37
+ }
38
+ execSync('npx next build', { cwd: ROOT, stdio: 'inherit', env: { ...process.env } });
39
+ }
40
+
31
41
  // ── Parse arguments ──
32
42
 
33
43
  function getArg(name) {
@@ -173,7 +183,7 @@ function stopServer() {
173
183
  function startBackground() {
174
184
  if (!existsSync(join(ROOT, '.next', 'BUILD_ID'))) {
175
185
  console.log('[forge] Building...');
176
- execSync('npx next build', { cwd: ROOT, stdio: 'inherit', env: { ...process.env } });
186
+ buildNext();
177
187
  }
178
188
 
179
189
  const logFd = openSync(LOG_FILE, 'a');
@@ -221,7 +231,7 @@ if (isRebuild || existsSync(join(ROOT, '.next', 'BUILD_ID'))) {
221
231
  if (isRebuild || lastBuiltVersion !== pkgVersion) {
222
232
  console.log(`[forge] Rebuilding (v${pkgVersion})...`);
223
233
  execSync('rm -rf .next', { cwd: ROOT });
224
- execSync('npx next build', { cwd: ROOT, stdio: 'inherit', env: { ...process.env } });
234
+ buildNext();
225
235
  writeFileSync(versionFile, pkgVersion);
226
236
  if (isRebuild) {
227
237
  console.log('[forge] Rebuild complete');
@@ -254,7 +264,7 @@ if (isDev) {
254
264
  } else {
255
265
  if (!existsSync(join(ROOT, '.next', 'BUILD_ID'))) {
256
266
  console.log('[forge] Building...');
257
- execSync('npx next build', { cwd: ROOT, stdio: 'inherit', env: { ...process.env } });
267
+ buildNext();
258
268
  }
259
269
  console.log(`[forge] Starting server (port ${webPort}, terminal ${terminalPort}, data ${DATA_DIR})`);
260
270
  startServices();
@@ -185,6 +185,9 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
185
185
  const [viewMode, setViewMode] = useState<'file' | 'diff'>('file');
186
186
  const [sidebarOpen, setSidebarOpen] = useState(true);
187
187
  const [codeOpen, setCodeOpen] = useState(false);
188
+ const [editing, setEditing] = useState(false);
189
+ const [editContent, setEditContent] = useState('');
190
+ const [saving, setSaving] = useState(false);
188
191
 
189
192
  const handleCodeOpenChange = useCallback((open: boolean) => {
190
193
  setCodeOpen(open);
@@ -626,7 +629,43 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
626
629
  <>
627
630
  <span className="text-xs font-semibold text-[var(--text-primary)] truncate">{selectedFile}</span>
628
631
  {language && (
629
- <span className="text-[9px] text-[var(--text-secondary)] ml-auto">{LANG_MAP[language] || language}</span>
632
+ <span className="text-[9px] text-[var(--text-secondary)] ml-auto mr-2">{LANG_MAP[language] || language}</span>
633
+ )}
634
+ {content !== null && !editing && (
635
+ <button
636
+ onClick={() => { setEditing(true); setEditContent(content); }}
637
+ className="text-[9px] px-2 py-0.5 border border-[var(--border)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)] shrink-0 ml-auto"
638
+ >
639
+ Edit
640
+ </button>
641
+ )}
642
+ {editing && (
643
+ <>
644
+ <button
645
+ disabled={saving}
646
+ onClick={async () => {
647
+ if (!currentDir || !selectedFile) return;
648
+ setSaving(true);
649
+ await fetch('/api/code', {
650
+ method: 'PUT',
651
+ headers: { 'Content-Type': 'application/json' },
652
+ body: JSON.stringify({ dir: currentDir, file: selectedFile, content: editContent }),
653
+ });
654
+ setContent(editContent);
655
+ setEditing(false);
656
+ setSaving(false);
657
+ }}
658
+ className="text-[9px] px-2 py-0.5 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50 shrink-0 ml-auto"
659
+ >
660
+ {saving ? 'Saving...' : 'Save'}
661
+ </button>
662
+ <button
663
+ onClick={() => setEditing(false)}
664
+ className="text-[9px] px-2 py-0.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] shrink-0"
665
+ >
666
+ Cancel
667
+ </button>
668
+ </>
630
669
  )}
631
670
  </>
632
671
  ) : (
@@ -688,6 +727,28 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
688
727
  </pre>
689
728
  </div>
690
729
  ) : selectedFile && content !== null ? (
730
+ editing ? (
731
+ <div className="flex-1 overflow-hidden flex flex-col">
732
+ <textarea
733
+ value={editContent}
734
+ onChange={e => setEditContent(e.target.value)}
735
+ onKeyDown={e => {
736
+ // Tab key inserts 2 spaces
737
+ if (e.key === 'Tab') {
738
+ e.preventDefault();
739
+ const ta = e.target as HTMLTextAreaElement;
740
+ const start = ta.selectionStart;
741
+ const end = ta.selectionEnd;
742
+ setEditContent(editContent.slice(0, start) + ' ' + editContent.slice(end));
743
+ setTimeout(() => { ta.selectionStart = ta.selectionEnd = start + 2; }, 0);
744
+ }
745
+ }}
746
+ className="flex-1 w-full p-4 bg-[var(--bg-primary)] text-[var(--text-primary)] text-[12px] leading-[1.5] font-mono resize-none focus:outline-none"
747
+ style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace', tabSize: 2 }}
748
+ spellCheck={false}
749
+ />
750
+ </div>
751
+ ) : (
691
752
  <div className="flex-1 overflow-auto bg-[var(--bg-primary)]">
692
753
  <pre className="p-4 text-[12px] leading-[1.5] font-mono text-[var(--text-primary)] whitespace-pre" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace', tabSize: 2, overflow: 'auto', maxWidth: 0, minWidth: '100%' }}>
693
754
  {content.split('\n').map((line, i) => (
@@ -698,6 +759,7 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
698
759
  ))}
699
760
  </pre>
700
761
  </div>
762
+ )
701
763
  ) : (
702
764
  <div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
703
765
  <p className="text-xs">{currentDir ? 'Select a file to view' : 'Terminal will show files for its working directory'}</p>
@@ -8,7 +8,6 @@ import SessionView from './SessionView';
8
8
  import NewTaskModal from './NewTaskModal';
9
9
  import SettingsModal from './SettingsModal';
10
10
  import TunnelToggle from './TunnelToggle';
11
- import MonitorPanel from './MonitorPanel';
12
11
  import type { Task } from '@/src/types';
13
12
  import type { WebTerminalHandle } from './WebTerminal';
14
13
 
@@ -45,7 +44,6 @@ export default function Dashboard({ user }: { user: any }) {
45
44
  const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
46
45
  const [showNewTask, setShowNewTask] = useState(false);
47
46
  const [showSettings, setShowSettings] = useState(false);
48
- const [showMonitor, setShowMonitor] = useState(false);
49
47
  const [usage, setUsage] = useState<UsageSummary[]>([]);
50
48
  const [providers, setProviders] = useState<ProviderInfo[]>([]);
51
49
  const [projects, setProjects] = useState<ProjectInfo[]>([]);
@@ -129,6 +127,19 @@ export default function Dashboard({ user }: { user: any }) {
129
127
  {upgrading ? 'Upgrading...' : `Update v${versionInfo.latest}`}
130
128
  </button>
131
129
  )}
130
+ {!versionInfo.hasUpdate && !upgradeResult && (
131
+ <button
132
+ onClick={async () => {
133
+ const res = await fetch('/api/version?force=1');
134
+ const data = await res.json();
135
+ setVersionInfo(data);
136
+ }}
137
+ className="text-[9px] px-1.5 py-0.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
138
+ title="Check for updates"
139
+ >
140
+
141
+ </button>
142
+ )}
132
143
  {upgradeResult && (
133
144
  <span className="text-[9px] text-[var(--green)] max-w-[200px] truncate" title={upgradeResult}>
134
145
  {upgradeResult}
@@ -189,16 +200,6 @@ export default function Dashboard({ user }: { user: any }) {
189
200
  >
190
201
  Pipelines
191
202
  </button>
192
- <button
193
- onClick={() => setViewMode('sessions')}
194
- className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
195
- viewMode === 'sessions'
196
- ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
197
- : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
198
- }`}
199
- >
200
- Sessions
201
- </button>
202
203
  <button
203
204
  onClick={() => setViewMode('preview')}
204
205
  className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
@@ -237,10 +238,10 @@ export default function Dashboard({ user }: { user: any }) {
237
238
  </span>
238
239
  )}
239
240
  <button
240
- onClick={() => setShowMonitor(true)}
241
- className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
241
+ onClick={() => setViewMode('sessions')}
242
+ className={`text-xs ${viewMode === 'sessions' ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
242
243
  >
243
- Monitor
244
+ System Status
244
245
  </button>
245
246
  <button
246
247
  onClick={() => setShowSettings(true)}
@@ -413,8 +414,6 @@ export default function Dashboard({ user }: { user: any }) {
413
414
  />
414
415
  )}
415
416
 
416
- {showMonitor && <MonitorPanel onClose={() => setShowMonitor(false)} />}
417
-
418
417
  {showSettings && (
419
418
  <SettingsModal onClose={() => { setShowSettings(false); fetchData(); }} />
420
419
  )}
@@ -84,6 +84,9 @@ export default function DocsViewer() {
84
84
  const [search, setSearch] = useState('');
85
85
  const [terminalHeight, setTerminalHeight] = useState(250);
86
86
  const [sidebarOpen, setSidebarOpen] = useState(true);
87
+ const [editing, setEditing] = useState(false);
88
+ const [editContent, setEditContent] = useState('');
89
+ const [saving, setSaving] = useState(false);
87
90
  const dragRef = useRef<{ startY: number; startH: number } | null>(null);
88
91
 
89
92
  // Fetch tree
@@ -254,7 +257,42 @@ export default function DocsViewer() {
254
257
  {selectedFile ? (
255
258
  <>
256
259
  <span className="text-xs font-semibold text-[var(--text-primary)] truncate">{selectedFile.replace(/\.md$/, '')}</span>
257
- <span className="text-[9px] text-[var(--text-secondary)] ml-auto">{selectedFile}</span>
260
+ <span className="text-[9px] text-[var(--text-secondary)] ml-auto mr-2">{selectedFile}</span>
261
+ {content && !isImageFile(selectedFile) && !editing && (
262
+ <button
263
+ onClick={() => { setEditing(true); setEditContent(content); }}
264
+ className="text-[9px] px-2 py-0.5 border border-[var(--border)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)] shrink-0"
265
+ >
266
+ Edit
267
+ </button>
268
+ )}
269
+ {editing && (
270
+ <>
271
+ <button
272
+ disabled={saving}
273
+ onClick={async () => {
274
+ setSaving(true);
275
+ await fetch('/api/docs', {
276
+ method: 'PUT',
277
+ headers: { 'Content-Type': 'application/json' },
278
+ body: JSON.stringify({ root: activeRoot, file: selectedFile, content: editContent }),
279
+ });
280
+ setContent(editContent);
281
+ setEditing(false);
282
+ setSaving(false);
283
+ }}
284
+ className="text-[9px] px-2 py-0.5 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50 shrink-0"
285
+ >
286
+ {saving ? 'Saving...' : 'Save'}
287
+ </button>
288
+ <button
289
+ onClick={() => setEditing(false)}
290
+ className="text-[9px] px-2 py-0.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] shrink-0"
291
+ >
292
+ Cancel
293
+ </button>
294
+ </>
295
+ )}
258
296
  </>
259
297
  ) : (
260
298
  <span className="text-xs text-[var(--text-secondary)]">{roots[activeRoot] || 'Docs'}</span>
@@ -278,6 +316,17 @@ export default function DocsViewer() {
278
316
  />
279
317
  </div>
280
318
  ) : selectedFile && content ? (
319
+ editing ? (
320
+ <div className="flex-1 overflow-hidden flex flex-col">
321
+ <textarea
322
+ value={editContent}
323
+ onChange={e => setEditContent(e.target.value)}
324
+ className="flex-1 w-full p-4 bg-[var(--bg-primary)] text-[var(--text-primary)] text-[13px] font-mono leading-relaxed resize-none focus:outline-none"
325
+ style={{ tabSize: 2 }}
326
+ spellCheck={false}
327
+ />
328
+ </div>
329
+ ) : (
281
330
  <div className="flex-1 overflow-y-auto px-8 py-6">
282
331
  {loading ? (
283
332
  <div className="text-xs text-[var(--text-secondary)]">Loading...</div>
@@ -287,6 +336,7 @@ export default function DocsViewer() {
287
336
  </div>
288
337
  )}
289
338
  </div>
339
+ )
290
340
  ) : (
291
341
  <div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
292
342
  <p className="text-xs">Select a document to view</p>
@@ -32,6 +32,17 @@ interface Watcher {
32
32
  createdAt: string;
33
33
  }
34
34
 
35
+ interface MonitorData {
36
+ processes: {
37
+ nextjs: { running: boolean; pid: string };
38
+ terminal: { running: boolean; pid: string };
39
+ telegram: { running: boolean; pid: string };
40
+ tunnel: { running: boolean; pid: string; url: string };
41
+ };
42
+ sessions: { name: string; created: string; attached: boolean; windows: number }[];
43
+ uptime: string;
44
+ }
45
+
35
46
  export default function SessionView({
36
47
  projectName,
37
48
  projects,
@@ -51,7 +62,9 @@ export default function SessionView({
51
62
  const [syncing, setSyncing] = useState(false);
52
63
  const [watchers, setWatchers] = useState<Watcher[]>([]);
53
64
  const [batchMode, setBatchMode] = useState(false);
54
- const [selectedIds, setSelectedIds] = useState<Map<string, Set<string>>>(new Map()); // project → sessionIds
65
+ const [selectedIds, setSelectedIds] = useState<Map<string, Set<string>>>(new Map());
66
+ const [monitor, setMonitor] = useState<MonitorData | null>(null);
67
+ const [monitorOpen, setMonitorOpen] = useState(true);
55
68
  const bottomRef = useRef<HTMLDivElement>(null);
56
69
 
57
70
  // Load cached sessions tree
@@ -79,10 +92,17 @@ export default function SessionView({
79
92
  } catch {}
80
93
  }, []);
81
94
 
95
+ const refreshMonitor = useCallback(() => {
96
+ fetch('/api/monitor').then(r => r.json()).then(setMonitor).catch(() => {});
97
+ }, []);
98
+
82
99
  useEffect(() => {
83
- loadTree(true); // Initial sync
100
+ loadTree(true);
84
101
  loadWatchers();
85
- }, [loadTree, loadWatchers]);
102
+ refreshMonitor();
103
+ const timer = setInterval(refreshMonitor, 5000);
104
+ return () => clearInterval(timer);
105
+ }, [loadTree, loadWatchers, refreshMonitor]);
86
106
 
87
107
  // Auto-expand project if only one or if pre-selected
88
108
  useEffect(() => {
@@ -328,6 +348,56 @@ export default function SessionView({
328
348
  </div>
329
349
  )}
330
350
 
351
+ {/* Monitor */}
352
+ {monitor && (
353
+ <div className="border-b border-[var(--border)]">
354
+ <button
355
+ onClick={() => setMonitorOpen(v => !v)}
356
+ className="w-full flex items-center gap-1.5 px-2 py-1.5 hover:bg-[var(--bg-tertiary)] transition-colors"
357
+ >
358
+ <span className="text-[10px] text-[var(--text-secondary)]">{monitorOpen ? '▼' : '▶'}</span>
359
+ <span className="text-[9px] font-semibold text-[var(--text-secondary)] uppercase">Monitor</span>
360
+ {monitor.uptime && (
361
+ <span className="text-[8px] text-[var(--text-secondary)] ml-auto">{monitor.uptime}</span>
362
+ )}
363
+ </button>
364
+ {monitorOpen && (
365
+ <div className="px-2 pb-2 space-y-1.5">
366
+ {/* Processes */}
367
+ {[
368
+ { label: 'Next.js', ...monitor.processes.nextjs },
369
+ { label: 'Terminal', ...monitor.processes.terminal },
370
+ { label: 'Telegram', ...monitor.processes.telegram },
371
+ { label: 'Tunnel', ...monitor.processes.tunnel },
372
+ ].map(p => (
373
+ <div key={p.label} className="flex items-center gap-1.5 text-[10px]">
374
+ <span className={p.running ? 'text-green-400' : 'text-gray-500'}>●</span>
375
+ <span className="text-[var(--text-primary)]">{p.label}</span>
376
+ <span className="text-[var(--text-secondary)] font-mono ml-auto">{p.running ? `pid:${p.pid}` : 'stopped'}</span>
377
+ </div>
378
+ ))}
379
+ {monitor.processes.tunnel.running && monitor.processes.tunnel.url && (
380
+ <div className="text-[9px] text-[var(--accent)] truncate pl-4">{monitor.processes.tunnel.url}</div>
381
+ )}
382
+
383
+ {/* Tmux sessions */}
384
+ {monitor.sessions.length > 0 && (
385
+ <div className="pt-1">
386
+ <span className="text-[8px] font-semibold text-[var(--text-secondary)] uppercase">Tmux ({monitor.sessions.length})</span>
387
+ {monitor.sessions.map(s => (
388
+ <div key={s.name} className="flex items-center gap-1.5 text-[10px] mt-0.5">
389
+ <span className={s.attached ? 'text-green-400' : 'text-yellow-500'}>●</span>
390
+ <span className="font-mono text-[var(--text-primary)] truncate flex-1">{s.name}</span>
391
+ <span className="text-[8px] text-[var(--text-secondary)]">{s.attached ? 'attached' : 'detached'}</span>
392
+ </div>
393
+ ))}
394
+ </div>
395
+ )}
396
+ </div>
397
+ )}
398
+ </div>
399
+ )}
400
+
331
401
  {/* Tree */}
332
402
  <div className="flex-1 overflow-y-auto">
333
403
  {Object.keys(sessionTree).length === 0 && (
@@ -410,15 +410,38 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
410
410
  <label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">
411
411
  Claude Code Path
412
412
  </label>
413
- <p className="text-[10px] text-[var(--text-secondary)]">
414
- Full path to the claude binary. Run `which claude` to find it.
413
+ <div className="flex gap-2">
414
+ <input
415
+ value={settings.claudePath}
416
+ onChange={e => setSettings({ ...settings, claudePath: e.target.value })}
417
+ placeholder="Auto-detect or enter path manually"
418
+ className="flex-1 px-2 py-1.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]"
419
+ />
420
+ <button
421
+ type="button"
422
+ onClick={async () => {
423
+ try {
424
+ const res = await fetch('/api/detect-cli');
425
+ const data = await res.json();
426
+ const claude = data.tools?.find((t: any) => t.name === 'claude');
427
+ if (claude?.path) {
428
+ setSettings({ ...settings, claudePath: claude.path });
429
+ } else {
430
+ const hint = claude?.installHint || 'npm install -g @anthropic-ai/claude-code';
431
+ alert(`Claude Code not found.\n\nInstall:\n ${hint}`);
432
+ }
433
+ } catch { alert('Detection failed'); }
434
+ }}
435
+ className="text-[10px] px-2 py-1.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white transition-colors shrink-0"
436
+ >
437
+ Detect
438
+ </button>
439
+ </div>
440
+ <p className={`text-[9px] ${settings.claudePath ? 'text-[var(--text-secondary)]' : 'text-[var(--yellow)]'}`}>
441
+ {settings.claudePath
442
+ ? 'Click Detect to re-scan, or edit manually.'
443
+ : 'Not configured. Click Detect or run `which claude` in terminal to find the path.'}
415
444
  </p>
416
- <input
417
- value={settings.claudePath}
418
- onChange={e => setSettings({ ...settings, claudePath: e.target.value })}
419
- placeholder="/usr/local/bin/claude"
420
- className="w-full px-2 py-1.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]"
421
- />
422
445
  </div>
423
446
 
424
447
  {/* Telegram Notifications */}
package/lib/init.ts CHANGED
@@ -45,6 +45,21 @@ function migrateSecrets() {
45
45
  }
46
46
  }
47
47
 
48
+ /** Auto-detect claude binary path if not configured */
49
+ function autoDetectClaude() {
50
+ try {
51
+ const settings = loadSettings();
52
+ if (settings.claudePath) return; // already configured
53
+ const { execSync } = require('node:child_process');
54
+ const path = execSync('which claude', { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
55
+ if (path) {
56
+ settings.claudePath = path;
57
+ saveSettings(settings);
58
+ console.log(`[init] Auto-detected claude: ${path}`);
59
+ }
60
+ } catch {}
61
+ }
62
+
48
63
  export function ensureInitialized() {
49
64
  if (gInit[initKey]) return;
50
65
  gInit[initKey] = true;
@@ -52,6 +67,9 @@ export function ensureInitialized() {
52
67
  // Migrate plaintext secrets on startup
53
68
  migrateSecrets();
54
69
 
70
+ // Auto-detect claude path if not configured
71
+ autoDetectClaude();
72
+
55
73
  // Task runner is safe in every worker (DB-level coordination)
56
74
  ensureRunnerStarted();
57
75
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.2.16",
3
+ "version": "0.2.18",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {