jevway 0.1.2 → 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.
Files changed (74) hide show
  1. package/README.md +227 -149
  2. package/SETUP.md +212 -0
  3. package/dist/browser/downloads.js +103 -0
  4. package/dist/browser/frames.js +21 -1
  5. package/dist/browser/snapshot.js +53 -0
  6. package/dist/browser/tab.js +59 -10
  7. package/dist/cli/daemon-call.js +78 -0
  8. package/dist/cli/dispatch.js +76 -0
  9. package/dist/cli/flags.js +152 -0
  10. package/dist/cli/help.js +46 -0
  11. package/dist/cli/inbox-command.js +199 -0
  12. package/dist/cli/journey-command.js +162 -0
  13. package/dist/cli/look-command.js +54 -0
  14. package/dist/cli/main.js +64 -0
  15. package/dist/cli/output.js +27 -0
  16. package/dist/cli/run-command.js +221 -0
  17. package/dist/cli/session-commands.js +132 -0
  18. package/dist/daemon/client.js +175 -0
  19. package/dist/daemon/paths.js +35 -0
  20. package/dist/daemon/protocol.js +28 -0
  21. package/dist/daemon/server.js +339 -0
  22. package/dist/inbox/inbox-file.js +43 -0
  23. package/dist/inbox/inbox-registry.js +33 -0
  24. package/dist/inbox/inbox.js +127 -0
  25. package/dist/inbox/mail-api.js +52 -0
  26. package/dist/inbox/message.js +34 -0
  27. package/dist/jev/replay.js +7 -3
  28. package/dist/jev/retry.js +29 -0
  29. package/dist/jev/secrets.js +4 -3
  30. package/dist/journeys/journey-doc.js +2 -1
  31. package/dist/journeys/journey-session.js +14 -3
  32. package/dist/journeys/render-viewer.js +163 -150
  33. package/dist/mcp/inbox-tools.js +82 -0
  34. package/dist/mcp/instructions.js +27 -0
  35. package/dist/mcp/journey-tools.js +117 -0
  36. package/dist/mcp/results.js +53 -0
  37. package/dist/mcp/server.js +57 -0
  38. package/dist/mcp/task-tools.js +213 -0
  39. package/dist/mcp.js +10 -337
  40. package/dist/notify/desktop.js +40 -0
  41. package/dist/notify/person-messages.js +50 -0
  42. package/dist/output/layout.js +86 -0
  43. package/dist/output/manifest.js +86 -0
  44. package/dist/output/run-folder.js +80 -0
  45. package/dist/recording/describe-step.js +69 -0
  46. package/dist/{task → recording}/recording-page.js +164 -18
  47. package/dist/{task → recording}/recording.js +33 -91
  48. package/dist/recording/run-page.js +155 -0
  49. package/dist/session/session-service.js +213 -0
  50. package/dist/setup/atomic-write.js +13 -0
  51. package/dist/setup/clients.js +180 -0
  52. package/dist/setup/command-output.js +15 -0
  53. package/dist/setup/doctor-command.js +16 -7
  54. package/dist/setup/doctor.js +17 -2
  55. package/dist/setup/env-file.js +22 -0
  56. package/dist/setup/setup-command.js +104 -0
  57. package/dist/setup/setup-wizard.js +216 -0
  58. package/dist/setup/skill-command.js +124 -0
  59. package/dist/setup/terminal-prompter.js +51 -0
  60. package/dist/setup/unified-diff.js +69 -0
  61. package/dist/task/ask-user.js +182 -0
  62. package/dist/task/downloads.js +63 -0
  63. package/dist/task/person-wait.js +104 -0
  64. package/dist/task/present.js +111 -0
  65. package/dist/task/run-task.js +332 -32
  66. package/dist/task/start-task.js +70 -11
  67. package/dist/task/totp.js +124 -0
  68. package/dist/task/upload-files.js +237 -5
  69. package/package.json +10 -8
  70. package/skill/jevway/SKILL.md +54 -15
  71. package/skill/jevway-inbox/SKILL.md +61 -0
  72. package/skill/jevway-inbox/scripts/inbox.mjs +209 -0
  73. package/INSTALL-FOR-AGENTS.md +0 -138
  74. package/dist/cli.js +0 -112
package/SETUP.md ADDED
@@ -0,0 +1,212 @@
1
+ # Setting up jevway (for agents)
2
+
3
+ You are an agent asked to set up jevway for a person and prove it works. jevway is an MCP server, `jevway-mcp`, plus a
4
+ CLI, `jevway`, both from the npm package `jevway`. It hands browser goals to TypeSafe Jev and returns one checked
5
+ summary per goal.
6
+
7
+ Read this whole file before you run anything. You will:
8
+ 1. check the machine's prerequisites with the person and help install what's missing;
9
+ 2. get the API key in place (the person does that part);
10
+ 3. register the MCP server with the client you're running in;
11
+ 4. pick the browser with the person;
12
+ 5. install the skill;
13
+ 6. check the setup;
14
+ 7. run a recorded smoke test, from Google to Wikipedia;
15
+ 8. report back with the run page, its video and screenshots, as proof.
16
+
17
+ Run only these yourself:
18
+ - `node --version`, `ffmpeg -version`, and the person-approved install commands from step 1;
19
+ - `npx -y jevway doctor --json` (or `--plan`, or `jevway doctor`);
20
+ - `npx -y jevway setup --client <name> --print [--json]`, which prints how to register jevway with a client and
21
+ changes nothing;
22
+ - `npx -y jevway skill --install <skills folder>`;
23
+ - `npm install -g jevway`;
24
+ - the client's own `mcp add` command, or an edit to its MCP config file that the person approved;
25
+ - the jevway tools themselves (or `npx -y jevway run …`) for the smoke test.
26
+
27
+ Never run plain `jevway setup` yourself: it is the person's interactive setup, and without a terminal it exits 2 with
28
+ `{ "ok": false, "error": { "code": "needs_tty", … } }`.
29
+
30
+ Done means: `jevway doctor --json` prints `"ready": true`, the client lists the `jevway_*` tools, and the smoke test
31
+ ended `done` with a run page you showed the person.
32
+
33
+ ## Rules
34
+
35
+ - **Show a diff before editing any MCP config file, and wait for approval.** Keep the person's other servers as they
36
+ are. When the client has an `mcp add` command, prefer it.
37
+ - **Never ask for the key in the chat, and never echo it.** The person puts it in `~/.config/jevway/env` themselves
38
+ (below), or in the client's env block. Don't write a key into config that is committed to git or synced between
39
+ machines.
40
+ - **Never relaunch, close or restart a browser you didn't start.** For the person's own Chrome, ask them to allow
41
+ remote debugging. If they'd rather keep it untouched, use `JEVWAY_BROWSER=launch` (a throwaway profile jevway starts
42
+ and closes).
43
+ - **Pass the key explicitly when the client filters the environment.** Codex, for example, gives MCP servers only a few
44
+ variables. The env file works on every client, because jevway reads it itself; the client only has to pass `HOME`.
45
+ Otherwise put `TYPESAFE_API_KEY` in that server's env block.
46
+ - **Stop and ask** when a step would change another browser tool's config, or when the doctor reports something this
47
+ file doesn't cover.
48
+
49
+ ## 1. Check the machine
50
+
51
+ ```bash
52
+ npx -y jevway doctor --json
53
+ ```
54
+
55
+ It prints `{ ready, findings: [{ check, level, message, fix? }] }`; `--plan` prints the steps still missing, numbered.
56
+ It needs no key to run and never prints one. It checks:
57
+ - Node 24 or later;
58
+ - a key, and which route it selects;
59
+ - the browser source;
60
+ - ffmpeg (optional: only for recordings).
61
+
62
+ `npx` itself needs Node, so check `node --version` first. For anything missing, propose the command that fits the
63
+ person's OS and package manager, and run it only after they say yes (or wait while they install it themselves, then
64
+ check again):
65
+
66
+ | Tool | Needed for | macOS (Homebrew) | Debian / Ubuntu | Fedora | Windows (winget) |
67
+ | --- | --- | --- | --- | --- | --- |
68
+ | Node.js 24+ | running jevway (required) | `brew install node` | Node 24 from NodeSource or nvm | `sudo dnf install nodejs` (check it's 24+) | `winget install OpenJS.NodeJS` |
69
+ | Chrome or Chromium | every run (required) | `brew install --cask google-chrome` | Google's `.deb`, or `sudo apt install chromium` | `sudo dnf install chromium` | `winget install Google.Chrome` |
70
+ | ffmpeg | the video in a recording (optional) | `brew install ffmpeg` | `sudo apt install ffmpeg` | `sudo dnf install ffmpeg-free` | `winget install Gyan.FFmpeg` |
71
+ | notify-send | the desktop notification when a person is needed (optional, Linux) | built in | `sudo apt install libnotify-bin` | `sudo dnf install libnotify` | not used |
72
+
73
+ A Chrome in an unusual place: set `CHROME_PATH` in `~/.config/jevway/env`.
74
+
75
+ If the first `npx` start is slow, or a client times out starting the server, install once with `npm install -g jevway`.
76
+ Then use `jevway-mcp` as the command instead of `npx -y -p jevway jevway-mcp`.
77
+
78
+ ## 2. The key
79
+
80
+ jevway needs one of these:
81
+ - `TYPESAFE_API_KEY` (preferred): Jev on the TypeSafe API, with screenshots and Box.
82
+ - `OPENROUTER_API_KEY`: Jev, and the other models, through OpenRouter.
83
+
84
+ The easiest way for the person: run `npx -y jevway setup` in their own terminal. It reads the key with hidden input,
85
+ writes only that line to `~/.config/jevway/env` (mode 600), tests the browser, and offers to register their MCP
86
+ clients, showing each change first.
87
+
88
+ Or they add the line themselves, in their own editor or terminal, never in this chat:
89
+
90
+ ```bash
91
+ mkdir -p ~/.config/jevway && touch ~/.config/jevway/env && chmod 600 ~/.config/jevway/env
92
+ # then, in an editor: TYPESAFE_API_KEY=<their key>
93
+ ```
94
+
95
+ How the file is read:
96
+ - `jevway` and `jevway-mcp` read it at start (`$XDG_CONFIG_HOME/jevway/env` when that is set).
97
+ - They take only the keys, `CHROME_PATH` and `JEVWAY_*` settings.
98
+ - A variable already in the environment wins.
99
+
100
+ Without a key the MCP server still starts and lists its tools. `jevway_look` works, and each other call answers with
101
+ the fix.
102
+
103
+ ## 3. Register the MCP server
104
+
105
+ Use the server name `jevway`. Add `JEVWAY_BROWSER` only if the person chose something other than `auto`.
106
+
107
+ **Claude Code.** The plugin brings the server and the skill in one install. Run these inside Claude Code:
108
+
109
+ ```text
110
+ /plugin marketplace add AbdallahAHO/jevway
111
+ /plugin install jevway@jevway
112
+ ```
113
+
114
+ Otherwise ask jevway for the exact command or snippet. It prints, and changes nothing:
115
+
116
+ ```bash
117
+ npx -y jevway setup --client claude-code --print # also: codex, cursor, opencode, other
118
+ npx -y jevway setup --client cursor --print --json # { client, kind, command?, file?, snippet }
119
+ ```
120
+
121
+ - `kind: "command"` (Claude Code, Codex): run `command`. For Codex, `snippet` is the table it writes to `file`.
122
+ - `kind: "config"` (Cursor, OpenCode): merge `snippet` into `file`, keeping every other server; show the person the
123
+ diff first and wait for approval. `other` gives the `command` and `args` for any stdio MCP client.
124
+
125
+ ## 4. The browser
126
+
127
+ Ask the person which browser jevway should use, and set `JEVWAY_BROWSER` in the server's env (or `--browser` for
128
+ the CLI) when it isn't `auto`. For the smoke test a visible one is best, so they can pass a check if one appears.
129
+
130
+ | Value | What it uses | What the person does |
131
+ | --- | --- | --- |
132
+ | `auto` (default) | their running Chrome, shared with `chrome-devtools-mcp --auto-connect` | open `chrome://inspect/#remote-debugging` and allow it; Chrome asks once more on jevway's first connection |
133
+ | `launch` / `launch-headless` | a throwaway profile jevway starts and closes | nothing (Chrome must be installed, or `CHROME_PATH` set) |
134
+ | `http://127.0.0.1:<port>` | a Chrome started with `--remote-debugging-port=<port>` and its own `--user-data-dir` | start that Chrome; never point it at their everyday profile |
135
+
136
+ ## 5. The skill (clients other than the Claude Code plugin)
137
+
138
+ The skill says when to use `jevway_task` and when to use chrome-devtools. It ships in the package; `jevway skill`
139
+ prints it, and this copies it into a client's skills folder as `<folder>/jevway/`:
140
+
141
+ ```bash
142
+ npx -y jevway skill --install ~/.codex/skills # or ~/.claude/skills, ~/.cursor/skills, ~/.config/opencode/skills
143
+ ```
144
+
145
+ It prints `{ ok, status, path, files }`. A different `jevway` skill already there (or a symlink to one) is left alone
146
+ with `"code": "skill_exists"` and exit 1; replace it with `--force` only if the person agrees.
147
+
148
+ ## 6. Check the setup
149
+
150
+ 1. `npx -y jevway doctor --json` prints `"ready": true` and exits 0 (every finding `ok` or `warn`). For every finding
151
+ that isn't `ok`, tell the person the fix in one line and wait; re-run it after each fix (`--plan` lists the steps
152
+ left).
153
+ 2. The client lists the tools: `jevway_task`, `jevway_look`, `jevway_end`, the five `jevway_journey_*` tools and
154
+ `jevway_inbox_create` / `jevway_inbox_wait`.
155
+ - In Claude Code: `/mcp`.
156
+ - Elsewhere, the client's MCP list.
157
+ - Or the MCP Inspector:
158
+ `npx @modelcontextprotocol/inspector --cli npx -y -p jevway jevway-mcp --method tools/list`.
159
+ 3. Optional, costs a fraction of a cent: `npx -y jevway doctor --live` makes one real Jev request and reports its
160
+ latency.
161
+
162
+ ## 7. The smoke test
163
+
164
+ Call `jevway_task` with:
165
+
166
+ ```json
167
+ {
168
+ "url": "https://www.google.com",
169
+ "goal": "Search Google for the Rosetta Stone and open its English Wikipedia article",
170
+ "values": { "query": "Rosetta Stone Wikipedia" },
171
+ "done_when": "The English Wikipedia article about the Rosetta Stone is open",
172
+ "allowed_hosts": ["google.com", "*.google.com", "en.wikipedia.org"],
173
+ "record": true,
174
+ "detail": "full"
175
+ }
176
+ ```
177
+
178
+ What to expect, and what to do:
179
+ - **A cookie banner** is handled by jevway (it declines when it can).
180
+ - **A reCAPTCHA ("unusual traffic")** is common for automated browsers on Google. In a visible browser jevway brings
181
+ the tab to the front, sends the person a desktop notification and waits up to 2 minutes for them to pass it; it
182
+ never solves it. Tell the person it may happen before you start.
183
+ - **A result with `ask_user`** (a bot check, a sign-in, a missing value): put that question to the person with your
184
+ own ask tool, then do what its `then` says.
185
+ - **Google keeps blocking**: run the same goal from `https://duckduckgo.com` with
186
+ `allowed_hosts: ["duckduckgo.com", "en.wikipedia.org"]`. It usually finishes in 5 steps.
187
+ - **No MCP in your client**: run the same test with the CLI, which prints one JSON result on stdout:
188
+
189
+ ```bash
190
+ npx -y jevway run --browser launch --record --detail full --url https://www.google.com \
191
+ --goal "Search Google for the Rosetta Stone and open its English Wikipedia article" \
192
+ --values '{"query":"Rosetta Stone Wikipedia"}' \
193
+ --done-when "The English Wikipedia article about the Rosetta Stone is open" \
194
+ --allowed-hosts "google.com,*.google.com,en.wikipedia.org"
195
+ ```
196
+
197
+ End the session with `jevway_end` when you have what you need.
198
+
199
+ ## 8. Report back
200
+
201
+ Tell the person, in this shape:
202
+ - **Outcome:** `status` and `reason`, and the `story` as a numbered list.
203
+ - **Proof:** the run page `files.page` (open it for them: `open` on macOS, `xdg-open` on Linux, `start` on Windows),
204
+ the video `files.video` (present when ffmpeg is installed), the end screenshot `files.screenshot` (show it if you
205
+ can display images), and the run folder `files.run_dir` with its `manifest.json`.
206
+ - **Cost:** `jev.requests` and `jev.est_cost_usd` (an estimate; a typical smoke test is well under a cent).
207
+ - **Setup:** what you installed or changed, with the person's yes for each.
208
+ - **Anything that needed them**, and anything that didn't work, with the fix.
209
+
210
+ The run page opens with the goal and how it ended, then the video with every step on a timeline and a strip of
211
+ screenshots kept in sync. It is the proof that the whole chain works: the client, the MCP server, the key, the
212
+ browser and Jev.
@@ -0,0 +1,103 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join, relative, isAbsolute } from 'node:path';
4
+ const STAGING = new WeakMap();
5
+ /**
6
+ * Sets how the browser behind `cdp` downloads, once per connection: jevway's launched browser saves every download under
7
+ * its guid in a staging folder of its own; the person's Chrome (`auto`, a debugging URL) keeps its own behaviour and only
8
+ * reports downloads. A browser that refuses never fails a run: its downloads go unreported.
9
+ *
10
+ * @example
11
+ * await useDownloads(cdp, 'launch-headless'); // Browser.setDownloadBehavior { behavior: 'allowAndName', downloadPath: '/tmp/jevway-downloads-…' }
12
+ */
13
+ export const useDownloads = async (cdp, source) => {
14
+ if (source !== 'launch' && source !== 'launch-headless') {
15
+ await cdp.send('Browser.setDownloadBehavior', { behavior: 'default', eventsEnabled: true }).catch(() => undefined);
16
+ return;
17
+ }
18
+ const downloadPath = mkdtempSync(join(tmpdir(), 'jevway-downloads-'));
19
+ STAGING.set(cdp, downloadPath);
20
+ await cdp.send('Browser.setDownloadBehavior', { behavior: 'allowAndName', downloadPath, eventsEnabled: true }).catch(() => undefined);
21
+ };
22
+ /** Removes the staging folder of a launched browser's connection, with whatever was never kept. */
23
+ export const dropStagedDownloads = (cdp) => {
24
+ const dir = STAGING.get(cdp);
25
+ if (dir)
26
+ rmSync(dir, { recursive: true, force: true });
27
+ STAGING.delete(cdp);
28
+ };
29
+ const inside = (dir, path) => {
30
+ if (!dir || !path)
31
+ return false;
32
+ const rel = relative(dir, path);
33
+ return Boolean(rel) && !rel.startsWith('..') && !isAbsolute(rel);
34
+ };
35
+ /**
36
+ * The downloads one tab started: `owns` says whether a frame id is the tab's (its page, one of its frames, a tab it
37
+ * opened). Every other download, in the person's other tabs too, is never recorded.
38
+ *
39
+ * @example
40
+ * const downloads = new TabDownloads(cdp, (frameId) => frameId === targetId);
41
+ * const finished = await downloads.settle({ waitMs: 30_000 });
42
+ */
43
+ export class TabDownloads {
44
+ #cdp;
45
+ #owns;
46
+ #all = new Map();
47
+ #untaken = [];
48
+ #changed = new Set();
49
+ constructor(cdp, owns) {
50
+ this.#cdp = cdp;
51
+ this.#owns = owns;
52
+ }
53
+ /** Starts listening; returns the unsubscribe. */
54
+ watch() {
55
+ return this.#cdp.onEvent((method, params, sessionId) => {
56
+ if (sessionId || !method.startsWith('Browser.download'))
57
+ return;
58
+ if (method === 'Browser.downloadWillBegin' && this.#owns(params.frameId)) {
59
+ this.#all.set(params.guid, { guid: params.guid, name: params.suggestedFilename, url: params.url, state: 'inProgress', bytes: 0, staged: false });
60
+ this.#untaken.push(params.guid);
61
+ }
62
+ const known = method === 'Browser.downloadProgress' && this.#all.get(params.guid);
63
+ if (!known)
64
+ return;
65
+ const filePath = params.filePath ?? known.filePath;
66
+ this.#all.set(params.guid, {
67
+ ...known,
68
+ state: params.state,
69
+ bytes: params.receivedBytes ?? known.bytes,
70
+ ...(filePath && { filePath }),
71
+ staged: inside(STAGING.get(this.#cdp), filePath),
72
+ });
73
+ for (const changed of this.#changed)
74
+ changed();
75
+ });
76
+ }
77
+ /**
78
+ * Takes the downloads begun since the last take, after waiting up to `waitMs` (or until `signal`) for those still
79
+ * running. Each comes back as it stands then: completed, canceled, or still in progress.
80
+ */
81
+ async settle({ waitMs, signal }) {
82
+ const taken = this.#untaken.splice(0);
83
+ const running = () => taken.some((guid) => this.#all.get(guid)?.state === 'inProgress');
84
+ if (taken.length && running() && !signal?.aborted) {
85
+ await new Promise((resolve) => {
86
+ const done = () => {
87
+ clearTimeout(timer);
88
+ signal?.removeEventListener('abort', done);
89
+ this.#changed.delete(check);
90
+ resolve();
91
+ };
92
+ const check = () => {
93
+ if (!running())
94
+ done();
95
+ };
96
+ const timer = setTimeout(done, waitMs);
97
+ signal?.addEventListener('abort', done, { once: true });
98
+ this.#changed.add(check);
99
+ });
100
+ }
101
+ return taken.map((guid) => ({ ...this.#all.get(guid) }));
102
+ }
103
+ }
@@ -134,6 +134,7 @@ export class Frames {
134
134
  #sessionId;
135
135
  #counter;
136
136
  #touch = false;
137
+ #files = false;
137
138
  #mainFrameId = '';
138
139
  #viewport = { w: 0, h: 0 };
139
140
  /** Out-of-process frames attached so far, by target id (= frame id). */
@@ -164,6 +165,20 @@ export class Frames {
164
165
  for (const { sessionId } of this.#children.values())
165
166
  void this.#emulateTouch(sessionId);
166
167
  }
168
+ /**
169
+ * The caller declared files: frame reads list file inputs and drop zones, and out-of-process frames keep native file
170
+ * pickers closed too (the page's own session does that for same-process frames).
171
+ */
172
+ set files(enabled) {
173
+ if (enabled && !this.#files)
174
+ for (const { sessionId } of this.#children.values())
175
+ void this.#interceptFileChooser(sessionId);
176
+ this.#files = enabled;
177
+ }
178
+ /** Whether a protocol frame id is this tab's: its page, a frame read on it, or an out-of-process frame attached to it. */
179
+ holds(frameId) {
180
+ return Boolean(frameId) && (frameId === this.#mainFrameId || this.#known.has(frameId) || this.#children.has(frameId));
181
+ }
167
182
  /** The frame holding an element id: undefined for the main frame and for a frame no longer on the page. */
168
183
  ofNode(node) {
169
184
  const base = idBaseOf(node);
@@ -228,7 +243,7 @@ export class Frames {
228
243
  // The text walk keeps to the part of the frame the page shows, in the frame's own coordinates.
229
244
  const { offset, region } = placement;
230
245
  const view = { x: region.x - offset.x, y: region.y - offset.y, w: region.w, h: region.h };
231
- const response = await this.evaluate(frame, `window.__jevIdBase=${frame.base};window.__jevView=${JSON.stringify(view)};window.__jevWanted=${JSON.stringify(wanted)};${script}`, { timeout: FRAME_READ_MS });
246
+ const response = await this.evaluate(frame, `window.__jevIdBase=${frame.base};window.__jevView=${JSON.stringify(view)};window.__jevWanted=${JSON.stringify(wanted)};window.__jevFiles=${this.#files};${script}`, { timeout: FRAME_READ_MS });
232
247
  const state = response.exceptionDetails ? null : response.result?.value;
233
248
  if (!state)
234
249
  return null;
@@ -404,6 +419,8 @@ export class Frames {
404
419
  await this.#send('Page.addScriptToEvaluateOnNewDocument', { source: this.#counter, worldName: WORLD }, sessionId);
405
420
  await this.#send('Emulation.setFocusEmulationEnabled', { enabled: true }, sessionId).catch(() => undefined);
406
421
  await this.#emulateTouch(sessionId);
422
+ if (this.#files)
423
+ await this.#interceptFileChooser(sessionId);
407
424
  await this.#autoAttach(sessionId);
408
425
  }
409
426
  catch {
@@ -418,6 +435,9 @@ export class Frames {
418
435
  const enabled = this.#touch;
419
436
  return this.#send('Emulation.setTouchEmulationEnabled', { enabled, maxTouchPoints: enabled ? 5 : 1 }, sessionId).catch(() => undefined);
420
437
  }
438
+ #interceptFileChooser(sessionId) {
439
+ return this.#send('Page.setInterceptFileChooserDialog', { enabled: true }, sessionId).catch(() => undefined);
440
+ }
421
441
  #autoAttach(sessionId) {
422
442
  return this.#send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true, filter: [{ type: 'iframe' }] }, sessionId);
423
443
  }
@@ -424,10 +424,12 @@
424
424
  }
425
425
  return null;
426
426
  };
427
+ const anchors = [];
427
428
  for (const input of fileInputs) {
428
429
  if (input.matches(':disabled') || input.closest('[aria-disabled="true"],[inert]')) continue;
429
430
  const anchor = standInFor(input);
430
431
  if (!anchor || outOfView(anchor.getBoundingClientRect())) continue;
432
+ anchors.push(anchor);
431
433
  const r = cache.clickRect(anchor),
432
434
  own = (anchor === input ? name(input) : name(input) || name(anchor)).replace(/\s+/g, ' ').trim();
433
435
  actions.push({
@@ -439,6 +441,57 @@
439
441
  rect: { x: r.x, y: r.y, w: r.width, h: r.height },
440
442
  });
441
443
  }
444
+ // jevway: a drop zone with no file input in it is listed only on clear markers: an ondrop attribute, a drop-zone
445
+ // name in its class, id or a data- attribute, or a short text asking for a file to be dropped ("Drag and drop",
446
+ // "Drop your CV here"; its nearest box of 40×40 or more). The innermost zone wins; one holding or inside a file field's stand-in
447
+ // is that field already. The file is dropped onto it.
448
+ const DROP_NAME = /drop-?zone|drop-?area|file-?drop|drop-?target/i;
449
+ const DROP_TEXT =
450
+ /\bdrag\s*(?:and|&|'?n'?)\s*drop\b|\bdrop\s+(?:your\s+|a\s+|an\s+|the\s+)?(?:files?|cv|resume|résumé|images?|photos?|documents?|attachments?)\b/i;
451
+ const named = (e) =>
452
+ e.hasAttribute('ondrop') ||
453
+ DROP_NAME.test(typeof e.className === 'string' ? e.className : '') ||
454
+ DROP_NAME.test(e.id) ||
455
+ [...e.attributes].some((a) => a.name.startsWith('data-') && DROP_NAME.test(a.name));
456
+ const big = (e) => {
457
+ const r = e.getBoundingClientRect();
458
+ return r.width >= 40 && r.height >= 40;
459
+ };
460
+ const zones = new Set(
461
+ [...document.querySelectorAll('[ondrop],[class*="drop" i],[id*="drop" i]')].filter(named),
462
+ );
463
+ const texts = document.createTreeWalker(document.body ?? document.documentElement, NodeFilter.SHOW_TEXT, {
464
+ acceptNode: (t) => (t.data.length <= 200 && DROP_TEXT.test(t.data) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP),
465
+ });
466
+ for (let t = texts.nextNode(), seen = 0; t && seen < 20; t = texts.nextNode(), seen++) {
467
+ let box = t.parentElement;
468
+ for (let up = 0; box && up < 4 && !big(box); up++) box = box.parentElement;
469
+ if (box && big(box)) zones.add(box);
470
+ }
471
+ const listed = [...zones].filter(
472
+ (zone) =>
473
+ zone !== document.body &&
474
+ zone !== document.documentElement &&
475
+ visible(zone) &&
476
+ big(zone) &&
477
+ !outOfView(zone.getBoundingClientRect()) &&
478
+ !zone.querySelector('input[type=file]') &&
479
+ !anchors.some((anchor) => anchor.contains(zone) || zone.contains(anchor)),
480
+ );
481
+ for (const zone of listed) {
482
+ if (listed.some((other) => other !== zone && zone.contains(other))) continue;
483
+ const r = cache.clickRect(zone),
484
+ text = (zone.innerText || '').replace(/\s+/g, ' ').trim();
485
+ actions.push({
486
+ node: identity(zone),
487
+ role: 'file',
488
+ kind: 'upload',
489
+ drop: true,
490
+ label: cap(name(zone).replace(/\s+/g, ' ').trim() || text || 'drop zone', 120) + ' (drop zone)',
491
+ value: '',
492
+ rect: { x: r.x, y: r.y, w: r.width, h: r.height },
493
+ });
494
+ }
442
495
  }
443
496
  // jevway: controls sharing a role and label (a product grid's "Add to cart" buttons) are told apart by the card or
444
497
  // row each sits in, as a person reads the page: "Add to cart — Sauce Labs Bike Light". Only colliding labels change;
@@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs';
4
4
  import { isDeepStrictEqual } from 'node:util';
5
5
  import { CdpError } from './cdp.js';
6
6
  import { DEVICES } from './devices.js';
7
+ import { TabDownloads } from './downloads.js';
7
8
  import { Frames, idBaseOf, mergeFrames, whenAborted } from './frames.js';
8
9
  import { messageNames } from './words.js';
9
10
  // Atomic DOM read from browser-use/jev-ultrafast (MIT): visible text + indexed controls + freshness guards.
@@ -72,6 +73,9 @@ export class Tab {
72
73
  #device;
73
74
  /** Page tabs this tab opened (target=_blank, window.open) that the run has not taken yet, oldest first. */
74
75
  #opened = [];
76
+ /** Every page tab this tab opened, taken or not: a download one of them starts is this tab's. */
77
+ #openedEver = new Set();
78
+ #downloads;
75
79
  #closed = false;
76
80
  #unsubscribe = [];
77
81
  /** The running call's stop signal: the waits after an input end early on it (see `stopSignal`). */
@@ -85,6 +89,7 @@ export class Tab {
85
89
  this.targetId = targetId;
86
90
  this.sessionId = sessionId;
87
91
  this.#frames = new Frames(cdp, sessionId, INPUT_COUNTER);
92
+ this.#downloads = new TabDownloads(cdp, (frameId) => frameId === targetId || this.#openedEver.has(frameId) || this.#frames.holds(frameId));
88
93
  }
89
94
  /**
90
95
  * Opens a background tab so the user's visible tab stays put. `newWindow` keeps the page rendering
@@ -233,13 +238,16 @@ export class Tab {
233
238
  // opened (the user's own, in their Chrome) is never attached, read or changed.
234
239
  tab.#unsubscribe.push(cdp.onEvent((method, params, sessionId) => {
235
240
  const info = params?.targetInfo;
236
- if (method === 'Target.targetCreated' && info?.type === 'page' && info.openerId === targetId)
241
+ if (method === 'Target.targetCreated' && info?.type === 'page' && info.openerId === targetId) {
237
242
  tab.#opened.push(info.targetId);
243
+ tab.#openedEver.add(info.targetId);
244
+ }
238
245
  if (method === 'Target.targetDestroyed' && params?.targetId === targetId)
239
246
  tab.#closed = true;
240
247
  if (method === 'Target.detachedFromTarget' && params?.sessionId === tab.sessionId && !sessionId)
241
248
  tab.#closed = true;
242
249
  }));
250
+ tab.#unsubscribe.push(tab.#downloads.watch());
243
251
  await cdp.send('Target.setDiscoverTargets', { discover: true }).catch(() => undefined);
244
252
  return tab;
245
253
  }
@@ -376,29 +384,64 @@ export class Tab {
376
384
  if (files && !this.#files)
377
385
  void this.#call('Page.setInterceptFileChooserDialog', { enabled: true }).catch(() => undefined);
378
386
  this.#files = files;
387
+ this.#frames.files = files;
379
388
  }
380
389
  /**
381
- * Sets a declared file on an observed file input (hidden ones too), after the same element-level freshness check
382
- * as a click. The path comes from the caller's `files`, never from the model or `values`.
390
+ * Sets a declared file on an observed file input (hidden ones too, in the page or in a frame), or drops it onto an
391
+ * observed drop zone, after the same element-level freshness check as a click. The path comes from the caller's
392
+ * `files`, never from the model or `values`.
383
393
  *
384
394
  * @example
385
395
  * await tab.upload(page.actions.find((a) => a.kind === 'upload')!, page, '/Users/ada/me.png');
386
396
  */
387
397
  async upload(action, page, path) {
388
- // The file is set through the page's own session; a frame's node lives in another context, so it is refused by name.
389
- if (action.node !== undefined && idBaseOf(action.node)) {
390
- throw new TargetUnreachable(`"${action.label}" is inside a frame, and jevway uploads only to file fields in the page itself`);
391
- }
392
398
  if (!(await this.fresh(page, action)))
393
399
  throw new StalePage('Page changed since this decision');
394
- const { result } = await this.#call('Runtime.evaluate', {
395
- expression: `(() => { const e = window.__jevFast?.nodes.get(${Number(action.node)}); return e?.isConnected && e.matches('input[type=file]:not(:disabled)') ? e : null; })()`,
400
+ if (action.drop)
401
+ return this.#drop(action, path);
402
+ // A frame's node lives in the frame's own world, and its file is set through the session that owns the frame.
403
+ const frame = this.#frameOf(action.node);
404
+ const expression = `(() => { const e = window.__jevFast?.nodes.get(${Number(action.node)}); return e?.isConnected && e.matches('input[type=file]:not(:disabled)') ? e : null; })()`;
405
+ const { result } = await (frame ? this.#frames.evaluate(frame, expression, { returnByValue: false }) : this.#call('Runtime.evaluate', { expression })).catch((error) => {
406
+ throw error instanceof CdpError && !TIMED_OUT.test(error.message) ? new StalePage(error.message) : error;
396
407
  });
397
408
  if (!result?.objectId)
398
409
  throw new TargetUnreachable('The file field is gone or disabled');
399
- await this.#call('DOM.setFileInputFiles', { files: [path], objectId: result.objectId });
410
+ await this.cdp.send('DOM.setFileInputFiles', { files: [path], objectId: result.objectId }, frame?.sessionId ?? this.sessionId);
400
411
  this.#afterInput = action;
401
412
  }
413
+ // A drop zone with no file input: a trusted drag of the file onto its visible centre, checked to have reached the page.
414
+ async #drop(action, path) {
415
+ const frame = this.#frameOf(action.node);
416
+ const resolved = await this.#evaluateFor(frame, `(${RESOLVE_TARGET})(${JSON.stringify(action)})`);
417
+ if (!resolved || resolved.covered)
418
+ throw new TargetUnreachable('Target changed or is covered', Boolean(resolved?.byPopup), resolved?.coverNodes ?? []);
419
+ const target = frame ? await this.#frames.toViewport(frame, resolved) : resolved;
420
+ if ('covered' in target)
421
+ throw new TargetUnreachable('Target changed or is covered', false, target.coverNodes);
422
+ await this.#evaluateFor(frame, WATCH_DROP);
423
+ const data = { items: [], files: [path], dragOperationsMask: DRAG_COPY };
424
+ for (const type of ['dragEnter', 'dragOver', 'drop'])
425
+ await this.#call('Input.dispatchDragEvent', { type, x: target.x, y: target.y, data });
426
+ if (!(await this.#evaluateFor(frame, 'window.__jevDropped===true')))
427
+ throw new TargetUnreachable('The drop zone did not take the file');
428
+ this.#afterInput = action;
429
+ }
430
+ /**
431
+ * The downloads this tab (its page, its frames, a tab it opened) started since the last call, after waiting up to
432
+ * `waitMs` for those still running; the run's stop signal ends the wait early.
433
+ *
434
+ * @example
435
+ * await tab.downloadsAfterInput(); // [{ name: 'invoice-1042.pdf', state: 'completed', bytes: 86016, filePath, staged: true, … }]
436
+ */
437
+ downloadsAfterInput({ waitMs = DOWNLOAD_WAIT_MS } = {}) {
438
+ return this.#downloads.settle({ waitMs, signal: this.#stop });
439
+ }
440
+ /** The page as a PDF (its print styles, backgrounds included); headless and headed Chrome both print. */
441
+ async pdf() {
442
+ const { data } = await this.#call('Page.printToPDF', { printBackground: true });
443
+ return Buffer.from(data, 'base64');
444
+ }
402
445
  async observe() {
403
446
  if (this.#closed)
404
447
  throw new StalePage('The tab was closed');
@@ -845,6 +888,12 @@ const INTERACTIVE = 'a,button,input,select,textarea,[contenteditable="true"],' +
845
888
  const FIELD_LIKE = 'input:not([type="checkbox"],[type="radio"],[type="button"],[type="submit"],[type="reset"],[type="image"]),textarea,' +
846
889
  '[contenteditable="true"],[role="combobox"],[role="textbox"],[role="searchbox"],[role="spinbutton"]';
847
890
  // Resolves the observed node, rejects hidden/disabled/covered targets, and applies native selects in-page.
891
+ // How long a step waits for the downloads it started to finish.
892
+ const DOWNLOAD_WAIT_MS = 30_000;
893
+ // Input.dispatchDragEvent's operation mask for a copy, as a file dragged from the desktop offers.
894
+ const DRAG_COPY = 1;
895
+ // Notes a drop reaching the document (in the target's world): a page that refuses the drag never gets one.
896
+ const WATCH_DROP = `(() => { window.__jevDropped = false; window.addEventListener('drop', () => { window.__jevDropped = true; }, { capture: true, once: true }); return true; })()`;
848
897
  const RESOLVE_TARGET = `action => {
849
898
  const e=window.__jevFast?.nodes.get(action.node);
850
899
  if (!e?.isConnected || e.matches(':disabled') || e.closest('[aria-disabled="true"],[inert]') ||
@@ -0,0 +1,78 @@
1
+ import { resolve } from 'node:path';
2
+ import { DaemonFailure } from '../daemon/client.js';
3
+ import { outRoot } from '../output/layout.js';
4
+ import { CliError, usageError } from './flags.js';
5
+ import { eventPrinter } from './output.js';
6
+ const BROWSER = /^(auto|launch|launch-headless|https?:\/\/\S+|wss?:\/\/\S+)$/;
7
+ /** The browser and model a new session or one-shot run uses: the flags, else `JEVWAY_BROWSER` / `JEVWAY_MODEL` / `JEVWAY_SCREENSHOTS`. */
8
+ export const runnerChoice = (flags, env) => {
9
+ const pick = (flag, variable) => (typeof flags[flag] === 'string' ? flags[flag] : env[variable]?.trim() || undefined);
10
+ const browser = pick('browser', 'JEVWAY_BROWSER') ?? 'auto';
11
+ if (!BROWSER.test(browser)) {
12
+ throw usageError('invalid_value', `--browser must be auto, launch, launch-headless or a debugging URL, got "${browser}"`, 'Example: --browser launch-headless');
13
+ }
14
+ const model = pick('model', 'JEVWAY_MODEL');
15
+ const screenshots = pick('screenshots', 'JEVWAY_SCREENSHOTS');
16
+ return { browser, ...(model && { model }), ...(screenshots && { screenshots }) };
17
+ };
18
+ /** A runner for `choice`; an unknown model or screenshot mode is a usage error. */
19
+ export const runnerFor = (deps, { browser, ...choice }) => {
20
+ try {
21
+ return deps.createRunner(browser, choice);
22
+ }
23
+ catch (error) {
24
+ throw usageError('invalid_value', error.message, 'jevway run --help lists the models and screenshot modes');
25
+ }
26
+ };
27
+ /** Fails with `missing_key` (exit 2) before any browser or daemon starts when the model's key is not set. */
28
+ export const requireKey = (runner) => {
29
+ try {
30
+ runner.requireKey();
31
+ }
32
+ catch (error) {
33
+ throw usageError('missing_key', error.message, 'Run jevway setup in a terminal, or see jevway doctor');
34
+ }
35
+ };
36
+ /** Where this caller's files go: resolved here, so they land under its working directory whoever started the daemon. */
37
+ export const placement = ({ env, cwd }) => ({
38
+ outDir: outRoot(env, cwd),
39
+ ...(env.JEVWAY_JOURNEYS_DIR?.trim() && { journeysDir: resolve(cwd, env.JEVWAY_JOURNEYS_DIR.trim()) }),
40
+ cwd,
41
+ });
42
+ /** The running daemon, started when needed (a new session), or found (a session it should already hold). */
43
+ export const daemonFor = async (deps, need) => {
44
+ if ('start' in need)
45
+ return deps.daemon.ensure(eventPrinter(deps.io));
46
+ const running = await deps.daemon.find();
47
+ if (running)
48
+ return running;
49
+ throw new CliError(1, 'unknown_session', `Unknown ${need.kind} "${need.session}": no jevway daemon is running, and sessions end when it stops (or after its idle time).`, need.kind === 'session' ? 'Start a new one with jevway run --session new --url <url> --goal <goal>' : 'Start a new one with jevway journey start --url <url>');
50
+ };
51
+ /**
52
+ * Calls the daemon and closes the connection. A connection lost mid-call is `outcome_unknown` for a call that may
53
+ * have clicked or typed (`writes`), pointing at `jevway look` for the session, else `daemon_unavailable`.
54
+ */
55
+ export const callDaemon = async (connection, method, params, { writes, session, log, ...options }) => {
56
+ try {
57
+ return await connection.request(method, params, options);
58
+ }
59
+ catch (error) {
60
+ if (!(error instanceof DaemonFailure) || !error.lost)
61
+ throw error;
62
+ if (!writes)
63
+ throw new CliError(1, 'daemon_unavailable', error.message, `See ${log}; jevway stop and run the command again`);
64
+ const id = session?.();
65
+ throw new CliError(1, 'outcome_unknown', 'The daemon stopped answering while the call was running; it may already have clicked or typed.', id ? `Check the page with jevway look --session ${id} before repeating the call.` : 'Find the session with jevway sessions and check its page with jevway look --session <id> before repeating the call.');
66
+ }
67
+ finally {
68
+ connection.close();
69
+ }
70
+ };
71
+ /** How long a client waits for a call: always longer than the daemon's own limit for it. */
72
+ export const TIMEOUTS = {
73
+ /** A task call: its time budget (`--max-seconds`, default 300; a continue may carry up to 3600) plus two minutes. */
74
+ task: (maxSeconds, continuing) => ((maxSeconds ?? (continuing ? 3600 : 300)) + 120) * 1000,
75
+ quick: 60_000,
76
+ /** Autopilot has no step limit of its own. */
77
+ journey: 30 * 60_000,
78
+ };