@aion0/forge 0.4.10 → 0.4.11

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,18 +1,13 @@
1
- # Forge v0.4.10
1
+ # Forge v0.4.11
2
2
 
3
- Released: 2026-03-22
3
+ Released: 2026-03-23
4
4
 
5
- ## Changes since v0.4.9
5
+ ## Changes since v0.4.10
6
6
 
7
- ### Features
8
- - feat: browser panel refactor, pane close UX, help AI data dir, terminal resume fix
7
+ ### Other
8
+ - mobile page
9
+ - init version for mobile page
10
+ - change sync period time
9
11
 
10
- ### Bug Fixes
11
- - fix: auto-retry failed issues, batch dedup, gh auth hint
12
- - feat: browser panel refactor, pane close UX, help AI data dir, terminal resume fix
13
12
 
14
- ### Refactoring
15
- - refactor: Browser as independent panel with float/left/right modes
16
-
17
-
18
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.4.9...v0.4.10
13
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.4.10...v0.4.11
@@ -0,0 +1,23 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { getSessionFilePath, readSessionEntries } from '@/lib/claude-sessions';
3
+ import { statSync } from 'node:fs';
4
+
5
+ export async function GET(req: Request, { params }: { params: Promise<{ projectName: string }> }) {
6
+ const { projectName } = await params;
7
+ const url = new URL(req.url);
8
+ const sessionId = url.searchParams.get('sessionId');
9
+
10
+ if (!sessionId) {
11
+ return NextResponse.json({ error: 'sessionId required' }, { status: 400 });
12
+ }
13
+
14
+ const filePath = getSessionFilePath(decodeURIComponent(projectName), sessionId);
15
+ if (!filePath) {
16
+ return NextResponse.json({ entries: [], count: 0, fileSize: 0 });
17
+ }
18
+
19
+ const entries = readSessionEntries(filePath);
20
+ let fileSize = 0;
21
+ try { fileSize = statSync(filePath).size; } catch {}
22
+ return NextResponse.json({ entries, count: entries.length, fileSize });
23
+ }
@@ -0,0 +1,87 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { spawn } from 'node:child_process';
3
+ import { loadSettings } from '@/lib/settings';
4
+
5
+ export const dynamic = 'force-dynamic';
6
+ export const runtime = 'nodejs';
7
+
8
+ // POST /api/mobile-chat — send a message to claude and stream response
9
+ export async function POST(req: Request) {
10
+ const { message, projectPath, resume } = await req.json() as {
11
+ message: string;
12
+ projectPath: string;
13
+ resume?: boolean;
14
+ };
15
+
16
+ if (!message || !projectPath) {
17
+ return NextResponse.json({ error: 'message and projectPath required' }, { status: 400 });
18
+ }
19
+
20
+ const settings = loadSettings();
21
+ const claudePath = settings.claudePath || 'claude';
22
+
23
+ const args = ['-p', '--dangerously-skip-permissions', '--output-format', 'json'];
24
+ if (resume) args.push('-c');
25
+
26
+ const child = spawn(claudePath, args, {
27
+ cwd: projectPath,
28
+ env: { ...process.env },
29
+ stdio: ['pipe', 'pipe', 'pipe'],
30
+ });
31
+
32
+ child.stdin.write(message);
33
+ child.stdin.end();
34
+
35
+ const encoder = new TextEncoder();
36
+ let closed = false;
37
+
38
+ const stream = new ReadableStream({
39
+ start(controller) {
40
+ child.stdout.on('data', (chunk: Buffer) => {
41
+ if (closed) return;
42
+ try {
43
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'chunk', text: chunk.toString() })}\n\n`));
44
+ } catch {}
45
+ });
46
+
47
+ child.stderr.on('data', (chunk: Buffer) => {
48
+ if (closed) return;
49
+ const text = chunk.toString();
50
+ if (text.includes('npm update') || text.includes('WARN')) return;
51
+ try {
52
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'stderr', text })}\n\n`));
53
+ } catch {}
54
+ });
55
+
56
+ child.on('exit', (code) => {
57
+ if (closed) return;
58
+ closed = true;
59
+ try {
60
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', code })}\n\n`));
61
+ controller.close();
62
+ } catch {}
63
+ });
64
+
65
+ child.on('error', (err) => {
66
+ if (closed) return;
67
+ closed = true;
68
+ try {
69
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'error', message: err.message })}\n\n`));
70
+ controller.close();
71
+ } catch {}
72
+ });
73
+ },
74
+ cancel() {
75
+ closed = true;
76
+ try { child.kill('SIGTERM'); } catch {}
77
+ },
78
+ });
79
+
80
+ return new Response(stream, {
81
+ headers: {
82
+ 'Content-Type': 'text/event-stream',
83
+ 'Cache-Control': 'no-cache, no-transform',
84
+ Connection: 'keep-alive',
85
+ },
86
+ });
87
+ }
@@ -0,0 +1,9 @@
1
+ import { auth } from '@/lib/auth';
2
+ import { redirect } from 'next/navigation';
3
+ import MobileView from '@/components/MobileView';
4
+
5
+ export default async function MobilePage() {
6
+ const session = await auth();
7
+ if (!session) redirect('/login');
8
+ return <MobileView />;
9
+ }
package/app/page.tsx CHANGED
@@ -1,9 +1,21 @@
1
1
  import { auth } from '@/lib/auth';
2
2
  import { redirect } from 'next/navigation';
3
+ import { headers } from 'next/headers';
3
4
  import Dashboard from '@/components/Dashboard';
4
5
 
5
- export default async function Home() {
6
+ export default async function Home({ searchParams }: { searchParams: Promise<{ force?: string }> }) {
6
7
  const session = await auth();
7
8
  if (!session) redirect('/login');
9
+
10
+ const params = await searchParams;
11
+
12
+ // Auto-detect mobile and redirect (skip if ?force=desktop)
13
+ if (params.force !== 'desktop') {
14
+ const headersList = await headers();
15
+ const ua = headersList.get('user-agent') || '';
16
+ const isMobile = /iPhone|iPad|iPod|Android|webOS|BlackBerry|IEMobile|Opera Mini/i.test(ua);
17
+ if (isMobile) redirect('/mobile');
18
+ }
19
+
8
20
  return <Dashboard user={session.user} />;
9
21
  }
@@ -29,10 +29,17 @@ export default function BrowserPanel({ onClose }: { onClose?: () => void }) {
29
29
  return () => clearInterval(timer);
30
30
  }, [fetchPreviews]);
31
31
 
32
+ const normalizeUrl = (val: string): string => {
33
+ if (/^\d+$/.test(val)) return `http://localhost:${val}`;
34
+ if (!/^https?:\/\//i.test(val)) return `http://${val}`;
35
+ return val;
36
+ };
37
+
32
38
  const navigate = (url: string) => {
33
- setBrowserUrl(url);
34
- localStorage.setItem('forge-browser-url', url);
35
- if (browserUrlRef.current) browserUrlRef.current.value = url;
39
+ const normalized = normalizeUrl(url);
40
+ setBrowserUrl(normalized);
41
+ localStorage.setItem('forge-browser-url', normalized);
42
+ if (browserUrlRef.current) browserUrlRef.current.value = normalized;
36
43
  setBrowserKey(k => k + 1);
37
44
  };
38
45
 
@@ -96,8 +103,7 @@ export default function BrowserPanel({ onClose }: { onClose?: () => void }) {
96
103
  if (e.key === 'Enter') {
97
104
  const val = (e.target as HTMLInputElement).value.trim();
98
105
  if (!val) return;
99
- const url = /^\d+$/.test(val) ? `http://localhost:${val}` : val;
100
- navigate(url);
106
+ navigate(val);
101
107
  }
102
108
  }}
103
109
  className="flex-1 bg-[var(--bg-secondary)] border border-[var(--border)] rounded px-2 py-0.5 text-[10px] text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] min-w-0"
@@ -558,6 +558,12 @@ export default function Dashboard({ user }: { user: any }) {
558
558
  >
559
559
  Logs
560
560
  </button>
561
+ <a
562
+ href="/mobile"
563
+ className="block 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)]"
564
+ >
565
+ Mobile View
566
+ </a>
561
567
  <div className="border-t border-[var(--border)] my-1" />
562
568
  <button
563
569
  onClick={() => signOut({ callbackUrl: '/login' })}
@@ -0,0 +1,365 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef, useCallback } from 'react';
4
+
5
+ interface Project { name: string; path: string }
6
+ interface SessionInfo { sessionId: string; summary?: string; firstPrompt?: string; modified?: string }
7
+ interface ChatMessage { role: 'user' | 'assistant' | 'system'; content: string; timestamp: string }
8
+
9
+ export default function MobileView() {
10
+ const [projects, setProjects] = useState<Project[]>([]);
11
+ const [selectedProject, setSelectedProject] = useState<Project | null>(null);
12
+ const [sessions, setSessions] = useState<SessionInfo[]>([]);
13
+ const [showSessions, setShowSessions] = useState(false);
14
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
15
+ const [input, setInput] = useState('');
16
+ const [loading, setLoading] = useState(false);
17
+ const [tunnelUrl, setTunnelUrl] = useState<string | null>(null);
18
+ const [debug, setDebug] = useState<string[]>([]);
19
+ const [debugLevel, setDebugLevel] = useState<'off' | 'simple' | 'verbose'>('off');
20
+ const debugLevelRef = useRef<'off' | 'simple' | 'verbose'>('off');
21
+ const [hasSession, setHasSession] = useState(false);
22
+ const scrollRef = useRef<HTMLDivElement>(null);
23
+ const inputRef = useRef<HTMLInputElement>(null);
24
+ const abortRef = useRef<AbortController | null>(null);
25
+
26
+ // Fetch projects
27
+ useEffect(() => {
28
+ fetch('/api/projects').then(r => r.json())
29
+ .then(data => { if (Array.isArray(data)) setProjects(data); })
30
+ .catch(() => {});
31
+ fetch('/api/tunnel').then(r => r.json())
32
+ .then(data => { setTunnelUrl(data.url || null); })
33
+ .catch(() => {});
34
+ }, []);
35
+
36
+ // Auto-scroll
37
+ useEffect(() => {
38
+ if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
39
+ }, [messages]);
40
+
41
+ // Fetch sessions for project
42
+ const fetchSessions = useCallback(async (projectName: string) => {
43
+ try {
44
+ const res = await fetch(`/api/claude-sessions/${encodeURIComponent(projectName)}`);
45
+ const data = await res.json();
46
+ const list = Array.isArray(data) ? data : [];
47
+ setSessions(list);
48
+ setHasSession(list.length > 0);
49
+ return list;
50
+ } catch { setSessions([]); return []; }
51
+ }, []);
52
+
53
+ // Load session history
54
+ const loadHistory = useCallback(async (projectName: string, sessionId: string) => {
55
+ try {
56
+ const res = await fetch(`/api/claude-sessions/${encodeURIComponent(projectName)}/entries?sessionId=${encodeURIComponent(sessionId)}`);
57
+ const data = await res.json();
58
+ const entries = data.entries || [];
59
+ // Convert entries to chat messages (only user + assistant_text)
60
+ const chatMessages: ChatMessage[] = [];
61
+ for (const e of entries) {
62
+ if (e.type === 'user') {
63
+ chatMessages.push({ role: 'user', content: e.content, timestamp: e.timestamp || '' });
64
+ } else if (e.type === 'assistant_text') {
65
+ chatMessages.push({ role: 'assistant', content: e.content, timestamp: e.timestamp || '' });
66
+ }
67
+ }
68
+ setMessages(chatMessages);
69
+ } catch {}
70
+ }, []);
71
+
72
+ // Select project
73
+ const selectProject = useCallback(async (project: Project) => {
74
+ setSelectedProject(project);
75
+ setShowSessions(false);
76
+ setMessages([]);
77
+
78
+ const sessionList = await fetchSessions(project.name);
79
+ // Load last session history if exists
80
+ if (sessionList.length > 0) {
81
+ await loadHistory(project.name, sessionList[0].sessionId);
82
+ }
83
+ }, [fetchSessions, loadHistory]);
84
+
85
+ // View specific session
86
+ const viewSession = useCallback(async (sessionId: string) => {
87
+ if (!selectedProject) return;
88
+ setShowSessions(false);
89
+ setMessages([]);
90
+ await loadHistory(selectedProject.name, sessionId);
91
+ }, [selectedProject, loadHistory]);
92
+
93
+ // Send message
94
+ const sendMessage = async () => {
95
+ const text = input.trim();
96
+ if (!text || !selectedProject || loading) return;
97
+
98
+ // Add user message
99
+ setMessages(prev => [...prev, { role: 'user', content: text, timestamp: new Date().toISOString() }]);
100
+ setInput('');
101
+ setLoading(true);
102
+ setDebug(d => [...d.slice(-20), `Send: "${text.slice(0, 40)}"`]);
103
+ inputRef.current?.focus();
104
+
105
+ // Stream response from API
106
+ const abort = new AbortController();
107
+ abortRef.current = abort;
108
+ let assistantText = '';
109
+ const startTime = Date.now();
110
+
111
+ try {
112
+ const res = await fetch('/api/mobile-chat', {
113
+ method: 'POST',
114
+ headers: { 'Content-Type': 'application/json' },
115
+ body: JSON.stringify({
116
+ message: text,
117
+ projectPath: selectedProject.path,
118
+ resume: false,
119
+ }),
120
+ signal: abort.signal,
121
+ });
122
+
123
+ const reader = res.body?.getReader();
124
+ if (!reader) throw new Error('No reader');
125
+
126
+ const decoder = new TextDecoder();
127
+
128
+ // Add empty assistant message to fill in
129
+ setMessages(prev => [...prev, { role: 'assistant', content: '...', timestamp: new Date().toISOString() }]);
130
+
131
+ while (true) {
132
+ const { value, done } = await reader.read();
133
+ if (done) break;
134
+
135
+ const chunk = decoder.decode(value, { stream: true });
136
+ for (const line of chunk.split('\n')) {
137
+ if (!line.startsWith('data: ')) continue;
138
+ try {
139
+ const data = JSON.parse(line.slice(6));
140
+ if (data.type === 'chunk') {
141
+ assistantText += data.text;
142
+ if (debugLevelRef.current === 'verbose') {
143
+ // Show content preview in verbose mode
144
+ const preview = data.text.replace(/\n/g, '↵').slice(0, 80);
145
+ setDebug(d => [...d.slice(-50), `chunk: ${preview}`]);
146
+ }
147
+ } else if (data.type === 'stderr') {
148
+ if (debugLevelRef.current !== 'off') {
149
+ setDebug(d => [...d.slice(-50), `stderr: ${data.text.trim().slice(0, 100)}`]);
150
+ }
151
+ } else if (data.type === 'error') {
152
+ assistantText = `Error: ${data.message}`;
153
+ setDebug(d => [...d.slice(-50), `ERROR: ${data.message}`]);
154
+ } else if (data.type === 'done') {
155
+ if (debugLevelRef.current !== 'off') setDebug(d => [...d.slice(-50), `done: exit ${data.code}`]);
156
+ }
157
+ } catch {}
158
+ }
159
+
160
+ // Update assistant message with latest text
161
+ if (assistantText) {
162
+ let displayText = assistantText;
163
+ try {
164
+ const parsed = JSON.parse(assistantText);
165
+ if (parsed.result) displayText = parsed.result;
166
+ } catch {}
167
+ setMessages(prev => {
168
+ const updated = [...prev];
169
+ updated[updated.length - 1] = { role: 'assistant', content: displayText, timestamp: new Date().toISOString() };
170
+ return updated;
171
+ });
172
+ }
173
+ }
174
+
175
+ // Final parse
176
+ try {
177
+ const parsed = JSON.parse(assistantText);
178
+ const finalText = parsed.result || assistantText;
179
+ setMessages(prev => {
180
+ const updated = [...prev];
181
+ updated[updated.length - 1] = { role: 'assistant', content: finalText, timestamp: new Date().toISOString() };
182
+ return updated;
183
+ });
184
+ } catch {}
185
+
186
+ // After first message, future ones should use -c
187
+ setHasSession(true);
188
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
189
+ setDebug(d => [...d.slice(-50), `Response complete (${elapsed}s, ${assistantText.length} chars)`]);
190
+ } catch (e: any) {
191
+ if (e.name !== 'AbortError') {
192
+ setDebug(d => [...d.slice(-20), `Error: ${e.message}`]);
193
+ setMessages(prev => [...prev.slice(0, -1), { role: 'system', content: `Failed: ${e.message}`, timestamp: new Date().toISOString() }]);
194
+ }
195
+ }
196
+
197
+ setLoading(false);
198
+ abortRef.current = null;
199
+ };
200
+
201
+ // Stop generation
202
+ const stopGeneration = () => {
203
+ if (abortRef.current) abortRef.current.abort();
204
+ };
205
+
206
+ // Close tunnel
207
+ const closeTunnel = async () => {
208
+ if (!confirm('Close tunnel? You will lose remote access.')) return;
209
+ await fetch('/api/tunnel', {
210
+ method: 'POST',
211
+ headers: { 'Content-Type': 'application/json' },
212
+ body: JSON.stringify({ action: 'stop' }),
213
+ });
214
+ setTunnelUrl(null);
215
+ };
216
+
217
+ return (
218
+ <div className="h-[100dvh] flex flex-col bg-[#0d1117] text-[#e6edf3]">
219
+ {/* Header */}
220
+ <header className="shrink-0 flex items-center gap-1.5 px-2 py-2 bg-[#161b22] border-b border-[#30363d]">
221
+ <span className="text-xs font-bold text-[#7c5bf0]">Forge</span>
222
+ <select
223
+ value={selectedProject?.path || ''}
224
+ onChange={e => {
225
+ const p = projects.find(p => p.path === e.target.value);
226
+ if (p) selectProject(p);
227
+ }}
228
+ className="flex-1 bg-[#0d1117] border border-[#30363d] rounded px-2 py-1 text-xs text-[#e6edf3] min-w-0"
229
+ >
230
+ <option value="">Project</option>
231
+ {projects.map(p => (
232
+ <option key={p.path} value={p.path}>{p.name}</option>
233
+ ))}
234
+ </select>
235
+ {selectedProject && (
236
+ <>
237
+ <button
238
+ onClick={() => { setShowSessions(v => !v); if (!showSessions) fetchSessions(selectedProject.name); }}
239
+ className="text-xs px-2 py-1 border border-[#30363d] rounded text-[#8b949e] active:bg-[#30363d]"
240
+ >Sessions</button>
241
+ <button
242
+ onClick={async () => {
243
+ const list = await fetchSessions(selectedProject.name);
244
+ if (list.length > 0) {
245
+ await loadHistory(selectedProject.name, list[0].sessionId);
246
+ setDebug(d => [...d.slice(-20), `Refreshed: ${list[0].sessionId.slice(0, 8)}`]);
247
+ }
248
+ }}
249
+ className="text-sm px-3 py-1 border border-[#30363d] rounded text-[#8b949e] active:bg-[#30363d]"
250
+ >↻</button>
251
+ </>
252
+ )}
253
+ {tunnelUrl && (
254
+ <button onClick={closeTunnel} className="text-xs px-1.5 py-1 border border-green-700 rounded text-green-400" title={tunnelUrl}>●</button>
255
+ )}
256
+ <a href="/?force=desktop" className="text-[9px] px-1.5 py-1 border border-[#30363d] rounded text-[#8b949e] active:bg-[#30363d]" title="Switch to desktop view">PC</a>
257
+ </header>
258
+
259
+ {/* Session list */}
260
+ {showSessions && (
261
+ <div className="shrink-0 max-h-[40vh] overflow-y-auto bg-[#161b22] border-b border-[#30363d]">
262
+ {sessions.length === 0 ? (
263
+ <div className="px-3 py-4 text-xs text-[#8b949e] text-center">No sessions found</div>
264
+ ) : sessions.map(s => (
265
+ <button
266
+ key={s.sessionId}
267
+ onClick={() => viewSession(s.sessionId)}
268
+ className="w-full text-left px-3 py-2 border-b border-[#30363d]/50 hover:bg-[#1c2128] text-xs"
269
+ >
270
+ <div className="flex items-center gap-2">
271
+ <span className="text-[#e6edf3] font-mono truncate">{s.sessionId.slice(0, 12)}</span>
272
+ {s.modified && <span className="text-[#8b949e] ml-auto shrink-0">{new Date(s.modified).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>}
273
+ </div>
274
+ {(s.summary || s.firstPrompt) && (
275
+ <div className="text-[#8b949e] mt-0.5 truncate">{s.summary || s.firstPrompt}</div>
276
+ )}
277
+ </button>
278
+ ))}
279
+ </div>
280
+ )}
281
+
282
+ {/* Messages */}
283
+ <div ref={scrollRef} className="flex-1 overflow-y-auto px-3 py-2 min-h-0 space-y-3">
284
+ {!selectedProject ? (
285
+ <div className="h-full flex items-center justify-center text-sm text-[#8b949e]">
286
+ Select a project to start
287
+ </div>
288
+ ) : messages.length === 0 ? (
289
+ <div className="h-full flex items-center justify-center text-sm text-[#8b949e]">
290
+ {hasSession ? 'Session loaded. Type a message.' : 'No sessions yet. Type a message to start.'}
291
+ </div>
292
+ ) : messages.map((msg, i) => (
293
+ <div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
294
+ <div className={`max-w-[85%] rounded-2xl px-3 py-2 text-sm whitespace-pre-wrap break-words ${
295
+ msg.role === 'user'
296
+ ? 'bg-[#7c5bf0] text-white rounded-br-sm'
297
+ : msg.role === 'system'
298
+ ? 'bg-red-900/30 text-red-300 rounded-bl-sm'
299
+ : 'bg-[#1c2128] text-[#e6edf3] rounded-bl-sm'
300
+ }`}>
301
+ {msg.content}
302
+ </div>
303
+ </div>
304
+ ))}
305
+ {loading && (
306
+ <div className="flex justify-start">
307
+ <div className="bg-[#1c2128] rounded-2xl rounded-bl-sm px-3 py-2 text-sm text-[#8b949e]">
308
+ Thinking...
309
+ </div>
310
+ </div>
311
+ )}
312
+ </div>
313
+
314
+ {/* Input */}
315
+ <div className="shrink-0 flex items-center gap-2 px-3 py-2 bg-[#161b22] border-t border-[#30363d]">
316
+ <input
317
+ ref={inputRef}
318
+ type="text"
319
+ value={input}
320
+ onChange={e => setInput(e.target.value)}
321
+ onKeyDown={e => { if (e.key === 'Enter' && !loading) sendMessage(); }}
322
+ placeholder={selectedProject ? 'Type a message...' : 'Select a project first'}
323
+ disabled={!selectedProject}
324
+ className="flex-1 bg-[#0d1117] border border-[#30363d] rounded-lg px-3 py-2 text-sm text-[#e6edf3] focus:outline-none focus:border-[#7c5bf0] disabled:opacity-50 min-w-0"
325
+ autoComplete="off"
326
+ autoCorrect="off"
327
+ />
328
+ {loading ? (
329
+ <button
330
+ onClick={stopGeneration}
331
+ className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-medium shrink-0"
332
+ >Stop</button>
333
+ ) : (
334
+ <button
335
+ onClick={sendMessage}
336
+ disabled={!selectedProject || !input.trim()}
337
+ className="px-4 py-2 bg-[#7c5bf0] text-white rounded-lg text-sm font-medium disabled:opacity-50 shrink-0"
338
+ >Send</button>
339
+ )}
340
+ </div>
341
+
342
+ {/* Debug log */}
343
+ <div className="shrink-0 bg-[#0d1117] border-t border-[#30363d]">
344
+ <div className="flex items-center gap-2 px-3 py-1">
345
+ <span className="text-[9px] text-[#8b949e]">Debug:</span>
346
+ {(['off', 'simple', 'verbose'] as const).map(level => (
347
+ <button
348
+ key={level}
349
+ onClick={() => { setDebugLevel(level); debugLevelRef.current = level; if (level === 'off') setDebug([]); }}
350
+ className={`text-[9px] px-1.5 py-0.5 rounded ${debugLevel === level ? 'bg-[#30363d] text-[#e6edf3]' : 'text-[#8b949e]'}`}
351
+ >{level}</button>
352
+ ))}
353
+ {debug.length > 0 && (
354
+ <button onClick={() => setDebug([])} className="text-[9px] text-[#8b949e] ml-auto">Clear</button>
355
+ )}
356
+ </div>
357
+ {debugLevel !== 'off' && debug.length > 0 && (
358
+ <div className="px-3 py-1 max-h-32 overflow-y-auto border-t border-[#30363d]/50">
359
+ {debug.map((d, i) => <div key={i} className="text-[9px] text-[#8b949e] font-mono">{d}</div>)}
360
+ </div>
361
+ )}
362
+ </div>
363
+ </div>
364
+ );
365
+ }
@@ -257,8 +257,8 @@ export function tailSessionFile(
257
257
 
258
258
  watcher.on('error', (err) => onError?.(err));
259
259
 
260
- // Poll every 5 seconds as fallback
261
- const pollTimer = setInterval(readNewBytes, 5000);
260
+ // Poll every 1 second as fallback (fs.watch is unreliable on macOS)
261
+ const pollTimer = setInterval(readNewBytes, 1000);
262
262
 
263
263
  return () => {
264
264
  watcher.close();
package/lib/init.ts CHANGED
@@ -103,7 +103,7 @@ export function ensureInitialized() {
103
103
  try {
104
104
  const { syncSkills } = require('./skills');
105
105
  syncSkills().catch(() => {});
106
- setInterval(() => { syncSkills().catch(() => {}); }, 30 * 60 * 1000);
106
+ setInterval(() => { syncSkills().catch(() => {}); }, 60 * 60 * 1000);
107
107
  } catch {}
108
108
 
109
109
  // Task runner is safe in every worker (DB-level coordination)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.4.10",
3
+ "version": "0.4.11",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {