@hamp10/agentforge 0.1.0

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,298 @@
1
+ /**
2
+ * Hampagent tool definitions + implementations.
3
+ * Matches openclaw's tool surface area, optimized for AgentForge.
4
+ */
5
+
6
+ import { spawn, execSync } from 'child_process';
7
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from 'fs';
8
+ import path from 'path';
9
+ import { browserAction } from './browser.js';
10
+
11
+ // ── Tool schema definitions (Anthropic tool_use format) ────────────────────
12
+
13
+ export const TOOLS = [
14
+ {
15
+ name: 'bash',
16
+ description: 'Execute shell commands on the user\'s Mac. Returns stdout + stderr. Use for: running scripts, installing packages, git operations, checking system state, serving files, running processes. Commands run in the task working directory.',
17
+ input_schema: {
18
+ type: 'object',
19
+ properties: {
20
+ command: { type: 'string', description: 'Shell command to execute' },
21
+ timeout_ms: { type: 'number', description: 'Timeout in ms (default 30000, max 300000 for long builds)' }
22
+ },
23
+ required: ['command']
24
+ }
25
+ },
26
+ {
27
+ name: 'read',
28
+ description: 'Read a file. Returns contents with line numbers. Use offset+limit for large files.',
29
+ input_schema: {
30
+ type: 'object',
31
+ properties: {
32
+ file_path: { type: 'string', description: 'Path to file (absolute or relative to working dir)' },
33
+ offset: { type: 'number', description: 'Start line, 1-indexed (default: 1)' },
34
+ limit: { type: 'number', description: 'Max lines to return (default: all)' }
35
+ },
36
+ required: ['file_path']
37
+ }
38
+ },
39
+ {
40
+ name: 'write',
41
+ description: 'Write content to a file. Creates parent directories automatically.',
42
+ input_schema: {
43
+ type: 'object',
44
+ properties: {
45
+ file_path: { type: 'string' },
46
+ content: { type: 'string' }
47
+ },
48
+ required: ['file_path', 'content']
49
+ }
50
+ },
51
+ {
52
+ name: 'edit',
53
+ description: 'Replace exact text in a file. old_string must match exactly (whitespace included). Fails if not found or not unique — use read first to verify.',
54
+ input_schema: {
55
+ type: 'object',
56
+ properties: {
57
+ file_path: { type: 'string' },
58
+ old_string: { type: 'string', description: 'Exact text to find — must be unique in the file' },
59
+ new_string: { type: 'string', description: 'Replacement text' },
60
+ replace_all: { type: 'boolean', description: 'Replace all occurrences (default false)' }
61
+ },
62
+ required: ['file_path', 'old_string', 'new_string']
63
+ }
64
+ },
65
+ {
66
+ name: 'glob',
67
+ description: 'Find files matching a pattern. Returns file paths sorted by modification time.',
68
+ input_schema: {
69
+ type: 'object',
70
+ properties: {
71
+ pattern: { type: 'string', description: 'Glob pattern e.g. "**/*.js", "src/**/*.ts", "*.json"' },
72
+ path: { type: 'string', description: 'Directory to search in (default: working dir)' }
73
+ },
74
+ required: ['pattern']
75
+ }
76
+ },
77
+ {
78
+ name: 'grep',
79
+ description: 'Search file contents with regex. Returns matching lines with file:line format.',
80
+ input_schema: {
81
+ type: 'object',
82
+ properties: {
83
+ pattern: { type: 'string', description: 'Regex pattern to search for' },
84
+ path: { type: 'string', description: 'File or directory to search (default: working dir)' },
85
+ glob: { type: 'string', description: 'File filter pattern e.g. "*.js", "*.{ts,tsx}"' },
86
+ output_mode: { type: 'string', enum: ['content', 'files_with_matches', 'count'], description: 'Output format (default: content)' },
87
+ '-i': { type: 'boolean', description: 'Case insensitive' },
88
+ '-A': { type: 'number', description: 'Lines after match' },
89
+ '-B': { type: 'number', description: 'Lines before match' }
90
+ },
91
+ required: ['pattern']
92
+ }
93
+ },
94
+ {
95
+ name: 'browser',
96
+ description: 'Control AgentForge Browser (Chrome, already open on port 9223, logged into user\'s services). Use for ALL web tasks — browsing, searching, filling forms, clicking UI. Do NOT use curl/wget for web pages.',
97
+ input_schema: {
98
+ type: 'object',
99
+ properties: {
100
+ action: {
101
+ type: 'string',
102
+ enum: ['navigate', 'open', 'click', 'type', 'act', 'scroll', 'screenshot', 'snapshot', 'evaluate', 'back', 'forward', 'wait', 'url'],
103
+ description: 'navigate/open: go to URL | snapshot: get page content + interactive elements | click: click element | type: enter text | screenshot: capture page | evaluate: run JS | act: ref-based click/type from snapshot'
104
+ },
105
+ url: { type: 'string', description: 'URL for navigate/open' },
106
+ targetUrl: { type: 'string', description: 'URL alias for open action' },
107
+ selector: { type: 'string', description: 'CSS selector for click/type' },
108
+ ref: { description: 'Element index from snapshot for click/act' },
109
+ text: { type: 'string', description: 'Text for type action' },
110
+ script: { type: 'string', description: 'JavaScript to evaluate' },
111
+ expression: { type: 'string', description: 'JS expression alias' },
112
+ x: { type: 'number', description: 'X coordinate for click' },
113
+ y: { type: 'number', description: 'Y coordinate for click' },
114
+ ms: { type: 'number', description: 'Milliseconds for wait' },
115
+ request: {
116
+ type: 'object',
117
+ description: 'For act action: {kind: "click"|"type", ref: elementIndex, text: "..."}',
118
+ properties: {
119
+ kind: { type: 'string' },
120
+ ref: {},
121
+ selector: { type: 'string' },
122
+ text: { type: 'string' }
123
+ }
124
+ }
125
+ },
126
+ required: ['action']
127
+ }
128
+ }
129
+ ];
130
+
131
+ // ── Tool implementations ────────────────────────────────────────────────────
132
+
133
+ export async function executeTool(name, input, ctx) {
134
+ const { taskCwd, workDir, agentId, onImage } = ctx;
135
+
136
+ switch (name) {
137
+
138
+ case 'bash': {
139
+ const timeout = Math.min(input.timeout_ms || 30000, 300000);
140
+ return new Promise((resolve) => {
141
+ let out = '';
142
+ const proc = spawn('bash', ['-c', input.command], {
143
+ cwd: taskCwd,
144
+ env: { ...process.env, TERM: 'dumb' },
145
+ });
146
+
147
+ const timer = setTimeout(() => {
148
+ proc.kill('SIGKILL');
149
+ resolve(out ? `[timeout after ${timeout}ms]\n${out}` : `[timeout after ${timeout}ms]`);
150
+ }, timeout);
151
+
152
+ proc.stdout.on('data', d => { out += d.toString(); });
153
+ proc.stderr.on('data', d => { out += d.toString(); });
154
+
155
+ proc.on('close', (code) => {
156
+ clearTimeout(timer);
157
+ // Check for AGENTFORGE_IMAGE protocol
158
+ const lines = out.split('\n');
159
+ const filtered = [];
160
+ for (const line of lines) {
161
+ if (line.trim().startsWith('AGENTFORGE_IMAGE:')) {
162
+ const imgPath = line.trim().slice('AGENTFORGE_IMAGE:'.length).trim();
163
+ if (onImage && existsSync(imgPath)) {
164
+ try {
165
+ const data = readFileSync(imgPath);
166
+ const ext = imgPath.split('.').pop().toLowerCase();
167
+ const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : ext === 'gif' ? 'image/gif' : 'image/png';
168
+ onImage(`data:${mime};base64,${data.toString('base64')}`);
169
+ } catch (e) { /* ignore */ }
170
+ }
171
+ } else {
172
+ filtered.push(line);
173
+ }
174
+ }
175
+ const result = filtered.join('\n').trimEnd();
176
+ resolve(result || `Exit ${code}`);
177
+ });
178
+ proc.on('error', (err) => { clearTimeout(timer); resolve(`Error: ${err.message}`); });
179
+ });
180
+ }
181
+
182
+ case 'read': {
183
+ const fp = resolveFile(input.file_path, taskCwd);
184
+ if (!existsSync(fp)) return `File not found: ${fp}`;
185
+ try {
186
+ const lines = readFileSync(fp, 'utf8').split('\n');
187
+ const start = Math.max(0, (input.offset || 1) - 1);
188
+ const end = input.limit ? start + input.limit : lines.length;
189
+ const slice = lines.slice(start, end);
190
+ return slice.map((l, i) => `${String(start + i + 1).padStart(6)} ${l}`).join('\n');
191
+ } catch (e) {
192
+ return `Error reading ${fp}: ${e.message}`;
193
+ }
194
+ }
195
+
196
+ case 'write': {
197
+ const fp = resolveFile(input.file_path, taskCwd);
198
+ try {
199
+ mkdirSync(path.dirname(fp), { recursive: true });
200
+ writeFileSync(fp, input.content, 'utf8');
201
+ return `Written ${input.content.length} bytes to ${fp}`;
202
+ } catch (e) {
203
+ return `Error writing ${fp}: ${e.message}`;
204
+ }
205
+ }
206
+
207
+ case 'edit': {
208
+ const fp = resolveFile(input.file_path, taskCwd);
209
+ if (!existsSync(fp)) return `File not found: ${fp}`;
210
+ try {
211
+ const content = readFileSync(fp, 'utf8');
212
+ const count = content.split(input.old_string).length - 1;
213
+ if (count === 0) return `Error: old_string not found in ${fp}\nFirst 200 chars of file:\n${content.slice(0, 200)}`;
214
+ if (count > 1 && !input.replace_all) return `Error: old_string found ${count} times — must be unique, or set replace_all: true`;
215
+ const newContent = input.replace_all
216
+ ? content.split(input.old_string).join(input.new_string)
217
+ : content.replace(input.old_string, input.new_string);
218
+ writeFileSync(fp, newContent, 'utf8');
219
+ return `Edited ${fp} (${count} replacement${count > 1 ? 's' : ''})`;
220
+ } catch (e) {
221
+ return `Error editing ${fp}: ${e.message}`;
222
+ }
223
+ }
224
+
225
+ case 'glob': {
226
+ const searchDir = input.path ? resolveFile(input.path, taskCwd) : taskCwd;
227
+ try {
228
+ // Use bash find + glob expansion for maximum compatibility
229
+ const pattern = input.pattern;
230
+ const cmd = `cd ${JSON.stringify(searchDir)} && find . -type f | grep -E ${JSON.stringify(globToRegex(pattern))} | sort -t'/' -k2 | head -200`;
231
+ const result = execSync(cmd, { cwd: searchDir, timeout: 10000 }).toString().trim();
232
+ if (!result) return '(no matches)';
233
+ return result.split('\n').map(f => f.replace(/^\.\//, '')).join('\n');
234
+ } catch {
235
+ try {
236
+ // Fallback: simple find
237
+ const result = execSync(`find ${JSON.stringify(searchDir)} -name "${input.pattern.replace(/\*\*\//g, '').split('/').pop()}" 2>/dev/null | head -100`, { timeout: 8000 }).toString().trim();
238
+ return result || '(no matches)';
239
+ } catch { return '(no matches)'; }
240
+ }
241
+ }
242
+
243
+ case 'grep': {
244
+ const searchPath = input.path ? resolveFile(input.path, taskCwd) : taskCwd;
245
+ const flags = ['-n', '--color=never'];
246
+ if (input['-i']) flags.push('-i');
247
+ if (input['-A']) flags.push(`-A${input['-A']}`);
248
+ if (input['-B']) flags.push(`-B${input['-B']}`);
249
+ if (input.glob) flags.push(`--include=${input.glob}`);
250
+ if (input.output_mode === 'files_with_matches') flags.push('-l');
251
+ if (input.output_mode === 'count') flags.push('-c');
252
+ flags.push('-r');
253
+ try {
254
+ const result = execSync(`grep ${flags.join(' ')} ${JSON.stringify(input.pattern)} ${JSON.stringify(searchPath)} 2>/dev/null | head -200`, { timeout: 15000 }).toString().trim();
255
+ return result || '(no matches)';
256
+ } catch {
257
+ return '(no matches)';
258
+ }
259
+ }
260
+
261
+ case 'browser': {
262
+ const result = await browserAction(input);
263
+ // Handle screenshot result — save to workspace and emit image
264
+ if (result && result.__screenshot) {
265
+ const imgPath = path.join(workDir || taskCwd, `browser_screenshot_${Date.now()}.png`);
266
+ try {
267
+ mkdirSync(path.dirname(imgPath), { recursive: true });
268
+ writeFileSync(imgPath, Buffer.from(result.base64, 'base64'));
269
+ if (onImage) onImage(`data:image/png;base64,${result.base64}`);
270
+ return `Screenshot saved to ${imgPath}. Image sent to chat.`;
271
+ } catch (e) {
272
+ if (onImage) onImage(`data:image/png;base64,${result.base64}`);
273
+ return `Screenshot taken (${Math.round(result.base64.length * 0.75 / 1024)}KB)`;
274
+ }
275
+ }
276
+ return result;
277
+ }
278
+
279
+ default:
280
+ return `Unknown tool: ${name}`;
281
+ }
282
+ }
283
+
284
+ // ── Helpers ─────────────────────────────────────────────────────────────────
285
+
286
+ function resolveFile(filePath, cwd) {
287
+ if (path.isAbsolute(filePath)) return filePath;
288
+ return path.join(cwd || process.cwd(), filePath);
289
+ }
290
+
291
+ function globToRegex(pattern) {
292
+ // Convert glob pattern to regex for grep filtering
293
+ return pattern
294
+ .replace(/\./g, '\\.')
295
+ .replace(/\*\*/g, '.*')
296
+ .replace(/\*/g, '[^/]*')
297
+ .replace(/\?/g, '[^/]');
298
+ }
@@ -0,0 +1,260 @@
1
+ #!/usr/bin/env node
2
+ // AgentForge Preview Server
3
+ // Usage: node preview-server.js <directory> <port> <agentId>
4
+ //
5
+ // Serves a static site and injects a click-to-comment overlay.
6
+ // Feedback is forwarded to the AgentForge server as a new agent task.
7
+
8
+ import { createServer } from 'http';
9
+ import { readFileSync, existsSync, statSync } from 'fs';
10
+ import { join, extname, resolve } from 'path';
11
+ import { homedir } from 'os';
12
+
13
+ const MIME_TYPES = {
14
+ '.html': 'text/html; charset=utf-8',
15
+ '.css': 'text/css',
16
+ '.js': 'application/javascript',
17
+ '.json': 'application/json',
18
+ '.png': 'image/png',
19
+ '.jpg': 'image/jpeg',
20
+ '.jpeg': 'image/jpeg',
21
+ '.gif': 'image/gif',
22
+ '.svg': 'image/svg+xml',
23
+ '.ico': 'image/x-icon',
24
+ '.woff': 'font/woff',
25
+ '.woff2': 'font/woff2',
26
+ '.ttf': 'font/ttf',
27
+ '.mp4': 'video/mp4',
28
+ '.webm': 'video/webm',
29
+ };
30
+
31
+ const [,, dir = '.', port = '8080', agentId = ''] = process.argv;
32
+ const serveDir = resolve(dir);
33
+ const PORT = parseInt(port);
34
+
35
+ // Read AgentForge config for Railway URL and worker token
36
+ let railwayUrl = '';
37
+ let workerToken = '';
38
+ try {
39
+ const configPath = join(homedir(), '.agentforge', 'config.json');
40
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
41
+ railwayUrl = (config.url || '').replace(/\/+$/, '');
42
+ workerToken = config.token || '';
43
+ } catch (e) {
44
+ console.warn('⚠️ Could not read ~/.agentforge/config.json — feedback forwarding disabled');
45
+ }
46
+
47
+ // Overlay script injected into every HTML page
48
+ const overlayScript = `<script data-agentforge-overlay>
49
+ (function() {
50
+ if (window.__agentforgeOverlay) return;
51
+ window.__agentforgeOverlay = true;
52
+
53
+ let popover = null;
54
+
55
+ function getSelector(el) {
56
+ if (!el || el === document.body) return 'body';
57
+ const parts = [];
58
+ let cur = el;
59
+ while (cur && cur !== document.body && parts.length < 4) {
60
+ let sel = cur.tagName.toLowerCase();
61
+ if (cur.id) { sel += '#' + cur.id; parts.unshift(sel); break; }
62
+ if (cur.className && typeof cur.className === 'string') {
63
+ const cls = [...cur.classList].filter(c => !c.match(/^(active|hover|focus|open|visible|hidden)$/)).slice(0, 2).join('.');
64
+ if (cls) sel += '.' + cls;
65
+ }
66
+ const siblings = cur.parentElement ? [...cur.parentElement.children].filter(s => s.tagName === cur.tagName) : [];
67
+ if (siblings.length > 1) sel += ':nth-of-type(' + (siblings.indexOf(cur) + 1) + ')';
68
+ parts.unshift(sel);
69
+ cur = cur.parentElement;
70
+ }
71
+ return parts.join(' > ');
72
+ }
73
+
74
+ function dismiss() { popover && (popover.remove(), popover = null); }
75
+
76
+ function showPopover(x, y, el) {
77
+ dismiss();
78
+ const selector = getSelector(el);
79
+ const snippet = el.textContent?.trim().replace(/\\s+/g, ' ').slice(0, 50) || '';
80
+
81
+ popover = document.createElement('div');
82
+ popover.dataset.agentforgePopover = '';
83
+ popover.style.cssText = [
84
+ 'position:fixed', 'z-index:2147483647', 'width:300px',
85
+ 'background:#111827', 'border:1px solid #374151', 'border-radius:12px',
86
+ 'padding:14px', 'box-shadow:0 20px 60px rgba(0,0,0,.7)',
87
+ 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
88
+ 'font-size:13px', 'color:#e5e7eb', 'box-sizing:border-box'
89
+ ].join(';');
90
+
91
+ let left = x + 14, top = y + 14;
92
+ if (left + 300 > window.innerWidth - 8) left = x - 314;
93
+ if (top + 200 > window.innerHeight - 8) top = y - 214;
94
+ popover.style.left = Math.max(8, left) + 'px';
95
+ popover.style.top = Math.max(8, top) + 'px';
96
+
97
+ popover.innerHTML = \`
98
+ <div style="font-size:11px;color:#6b7280;font-family:monospace;margin-bottom:4px;word-break:break-all;white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="\${selector}">\${selector}</div>
99
+ \${snippet ? \`<div style="font-size:11px;color:#9ca3af;margin-bottom:10px;font-style:italic;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">"\${snippet}"</div>\` : '<div style="margin-bottom:10px"></div>'}
100
+ <textarea id="_af_txt" placeholder="Feedback for the agent…" style="
101
+ width:100%;box-sizing:border-box;height:76px;resize:none;
102
+ background:#1f2937;border:1px solid #374151;border-radius:8px;
103
+ color:#e5e7eb;padding:8px 10px;font-size:13px;outline:none;
104
+ font-family:inherit;line-height:1.4;
105
+ "></textarea>
106
+ <div style="display:flex;gap:8px;margin-top:8px;align-items:center;">
107
+ <button id="_af_send" style="
108
+ flex:1;background:#6366f1;color:#fff;border:none;border-radius:8px;
109
+ padding:8px;font-size:13px;font-weight:600;cursor:pointer;
110
+ ">Send to Agent</button>
111
+ <button id="_af_close" style="
112
+ background:#1f2937;color:#9ca3af;border:1px solid #374151;border-radius:8px;
113
+ padding:8px 10px;font-size:13px;cursor:pointer;
114
+ ">✕</button>
115
+ </div>
116
+ <div id="_af_status" style="font-size:11px;text-align:center;margin-top:6px;min-height:14px;color:#6b7280;"></div>
117
+ \`;
118
+
119
+ document.body.appendChild(popover);
120
+ popover.querySelector('#_af_txt').focus();
121
+
122
+ popover.querySelector('#_af_close').onclick = dismiss;
123
+
124
+ popover.querySelector('#_af_send').onclick = async () => {
125
+ const comment = popover.querySelector('#_af_txt').value.trim();
126
+ if (!comment) return;
127
+ const btn = popover.querySelector('#_af_send');
128
+ const status = popover.querySelector('#_af_status');
129
+ btn.disabled = true; btn.textContent = 'Sending…';
130
+ try {
131
+ const r = await fetch('/agentforge-feedback', {
132
+ method: 'POST',
133
+ headers: { 'Content-Type': 'application/json' },
134
+ body: JSON.stringify({ comment, selector, elementText: snippet })
135
+ });
136
+ if (r.ok) {
137
+ status.style.color = '#6366f1';
138
+ status.textContent = '✓ Sent';
139
+ setTimeout(dismiss, 1000);
140
+ } else throw new Error('Server error ' + r.status);
141
+ } catch(err) {
142
+ status.style.color = '#ef4444';
143
+ status.textContent = err.message;
144
+ btn.disabled = false; btn.textContent = 'Send to Agent';
145
+ }
146
+ };
147
+ }
148
+
149
+ // Click → show popover (capture phase so it works on everything)
150
+ document.addEventListener('click', e => {
151
+ if (e.target.closest('[data-agentforge-popover]') || e.target.closest('[data-agentforge-overlay]')) return;
152
+ if (popover) { dismiss(); return; }
153
+ e.preventDefault(); e.stopPropagation();
154
+ const prev = e.target.style.outline;
155
+ e.target.style.outline = '2px solid #6366f1';
156
+ setTimeout(() => { e.target.style.outline = prev; }, 800);
157
+ showPopover(e.clientX, e.clientY, e.target);
158
+ }, true);
159
+
160
+ document.addEventListener('keydown', e => { if (e.key === 'Escape') dismiss(); });
161
+
162
+ // Badge
163
+ const badge = document.createElement('div');
164
+ badge.dataset.agentforgeOverlay = '';
165
+ badge.style.cssText = 'position:fixed;bottom:14px;right:14px;z-index:2147483646;background:#111827;border:1px solid #374151;border-radius:20px;padding:5px 11px;font-family:-apple-system,sans-serif;font-size:11px;color:#6b7280;pointer-events:none;';
166
+ badge.textContent = '⬡ AgentForge Preview — click to comment';
167
+ document.addEventListener('DOMContentLoaded', () => document.body.appendChild(badge));
168
+ if (document.body) document.body.appendChild(badge);
169
+ })();
170
+ </script>`;
171
+
172
+ const server = createServer(async (req, res) => {
173
+ // Feedback endpoint
174
+ if (req.method === 'POST' && req.url === '/agentforge-feedback') {
175
+ let body = '';
176
+ req.on('data', c => body += c);
177
+ req.on('end', async () => {
178
+ res.setHeader('Access-Control-Allow-Origin', '*');
179
+ try {
180
+ const { comment, selector, elementText } = JSON.parse(body);
181
+ if (!comment) { res.writeHead(400); res.end('{"error":"no comment"}'); return; }
182
+
183
+ const message = [
184
+ '[Visual feedback on your site preview]',
185
+ `Element: ${selector}${elementText ? ` — "${elementText}"` : ''}`,
186
+ `Feedback: ${comment}`
187
+ ].join('\n');
188
+
189
+ console.log(`\n📝 Feedback received:\n ${selector}\n "${comment}"\n`);
190
+
191
+ if (railwayUrl && workerToken && agentId) {
192
+ const resp = await fetch(`${railwayUrl}/api/preview/feedback`, {
193
+ method: 'POST',
194
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${workerToken}` },
195
+ body: JSON.stringify({ agentId, message })
196
+ });
197
+ if (!resp.ok) console.warn('⚠️ Failed to forward feedback to AgentForge:', resp.status);
198
+ } else {
199
+ console.log(' (No AgentForge config — feedback logged locally only)');
200
+ }
201
+
202
+ res.writeHead(200, { 'Content-Type': 'application/json' });
203
+ res.end('{"success":true}');
204
+ } catch (e) {
205
+ res.writeHead(400); res.end(`{"error":${JSON.stringify(e.message)}}`);
206
+ }
207
+ });
208
+ return;
209
+ }
210
+
211
+ if (req.method === 'OPTIONS') {
212
+ res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, GET', 'Access-Control-Allow-Headers': 'Content-Type' });
213
+ res.end(); return;
214
+ }
215
+
216
+ // Static file serving
217
+ let urlPath = decodeURIComponent(req.url.split('?')[0]);
218
+ if (urlPath === '/') urlPath = '/index.html';
219
+
220
+ let filePath = resolve(join(serveDir, urlPath));
221
+ if (!filePath.startsWith(serveDir)) { res.writeHead(403); res.end('Forbidden'); return; }
222
+
223
+ if (existsSync(filePath) && statSync(filePath).isDirectory()) {
224
+ filePath = join(filePath, 'index.html');
225
+ }
226
+
227
+ if (!existsSync(filePath)) {
228
+ // SPA fallback
229
+ const fallback = join(serveDir, 'index.html');
230
+ if (existsSync(fallback)) filePath = fallback;
231
+ else { res.writeHead(404); res.end('Not found'); return; }
232
+ }
233
+
234
+ try {
235
+ const ext = extname(filePath).toLowerCase();
236
+ const mime = MIME_TYPES[ext] || 'application/octet-stream';
237
+ let content = readFileSync(filePath);
238
+
239
+ if (ext === '.html') {
240
+ let html = content.toString();
241
+ const insertAt = html.lastIndexOf('</body>');
242
+ html = insertAt >= 0
243
+ ? html.slice(0, insertAt) + overlayScript + '\n' + html.slice(insertAt)
244
+ : html + overlayScript;
245
+ content = html;
246
+ }
247
+
248
+ res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': 'no-cache' });
249
+ res.end(content);
250
+ } catch (e) {
251
+ res.writeHead(500); res.end('Server error');
252
+ }
253
+ });
254
+
255
+ server.listen(PORT, () => {
256
+ console.log(`\n🔍 AgentForge Preview → http://localhost:${PORT}`);
257
+ console.log(` Directory : ${serveDir}`);
258
+ console.log(` Agent : ${agentId || '(not set)'}`);
259
+ console.log(` Click any element on the page to leave feedback for the agent\n`);
260
+ });