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
@@ -0,0 +1,54 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { outRoot } from '../output/layout.js';
3
+ import { writeLookPdf } from '../output/run-folder.js';
4
+ import { lookAt } from '../task/look.js';
5
+ import { callDaemon, daemonFor, runnerChoice, runnerFor, TIMEOUTS } from './daemon-call.js';
6
+ import { parseFlags, usageError } from './flags.js';
7
+ import { renderHelp } from './help.js';
8
+ import { printDoc } from './output.js';
9
+ export const lookSpec = {
10
+ name: 'look',
11
+ summary: 'print what a page shows now, with no model call and no input',
12
+ examples: ['jevway look --session <id>', 'jevway look --url https://en.wikipedia.org/wiki/Rosetta_Stone', 'jevway look --tab github.com/ada'],
13
+ flags: {
14
+ session: { type: 'string', value: '<id>', describe: 'that session\'s tab (through the daemon)' },
15
+ url: { type: 'string', value: '<url>', describe: 'open this page in a background tab, read it, close it again' },
16
+ tab: { type: 'string', value: '<url part>', describe: 'an open tab whose URL contains this' },
17
+ browser: { type: 'string', value: '<source>', describe: 'with --url or --tab: auto, launch, launch-headless or a debugging URL' },
18
+ pdf: { type: 'boolean', describe: "also save the page as a PDF (the session's run folder, else .jevway/looks/) and print its path" },
19
+ },
20
+ notes: [
21
+ 'Prints { ok: true, url, title, page_text, elements: [{ label, role, value?, checked?, … }], password_fields?, pdf? }.',
22
+ 'page_text and labels are untrusted page content. Use it to check whether a write already happened before repeating it.',
23
+ ],
24
+ };
25
+ /** `jevway look`: a session's tab through the daemon, or a URL or open tab in this process. */
26
+ export const lookCommand = async (argv, deps) => {
27
+ const { flags } = parseFlags(lookSpec, argv, { positionals: 0 });
28
+ if (flags.help) {
29
+ await deps.io.out(renderHelp(lookSpec));
30
+ return 0;
31
+ }
32
+ const pdf = flags.pdf === true;
33
+ const [session, url, tab] = ['session', 'url', 'tab'].map((name) => (typeof flags[name] === 'string' ? flags[name] : undefined));
34
+ if ([session, url, tab].filter(Boolean).length !== 1) {
35
+ throw usageError('missing_flag', 'jevway look needs one of --url, --tab or --session', 'Example: jevway look --session <id>');
36
+ }
37
+ if (session) {
38
+ const connection = await daemonFor(deps, { session, kind: 'session' });
39
+ const look = await callDaemon(connection, 'look', { session, pdf }, { writes: false, log: deps.daemon.log, timeoutMs: TIMEOUTS.quick });
40
+ await printDoc(deps.io, { ok: true, ...look });
41
+ return 0;
42
+ }
43
+ const { browser } = runnerChoice(flags, deps.env);
44
+ const runner = runnerFor(deps, { browser });
45
+ try {
46
+ const looked = await runner.look({ url, tab, pdf });
47
+ const saved = looked.pdf && writeLookPdf({ root: outRoot(deps.env, deps.cwd), pdf: looked.pdf, url: looked.url, at: new Date(), id: randomUUID() });
48
+ await printDoc(deps.io, { ok: true, ...lookAt(looked), ...(saved && { pdf: saved }) });
49
+ return 0;
50
+ }
51
+ finally {
52
+ await runner.close().catch(() => undefined);
53
+ }
54
+ };
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { daemonAccess, spawnDaemon } from '../daemon/client.js';
5
+ import { daemonEnv, envFingerprint, logPath, socketPath, stateDir } from '../daemon/paths.js';
6
+ import { loadEnvFile } from '../setup/env-file.js';
7
+ import { createRunner } from '../task/start-task.js';
8
+ import { runCli } from './dispatch.js';
9
+ import { printFailure } from './output.js';
10
+ // Keys and JEVWAY_* settings the environment lacks come from ~/.config/jevway/env.
11
+ const fromEnvFile = loadEnvFile();
12
+ // The same depth from src/cli/ and dist/cli/.
13
+ const { version } = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
14
+ const cwd = process.cwd();
15
+ const dir = stateDir(process.env);
16
+ const spawnEnv = daemonEnv(process.env, cwd);
17
+ let printed = false;
18
+ const io = {
19
+ // Awaited, so the document is flushed before exit: on macOS a piped stdout is asynchronous.
20
+ out: (text) => new Promise((resolve) => {
21
+ printed = true;
22
+ process.stdout.write(text, () => resolve());
23
+ }),
24
+ err: (text) => void process.stderr.write(text),
25
+ };
26
+ // Nothing escapes as a stack trace: an error before the result prints as the one JSON document; one after it is dropped.
27
+ const lastResort = async (error) => {
28
+ if (!printed)
29
+ process.exit(await printFailure(io, error));
30
+ };
31
+ process.on('uncaughtException', lastResort);
32
+ process.on('unhandledRejection', lastResort);
33
+ let presses = 0;
34
+ const onInterrupt = (handler) => {
35
+ const press = () => {
36
+ // A second Ctrl-C leaves at once.
37
+ if (++presses > 1)
38
+ process.exit(130);
39
+ handler();
40
+ };
41
+ process.on('SIGINT', press);
42
+ process.on('SIGTERM', press);
43
+ return () => {
44
+ process.off('SIGINT', press);
45
+ process.off('SIGTERM', press);
46
+ };
47
+ };
48
+ const code = await runCli(process.argv.slice(2), {
49
+ io,
50
+ env: process.env,
51
+ cwd,
52
+ version,
53
+ fromEnvFile,
54
+ createRunner,
55
+ daemon: daemonAccess({
56
+ socket: socketPath(dir),
57
+ log: logPath(dir),
58
+ version,
59
+ fingerprint: envFingerprint(spawnEnv),
60
+ start: () => spawnDaemon({ entry: fileURLToPath(import.meta.url), stateDir: dir, env: spawnEnv }),
61
+ }),
62
+ onInterrupt,
63
+ });
64
+ process.exit(code);
@@ -0,0 +1,27 @@
1
+ import { DaemonFailure } from '../daemon/client.js';
2
+ import { UnknownSession } from '../session/session-service.js';
3
+ import { CliError } from './flags.js';
4
+ // Daemon error codes that mean the command line itself was wrong.
5
+ const USAGE_CODES = new Set(['unknown_flag', 'unknown_command', 'missing_flag', 'missing_value', 'invalid_value', 'unexpected_argument', 'missing_command', 'missing_key']);
6
+ export const printDoc = async (io, doc) => {
7
+ await io.out(`${JSON.stringify(doc, null, 2)}\n`);
8
+ };
9
+ /** Writes each event as one JSON line on stderr. */
10
+ export const eventPrinter = (io) => (event) => io.err(`${JSON.stringify(event)}\n`);
11
+ /** Any failure as the CLI reports it: its exit code and the error body, never a stack trace. */
12
+ export const describeFailure = (error) => {
13
+ if (error instanceof CliError)
14
+ return { exitCode: error.exitCode, code: error.code, message: error.message, hint: error.hint };
15
+ if (error instanceof DaemonFailure)
16
+ return { exitCode: USAGE_CODES.has(error.code) ? 2 : 1, code: error.code, message: error.message, hint: error.hint };
17
+ if (error instanceof UnknownSession)
18
+ return { exitCode: 1, code: error.code, message: error.message, hint: 'List the open ones with jevway sessions' };
19
+ const message = error instanceof Error ? error.message : String(error);
20
+ return { exitCode: 1, code: 'failed', message, hint: 'The message says what failed; jevway doctor checks the key and the browser.' };
21
+ };
22
+ /** Prints `{ ok: false, error }` for `error` and returns the exit code. */
23
+ export const printFailure = async (io, error) => {
24
+ const { exitCode, ...body } = describeFailure(error);
25
+ await printDoc(io, { ok: false, error: body });
26
+ return exitCode;
27
+ };
@@ -0,0 +1,221 @@
1
+ import { resolve } from 'node:path';
2
+ import { z } from 'zod';
3
+ import { createSessionService } from '../session/session-service.js';
4
+ import { CHECKS } from '../task/checks.js';
5
+ import { presentSummary } from '../task/present.js';
6
+ import { checkTotpSecrets } from '../task/totp.js';
7
+ import { callDaemon, daemonFor, placement, requireKey, runnerChoice, runnerFor, TIMEOUTS } from './daemon-call.js';
8
+ import { choiceFlag, intFlag, jsonFlag, listFlag, parseFlags, usageError } from './flags.js';
9
+ import { renderHelp } from './help.js';
10
+ import { eventPrinter, printDoc } from './output.js';
11
+ import { BOT_CHECK_NAMES } from '../task/ask-user.js';
12
+ const TEXT_BY_KEY = z.record(z.string(), z.string());
13
+ export const runSpec = {
14
+ name: 'run',
15
+ summary: 'hand a browser goal to Jev and print one JSON result',
16
+ examples: [
17
+ 'jevway run --url https://en.wikipedia.org --goal "Open the article about the Rosetta Stone" --done-when "The Rosetta Stone article is open"',
18
+ 'jevway run --session new --browser launch-headless --url https://shop.example/checkout --goal "Fill in the delivery details" --values \'{"name":"Ada Lovelace","email":"ada@example.com"}\'',
19
+ 'jevway run --session <id> --goal "Fill in the delivery details" --values \'{"phone":"+44 20 7946 0000"}\'',
20
+ 'jevway run --session <id> --goal "Delete the repository ada/scratch" --confirm-irreversible --confirm-target "ada/scratch"',
21
+ ],
22
+ flags: {
23
+ goal: { type: 'string', value: '<text>', describe: 'the full outcome wanted, in plain language (every call)' },
24
+ url: { type: 'string', value: '<url>', describe: 'open this page in a new tab (start)' },
25
+ tab: { type: 'string', value: '<url part>', describe: 'or drive an open tab whose URL contains this (start)' },
26
+ session: { type: 'string', value: '<id|new>', describe: 'new: keep the tab in the background daemon between calls; <id>: continue that session' },
27
+ values: { type: 'string', value: '<json>', describe: 'text values Jev may type, e.g. \'{"city":"Lisbon"}\'' },
28
+ 'secret-keys': { type: 'string', value: '<a,b>', describe: 'keys of --values that are secrets: Jev sees the key only, output shows [secret:<key>]' },
29
+ files: { type: 'string', value: '<json>', describe: 'files Jev may upload, key to absolute path; Jev sees names, never paths' },
30
+ totp: { type: 'string', value: '<json>', describe: '2FA on the user\'s own account: key to authenticator secret (base32 or otpauth:// URI); the current code is typed' },
31
+ 'done-when': { type: 'string', value: '<text>', describe: 'visible evidence of success on the page the call ends on' },
32
+ checks: { type: 'string', value: '<json>', describe: 'facts code checks on the final page, e.g. \'[{"type":"url_contains","text":"/issues/"}]\'' },
33
+ 'confirm-irreversible': { type: 'boolean', describe: 'allow one click flagged irreversible, only when the user\'s request names it' },
34
+ 'confirm-target': { type: 'string', value: '<words>', describe: 'with --confirm-irreversible: the user\'s words naming the target' },
35
+ 'max-steps': { type: 'string', value: '<n>', describe: 'step limit, 1 to 100 (default 40)' },
36
+ 'max-seconds': { type: 'string', value: '<n>', describe: 'time budget per call, 1 to 3600 (default 300); past it the run ends step_budget' },
37
+ 'wait-for-person': { type: 'string', value: '<seconds>', describe: 'visible browser: wait this long for a person at a bot check or sign-in, 0 to 3600 (default $JEVWAY_PERSON_WAIT_SECONDS or 120; 0 off)' },
38
+ 'allowed-hosts': { type: 'string', value: '<a.com,*.b.com>', describe: 'hosts the run may be on; leaving them ends it blocked' },
39
+ 'blocked-hosts': { type: 'string', value: '<a.com>', describe: 'hosts it must never be on' },
40
+ 'allow-coordinate-clicks': { type: 'boolean', describe: 'click a boxed control the DOM never reported (Jev 1.14 direct only; start)' },
41
+ record: { type: 'boolean', describe: 'keep a recording for people in the run folder: video, step shots, index.html (start)' },
42
+ 'record-dir': { type: 'string', value: '<dir>', describe: 'put the whole run folder, recording included, here instead (start; --record <dir> works too)' },
43
+ detail: { type: 'string', value: 'concise|full', describe: 'full adds the raw steps with confidences, Jev\'s stats and stuck candidates' },
44
+ screenshot: { type: 'boolean', describe: 'accepted for parity with MCP; the result always carries files.screenshot' },
45
+ browser: { type: 'string', value: '<source>', describe: 'auto, launch, launch-headless or http://127.0.0.1:<port> (default $JEVWAY_BROWSER or auto; start)' },
46
+ model: { type: 'string', value: '<name>', describe: 'jev, pplx-decider, clef-omni, clef, clef-flash or gpt-6-luna (default $JEVWAY_MODEL or jev; start)' },
47
+ screenshots: { type: 'string', value: 'off|fallback|always', describe: 'marked screenshots for vision models (default per model; start)' },
48
+ 'keep-open': { type: 'boolean', describe: 'without --session: leave the tabs open at the end' },
49
+ },
50
+ notes: [
51
+ 'Without --session the run happens in this process and its tabs close at the end, as before. With --session the',
52
+ 'background daemon keeps the tab (and a launched browser) between calls; jevway end <id> closes it, jevway stop',
53
+ 'stops the daemon. (start) flags count on a session\'s first call only.',
54
+ '',
55
+ 'Prints { ok: true, session?, status, reason, next, story, url, title, page_text, files, … }; progress as NDJSON on',
56
+ 'stderr. Exit 0 when status is done, 1 on a hand-back (needs_input, needs_confirmation, uncertain, step_budget,',
57
+ 'blocked) or a tool error, 2 on bad usage or a missing key. outcome_unknown: the daemon went away mid-call; check',
58
+ 'with jevway look --session <id> before repeating it.',
59
+ ],
60
+ };
61
+ /**
62
+ * `next` in the CLI's words: flags instead of MCP parameters, and the session to pass (or, for a one-shot run, how to
63
+ * keep one).
64
+ *
65
+ * @example
66
+ * cliNext('Add the missing values to `values` and call again with this session.', 'abc')
67
+ * // 'Add the missing values to `--values` and run again with `--session abc`.'
68
+ */
69
+ export const cliNext = (next, session) => {
70
+ const again = (call, joiner = '') => {
71
+ const run = `${call === 'C' ? 'R' : 'r'}un again`;
72
+ if (session)
73
+ return `${run} with \`--session ${session}\`${joiner}`;
74
+ return joiner ? `${run} with ` : run;
75
+ };
76
+ let text = next
77
+ .replace(/`confirm_irreversible: true`/g, '`--confirm-irreversible`')
78
+ .replace(/`values\.\w+`(?:, `values\.\w+`)*/g, (run) => {
79
+ const keys = [...run.matchAll(/`values\.(\w+)`/g)].map(([, key]) => `"${key}"`);
80
+ return `\`--values\` ${keys.length > 1 ? 'keys' : 'key'} ${keys.join(', ')}`;
81
+ })
82
+ .replace(/`(values|goal|confirm_target|max_steps|max_seconds|secret_keys)`/g, (_, name) => `\`--${name.replace(/_/g, '-')}\``)
83
+ .replace(/([Cc])all again with this session(, | and )?/g, (_, call, joiner) => again(call, joiner));
84
+ text = session
85
+ ? text.replace('end the session when', `end it with \`jevway end ${session}\` when`)
86
+ : text.replace('; end the session when you no longer need its tab', '');
87
+ return !session && /un again/.test(text) ? `${text} Pass \`--session new\` to keep the tab between calls.` : text;
88
+ };
89
+ // A one-shot run closes its browser, so "pass it in the Chrome window" can't work any more: offer a session instead.
90
+ const askAgainWithSession = (wall) => ({
91
+ question: `The ${wall.kind === 'sign_in' ? 'sign-in' : `${(wall.name && BOT_CHECK_NAMES[wall.name]) ?? 'bot'} check`} on ${wall.origin} stopped this run, and a one-shot run closes its browser. Run it again with a session, so jevway keeps the tab and waits for you to ${wall.kind === 'sign_in' ? 'sign in' : 'pass it'}?`,
92
+ header: wall.kind === 'sign_in' ? 'Sign in' : 'Bot check',
93
+ options: [
94
+ { label: 'Run again', description: 'Keep the tab open and wait for me' },
95
+ { label: 'Skip', description: 'Leave it; stop here' },
96
+ ],
97
+ then: 'On Run again, repeat the command with `--session new` and a visible `--browser` (auto or launch): jevway waits up to 120 s for the person; on Skip, tell the user the step was not done.',
98
+ });
99
+ /**
100
+ * A presented result in the CLI's words: `next` and the `ask_user` question's `then` name flags, and a one-shot run
101
+ * stopped at a wall a person could pass asks to run again with a session.
102
+ */
103
+ export const inCliWords = (result, session) => {
104
+ const oneShotWall = !session && result.ask_user && result.wall && result.wall.person_can_act !== false;
105
+ const ask = oneShotWall ? askAgainWithSession(result.wall) : result.ask_user && { ...result.ask_user, then: cliNext(result.ask_user.then, session) };
106
+ return { ...result, next: cliNext(result.next, session), ...(ask && { ask_user: ask }) };
107
+ };
108
+ // Today's `--record <dir>` keeps working: a value after --record is the folder.
109
+ const withRecordDir = (argv) => argv.flatMap((arg, i) => (arg === '--record' && argv[i + 1] !== undefined && !argv[i + 1].startsWith('-') ? ['--record-dir'] : [arg]));
110
+ const TOTP_HINT = 'Pass the base32 secret behind the QR code or its otpauth://totp/ URI by key, e.g. --totp \'{"otp":"JBSWY3DPEHPK3PXP"}\'';
111
+ /** `--totp` as JSON, each secret checked here so a bad one is a usage error (exit 2) that never repeats it. */
112
+ const totpFlag = (raw) => {
113
+ let totp;
114
+ try {
115
+ totp = jsonFlag('totp', raw, TEXT_BY_KEY, '{"otp":"JBSWY3DPEHPK3PXP"}');
116
+ }
117
+ catch {
118
+ // A JSON parse error quotes the input, and the input is the secret.
119
+ throw usageError('invalid_value', '--totp must be a JSON object of key to secret', TOTP_HINT);
120
+ }
121
+ try {
122
+ checkTotpSecrets(totp);
123
+ }
124
+ catch (error) {
125
+ throw usageError('invalid_value', `--totp: ${error.message}`, TOTP_HINT);
126
+ }
127
+ return totp;
128
+ };
129
+ const defined = (value) => Object.fromEntries(Object.entries(value).filter(([, inner]) => inner !== undefined));
130
+ /** `jevway run`: one goal, in-process without `--session`, through the daemon with it. */
131
+ export const runCommand = async (argv, deps) => {
132
+ const { flags } = parseFlags(runSpec, withRecordDir(argv), { positionals: 0 });
133
+ if (flags.help) {
134
+ await deps.io.out(renderHelp(runSpec));
135
+ return 0;
136
+ }
137
+ const text = (name) => (typeof flags[name] === 'string' ? flags[name] : undefined);
138
+ const yes = (name) => (flags[name] === true ? true : undefined);
139
+ const goal = text('goal');
140
+ if (!goal)
141
+ throw usageError('missing_flag', 'jevway run needs --goal', 'Example: jevway run --url https://example.com --goal "Open the pricing page"');
142
+ const session = text('session');
143
+ const starting = !session || session === 'new';
144
+ if (starting && !text('url') && !text('tab')) {
145
+ throw usageError('missing_flag', 'jevway run needs --url or --tab (or --session to continue)', 'Example: jevway run --url https://example.com --goal "Open the pricing page"');
146
+ }
147
+ const detail = choiceFlag('detail', text('detail'), ['concise', 'full']) ?? 'concise';
148
+ const recordDir = text('record-dir');
149
+ const request = defined({
150
+ goal,
151
+ url: text('url'),
152
+ tab: text('tab'),
153
+ values: jsonFlag('values', text('values'), TEXT_BY_KEY, '{"city":"Lisbon"}'),
154
+ secretKeys: listFlag(text('secret-keys')),
155
+ files: jsonFlag('files', text('files'), TEXT_BY_KEY, '{"avatar":"/Users/ada/me.png"}'),
156
+ totp: totpFlag(text('totp')),
157
+ doneWhen: text('done-when'),
158
+ checks: jsonFlag('checks', text('checks'), CHECKS, '[{"type":"url_contains","text":"/issues/"}]'),
159
+ maxSteps: intFlag('max-steps', text('max-steps'), { min: 1, max: 100 }),
160
+ maxSeconds: intFlag('max-seconds', text('max-seconds'), { min: 1, max: 3600 }),
161
+ waitForPersonSeconds: intFlag('wait-for-person', text('wait-for-person'), { min: 0, max: 3600 }),
162
+ allowedHosts: listFlag(text('allowed-hosts')),
163
+ blockedHosts: listFlag(text('blocked-hosts')),
164
+ confirmIrreversible: yes('confirm-irreversible'),
165
+ confirmTarget: text('confirm-target'),
166
+ allowCoordinateClicks: yes('allow-coordinate-clicks'),
167
+ record: yes('record'),
168
+ recordDir: recordDir && resolve(deps.cwd, recordDir),
169
+ });
170
+ const choice = runnerChoice(flags, deps.env);
171
+ if (!session)
172
+ return runHere(deps, request, { choice, detail, keepOpen: Boolean(yes('keep-open')) });
173
+ if (starting)
174
+ requireKey(runnerFor(deps, choice));
175
+ const connection = await daemonFor(deps, starting ? { start: true } : { session, kind: 'session' });
176
+ const printEvent = eventPrinter(deps.io);
177
+ let id = starting ? undefined : session;
178
+ const cancel = new AbortController();
179
+ const stopListening = deps.onInterrupt(() => cancel.abort());
180
+ try {
181
+ const result = await callDaemon(connection, 'task', { request: { ...request, ...(id && { session: id }) }, detail, runner: choice, place: placement(deps) }, {
182
+ writes: true,
183
+ session: () => id,
184
+ log: deps.daemon.log,
185
+ signal: cancel.signal,
186
+ timeoutMs: TIMEOUTS.task(request.maxSeconds, !starting),
187
+ onEvent: (event) => {
188
+ if (event?.type === 'started')
189
+ id = event.session;
190
+ printEvent(event);
191
+ },
192
+ });
193
+ await printDoc(deps.io, { ok: true, ...inCliWords(result, result.session) });
194
+ return result.status === 'done' ? 0 : 1;
195
+ }
196
+ finally {
197
+ stopListening();
198
+ }
199
+ };
200
+ /** A one-shot run in this process, as before the daemon: its own browser connection, its tabs closed at the end. */
201
+ const runHere = async (deps, request, { choice, detail, keepOpen }) => {
202
+ const runner = runnerFor(deps, choice);
203
+ requireKey(runner);
204
+ const sessions = createSessionService(runner, { env: deps.env, cwd: deps.cwd });
205
+ // Ctrl-C stops the run at its next check (an action under way finishes), so the result still prints and the tab closes.
206
+ const cancel = new AbortController();
207
+ const stopListening = deps.onInterrupt(() => cancel.abort());
208
+ try {
209
+ const result = await sessions.task(request, { signal: cancel.signal, onEvent: eventPrinter(deps.io) });
210
+ if (!keepOpen)
211
+ await sessions.end(result.session).catch(() => undefined);
212
+ // Nothing is left to continue, so the result names no session.
213
+ const { session: _session, ...presented } = presentSummary(result, { detail });
214
+ await printDoc(deps.io, { ok: true, ...inCliWords(presented, undefined) });
215
+ return presented.status === 'done' ? 0 : 1;
216
+ }
217
+ finally {
218
+ stopListening();
219
+ await sessions.close().catch(() => undefined);
220
+ }
221
+ };
@@ -0,0 +1,132 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { DaemonRunning, startDaemon } from '../daemon/server.js';
4
+ import { envFingerprint } from '../daemon/paths.js';
5
+ import { callDaemon, daemonFor, TIMEOUTS } from './daemon-call.js';
6
+ import { CliError, durationFlag, parseFlags, usageError } from './flags.js';
7
+ import { renderHelp } from './help.js';
8
+ import { printDoc } from './output.js';
9
+ export const sessionsSpec = {
10
+ name: 'sessions',
11
+ summary: 'list the daemon\'s open sessions and journeys',
12
+ examples: ['jevway sessions'],
13
+ flags: {},
14
+ notes: ['Prints { ok: true, daemon: { pid, version, socket, log, idle_timeout_s } | null, sessions: [{ session, kind, goal, status, url, running, run_dir, … }] }. Never starts the daemon.'],
15
+ };
16
+ export const endSpec = {
17
+ name: 'end',
18
+ argument: '<id>',
19
+ summary: 'end a session (or finish a journey) and close the tabs it opened',
20
+ examples: ['jevway end <id>', 'jevway end <id> --keep-tab'],
21
+ flags: {
22
+ session: { type: 'string', value: '<id>', describe: 'the session (or pass it as the argument)' },
23
+ 'keep-tab': { type: 'boolean', describe: 'leave its tabs open' },
24
+ },
25
+ notes: ['Prints { ok: true, session, run_dir }. A session the daemon does not hold is unknown_session, exit 1.'],
26
+ };
27
+ export const serveSpec = {
28
+ name: 'serve',
29
+ summary: 'run the session daemon in the foreground (other commands start it in the background when needed)',
30
+ examples: ['jevway serve', 'jevway serve --idle-timeout 0'],
31
+ flags: { 'idle-timeout': { type: 'string', value: '<duration>', describe: 'end sessions idle this long, then exit: seconds, or 90s, 30m, 2h; 0 never (default 1h)' } },
32
+ notes: [
33
+ 'Prints { ok: true, socket, pid, version, idle_timeout_s } once it listens, then NDJSON log lines on stderr until it stops.',
34
+ 'Socket and log: $JEVWAY_STATE_DIR, else $XDG_RUNTIME_DIR/jevway, else ~/.cache/jevway (daemon.sock, daemon.log).',
35
+ 'It keeps the environment it started with; after changing keys, jevway stop and the next command starts a fresh one.',
36
+ ],
37
+ };
38
+ export const stopSpec = {
39
+ name: 'stop',
40
+ summary: 'stop the daemon: end every session, close the tabs they opened and any browser it launched',
41
+ examples: ['jevway stop'],
42
+ flags: {},
43
+ notes: ['Prints { ok: true, stopped: true, ended: <sessions> }, or { ok: true, stopped: false } when none was running.'],
44
+ };
45
+ const helped = async (spec, argv, deps, positionals = 0) => {
46
+ const parsed = parseFlags(spec, argv, { positionals });
47
+ if (!parsed.flags.help)
48
+ return parsed;
49
+ await deps.io.out(renderHelp(spec));
50
+ return undefined;
51
+ };
52
+ const daemonInfo = (hello, deps) => ({
53
+ pid: hello.pid,
54
+ version: hello.version,
55
+ socket: deps.daemon.socket,
56
+ log: deps.daemon.log,
57
+ idle_timeout_s: hello.idle_timeout_s,
58
+ started_at: hello.started_at,
59
+ });
60
+ /** `jevway sessions`: what the running daemon holds; no daemon is an empty list. */
61
+ export const sessionsCommand = async (argv, deps) => {
62
+ if (!(await helped(sessionsSpec, argv, deps)))
63
+ return 0;
64
+ const connection = await deps.daemon.find();
65
+ if (!connection) {
66
+ await printDoc(deps.io, { ok: true, daemon: null, sessions: [] });
67
+ return 0;
68
+ }
69
+ const { daemon, sessions } = await callDaemon(connection, 'sessions', {}, { writes: false, log: deps.daemon.log, timeoutMs: TIMEOUTS.quick });
70
+ await printDoc(deps.io, { ok: true, daemon: daemonInfo(daemon, deps), sessions });
71
+ return 0;
72
+ };
73
+ /** `jevway end <id>`: ends a session or finishes a journey. */
74
+ export const endCommand = async (argv, deps) => {
75
+ const parsed = await helped(endSpec, argv, deps, 1);
76
+ if (!parsed)
77
+ return 0;
78
+ const session = parsed.positionals[0] ?? parsed.flags.session;
79
+ if (!session)
80
+ throw usageError('missing_flag', 'jevway end needs the session id', 'Example: jevway end <id> (jevway sessions lists them)');
81
+ const connection = await daemonFor(deps, { session, kind: 'session' });
82
+ const ended = await callDaemon(connection, 'end', { session, keepTab: parsed.flags['keep-tab'] === true }, { writes: true, session: () => session, log: deps.daemon.log, timeoutMs: TIMEOUTS.quick });
83
+ await printDoc(deps.io, { ok: true, ...ended });
84
+ return 0;
85
+ };
86
+ /** `jevway stop`: stops the running daemon, if any. */
87
+ export const stopCommand = async (argv, deps) => {
88
+ if (!(await helped(stopSpec, argv, deps)))
89
+ return 0;
90
+ const connection = await deps.daemon.find();
91
+ if (!connection) {
92
+ await printDoc(deps.io, { ok: true, stopped: false });
93
+ return 0;
94
+ }
95
+ const result = await callDaemon(connection, 'stop', {}, { writes: false, log: deps.daemon.log, timeoutMs: TIMEOUTS.quick });
96
+ await Promise.race([connection.closed, new Promise((resolve) => setTimeout(resolve, 2_000).unref())]);
97
+ await printDoc(deps.io, { ok: true, ...result });
98
+ return 0;
99
+ };
100
+ /** `jevway serve`: the daemon in the foreground, until `jevway stop`, Ctrl-C or the idle timeout. */
101
+ export const serveCommand = async (argv, deps) => {
102
+ const parsed = await helped(serveSpec, argv, deps);
103
+ if (!parsed)
104
+ return 0;
105
+ const raw = parsed.flags['idle-timeout'];
106
+ const idleMs = typeof raw === 'string' ? durationFlag('idle-timeout', raw) : 3_600_000;
107
+ const { socket } = deps.daemon;
108
+ if (process.platform !== 'win32')
109
+ mkdirSync(dirname(socket), { recursive: true, mode: 0o700 });
110
+ let daemon;
111
+ try {
112
+ daemon = await startDaemon({
113
+ socket,
114
+ version: deps.version,
115
+ fingerprint: envFingerprint(deps.env),
116
+ idleMs,
117
+ runnerFor: ({ browser, ...choice }) => deps.createRunner(browser, choice),
118
+ log: (entry) => deps.io.err(`${JSON.stringify({ time: new Date().toISOString(), ...entry })}\n`),
119
+ });
120
+ }
121
+ catch (error) {
122
+ if (error instanceof DaemonRunning)
123
+ throw new CliError(1, error.code, error.message, 'jevway sessions shows it; jevway stop stops it');
124
+ throw error;
125
+ }
126
+ const stopListening = deps.onInterrupt(() => void daemon.stop());
127
+ // One line, so a background daemon's log stays one JSON object per line.
128
+ await deps.io.out(`${JSON.stringify({ ok: true, socket, pid: process.pid, version: deps.version, idle_timeout_s: idleMs ? idleMs / 1000 : null })}\n`);
129
+ await daemon.closed;
130
+ stopListening();
131
+ return 0;
132
+ };