pw-repl 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.
package/lib/server.js ADDED
@@ -0,0 +1,91 @@
1
+ // Opt-in command server (pw-repl serve): POST /run {"command": "..."} runs one
2
+ // command through the same queue as the prompt and returns its output.
3
+ const http = require('http');
4
+ const net = require('net');
5
+ const fs = require('fs');
6
+ const out = require('./output');
7
+ const runner = require('./runner');
8
+ const { onShutdown, onBeforeExit } = require('./state');
9
+ const { parseEndpoint, describe } = require('./client');
10
+
11
+ const MAX_BODY = 1024 * 1024;
12
+ const FLUSH_TIMEOUT = 1000;
13
+
14
+ // Answers being written, so the REPL can let them reach their senders before it exits.
15
+ const sending = new Set();
16
+
17
+ function send(res, code, result) {
18
+ res.writeHead(code, { 'Content-Type': 'application/json' });
19
+ const done = new Promise(resolve => { res.once('finish', resolve); res.once('close', resolve); });
20
+ sending.add(done);
21
+ done.then(() => sending.delete(done));
22
+ res.end(`${JSON.stringify(result)}\n`);
23
+ }
24
+
25
+ onBeforeExit(() => Promise.race([Promise.all(sending), new Promise(resolve => setTimeout(resolve, FLUSH_TIMEOUT))]));
26
+
27
+ function handle(req, res) {
28
+ // A web page can reach a loopback port; it always sends Origin on a
29
+ // cross-origin POST, and cannot send a JSON content type without a
30
+ // preflight this server never answers.
31
+ if (req.headers.origin) return send(res, 403, { status: 'error', output: 'Requests from web pages are refused' });
32
+ if (req.method === 'GET' && req.url === '/health') return send(res, 200, { status: 'ok' });
33
+ if (req.method !== 'POST' || req.url !== '/run') return send(res, 404, { status: 'error', output: 'Use POST /run or GET /health' });
34
+ if (!/^application\/json\b/.test(req.headers['content-type'] || '')) return send(res, 415, { status: 'error', output: 'Content-Type must be application/json' });
35
+ let body = '';
36
+ req.setEncoding('utf8');
37
+ req.on('data', chunk => {
38
+ body += chunk;
39
+ if (body.length > MAX_BODY) { send(res, 413, { status: 'error', output: 'Request too large' }); req.destroy(); }
40
+ });
41
+ req.on('end', async () => {
42
+ let command;
43
+ try { ({ command } = JSON.parse(body)); } catch {}
44
+ if (typeof command !== 'string' || !command.trim() || /[\r\n]/.test(command)) {
45
+ return send(res, 400, { status: 'error', output: 'Body must be {"command": "<one line>"}' });
46
+ }
47
+ const result = await runner.submit(command.trim());
48
+ if (!result.uncertain) return send(res, 200, { status: result.status, output: result.output });
49
+ // unconfirmed: the command may or may not have done what it was sent to do.
50
+ if (result.interrupted) return send(res, 200, { status: result.status, output: result.output, unconfirmed: true });
51
+ send(res, 200, { status: result.status, output: `${result.output}\nBrowser command outcome is unknown; the REPL is disconnecting.`, unconfirmed: true });
52
+ runner.giveUp();
53
+ });
54
+ }
55
+
56
+ // A socket file left by a REPL that died is removed; one still answering is not.
57
+ async function clearStaleSocket(socketPath) {
58
+ let stat;
59
+ try { stat = fs.statSync(socketPath); } catch { return; }
60
+ if (!stat.isSocket()) throw new Error(`${socketPath} exists and is not a socket`);
61
+ const live = await new Promise(resolve => {
62
+ const probe = net.connect(socketPath);
63
+ probe.on('connect', () => { probe.destroy(); resolve(true); });
64
+ probe.on('error', () => resolve(false));
65
+ });
66
+ if (live) throw new Error(`Another REPL is already serving on ${socketPath}`);
67
+ fs.unlinkSync(socketPath);
68
+ }
69
+
70
+ async function serve(endpointArg) {
71
+ const endpoint = parseEndpoint(endpointArg);
72
+ if (endpoint.socket) await clearStaleSocket(endpoint.socket);
73
+ const server = http.createServer(handle);
74
+ // Owner-only from the moment the socket file exists.
75
+ const previousUmask = endpoint.socket ? process.umask(0o177) : null;
76
+ try {
77
+ await new Promise((resolve, reject) => {
78
+ server.once('error', reject);
79
+ if (endpoint.socket) server.listen(endpoint.socket, resolve);
80
+ else server.listen(endpoint.port, endpoint.host, resolve);
81
+ });
82
+ } finally {
83
+ if (previousUmask !== null) process.umask(previousUmask);
84
+ }
85
+ const removeSocket = () => { if (endpoint.socket) try { fs.unlinkSync(endpoint.socket); } catch {} };
86
+ onShutdown(() => { server.close(); removeSocket(); });
87
+ process.on('exit', removeSocket);
88
+ out.log(`Serving commands on ${describe(endpoint)}`);
89
+ }
90
+
91
+ module.exports = { serve };
package/lib/start.js ADDED
@@ -0,0 +1,117 @@
1
+ // Connects to the browser and runs the prompt (and the server with serve).
2
+ const { chromium } = require('playwright-core');
3
+ const readline = require('readline');
4
+ const { state, withTimeout, shutdown, beforeExit } = require('./state');
5
+ const out = require('./output');
6
+ const { listTabs, watchPage, complete } = require('./commands');
7
+ const runner = require('./runner');
8
+
9
+ const CDP_URL = process.env.PW_CDP_URL || 'http://localhost:9222';
10
+
11
+ async function start(options) {
12
+ const START_URL = options.startUrl || process.env.PW_START_URL || null;
13
+ out.log(`Connecting to ${CDP_URL}...`);
14
+ state.browser = await chromium.connectOverCDP(CDP_URL);
15
+ if (state.stopping) {
16
+ try { await withTimeout(state.browser.close(), 'Chromium shutdown'); }
17
+ catch (error) { process.exitCode = 1; out.error(`Could not confirm Chromium shutdown: ${error.message}`); }
18
+ return;
19
+ }
20
+ state.browser.on('disconnected', () => {
21
+ if (state.stopping) return;
22
+ state.connectionLost = true;
23
+ state.stopping = true;
24
+ process.exitCode = 1;
25
+ out.error('Chromium connection lost; queued commands will not run.');
26
+ void shutdown().then(() => process.exit(1));
27
+ });
28
+ out.log('Connected to Chromium via CDP');
29
+
30
+ const contexts = state.browser.contexts();
31
+ const pages = contexts.flatMap(c => c.pages());
32
+
33
+ state.page = pages[0];
34
+ state.tabListing = pages.slice();
35
+
36
+ // Hooks first, so the start URL's own requests, logs and dialogs are recorded.
37
+ for (const ctx of contexts) {
38
+ ctx.on('page', watchPage);
39
+ ctx.pages().forEach(watchPage);
40
+ }
41
+
42
+ if (START_URL) {
43
+ if (!state.page) {
44
+ state.page = await contexts[0].newPage();
45
+ }
46
+ await state.page.goto(START_URL, { waitUntil: 'networkidle', timeout: 15000 });
47
+ out.log(`Navigated to: ${state.page.url()}`);
48
+ }
49
+
50
+ if (!state.page) {
51
+ state.page = await (contexts[0] || await state.browser.newContext()).newPage();
52
+ out.log('Created new page');
53
+ }
54
+
55
+ out.log('');
56
+ await listTabs();
57
+ out.log('');
58
+ if (options.serve) await require('./server').serve(options.endpoint);
59
+ out.log("Run 'help' to see the commands. They act on the selected tab (*).");
60
+ out.log('');
61
+
62
+ // The prompt shows whether the command server is on.
63
+ state.promptBase = options.serve ? 'pw[serve]> ' : 'pw> ';
64
+ const rl = readline.createInterface({
65
+ input: process.stdin,
66
+ output: process.stdout,
67
+ prompt: state.promptBase,
68
+ completer: complete,
69
+ historySize: 1000,
70
+ removeHistoryDuplicates: true,
71
+ });
72
+ state.rl = rl;
73
+ out.onIdlePrint({
74
+ idle: () => !runner.busy() && !state.stopping,
75
+ print: text => {
76
+ if (process.stdout.isTTY) { readline.clearLine(process.stdout, 0); readline.cursorTo(process.stdout, 0); }
77
+ console.log(text);
78
+ rl.prompt(true);
79
+ },
80
+ });
81
+
82
+ rl.prompt();
83
+ rl.on('line', line => {
84
+ // Up-arrow should bring back the command, not the caller's completion id.
85
+ const bare = line.trim().replace(/^@[A-Za-z0-9][A-Za-z0-9._-]*\s+/, '');
86
+ if (bare !== line.trim() && rl.history[0] === line.trim()) {
87
+ rl.history.shift();
88
+ if (bare && rl.history[0] !== bare) rl.history.unshift(bare);
89
+ }
90
+ runner.enqueue(line);
91
+ });
92
+ rl.on('close', () => {
93
+ if (state.stopping) return;
94
+ endPromptLine();
95
+ runner.drained().then(() => shutdown(), () => shutdown())
96
+ .then(() => process.exit(process.exitCode || 0));
97
+ });
98
+ rl.on('SIGINT', stop);
99
+ }
100
+
101
+ // Ctrl-C and Ctrl-D leave the cursor after the prompt; the shell's prompt
102
+ // belongs on a line of its own.
103
+ function endPromptLine() {
104
+ if (process.stdout.isTTY) process.stdout.write('\n');
105
+ }
106
+
107
+ // Like quit: a server command still running is answered before the REPL exits.
108
+ function stop() {
109
+ endPromptLine();
110
+ runner.answerInterrupted();
111
+ shutdown().then(beforeExit).then(() => process.exit(process.exitCode || 0));
112
+ }
113
+
114
+ // SIGHUP too: closing the terminal must still remove the server's socket.
115
+ for (const signal of ['SIGTERM', 'SIGHUP']) process.on(signal, stop);
116
+
117
+ module.exports = { start: options => start(options).catch(e => { out.error(e.message); shutdown().finally(() => process.exit(1)); }) };
package/lib/state.js ADDED
@@ -0,0 +1,61 @@
1
+ // Session state shared by the entry point, the runner, and the commands, plus
2
+ // the shutdown that ends the session.
3
+ const out = require('./output');
4
+
5
+ const SHUTDOWN_TIMEOUT = 5000;
6
+
7
+ const state = {
8
+ browser: null,
9
+ page: null,
10
+ previousPage: null,
11
+ tabListing: [],
12
+ rl: null,
13
+ promptBase: 'pw> ',
14
+ stopping: false,
15
+ connectionLost: false,
16
+ shutdownFailed: false,
17
+ previousCommandAt: 0,
18
+ currentCommandAt: 0,
19
+ };
20
+
21
+ const cleanups = [];
22
+ const exitWaits = [];
23
+ let shutdownPromise = null;
24
+
25
+ function withTimeout(promise, label, duration = SHUTDOWN_TIMEOUT) {
26
+ return new Promise((resolve, reject) => {
27
+ const timer = setTimeout(() => reject(new Error(`${label} timed out`)), duration);
28
+ promise.then(
29
+ value => { clearTimeout(timer); resolve(value); },
30
+ error => { clearTimeout(timer); reject(error); },
31
+ );
32
+ });
33
+ }
34
+
35
+ function onShutdown(fn) {
36
+ cleanups.push(fn);
37
+ }
38
+
39
+ // Things to finish before the process exits, e.g. answers still being sent.
40
+ function onBeforeExit(fn) {
41
+ exitWaits.push(fn);
42
+ }
43
+
44
+ async function beforeExit() {
45
+ await Promise.all(exitWaits.map(fn => fn()));
46
+ }
47
+
48
+ async function shutdown() {
49
+ if (shutdownPromise) return shutdownPromise;
50
+ state.stopping = true;
51
+ shutdownPromise = (async () => {
52
+ cleanups.forEach(fn => fn());
53
+ if (state.browser) {
54
+ try { await withTimeout(state.browser.close(), 'Chromium shutdown'); }
55
+ catch (error) { state.shutdownFailed = true; process.exitCode = 1; out.error(`Could not confirm Chromium shutdown: ${error.message}`); }
56
+ }
57
+ })();
58
+ return shutdownPromise;
59
+ }
60
+
61
+ module.exports = { state, withTimeout, onShutdown, onBeforeExit, beforeExit, shutdown };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "pw-repl",
3
+ "version": "0.1.0",
4
+ "description": "Text REPL for driving an existing Chromium through Playwright over CDP",
5
+ "keywords": [
6
+ "playwright",
7
+ "repl",
8
+ "cdp",
9
+ "chromium",
10
+ "browser",
11
+ "devtools",
12
+ "agents"
13
+ ],
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/arcanemachine/playwright-repl.git"
18
+ },
19
+ "bin": {
20
+ "pw-repl": "bin/pw-repl.js"
21
+ },
22
+ "files": [
23
+ "bin/",
24
+ "lib/",
25
+ "AGENTS.md",
26
+ "skill/",
27
+ "README.md",
28
+ "LICENSE.md"
29
+ ],
30
+ "scripts": {
31
+ "start": "node bin/pw-repl.js run",
32
+ "test": "node --test 'test/*.test.js'",
33
+ "prepublishOnly": "npm test"
34
+ },
35
+ "engines": {
36
+ "node": ">=20"
37
+ },
38
+ "dependencies": {
39
+ "playwright-core": "^1.63.0"
40
+ }
41
+ }
package/skill/SKILL.md ADDED
@@ -0,0 +1,102 @@
1
+ ---
2
+ name: pw-repl
3
+ description: Inspect and drive a Chromium browser, possibly one a person is using, through the pw-repl (playwright-repl) REPL - tabs, page snapshots, clicks and typing, requests and their bodies, console messages, fake responses, cutting the network, and recording what the person does. Use when asked to look at, debug or test something in a browser that runs with remote debugging.
4
+ ---
5
+
6
+ # pw-repl
7
+
8
+ pw-repl (playwright-repl) is a REPL that drives a running Chromium over CDP. The browser may be a
9
+ shared session: a person can have their own tabs open in it and be using it while you work.
10
+
11
+ `pw-repl` below is the command the `pw-repl` npm package installs. Without a global install,
12
+ `npx pw-repl` works the same; from a clone, `<clone>/bin/pw-repl.js`.
13
+
14
+ ## Start it
15
+
16
+ The REPL runs in the tmux session `playwright-repl`. Check it first:
17
+
18
+ ```bash
19
+ tmux capture-pane -t playwright-repl -p -S -5
20
+ ```
21
+
22
+ If the pane is at a shell prompt, start the REPL there. If there is no session, ask the user before
23
+ creating one.
24
+
25
+ ```bash
26
+ tmux send-keys -t playwright-repl -l 'pw-repl run'
27
+ tmux send-keys -t playwright-repl Enter
28
+ ```
29
+
30
+ `pw-repl serve` is the same plus a command server on `/tmp/playwright-repl.sock` (owner-only); its
31
+ prompt is `pw[serve]>` instead of `pw>`. It is opt-in: use it when the user asks for it or agrees.
32
+ Prefer the socket: `pw-repl serve <port>` (TCP on 127.0.0.1) has no authentication, so any local
33
+ process could drive the browser; use it only for a client that cannot reach the socket. Both take an
34
+ optional start URL, which navigates tab [0]. Stop either at the pane (`quit`, or Ctrl-C); `send quit` is
35
+ refused so an agent cannot close a REPL the user is using.
36
+
37
+ ## Send commands
38
+
39
+ ```bash
40
+ pw-repl send tab
41
+ pw-repl send -t 90 'screenshot -d 60' # wait longer than the 20s default
42
+ ```
43
+
44
+ Always send commands with `pw-repl send`; don't type into the pane yourself. Unquoted words are one
45
+ command. It works however the REPL was started:
46
+
47
+ - `run`: `send` types the command into the tmux pane and reads the result back off the screen. It
48
+ types only when the pane's last line is a bare prompt, so nothing lands in a shell or in the middle
49
+ of what the user is typing; while a command is running, the user is typing, or the REPL is exiting,
50
+ it refuses (exit 64). Wait and retry.
51
+ - `serve`: `send` sends it over the socket and gets the output back as JSON. Same commands, more
52
+ reliable results: nothing is scraped, long output isn't cut off by scrollback, and a command can
53
+ never land in a shell. If you'll send many commands or read long output, ask the user whether to
54
+ restart the REPL with `pw-repl serve`.
55
+
56
+ `pw-repl where` says which one a command would reach (the server, or the tmux pane running the
57
+ REPL), or why neither is reachable, without running anything.
58
+
59
+ Either way, every command you run and its output show in the pane (server commands as `[server]` lines),
60
+ so the user sees what you do. The pane shows REPL commands only, not what the user clicked in the
61
+ browser (unless `watch on --live` is on); for that, look at the browser itself: `tab` and `info` for
62
+ where they are, `requests` for the requests their clicks made (`body <#>` for what one returned),
63
+ `console` for console messages and page errors. When the user wants to show you what they do, `watch on`
64
+ on their tab records each step with the requests it caused (`watch on --changes` adds what each step
65
+ changed on the page); `watch` reads it back, and `watch new` only what it has not shown yet. Watching
66
+ and reading are fine on the user's tabs; the rule below is about acting on them.
67
+
68
+ Exit status: `0` ok, `1` the command failed, `2` completion not confirmed (outcome unknown: do not
69
+ blindly retry a change), `64` usage or the REPL is not reachable. `pw-repl --help` has the options.
70
+
71
+ ## Learn the commands
72
+
73
+ Run `pw-repl send help`. It lists six topics; `help <topic>` lists their commands, `help <command>`
74
+ gives usage and caveats, and `help --all` prints everything at once. It needs no running REPL. The help is
75
+ the command reference; this file does not repeat it.
76
+
77
+ ## Shared-browser rules
78
+
79
+ - Act only on tabs you opened (`tab new`), unless the user asks you to act on theirs (e.g. a `route` in
80
+ their tab while they test); then say what you are doing and undo it the moment you are done. The REPL
81
+ selects tab [0] at startup, and that tab may be the user's: run `tab` before acting. Closing your
82
+ tab goes back only to a tab you opened; otherwise no tab is selected. Tab numbers change when tabs
83
+ open or close; `tab <url-part>` and `tab close <url-part>` pick a tab by its URL and refuse if it is
84
+ ambiguous.
85
+ - Dialogs are never answered automatically. The person at the browser handles them.
86
+ - Before leaving: turn off the modes you turned on (`modes` lists what is on in every tab; the prompt
87
+ shows the selected tab's, e.g. `(watch routes:1) pw>`), and close the tabs you opened. `modes off`
88
+ turns off everything, including modes the user turned on, so use it only when they are all yours.
89
+ - The tmux session may be attached by the user. Never kill it.
90
+
91
+ ## Environment
92
+
93
+ - Chromium must be running with `--remote-debugging-port=9222`.
94
+ - `PW_CDP_URL` — CDP endpoint (default `http://localhost:9222`).
95
+ - `PW_SCREENSHOT_DIR` — where screenshots go (default `/tmp`). They are all named `screenshot-*.png`,
96
+ so `rm /tmp/screenshot-*.png` cleans up.
97
+ - `PW_ENDPOINT` / `PW_TMUX_SESSION` — defaults for `send -e` / `-s`. `PW_SOCKET` — the socket `send`
98
+ looks for when neither is given (default `/tmp/playwright-repl.sock`).
99
+
100
+ ## Custom rules
101
+
102
+ No custom rules have been added yet.