@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.
@@ -0,0 +1,194 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef, useCallback } from 'react';
4
+
5
+ export default function LogViewer() {
6
+ const [lines, setLines] = useState<string[]>([]);
7
+ const [total, setTotal] = useState(0);
8
+ const [fileSize, setFileSize] = useState(0);
9
+ const [filePath, setFilePath] = useState('');
10
+ const [search, setSearch] = useState('');
11
+ const [maxLines, setMaxLines] = useState(200);
12
+ const [autoRefresh, setAutoRefresh] = useState(true);
13
+ const [processes, setProcesses] = useState<{ pid: string; cpu: string; mem: string; cmd: string }[]>([]);
14
+ const [showProcesses, setShowProcesses] = useState(false);
15
+ const bottomRef = useRef<HTMLDivElement>(null);
16
+ const containerRef = useRef<HTMLDivElement>(null);
17
+ const [autoScroll, setAutoScroll] = useState(true);
18
+
19
+ const fetchLogs = useCallback(async () => {
20
+ try {
21
+ const res = await fetch(`/api/logs?lines=${maxLines}${search ? `&search=${encodeURIComponent(search)}` : ''}`);
22
+ const data = await res.json();
23
+ setLines(data.lines || []);
24
+ setTotal(data.total || 0);
25
+ setFileSize(data.size || 0);
26
+ if (data.file) setFilePath(data.file);
27
+ } catch {}
28
+ }, [maxLines, search]);
29
+
30
+ const fetchProcesses = async () => {
31
+ try {
32
+ const res = await fetch('/api/logs', {
33
+ method: 'POST',
34
+ headers: { 'Content-Type': 'application/json' },
35
+ body: JSON.stringify({ action: 'processes' }),
36
+ });
37
+ const data = await res.json();
38
+ setProcesses(data.processes || []);
39
+ } catch {}
40
+ };
41
+
42
+ const clearLogs = async () => {
43
+ if (!confirm('Clear all logs?')) return;
44
+ await fetch('/api/logs', {
45
+ method: 'POST',
46
+ headers: { 'Content-Type': 'application/json' },
47
+ body: JSON.stringify({ action: 'clear' }),
48
+ });
49
+ fetchLogs();
50
+ };
51
+
52
+ // Initial + auto refresh
53
+ useEffect(() => { fetchLogs(); }, [fetchLogs]);
54
+ useEffect(() => {
55
+ if (!autoRefresh) return;
56
+ const id = setInterval(fetchLogs, 3000);
57
+ return () => clearInterval(id);
58
+ }, [autoRefresh, fetchLogs]);
59
+
60
+ // Auto scroll
61
+ useEffect(() => {
62
+ if (autoScroll) bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
63
+ }, [lines, autoScroll]);
64
+
65
+ // Detect manual scroll
66
+ const onScroll = () => {
67
+ const el = containerRef.current;
68
+ if (!el) return;
69
+ const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
70
+ setAutoScroll(atBottom);
71
+ };
72
+
73
+ const formatSize = (bytes: number) => {
74
+ if (bytes < 1024) return `${bytes} B`;
75
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
76
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
77
+ };
78
+
79
+ const getLineColor = (line: string) => {
80
+ if (line.includes('[error]') || line.includes('Error') || line.includes('FATAL')) return 'text-red-400';
81
+ if (line.includes('[warn]') || line.includes('Warning') || line.includes('WARN')) return 'text-yellow-400';
82
+ if (line.includes('[forge]') || line.includes('[init]')) return 'text-cyan-400';
83
+ if (line.includes('[task]') || line.includes('[pipeline]')) return 'text-green-400';
84
+ if (line.includes('[telegram]') || line.includes('[terminal]')) return 'text-purple-400';
85
+ if (line.includes('[issue-scanner]') || line.includes('[watcher]')) return 'text-orange-300';
86
+ return 'text-[var(--text-primary)]';
87
+ };
88
+
89
+ return (
90
+ <div className="flex-1 flex flex-col min-h-0">
91
+ {/* Toolbar */}
92
+ <div className="flex items-center gap-2 px-4 py-2 border-b border-[var(--border)] shrink-0 flex-wrap">
93
+ <span className="text-xs font-semibold text-[var(--text-primary)]">Logs</span>
94
+ <span className="text-[8px] text-[var(--text-secondary)]">{total} lines · {formatSize(fileSize)}</span>
95
+
96
+ {/* Search */}
97
+ <input
98
+ type="text"
99
+ value={search}
100
+ onChange={e => setSearch(e.target.value)}
101
+ placeholder="Filter..."
102
+ className="px-2 py-0.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)] w-32 focus:outline-none focus:border-[var(--accent)]"
103
+ />
104
+
105
+ {/* Max lines */}
106
+ <select
107
+ value={maxLines}
108
+ onChange={e => setMaxLines(Number(e.target.value))}
109
+ className="px-1 py-0.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)]"
110
+ >
111
+ <option value={100}>100 lines</option>
112
+ <option value={200}>200 lines</option>
113
+ <option value={500}>500 lines</option>
114
+ <option value={1000}>1000 lines</option>
115
+ </select>
116
+
117
+ <div className="ml-auto flex items-center gap-2">
118
+ {/* Auto refresh toggle */}
119
+ <label className="flex items-center gap-1 text-[9px] text-[var(--text-secondary)] cursor-pointer">
120
+ <input type="checkbox" checked={autoRefresh} onChange={e => setAutoRefresh(e.target.checked)} className="accent-[var(--accent)]" />
121
+ Auto (3s)
122
+ </label>
123
+
124
+ {/* Processes */}
125
+ <button
126
+ onClick={() => { setShowProcesses(v => !v); fetchProcesses(); }}
127
+ className={`text-[9px] px-2 py-0.5 rounded ${showProcesses ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
128
+ >Processes</button>
129
+
130
+ {/* Refresh */}
131
+ <button onClick={fetchLogs} className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]">↻</button>
132
+
133
+ {/* Clear */}
134
+ <button onClick={clearLogs} className="text-[9px] text-[var(--red)] hover:underline">Clear</button>
135
+ </div>
136
+ </div>
137
+
138
+ {/* Processes panel */}
139
+ {showProcesses && processes.length > 0 && (
140
+ <div className="border-b border-[var(--border)] bg-[var(--bg-tertiary)] max-h-32 overflow-y-auto shrink-0">
141
+ <div className="px-4 py-1 text-[8px] text-[var(--text-secondary)] uppercase">Running Processes</div>
142
+ {processes.map(p => (
143
+ <div key={p.pid} className="px-4 py-0.5 text-[10px] font-mono flex gap-3">
144
+ <span className="text-[var(--accent)] w-12 shrink-0">{p.pid}</span>
145
+ <span className="text-green-400 w-10 shrink-0">{p.cpu}%</span>
146
+ <span className="text-yellow-400 w-10 shrink-0">{p.mem}%</span>
147
+ <span className="text-[var(--text-secondary)] truncate">{p.cmd}</span>
148
+ </div>
149
+ ))}
150
+ </div>
151
+ )}
152
+
153
+ {/* Log content */}
154
+ <div
155
+ ref={containerRef}
156
+ onScroll={onScroll}
157
+ className="flex-1 overflow-auto bg-[var(--bg-primary)] font-mono text-[11px] leading-[1.6]"
158
+ >
159
+ {lines.length === 0 ? (
160
+ <div className="flex items-center justify-center h-full text-[var(--text-secondary)] text-xs">
161
+ {filePath ? 'No log entries' : 'Log file not found — server running in foreground?'}
162
+ </div>
163
+ ) : (
164
+ <div className="p-3">
165
+ {lines.map((line, i) => (
166
+ <div key={i} className={`${getLineColor(line)} hover:bg-[var(--bg-tertiary)] px-1`}>
167
+ {search ? (
168
+ // Highlight search matches
169
+ line.split(new RegExp(`(${search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')).map((part, j) =>
170
+ part.toLowerCase() === search.toLowerCase()
171
+ ? <span key={j} className="bg-[var(--yellow)]/30 text-[var(--yellow)]">{part}</span>
172
+ : part
173
+ )
174
+ ) : line}
175
+ </div>
176
+ ))}
177
+ <div ref={bottomRef} />
178
+ </div>
179
+ )}
180
+ </div>
181
+
182
+ {/* Footer */}
183
+ <div className="px-4 py-1 border-t border-[var(--border)] shrink-0 flex items-center gap-2 text-[8px] text-[var(--text-secondary)]">
184
+ <span>{filePath}</span>
185
+ {!autoScroll && (
186
+ <button
187
+ onClick={() => { setAutoScroll(true); bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }}
188
+ className="ml-auto text-[var(--accent)] hover:underline"
189
+ >↓ Scroll to bottom</button>
190
+ )}
191
+ </div>
192
+ </div>
193
+ );
194
+ }
@@ -192,9 +192,18 @@ export default function ProjectManager() {
192
192
  };
193
193
 
194
194
  // Group projects by root
195
+ const [collapsedRoots, setCollapsedRoots] = useState<Set<string>>(new Set());
195
196
  const roots = [...new Set(projects.map(p => p.root))];
196
197
  const favoriteProjects = projects.filter(p => favorites.includes(p.path));
197
198
 
199
+ const toggleRoot = (root: string) => {
200
+ setCollapsedRoots(prev => {
201
+ const next = new Set(prev);
202
+ if (next.has(root)) next.delete(root); else next.add(root);
203
+ return next;
204
+ });
205
+ };
206
+
198
207
  return (
199
208
  <div className="flex-1 flex min-h-0">
200
209
  {/* Left sidebar — project list */}
@@ -234,10 +243,14 @@ export default function ProjectManager() {
234
243
  {/* Favorites section */}
235
244
  {favoriteProjects.length > 0 && (
236
245
  <div>
237
- <div className="px-3 py-1 text-[9px] text-[var(--yellow)] uppercase bg-[var(--bg-tertiary)] flex items-center gap-1">
238
- <span>★</span> Favorites
239
- </div>
240
- {favoriteProjects.map(p => (
246
+ <button
247
+ onClick={() => toggleRoot('__favorites__')}
248
+ className="w-full px-3 py-1 text-[9px] text-[var(--yellow)] uppercase bg-[var(--bg-tertiary)] flex items-center gap-1 hover:bg-[var(--border)]/30"
249
+ >
250
+ <span className="text-[8px]">{collapsedRoots.has('__favorites__') ? '▸' : '▾'}</span>
251
+ <span>★</span> Favorites ({favoriteProjects.length})
252
+ </button>
253
+ {!collapsedRoots.has('__favorites__') && favoriteProjects.map(p => (
241
254
  <button
242
255
  key={`fav-${p.path}`}
243
256
  onClick={() => openProjectTab(p)}
@@ -262,12 +275,17 @@ export default function ProjectManager() {
262
275
  {roots.map(root => {
263
276
  const rootName = root.split('/').pop() || root;
264
277
  const rootProjects = projects.filter(p => p.root === root).sort((a, b) => a.name.localeCompare(b.name));
278
+ const isCollapsed = collapsedRoots.has(root);
265
279
  return (
266
280
  <div key={root}>
267
- <div className="px-3 py-1 text-[9px] text-[var(--text-secondary)] uppercase bg-[var(--bg-tertiary)]">
268
- {rootName}
269
- </div>
270
- {rootProjects.map(p => (
281
+ <button
282
+ onClick={() => toggleRoot(root)}
283
+ className="w-full px-3 py-1 text-[9px] text-[var(--text-secondary)] uppercase bg-[var(--bg-tertiary)] flex items-center gap-1 hover:bg-[var(--border)]/30"
284
+ >
285
+ <span className="text-[8px]">{isCollapsed ? '▸' : '▾'}</span>
286
+ {rootName} ({rootProjects.length})
287
+ </button>
288
+ {!isCollapsed && rootProjects.map(p => (
271
289
  <button
272
290
  key={p.path}
273
291
  onClick={() => openProjectTab(p)}
package/lib/auth.ts CHANGED
@@ -37,8 +37,10 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
37
37
  if (verifyLogin(password, sessionCode, isRemote)) {
38
38
  const { loadSettings } = await import('./settings');
39
39
  const settings = loadSettings();
40
+ console.log(`[auth] Login success (${isRemote ? 'remote' : 'local'})`);
40
41
  return { id: 'local', name: settings.displayName || 'Forge', email: settings.displayEmail || 'local@forge' };
41
42
  }
43
+ console.warn(`[auth] Login failed (${isRemote ? 'remote' : 'local'})`);
42
44
  return null;
43
45
  },
44
46
  }),
@@ -23,11 +23,13 @@ function getDownloadUrl(): string {
23
23
  const base = 'https://github.com/cloudflare/cloudflared/releases/latest/download';
24
24
 
25
25
  if (os === 'darwin') {
26
- // macOS universal binary
27
- return `${base}/cloudflared-darwin-amd64.tgz`;
26
+ return cpu === 'arm64'
27
+ ? `${base}/cloudflared-darwin-arm64.tgz`
28
+ : `${base}/cloudflared-darwin-amd64.tgz`;
28
29
  }
29
30
  if (os === 'linux') {
30
31
  if (cpu === 'arm64') return `${base}/cloudflared-linux-arm64`;
32
+ if (cpu === 'arm') return `${base}/cloudflared-linux-arm`;
31
33
  return `${base}/cloudflared-linux-amd64`;
32
34
  }
33
35
  if (os === 'win32') {
@@ -38,49 +40,87 @@ function getDownloadUrl(): string {
38
40
 
39
41
  // ─── Download helper ────────────────────────────────────────────
40
42
 
41
- function followRedirects(url: string, dest: string): Promise<void> {
43
+ const DOWNLOAD_TIMEOUT_MS = 120_000; // 2 minutes total per redirect hop
44
+
45
+ function followRedirects(url: string, dest: string, redirectsLeft = 10): Promise<void> {
42
46
  return new Promise((resolve, reject) => {
43
47
  const client = url.startsWith('https') ? https : http;
44
- client.get(url, { headers: { 'User-Agent': 'forge' } }, (res) => {
48
+ const req = client.get(url, { headers: { 'User-Agent': 'forge/1.0' } }, (res) => {
45
49
  if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
46
- followRedirects(res.headers.location, dest).then(resolve, reject);
50
+ res.resume(); // drain redirect response
51
+ if (redirectsLeft <= 0) {
52
+ reject(new Error('Too many redirects'));
53
+ return;
54
+ }
55
+ followRedirects(res.headers.location, dest, redirectsLeft - 1).then(resolve, reject);
47
56
  return;
48
57
  }
49
58
  if (res.statusCode !== 200) {
59
+ res.resume();
50
60
  reject(new Error(`Download failed: HTTP ${res.statusCode}`));
51
61
  return;
52
62
  }
53
63
  const file = createWriteStream(dest);
54
64
  res.pipe(file);
55
65
  file.on('finish', () => file.close(() => resolve()));
56
- file.on('error', reject);
57
- }).on('error', reject);
66
+ file.on('error', (err) => { try { unlinkSync(dest); } catch {} reject(err); });
67
+ res.on('error', (err) => { try { unlinkSync(dest); } catch {} reject(err); });
68
+ });
69
+ req.setTimeout(DOWNLOAD_TIMEOUT_MS, () => {
70
+ req.destroy(new Error(`Download timed out after ${DOWNLOAD_TIMEOUT_MS / 1000}s`));
71
+ });
72
+ req.on('error', reject);
58
73
  });
59
74
  }
60
75
 
76
+ // Guard against concurrent downloads
77
+ let downloadPromise: Promise<string> | null = null;
78
+
61
79
  export async function downloadCloudflared(): Promise<string> {
62
80
  if (existsSync(BIN_PATH)) return BIN_PATH;
81
+ if (downloadPromise) return downloadPromise;
63
82
 
64
- mkdirSync(BIN_DIR, { recursive: true });
65
- const url = getDownloadUrl();
66
- const isTgz = url.endsWith('.tgz');
67
- const tmpPath = isTgz ? `${BIN_PATH}.tgz` : BIN_PATH;
68
-
69
- console.log(`[cloudflared] Downloading from ${url}...`);
70
- await followRedirects(url, tmpPath);
83
+ downloadPromise = (async () => {
84
+ mkdirSync(BIN_DIR, { recursive: true });
85
+ const url = getDownloadUrl();
86
+ const isTgz = url.endsWith('.tgz');
87
+ const tmpPath = isTgz ? `${BIN_PATH}.tgz` : `${BIN_PATH}.tmp`;
71
88
 
72
- if (isTgz) {
73
- // Extract tgz (macOS)
74
- execSync(`tar -xzf "${tmpPath}" -C "${BIN_DIR}"`, { encoding: 'utf-8' });
89
+ // Clean up any leftover partial files from a previous failed attempt
75
90
  try { unlinkSync(tmpPath); } catch {}
76
- }
77
91
 
78
- if (platform() !== 'win32') {
79
- chmodSync(BIN_PATH, 0o755);
80
- }
92
+ console.log(`[cloudflared] Downloading from ${url}...`);
93
+ try {
94
+ await followRedirects(url, tmpPath);
95
+ } catch (e) {
96
+ try { unlinkSync(tmpPath); } catch {}
97
+ throw e;
98
+ }
99
+
100
+ if (isTgz) {
101
+ // Extract tgz (macOS)
102
+ try {
103
+ execSync(`tar -xzf "${tmpPath}" -C "${BIN_DIR}"`, { encoding: 'utf-8' });
104
+ } finally {
105
+ try { unlinkSync(tmpPath); } catch {}
106
+ }
107
+ } else {
108
+ // Rename .tmp to final name atomically
109
+ const { renameSync } = require('node:fs');
110
+ renameSync(tmpPath, BIN_PATH);
111
+ }
112
+
113
+ if (platform() !== 'win32') {
114
+ chmodSync(BIN_PATH, 0o755);
115
+ }
116
+
117
+ console.log(`[cloudflared] Installed to ${BIN_PATH}`);
118
+ return BIN_PATH;
119
+ })().finally(() => {
120
+ downloadPromise = null;
121
+ });
81
122
 
82
- console.log(`[cloudflared] Installed to ${BIN_PATH}`);
83
- return BIN_PATH;
123
+ return downloadPromise;
84
124
  }
85
125
 
86
126
  export function isInstalled(): boolean {
@@ -130,6 +170,7 @@ function pushLog(line: string) {
130
170
  }
131
171
 
132
172
  export async function startTunnel(localPort: number = parseInt(process.env.PORT || '3000')): Promise<{ url?: string; error?: string }> {
173
+ console.log(`[tunnel] Starting tunnel on port ${localPort}...`);
133
174
  // Check if this worker already has a process
134
175
  if (state.process) {
135
176
  return state.url ? { url: state.url } : { error: 'Tunnel is starting...' };
@@ -207,6 +248,7 @@ export async function startTunnel(localPort: number = parseInt(process.env.PORT
207
248
  state.status = 'error';
208
249
  state.error = err.message;
209
250
  pushLog(`[error] ${err.message}`);
251
+ console.error(`[tunnel] Error: ${err.message}`);
210
252
  if (!resolved) {
211
253
  resolved = true;
212
254
  resolve({ error: err.message });
@@ -214,16 +256,19 @@ export async function startTunnel(localPort: number = parseInt(process.env.PORT
214
256
  });
215
257
 
216
258
  state.process.on('exit', (code) => {
259
+ const recentLog = state.log.slice(-5).join(' ').slice(0, 200);
260
+ const reason = code !== 0 ? `cloudflared failed (exit ${code}): ${recentLog || 'no output'}` : 'cloudflared stopped';
261
+ console.log(`[tunnel] ${reason}`);
217
262
  state.process = null;
218
263
  if (state.status !== 'error') {
219
264
  state.status = 'stopped';
220
265
  }
221
266
  state.url = null;
222
267
  saveTunnelState();
223
- pushLog(`[exit] cloudflared exited with code ${code}`);
268
+ pushLog(`[exit] ${reason}`);
224
269
  if (!resolved) {
225
270
  resolved = true;
226
- resolve({ error: `cloudflared exited with code ${code}` });
271
+ resolve({ error: reason });
227
272
  }
228
273
  });
229
274
 
@@ -241,6 +286,7 @@ export async function startTunnel(localPort: number = parseInt(process.env.PORT
241
286
  }
242
287
 
243
288
  export function stopTunnel() {
289
+ console.log('[tunnel] Stopping tunnel');
244
290
  stopHealthCheck();
245
291
  if (state.process) {
246
292
  state.process.kill('SIGTERM');
@@ -0,0 +1,34 @@
1
+ # Forge Overview
2
+
3
+ Forge is a self-hosted Vibe Coding platform for Claude Code. It provides a browser-based terminal, AI task orchestration, remote access, and mobile control via Telegram.
4
+
5
+ ## Quick Start
6
+
7
+ ```bash
8
+ npm install -g @aion0/forge
9
+ forge server start
10
+ ```
11
+
12
+ Open `http://localhost:3000`. First launch prompts you to set an admin password.
13
+
14
+ ## Requirements
15
+ - Node.js >= 20
16
+ - tmux (`brew install tmux` on macOS)
17
+ - Claude Code CLI (`npm install -g @anthropic-ai/claude-code`)
18
+
19
+ ## Data Location
20
+ - Config: `~/.forge/` (binaries)
21
+ - Data: `~/.forge/data/` (settings, database, state)
22
+ - Claude: `~/.claude/` (skills, commands, sessions)
23
+
24
+ ## Server Commands
25
+ ```bash
26
+ forge server start # background (default)
27
+ forge server start --foreground # foreground
28
+ forge server start --dev # dev mode with hot-reload
29
+ forge server stop # stop
30
+ forge server restart # restart
31
+ forge server start --port 4000 # custom port
32
+ forge server start --dir ~/.forge-test # custom data dir
33
+ forge --reset-password # reset admin password
34
+ ```
@@ -0,0 +1,37 @@
1
+ # Settings Configuration
2
+
3
+ Settings are stored in `~/.forge/data/settings.yaml`. Configure via the web UI (Settings button in top-right menu) or edit YAML directly.
4
+
5
+ ## All Settings Fields
6
+
7
+ | Field | Type | Default | Description |
8
+ |-------|------|---------|-------------|
9
+ | `projectRoots` | string[] | `[]` | Directories containing your projects (e.g. `~/Projects`) |
10
+ | `docRoots` | string[] | `[]` | Markdown/Obsidian vault directories |
11
+ | `claudePath` | string | `""` | Path to claude binary (auto-detected if empty) |
12
+ | `claudeHome` | string | `""` | Claude Code home directory (default: `~/.claude`) |
13
+ | `telegramBotToken` | string | `""` | Telegram Bot API token (encrypted) |
14
+ | `telegramChatId` | string | `""` | Telegram chat ID (comma-separated for multiple users) |
15
+ | `notifyOnComplete` | boolean | `true` | Telegram notification on task completion |
16
+ | `notifyOnFailure` | boolean | `true` | Telegram notification on task failure |
17
+ | `tunnelAutoStart` | boolean | `false` | Auto-start Cloudflare Tunnel on server startup |
18
+ | `telegramTunnelPassword` | string | `""` | Admin password for login + tunnel + secrets (encrypted) |
19
+ | `taskModel` | string | `"default"` | Model for background tasks |
20
+ | `pipelineModel` | string | `"default"` | Model for pipeline workflows |
21
+ | `telegramModel` | string | `"sonnet"` | Model for Telegram AI features |
22
+ | `skipPermissions` | boolean | `false` | Add `--dangerously-skip-permissions` to claude invocations |
23
+ | `notificationRetentionDays` | number | `30` | Auto-cleanup notifications older than N days |
24
+ | `skillsRepoUrl` | string | forge-skills URL | GitHub raw URL for skills registry |
25
+ | `displayName` | string | `"Forge"` | Display name shown in header |
26
+ | `displayEmail` | string | `""` | User email |
27
+
28
+ ## Admin Password
29
+
30
+ - Set on first launch (CLI prompt)
31
+ - Required for: login, tunnel start, secret changes, Telegram commands
32
+ - Reset: `forge --reset-password`
33
+ - Forgot? Run `forge --reset-password` in terminal
34
+
35
+ ## Encrypted Fields
36
+
37
+ `telegramBotToken` and `telegramTunnelPassword` are encrypted with AES-256-GCM. The encryption key is stored at `~/.forge/data/.encrypt-key`.
@@ -0,0 +1,41 @@
1
+ # Telegram Bot Setup
2
+
3
+ ## Setup Steps
4
+
5
+ 1. Open Telegram, search for [@BotFather](https://t.me/botfather)
6
+ 2. Send `/newbot`, follow prompts to create a bot
7
+ 3. Copy the bot token (looks like `6234567890:ABCDefGHIJKLMNOPQRSTUVWXYZ`)
8
+ 4. In Forge Settings, paste the token into **Telegram Bot Token**
9
+ 5. To get your Chat ID: send any message to your bot, then visit `https://api.telegram.org/bot<TOKEN>/getUpdates` — find `chat.id` in the response
10
+ 6. Paste the Chat ID into **Telegram Chat ID** in Settings
11
+ 7. The bot starts automatically after saving
12
+
13
+ ## Commands
14
+
15
+ | Command | Description |
16
+ |---------|-------------|
17
+ | `/task <project> <prompt>` | Create a background task |
18
+ | `/tasks [status]` | List tasks (running/queued/done/failed) |
19
+ | `/sessions [project]` | AI summary of Claude Code sessions |
20
+ | `/watch <id>` | Live stream task output |
21
+ | `/unwatch <id>` | Stop streaming |
22
+ | `/docs <query>` | Search Obsidian vault |
23
+ | `/note <text>` | Quick note to vault |
24
+ | `/peek <project>` | Preview running session |
25
+ | `/cancel <id>` | Cancel a task |
26
+ | `/retry <id>` | Retry a failed task |
27
+ | `/tunnel_start <password>` | Start Cloudflare Tunnel (returns URL + code) |
28
+ | `/tunnel_stop` | Stop tunnel |
29
+ | `/tunnel_code <password>` | Get session code for remote login |
30
+ | `/projects` | List configured projects |
31
+
32
+ ## Shortcuts
33
+ - Reply to a task message to interact with it
34
+ - Send `"project: instructions"` to quick-create a task
35
+ - Numbered lists — reply with a number to select
36
+
37
+ ## Troubleshooting
38
+
39
+ - **Bot not responding**: Check token is correct, restart server
40
+ - **"Unauthorized"**: Chat ID doesn't match configured value
41
+ - **Multiple users**: Set comma-separated Chat IDs (e.g. `123456,789012`)
@@ -0,0 +1,31 @@
1
+ # Remote Access (Cloudflare Tunnel)
2
+
3
+ ## How It Works
4
+
5
+ Forge creates a temporary Cloudflare Tunnel — a secure public URL that routes to your local Forge server. No Cloudflare account needed.
6
+
7
+ ## Start Tunnel
8
+
9
+ **From UI**: Click the "Tunnel" button in the top-right header.
10
+
11
+ **From Telegram**: `/tunnel_start <admin_password>`
12
+
13
+ **Auto-start**: Set `tunnelAutoStart: true` in Settings.
14
+
15
+ ## Login Flow
16
+
17
+ - **Local access** (localhost, LAN): Admin password only
18
+ - **Remote access** (via tunnel, `.trycloudflare.com`): Admin password + Session Code (2FA)
19
+
20
+ Session code is generated when tunnel starts. Get it via:
21
+ - Telegram: `/tunnel_code <password>`
22
+ - CLI: `forge tcode`
23
+
24
+ ## Troubleshooting
25
+
26
+ - **Tunnel stuck at "starting"**: Kill old cloudflared processes: `pkill -f cloudflared`
27
+ - **URL not reachable**: Tunnel may have timed out, restart it
28
+ - **Session cookie invalid after restart**: Set `AUTH_SECRET` in `~/.forge/data/.env.local`:
29
+ ```bash
30
+ echo "AUTH_SECRET=$(openssl rand -hex 32)" >> ~/.forge/data/.env.local
31
+ ```
@@ -0,0 +1,52 @@
1
+ # Background Tasks
2
+
3
+ ## What Are Tasks?
4
+
5
+ Tasks run Claude Code prompts in the background. They use `claude -p` (print mode) — execute and exit, no persistent session. Your code runs on your machine using your Claude subscription.
6
+
7
+ ## Create a Task
8
+
9
+ **From UI**: Click "+ New Task" in the Tasks tab.
10
+
11
+ **From CLI**:
12
+ ```bash
13
+ forge task my-project "fix the login bug"
14
+ forge task my-project "add unit tests for utils.ts" --new # fresh session
15
+ ```
16
+
17
+ **From Telegram**: `/task my-project fix the login bug`
18
+
19
+ ## Task Modes
20
+
21
+ | Mode | Description |
22
+ |------|-------------|
23
+ | `prompt` | Run Claude Code with a prompt (default) |
24
+ | `shell` | Execute raw shell command |
25
+ | `monitor` | Watch a session and trigger actions |
26
+
27
+ ## Watch Task Output
28
+
29
+ ```bash
30
+ forge watch <task-id> # live stream in terminal
31
+ ```
32
+
33
+ Or from Telegram: `/watch <task-id>`
34
+
35
+ ## CLI Commands
36
+
37
+ ```bash
38
+ forge tasks # list all tasks
39
+ forge tasks running # filter by status
40
+ forge status <id> # task details
41
+ forge cancel <id> # cancel
42
+ forge retry <id> # retry failed task
43
+ forge log <id> # execution log
44
+ ```
45
+
46
+ ## Features
47
+
48
+ - **Per-project concurrency**: One prompt task per project at a time, others queue
49
+ - **Session continuity**: All tasks in the same project share one Claude conversation
50
+ - **Cost tracking**: Token usage and USD cost per task
51
+ - **Git tracking**: Captures branch name and git diff after execution
52
+ - **Scheduled execution**: Set `scheduledAt` for deferred tasks