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.
- package/README.md +227 -149
- package/SETUP.md +212 -0
- package/dist/browser/downloads.js +103 -0
- package/dist/browser/frames.js +21 -1
- package/dist/browser/snapshot.js +53 -0
- package/dist/browser/tab.js +59 -10
- package/dist/cli/daemon-call.js +78 -0
- package/dist/cli/dispatch.js +76 -0
- package/dist/cli/flags.js +152 -0
- package/dist/cli/help.js +46 -0
- package/dist/cli/inbox-command.js +199 -0
- package/dist/cli/journey-command.js +162 -0
- package/dist/cli/look-command.js +54 -0
- package/dist/cli/main.js +64 -0
- package/dist/cli/output.js +27 -0
- package/dist/cli/run-command.js +221 -0
- package/dist/cli/session-commands.js +132 -0
- package/dist/daemon/client.js +175 -0
- package/dist/daemon/paths.js +35 -0
- package/dist/daemon/protocol.js +28 -0
- package/dist/daemon/server.js +339 -0
- package/dist/inbox/inbox-file.js +43 -0
- package/dist/inbox/inbox-registry.js +33 -0
- package/dist/inbox/inbox.js +127 -0
- package/dist/inbox/mail-api.js +52 -0
- package/dist/inbox/message.js +34 -0
- package/dist/jev/replay.js +7 -3
- package/dist/jev/retry.js +29 -0
- package/dist/jev/secrets.js +4 -3
- package/dist/journeys/journey-doc.js +2 -1
- package/dist/journeys/journey-session.js +14 -3
- package/dist/journeys/render-viewer.js +163 -150
- package/dist/mcp/inbox-tools.js +82 -0
- package/dist/mcp/instructions.js +27 -0
- package/dist/mcp/journey-tools.js +117 -0
- package/dist/mcp/results.js +53 -0
- package/dist/mcp/server.js +57 -0
- package/dist/mcp/task-tools.js +213 -0
- package/dist/mcp.js +10 -337
- package/dist/notify/desktop.js +40 -0
- package/dist/notify/person-messages.js +50 -0
- package/dist/output/layout.js +86 -0
- package/dist/output/manifest.js +86 -0
- package/dist/output/run-folder.js +80 -0
- package/dist/recording/describe-step.js +69 -0
- package/dist/{task → recording}/recording-page.js +164 -18
- package/dist/{task → recording}/recording.js +33 -91
- package/dist/recording/run-page.js +155 -0
- package/dist/session/session-service.js +213 -0
- package/dist/setup/atomic-write.js +13 -0
- package/dist/setup/clients.js +180 -0
- package/dist/setup/command-output.js +15 -0
- package/dist/setup/doctor-command.js +16 -7
- package/dist/setup/doctor.js +17 -2
- package/dist/setup/env-file.js +22 -0
- package/dist/setup/setup-command.js +104 -0
- package/dist/setup/setup-wizard.js +216 -0
- package/dist/setup/skill-command.js +124 -0
- package/dist/setup/terminal-prompter.js +51 -0
- package/dist/setup/unified-diff.js +69 -0
- package/dist/task/ask-user.js +182 -0
- package/dist/task/downloads.js +63 -0
- package/dist/task/person-wait.js +104 -0
- package/dist/task/present.js +111 -0
- package/dist/task/run-task.js +332 -32
- package/dist/task/start-task.js +70 -11
- package/dist/task/totp.js +124 -0
- package/dist/task/upload-files.js +237 -5
- package/package.json +10 -8
- package/skill/jevway/SKILL.md +54 -15
- package/skill/jevway-inbox/SKILL.md +61 -0
- package/skill/jevway-inbox/scripts/inbox.mjs +209 -0
- package/INSTALL-FOR-AGENTS.md +0 -138
- 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
|
+
}
|
package/dist/browser/frames.js
CHANGED
|
@@ -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
|
}
|
package/dist/browser/snapshot.js
CHANGED
|
@@ -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;
|
package/dist/browser/tab.js
CHANGED
|
@@ -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
|
|
382
|
-
* as a click. The path comes from the caller's
|
|
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
|
-
|
|
395
|
-
|
|
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
|
|
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
|
+
};
|