barebrowse 0.2.1 → 0.3.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.
- package/.claude/memory/AGENT_RULES.md +251 -0
- package/.claude/settings.local.json +37 -0
- package/.claude/skills/barebrowse/SKILL.md +107 -0
- package/.claude/stash/barebrowse-research-2026-02-22.md +49 -0
- package/.claude/stash/phase3-interactions-complete.md +69 -0
- package/.claude/stash/phase3-prep.md +88 -0
- package/.claude/stash/phase4-complete-2026-02-22.md +61 -0
- package/CHANGELOG.md +53 -0
- package/CLAUDE.md +4 -2
- package/README.md +54 -7
- package/barebrowse.context.md +27 -8
- package/cli.js +289 -48
- package/docs/00-context/assumptions.md +38 -0
- package/docs/{blueprint.md → 00-context/system-state.md} +30 -5
- package/docs/00-context/vision.md +52 -0
- package/docs/01-product/prd.md +284 -0
- package/docs/03-logs/bug-log.md +16 -0
- package/docs/03-logs/decisions-log.md +32 -0
- package/docs/03-logs/implementation-log.md +54 -0
- package/docs/03-logs/insights.md +35 -0
- package/docs/03-logs/validation-log.md +123 -0
- package/docs/04-process/definition-of-done.md +31 -0
- package/docs/04-process/dev-workflow.md +68 -0
- package/docs/{testing.md → 04-process/testing.md} +21 -2
- package/docs/README.md +55 -0
- package/docs/archive/poc-plan.md +230 -0
- package/mcp-server.js +1 -1
- package/package.json +1 -1
- package/src/aria.js +1 -1
- package/src/daemon.js +321 -0
- package/src/session-client.js +70 -0
package/src/daemon.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon.js -- Background HTTP server holding a connect() session.
|
|
3
|
+
*
|
|
4
|
+
* startDaemon() — spawn a detached child process running the daemon
|
|
5
|
+
* runDaemon() — the actual HTTP server (called via --daemon-internal)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createServer } from 'node:http';
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { writeFileSync, mkdirSync, existsSync, readFileSync, unlinkSync } from 'node:fs';
|
|
11
|
+
import { join, resolve } from 'node:path';
|
|
12
|
+
import { connect } from './index.js';
|
|
13
|
+
|
|
14
|
+
const SESSION_FILE = 'session.json';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Spawn a detached child process that runs the daemon.
|
|
18
|
+
* Parent polls for session.json, then exits.
|
|
19
|
+
*/
|
|
20
|
+
export async function startDaemon(opts, outputDir, initialUrl) {
|
|
21
|
+
const absDir = resolve(outputDir);
|
|
22
|
+
mkdirSync(absDir, { recursive: true });
|
|
23
|
+
|
|
24
|
+
// Clean stale session
|
|
25
|
+
const sessionPath = join(absDir, SESSION_FILE);
|
|
26
|
+
if (existsSync(sessionPath)) unlinkSync(sessionPath);
|
|
27
|
+
|
|
28
|
+
// Build child args
|
|
29
|
+
const args = [join(import.meta.dirname, '..', 'cli.js'), '--daemon-internal'];
|
|
30
|
+
args.push('--output-dir', absDir);
|
|
31
|
+
if (initialUrl) args.push('--url', initialUrl);
|
|
32
|
+
if (opts.mode) args.push('--mode', opts.mode);
|
|
33
|
+
if (opts.port) args.push('--port', String(opts.port));
|
|
34
|
+
if (opts.cookies === false) args.push('--no-cookies');
|
|
35
|
+
if (opts.browser) args.push('--browser', opts.browser);
|
|
36
|
+
if (opts.timeout) args.push('--timeout', String(opts.timeout));
|
|
37
|
+
if (opts.pruneMode) args.push('--prune-mode', opts.pruneMode);
|
|
38
|
+
if (opts.consent === false) args.push('--no-consent');
|
|
39
|
+
|
|
40
|
+
const child = spawn(process.execPath, args, {
|
|
41
|
+
detached: true,
|
|
42
|
+
stdio: 'ignore',
|
|
43
|
+
env: { ...process.env },
|
|
44
|
+
});
|
|
45
|
+
child.unref();
|
|
46
|
+
|
|
47
|
+
// Poll for session.json (50ms interval, 15s timeout)
|
|
48
|
+
const deadline = Date.now() + 15000;
|
|
49
|
+
while (Date.now() < deadline) {
|
|
50
|
+
if (existsSync(sessionPath)) {
|
|
51
|
+
try {
|
|
52
|
+
const data = JSON.parse(readFileSync(sessionPath, 'utf8'));
|
|
53
|
+
if (data.port && data.pid) return data;
|
|
54
|
+
} catch { /* partial write, retry */ }
|
|
55
|
+
}
|
|
56
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
57
|
+
}
|
|
58
|
+
throw new Error('Daemon failed to start within 15s');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Run the daemon HTTP server. Called by cli.js --daemon-internal.
|
|
63
|
+
* Holds a connect() session and serves commands over HTTP.
|
|
64
|
+
*/
|
|
65
|
+
export async function runDaemon(opts, outputDir, initialUrl) {
|
|
66
|
+
const absDir = resolve(outputDir);
|
|
67
|
+
mkdirSync(absDir, { recursive: true });
|
|
68
|
+
|
|
69
|
+
// Connect to browser
|
|
70
|
+
const page = await connect({
|
|
71
|
+
mode: opts.mode || 'headless',
|
|
72
|
+
port: opts.port ? Number(opts.port) : undefined,
|
|
73
|
+
consent: opts.consent,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Console log capture
|
|
77
|
+
const consoleLogs = [];
|
|
78
|
+
await page.cdp.send('Runtime.enable');
|
|
79
|
+
page.cdp.on('Runtime.consoleAPICalled', (params) => {
|
|
80
|
+
consoleLogs.push({
|
|
81
|
+
type: params.type,
|
|
82
|
+
timestamp: new Date().toISOString(),
|
|
83
|
+
args: params.args.map((a) => a.value ?? a.description ?? a.type),
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Network log capture (Network.enable already called by connect)
|
|
88
|
+
const networkLogs = [];
|
|
89
|
+
const pendingRequests = new Map();
|
|
90
|
+
|
|
91
|
+
page.cdp.on('Network.requestWillBeSent', (params) => {
|
|
92
|
+
pendingRequests.set(params.requestId, {
|
|
93
|
+
url: params.request.url,
|
|
94
|
+
method: params.request.method,
|
|
95
|
+
timestamp: new Date().toISOString(),
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
page.cdp.on('Network.responseReceived', (params) => {
|
|
100
|
+
const req = pendingRequests.get(params.requestId);
|
|
101
|
+
if (req) {
|
|
102
|
+
networkLogs.push({
|
|
103
|
+
...req,
|
|
104
|
+
status: params.response.status,
|
|
105
|
+
statusText: params.response.statusText,
|
|
106
|
+
mimeType: params.response.mimeType,
|
|
107
|
+
});
|
|
108
|
+
pendingRequests.delete(params.requestId);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
page.cdp.on('Network.loadingFailed', (params) => {
|
|
113
|
+
const req = pendingRequests.get(params.requestId);
|
|
114
|
+
if (req) {
|
|
115
|
+
networkLogs.push({
|
|
116
|
+
...req,
|
|
117
|
+
status: 0,
|
|
118
|
+
error: params.errorText,
|
|
119
|
+
});
|
|
120
|
+
pendingRequests.delete(params.requestId);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Navigate to initial URL if provided
|
|
125
|
+
if (initialUrl) {
|
|
126
|
+
if (opts.cookies !== false) {
|
|
127
|
+
try { await page.injectCookies(initialUrl, { browser: opts.browser }); } catch { /* no cookies */ }
|
|
128
|
+
}
|
|
129
|
+
await page.goto(initialUrl, opts.timeout ? Number(opts.timeout) : 30000);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Default prune mode
|
|
133
|
+
const defaultPruneMode = opts.pruneMode || 'act';
|
|
134
|
+
|
|
135
|
+
// Command handlers
|
|
136
|
+
const handlers = {
|
|
137
|
+
async goto({ url, timeout }) {
|
|
138
|
+
await page.goto(url, timeout || 30000);
|
|
139
|
+
return { ok: true };
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
async snapshot({ mode }) {
|
|
143
|
+
const pruneMode = mode || defaultPruneMode;
|
|
144
|
+
const text = await page.snapshot({ mode: pruneMode });
|
|
145
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
146
|
+
const file = join(absDir, `page-${ts}.yml`);
|
|
147
|
+
writeFileSync(file, text);
|
|
148
|
+
return { ok: true, file };
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
async screenshot({ format }) {
|
|
152
|
+
const data = await page.screenshot({ format: format || 'png' });
|
|
153
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
154
|
+
const ext = format || 'png';
|
|
155
|
+
const file = join(absDir, `screenshot-${ts}.${ext}`);
|
|
156
|
+
writeFileSync(file, Buffer.from(data, 'base64'));
|
|
157
|
+
return { ok: true, file };
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
async click({ ref }) {
|
|
161
|
+
await page.click(String(ref));
|
|
162
|
+
return { ok: true };
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
async type({ ref, text, clear }) {
|
|
166
|
+
await page.type(String(ref), text, clear ? { clear: true } : undefined);
|
|
167
|
+
return { ok: true };
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
async fill({ ref, text }) {
|
|
171
|
+
await page.type(String(ref), text, { clear: true });
|
|
172
|
+
return { ok: true };
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
async press({ key }) {
|
|
176
|
+
await page.press(key);
|
|
177
|
+
return { ok: true };
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
async scroll({ deltaY }) {
|
|
181
|
+
await page.scroll(Number(deltaY));
|
|
182
|
+
return { ok: true };
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
async hover({ ref }) {
|
|
186
|
+
await page.hover(String(ref));
|
|
187
|
+
return { ok: true };
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
async select({ ref, value }) {
|
|
191
|
+
await page.select(String(ref), value);
|
|
192
|
+
return { ok: true };
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
async eval({ expression }) {
|
|
196
|
+
const result = await page.cdp.send('Runtime.evaluate', {
|
|
197
|
+
expression,
|
|
198
|
+
returnByValue: true,
|
|
199
|
+
awaitPromise: true,
|
|
200
|
+
});
|
|
201
|
+
if (result.exceptionDetails) {
|
|
202
|
+
return { ok: false, error: result.exceptionDetails.text || 'eval error' };
|
|
203
|
+
}
|
|
204
|
+
return { ok: true, value: result.result.value };
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
async 'wait-idle'({ timeout }) {
|
|
208
|
+
await page.waitForNetworkIdle({ timeout: timeout || 30000 });
|
|
209
|
+
return { ok: true };
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
async 'wait-nav'({ timeout }) {
|
|
213
|
+
await page.waitForNavigation(timeout || 30000);
|
|
214
|
+
return { ok: true };
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
async 'console-logs'({ level, clear }) {
|
|
218
|
+
let logs = consoleLogs;
|
|
219
|
+
if (level) logs = logs.filter((l) => l.type === level);
|
|
220
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
221
|
+
const file = join(absDir, `console-${ts}.json`);
|
|
222
|
+
writeFileSync(file, JSON.stringify(logs, null, 2));
|
|
223
|
+
if (clear) consoleLogs.length = 0;
|
|
224
|
+
return { ok: true, file, count: logs.length };
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
async 'network-log'({ failed }) {
|
|
228
|
+
let logs = networkLogs;
|
|
229
|
+
if (failed) logs = logs.filter((l) => l.status === 0 || l.status >= 400);
|
|
230
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
231
|
+
const file = join(absDir, `network-${ts}.json`);
|
|
232
|
+
writeFileSync(file, JSON.stringify(logs, null, 2));
|
|
233
|
+
return { ok: true, file, count: logs.length };
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
async close() {
|
|
237
|
+
await page.close();
|
|
238
|
+
// Clean up session file
|
|
239
|
+
const sessionPath = join(absDir, SESSION_FILE);
|
|
240
|
+
if (existsSync(sessionPath)) unlinkSync(sessionPath);
|
|
241
|
+
// Respond before exiting
|
|
242
|
+
return { ok: true };
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
async status() {
|
|
246
|
+
return { ok: true, pid: process.pid, uptime: process.uptime() };
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
// Start HTTP server on random port
|
|
251
|
+
const server = createServer(async (req, res) => {
|
|
252
|
+
if (req.method === 'GET' && req.url === '/status') {
|
|
253
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
254
|
+
res.end(JSON.stringify({ ok: true, pid: process.pid }));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (req.method !== 'POST' || req.url !== '/command') {
|
|
259
|
+
res.writeHead(404);
|
|
260
|
+
res.end('Not found');
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let body = '';
|
|
265
|
+
for await (const chunk of req) body += chunk;
|
|
266
|
+
|
|
267
|
+
let parsed;
|
|
268
|
+
try {
|
|
269
|
+
parsed = JSON.parse(body);
|
|
270
|
+
} catch {
|
|
271
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
272
|
+
res.end(JSON.stringify({ ok: false, error: 'Invalid JSON' }));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const { command, args } = parsed;
|
|
277
|
+
const handler = handlers[command];
|
|
278
|
+
if (!handler) {
|
|
279
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
280
|
+
res.end(JSON.stringify({ ok: false, error: `Unknown command: ${command}` }));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
const result = await handler(args || {});
|
|
286
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
287
|
+
res.end(JSON.stringify(result));
|
|
288
|
+
|
|
289
|
+
// Exit after close command
|
|
290
|
+
if (command === 'close') {
|
|
291
|
+
server.close();
|
|
292
|
+
process.exit(0);
|
|
293
|
+
}
|
|
294
|
+
} catch (err) {
|
|
295
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
296
|
+
res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
await new Promise((resolve) => {
|
|
301
|
+
server.listen(0, '127.0.0.1', () => resolve());
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const port = server.address().port;
|
|
305
|
+
|
|
306
|
+
// Write session.json so parent/clients can find us
|
|
307
|
+
const sessionPath = join(absDir, SESSION_FILE);
|
|
308
|
+
writeFileSync(sessionPath, JSON.stringify({
|
|
309
|
+
port,
|
|
310
|
+
pid: process.pid,
|
|
311
|
+
startedAt: new Date().toISOString(),
|
|
312
|
+
}));
|
|
313
|
+
|
|
314
|
+
// Handle SIGTERM gracefully
|
|
315
|
+
process.on('SIGTERM', async () => {
|
|
316
|
+
try { await page.close(); } catch { /* already closed */ }
|
|
317
|
+
if (existsSync(sessionPath)) unlinkSync(sessionPath);
|
|
318
|
+
server.close();
|
|
319
|
+
process.exit(0);
|
|
320
|
+
});
|
|
321
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-client.js -- HTTP client to talk to the daemon.
|
|
3
|
+
*
|
|
4
|
+
* sendCommand() — POST a command to the running daemon
|
|
5
|
+
* readSession() — read session.json from output dir
|
|
6
|
+
* isAlive() — check if daemon is still responding
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
10
|
+
import { join, resolve } from 'node:path';
|
|
11
|
+
|
|
12
|
+
const SESSION_FILE = 'session.json';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Read session.json from the output directory.
|
|
16
|
+
* @returns {{ port: number, pid: number, startedAt: string } | null}
|
|
17
|
+
*/
|
|
18
|
+
export function readSession(outputDir) {
|
|
19
|
+
const sessionPath = join(resolve(outputDir), SESSION_FILE);
|
|
20
|
+
if (!existsSync(sessionPath)) return null;
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(readFileSync(sessionPath, 'utf8'));
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check if the daemon is alive by hitting GET /status.
|
|
30
|
+
*/
|
|
31
|
+
export async function isAlive(outputDir) {
|
|
32
|
+
const session = readSession(outputDir);
|
|
33
|
+
if (!session) return false;
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetch(`http://127.0.0.1:${session.port}/status`, {
|
|
36
|
+
signal: AbortSignal.timeout(2000),
|
|
37
|
+
});
|
|
38
|
+
return res.ok;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Send a command to the running daemon.
|
|
46
|
+
* @returns {Promise<object>} The daemon's response
|
|
47
|
+
*/
|
|
48
|
+
export async function sendCommand(command, args, outputDir) {
|
|
49
|
+
const session = readSession(outputDir);
|
|
50
|
+
if (!session) throw new Error('No active session. Run `barebrowse open` first.');
|
|
51
|
+
|
|
52
|
+
let res;
|
|
53
|
+
try {
|
|
54
|
+
res = await fetch(`http://127.0.0.1:${session.port}/command`, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: { 'Content-Type': 'application/json' },
|
|
57
|
+
body: JSON.stringify({ command, args }),
|
|
58
|
+
signal: AbortSignal.timeout(60000),
|
|
59
|
+
});
|
|
60
|
+
} catch (err) {
|
|
61
|
+
// ECONNREFUSED / ECONNRESET — daemon died
|
|
62
|
+
if (command === 'close') {
|
|
63
|
+
// Expected: daemon exited before response
|
|
64
|
+
return { ok: true };
|
|
65
|
+
}
|
|
66
|
+
throw new Error(`Daemon not responding (pid ${session.pid}). Session may be stale.`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return res.json();
|
|
70
|
+
}
|