jevway 0.1.2 → 0.2.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/INSTALL-FOR-AGENTS.md +32 -38
- package/README.md +193 -150
- package/dist/cli/daemon-call.js +78 -0
- package/dist/cli/dispatch.js +74 -0
- package/dist/cli/flags.js +152 -0
- package/dist/cli/help.js +46 -0
- package/dist/cli/journey-command.js +162 -0
- package/dist/cli/look-command.js +47 -0
- package/dist/cli/main.js +64 -0
- package/dist/cli/output.js +27 -0
- package/dist/cli/run-command.js +173 -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 +320 -0
- package/dist/jev/replay.js +7 -3
- package/dist/jev/retry.js +29 -0
- 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/instructions.js +18 -0
- package/dist/mcp/journey-tools.js +117 -0
- package/dist/mcp/results.js +53 -0
- package/dist/mcp/server.js +50 -0
- package/dist/mcp/task-tools.js +197 -0
- package/dist/mcp.js +10 -337
- package/dist/output/layout.js +50 -0
- package/dist/output/manifest.js +80 -0
- package/dist/output/run-folder.js +38 -0
- package/dist/recording/describe-step.js +66 -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 +193 -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 +101 -0
- package/dist/setup/terminal-prompter.js +51 -0
- package/dist/setup/unified-diff.js +69 -0
- package/dist/task/present.js +90 -0
- package/dist/task/run-task.js +74 -11
- package/dist/task/start-task.js +21 -5
- package/package.json +9 -7
- package/skill/jevway/SKILL.md +50 -14
- package/dist/cli.js +0 -112
|
@@ -0,0 +1,173 @@
|
|
|
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 { callDaemon, daemonFor, placement, requireKey, runnerChoice, runnerFor, TIMEOUTS } from './daemon-call.js';
|
|
7
|
+
import { choiceFlag, intFlag, jsonFlag, listFlag, parseFlags, usageError } from './flags.js';
|
|
8
|
+
import { renderHelp } from './help.js';
|
|
9
|
+
import { eventPrinter, printDoc } from './output.js';
|
|
10
|
+
const TEXT_BY_KEY = z.record(z.string(), z.string());
|
|
11
|
+
export const runSpec = {
|
|
12
|
+
name: 'run',
|
|
13
|
+
summary: 'hand a browser goal to Jev and print one JSON result',
|
|
14
|
+
examples: [
|
|
15
|
+
'jevway run --url https://en.wikipedia.org --goal "Open the article about the Rosetta Stone" --done-when "The Rosetta Stone article is open"',
|
|
16
|
+
'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"}\'',
|
|
17
|
+
'jevway run --session <id> --goal "Fill in the delivery details" --values \'{"phone":"+44 20 7946 0000"}\'',
|
|
18
|
+
'jevway run --session <id> --goal "Delete the repository ada/scratch" --confirm-irreversible --confirm-target "ada/scratch"',
|
|
19
|
+
],
|
|
20
|
+
flags: {
|
|
21
|
+
goal: { type: 'string', value: '<text>', describe: 'the full outcome wanted, in plain language (every call)' },
|
|
22
|
+
url: { type: 'string', value: '<url>', describe: 'open this page in a new tab (start)' },
|
|
23
|
+
tab: { type: 'string', value: '<url part>', describe: 'or drive an open tab whose URL contains this (start)' },
|
|
24
|
+
session: { type: 'string', value: '<id|new>', describe: 'new: keep the tab in the background daemon between calls; <id>: continue that session' },
|
|
25
|
+
values: { type: 'string', value: '<json>', describe: 'text values Jev may type, e.g. \'{"city":"Lisbon"}\'' },
|
|
26
|
+
'secret-keys': { type: 'string', value: '<a,b>', describe: 'keys of --values that are secrets: Jev sees the key only, output shows [secret:<key>]' },
|
|
27
|
+
files: { type: 'string', value: '<json>', describe: 'files Jev may upload, key to absolute path; Jev sees names, never paths' },
|
|
28
|
+
'done-when': { type: 'string', value: '<text>', describe: 'visible evidence of success on the page the call ends on' },
|
|
29
|
+
checks: { type: 'string', value: '<json>', describe: 'facts code checks on the final page, e.g. \'[{"type":"url_contains","text":"/issues/"}]\'' },
|
|
30
|
+
'confirm-irreversible': { type: 'boolean', describe: 'allow one click flagged irreversible, only when the user\'s request names it' },
|
|
31
|
+
'confirm-target': { type: 'string', value: '<words>', describe: 'with --confirm-irreversible: the user\'s words naming the target' },
|
|
32
|
+
'max-steps': { type: 'string', value: '<n>', describe: 'step limit, 1 to 100 (default 40)' },
|
|
33
|
+
'max-seconds': { type: 'string', value: '<n>', describe: 'time budget per call, 1 to 3600 (default 300); past it the run ends step_budget' },
|
|
34
|
+
'allowed-hosts': { type: 'string', value: '<a.com,*.b.com>', describe: 'hosts the run may be on; leaving them ends it blocked' },
|
|
35
|
+
'blocked-hosts': { type: 'string', value: '<a.com>', describe: 'hosts it must never be on' },
|
|
36
|
+
'allow-coordinate-clicks': { type: 'boolean', describe: 'click a boxed control the DOM never reported (Jev 1.14 direct only; start)' },
|
|
37
|
+
record: { type: 'boolean', describe: 'keep a recording for people in the run folder: video, step shots, index.html (start)' },
|
|
38
|
+
'record-dir': { type: 'string', value: '<dir>', describe: 'put the whole run folder, recording included, here instead (start; --record <dir> works too)' },
|
|
39
|
+
detail: { type: 'string', value: 'concise|full', describe: 'full adds the raw steps with confidences, Jev\'s stats and stuck candidates' },
|
|
40
|
+
screenshot: { type: 'boolean', describe: 'accepted for parity with MCP; the result always carries files.screenshot' },
|
|
41
|
+
browser: { type: 'string', value: '<source>', describe: 'auto, launch, launch-headless or http://127.0.0.1:<port> (default $JEVWAY_BROWSER or auto; start)' },
|
|
42
|
+
model: { type: 'string', value: '<name>', describe: 'jev, pplx-decider, clef-omni, clef, clef-flash or gpt-6-luna (default $JEVWAY_MODEL or jev; start)' },
|
|
43
|
+
screenshots: { type: 'string', value: 'off|fallback|always', describe: 'marked screenshots for vision models (default per model; start)' },
|
|
44
|
+
'keep-open': { type: 'boolean', describe: 'without --session: leave the tabs open at the end' },
|
|
45
|
+
},
|
|
46
|
+
notes: [
|
|
47
|
+
'Without --session the run happens in this process and its tabs close at the end, as before. With --session the',
|
|
48
|
+
'background daemon keeps the tab (and a launched browser) between calls; jevway end <id> closes it, jevway stop',
|
|
49
|
+
'stops the daemon. (start) flags count on a session\'s first call only.',
|
|
50
|
+
'',
|
|
51
|
+
'Prints { ok: true, session?, status, reason, next, story, url, title, page_text, files, … }; progress as NDJSON on',
|
|
52
|
+
'stderr. Exit 0 when status is done, 1 on a hand-back (needs_input, needs_confirmation, uncertain, step_budget,',
|
|
53
|
+
'blocked) or a tool error, 2 on bad usage or a missing key. outcome_unknown: the daemon went away mid-call; check',
|
|
54
|
+
'with jevway look --session <id> before repeating it.',
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* `next` in the CLI's words: flags instead of MCP parameters, and the session to pass (or, for a one-shot run, how to
|
|
59
|
+
* keep one).
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* cliNext('Add the missing values to `values` and call again with this session.', 'abc')
|
|
63
|
+
* // 'Add the missing values to `--values` and run again with `--session abc`.'
|
|
64
|
+
*/
|
|
65
|
+
export const cliNext = (next, session) => {
|
|
66
|
+
const again = (call, joiner = '') => {
|
|
67
|
+
const run = `${call === 'C' ? 'R' : 'r'}un again`;
|
|
68
|
+
if (session)
|
|
69
|
+
return `${run} with \`--session ${session}\`${joiner}`;
|
|
70
|
+
return joiner ? `${run} with ` : run;
|
|
71
|
+
};
|
|
72
|
+
let text = next
|
|
73
|
+
.replace(/`confirm_irreversible: true`/g, '`--confirm-irreversible`')
|
|
74
|
+
.replace(/`(values|goal|confirm_target|max_steps|max_seconds)`/g, (_, name) => `\`--${name.replace(/_/g, '-')}\``)
|
|
75
|
+
.replace(/([Cc])all again with this session(, | and )?/g, (_, call, joiner) => again(call, joiner));
|
|
76
|
+
text = session
|
|
77
|
+
? text.replace('end the session when', `end it with \`jevway end ${session}\` when`)
|
|
78
|
+
: text.replace('; end the session when you no longer need its tab', '');
|
|
79
|
+
return !session && /un again/.test(text) ? `${text} Pass \`--session new\` to keep the tab between calls.` : text;
|
|
80
|
+
};
|
|
81
|
+
// Today's `--record <dir>` keeps working: a value after --record is the folder.
|
|
82
|
+
const withRecordDir = (argv) => argv.flatMap((arg, i) => (arg === '--record' && argv[i + 1] !== undefined && !argv[i + 1].startsWith('-') ? ['--record-dir'] : [arg]));
|
|
83
|
+
const defined = (value) => Object.fromEntries(Object.entries(value).filter(([, inner]) => inner !== undefined));
|
|
84
|
+
/** `jevway run`: one goal, in-process without `--session`, through the daemon with it. */
|
|
85
|
+
export const runCommand = async (argv, deps) => {
|
|
86
|
+
const { flags } = parseFlags(runSpec, withRecordDir(argv), { positionals: 0 });
|
|
87
|
+
if (flags.help) {
|
|
88
|
+
await deps.io.out(renderHelp(runSpec));
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
const text = (name) => (typeof flags[name] === 'string' ? flags[name] : undefined);
|
|
92
|
+
const yes = (name) => (flags[name] === true ? true : undefined);
|
|
93
|
+
const goal = text('goal');
|
|
94
|
+
if (!goal)
|
|
95
|
+
throw usageError('missing_flag', 'jevway run needs --goal', 'Example: jevway run --url https://example.com --goal "Open the pricing page"');
|
|
96
|
+
const session = text('session');
|
|
97
|
+
const starting = !session || session === 'new';
|
|
98
|
+
if (starting && !text('url') && !text('tab')) {
|
|
99
|
+
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"');
|
|
100
|
+
}
|
|
101
|
+
const detail = choiceFlag('detail', text('detail'), ['concise', 'full']) ?? 'concise';
|
|
102
|
+
const recordDir = text('record-dir');
|
|
103
|
+
const request = defined({
|
|
104
|
+
goal,
|
|
105
|
+
url: text('url'),
|
|
106
|
+
tab: text('tab'),
|
|
107
|
+
values: jsonFlag('values', text('values'), TEXT_BY_KEY, '{"city":"Lisbon"}'),
|
|
108
|
+
secretKeys: listFlag(text('secret-keys')),
|
|
109
|
+
files: jsonFlag('files', text('files'), TEXT_BY_KEY, '{"avatar":"/Users/ada/me.png"}'),
|
|
110
|
+
doneWhen: text('done-when'),
|
|
111
|
+
checks: jsonFlag('checks', text('checks'), CHECKS, '[{"type":"url_contains","text":"/issues/"}]'),
|
|
112
|
+
maxSteps: intFlag('max-steps', text('max-steps'), { min: 1, max: 100 }),
|
|
113
|
+
maxSeconds: intFlag('max-seconds', text('max-seconds'), { min: 1, max: 3600 }),
|
|
114
|
+
allowedHosts: listFlag(text('allowed-hosts')),
|
|
115
|
+
blockedHosts: listFlag(text('blocked-hosts')),
|
|
116
|
+
confirmIrreversible: yes('confirm-irreversible'),
|
|
117
|
+
confirmTarget: text('confirm-target'),
|
|
118
|
+
allowCoordinateClicks: yes('allow-coordinate-clicks'),
|
|
119
|
+
record: yes('record'),
|
|
120
|
+
recordDir: recordDir && resolve(deps.cwd, recordDir),
|
|
121
|
+
});
|
|
122
|
+
const choice = runnerChoice(flags, deps.env);
|
|
123
|
+
if (!session)
|
|
124
|
+
return runHere(deps, request, { choice, detail, keepOpen: Boolean(yes('keep-open')) });
|
|
125
|
+
if (starting)
|
|
126
|
+
requireKey(runnerFor(deps, choice));
|
|
127
|
+
const connection = await daemonFor(deps, starting ? { start: true } : { session, kind: 'session' });
|
|
128
|
+
const printEvent = eventPrinter(deps.io);
|
|
129
|
+
let id = starting ? undefined : session;
|
|
130
|
+
const cancel = new AbortController();
|
|
131
|
+
const stopListening = deps.onInterrupt(() => cancel.abort());
|
|
132
|
+
try {
|
|
133
|
+
const result = await callDaemon(connection, 'task', { request: { ...request, ...(id && { session: id }) }, detail, runner: choice, place: placement(deps) }, {
|
|
134
|
+
writes: true,
|
|
135
|
+
session: () => id,
|
|
136
|
+
log: deps.daemon.log,
|
|
137
|
+
signal: cancel.signal,
|
|
138
|
+
timeoutMs: TIMEOUTS.task(request.maxSeconds, !starting),
|
|
139
|
+
onEvent: (event) => {
|
|
140
|
+
if (event?.type === 'started')
|
|
141
|
+
id = event.session;
|
|
142
|
+
printEvent(event);
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
await printDoc(deps.io, { ok: true, ...result, next: cliNext(result.next, result.session) });
|
|
146
|
+
return result.status === 'done' ? 0 : 1;
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
stopListening();
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
/** A one-shot run in this process, as before the daemon: its own browser connection, its tabs closed at the end. */
|
|
153
|
+
const runHere = async (deps, request, { choice, detail, keepOpen }) => {
|
|
154
|
+
const runner = runnerFor(deps, choice);
|
|
155
|
+
requireKey(runner);
|
|
156
|
+
const sessions = createSessionService(runner, { env: deps.env, cwd: deps.cwd });
|
|
157
|
+
// Ctrl-C stops the run at its next check (an action under way finishes), so the result still prints and the tab closes.
|
|
158
|
+
const cancel = new AbortController();
|
|
159
|
+
const stopListening = deps.onInterrupt(() => cancel.abort());
|
|
160
|
+
try {
|
|
161
|
+
const result = await sessions.task(request, { signal: cancel.signal, onEvent: eventPrinter(deps.io) });
|
|
162
|
+
if (!keepOpen)
|
|
163
|
+
await sessions.end(result.session).catch(() => undefined);
|
|
164
|
+
// Nothing is left to continue, so the result names no session.
|
|
165
|
+
const { session: _session, ...presented } = presentSummary(result, { detail });
|
|
166
|
+
await printDoc(deps.io, { ok: true, ...presented, next: cliNext(presented.next, undefined) });
|
|
167
|
+
return presented.status === 'done' ? 0 : 1;
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
stopListening();
|
|
171
|
+
await sessions.close().catch(() => undefined);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, openSync, renameSync, rmSync, statSync } from 'node:fs';
|
|
3
|
+
import { createConnection } from 'node:net';
|
|
4
|
+
import { logPath } from './paths.js';
|
|
5
|
+
import { readLines, sendLine } from './protocol.js';
|
|
6
|
+
/**
|
|
7
|
+
* A daemon call that failed: the daemon's own error (`code`, `message`, `hint`), or `lost` when the connection closed
|
|
8
|
+
* or timed out before an answer, so the call may or may not have happened.
|
|
9
|
+
*/
|
|
10
|
+
export class DaemonFailure extends Error {
|
|
11
|
+
code;
|
|
12
|
+
hint;
|
|
13
|
+
lost;
|
|
14
|
+
constructor(code, message, hint, lost = false) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.hint = hint;
|
|
18
|
+
this.lost = lost;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const NOT_LISTENING = new Set(['ENOENT', 'ECONNREFUSED', 'ENOTSOCK']);
|
|
22
|
+
const HELLO_TIMEOUT_MS = 5_000;
|
|
23
|
+
const STOP_TIMEOUT_MS = 60_000;
|
|
24
|
+
const START_TIMEOUT_MS = 10_000;
|
|
25
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
26
|
+
const lostConnection = (timedOut) => new DaemonFailure('daemon_unavailable', timedOut ? 'The daemon did not answer in time.' : 'The connection to the daemon closed before it answered.', 'Run the command again; jevway sessions shows what the daemon holds.', true);
|
|
27
|
+
const open = (socket) => new Promise((resolve, reject) => {
|
|
28
|
+
const connection = createConnection(socket);
|
|
29
|
+
const onError = (error) => {
|
|
30
|
+
if (NOT_LISTENING.has(error.code ?? ''))
|
|
31
|
+
resolve(undefined);
|
|
32
|
+
else
|
|
33
|
+
reject(new DaemonFailure('daemon_unavailable', `Could not reach the jevway daemon at ${socket}: ${error.message}`, 'Run jevway stop, then the command again.'));
|
|
34
|
+
};
|
|
35
|
+
connection.once('error', onError);
|
|
36
|
+
connection.once('connect', () => {
|
|
37
|
+
connection.off('error', onError);
|
|
38
|
+
resolve(connection);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
/**
|
|
42
|
+
* Connects to the daemon on `socket` and says hello; undefined when nothing listens there.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* const daemon = await connectDaemon('/Users/ada/.cache/jevway/daemon.sock');
|
|
46
|
+
* daemon?.hello.version // '0.1.2'
|
|
47
|
+
*/
|
|
48
|
+
export const connectDaemon = async (socket) => {
|
|
49
|
+
const connection = await open(socket);
|
|
50
|
+
if (!connection)
|
|
51
|
+
return undefined;
|
|
52
|
+
const waiting = new Map();
|
|
53
|
+
let nextId = 0;
|
|
54
|
+
let markClosed;
|
|
55
|
+
const closed = new Promise((resolve) => (markClosed = resolve));
|
|
56
|
+
readLines(connection, (message) => {
|
|
57
|
+
const call = waiting.get(message?.id);
|
|
58
|
+
if (!call)
|
|
59
|
+
return;
|
|
60
|
+
if ('event' in message)
|
|
61
|
+
return call.onEvent?.(message.event);
|
|
62
|
+
waiting.delete(message.id);
|
|
63
|
+
if ('error' in message)
|
|
64
|
+
call.reject(new DaemonFailure(message.error.code, message.error.message, message.error.hint));
|
|
65
|
+
else
|
|
66
|
+
call.resolve(message.result);
|
|
67
|
+
});
|
|
68
|
+
connection.on('error', () => undefined);
|
|
69
|
+
connection.on('close', () => {
|
|
70
|
+
for (const call of waiting.values())
|
|
71
|
+
call.reject(lostConnection(false));
|
|
72
|
+
waiting.clear();
|
|
73
|
+
markClosed();
|
|
74
|
+
});
|
|
75
|
+
const request = (method, params = {}, { onEvent, timeoutMs, signal } = {}) => new Promise((resolve, reject) => {
|
|
76
|
+
if (connection.destroyed)
|
|
77
|
+
return reject(lostConnection(false));
|
|
78
|
+
const id = ++nextId;
|
|
79
|
+
const onAbort = () => sendLine(connection, { cancel: id });
|
|
80
|
+
const timer = timeoutMs
|
|
81
|
+
? setTimeout(() => {
|
|
82
|
+
waiting.delete(id);
|
|
83
|
+
settle();
|
|
84
|
+
reject(lostConnection(true));
|
|
85
|
+
connection.destroy();
|
|
86
|
+
}, timeoutMs)
|
|
87
|
+
: undefined;
|
|
88
|
+
const settle = () => {
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
signal?.removeEventListener('abort', onAbort);
|
|
91
|
+
};
|
|
92
|
+
waiting.set(id, {
|
|
93
|
+
resolve: (value) => (settle(), resolve(value)),
|
|
94
|
+
reject: (error) => (settle(), reject(error)),
|
|
95
|
+
onEvent,
|
|
96
|
+
});
|
|
97
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
98
|
+
sendLine(connection, { id, method, params });
|
|
99
|
+
if (signal?.aborted)
|
|
100
|
+
onAbort();
|
|
101
|
+
});
|
|
102
|
+
try {
|
|
103
|
+
const hello = await request('hello', {}, { timeoutMs: HELLO_TIMEOUT_MS });
|
|
104
|
+
return { hello, request, close: () => connection.end(), closed };
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
connection.destroy();
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* How a command reaches the daemon on `socket`. `start` launches one (in the background for the real CLI, in-process in
|
|
113
|
+
* tests); the client then waits up to 10 s for it to answer.
|
|
114
|
+
*/
|
|
115
|
+
export const daemonAccess = ({ socket, log, version, fingerprint, start, startTimeoutMs = START_TIMEOUT_MS, }) => ({
|
|
116
|
+
socket,
|
|
117
|
+
log,
|
|
118
|
+
find: () => connectDaemon(socket),
|
|
119
|
+
async ensure(onEvent) {
|
|
120
|
+
let reason;
|
|
121
|
+
const running = await connectDaemon(socket);
|
|
122
|
+
if (running) {
|
|
123
|
+
const { hello } = running;
|
|
124
|
+
reason =
|
|
125
|
+
hello.version !== version
|
|
126
|
+
? `the running daemon is version ${hello.version}, this jevway is ${version}`
|
|
127
|
+
: hello.fingerprint !== fingerprint
|
|
128
|
+
? 'the running daemon was started with other settings (keys, CHROME_PATH, JEVWAY_ALLOWED_HOSTS/JEVWAY_BLOCKED_HOSTS or JEVWAY_REPLAY)'
|
|
129
|
+
: undefined;
|
|
130
|
+
if (!reason)
|
|
131
|
+
return running;
|
|
132
|
+
if (hello.sessions > 0) {
|
|
133
|
+
running.close();
|
|
134
|
+
throw new DaemonFailure('daemon_busy', `${reason}, and it holds ${hello.sessions} session${hello.sessions === 1 ? '' : 's'}`, 'List them with jevway sessions; jevway stop ends them and the next command starts a new daemon');
|
|
135
|
+
}
|
|
136
|
+
await running.request('stop', {}, { timeoutMs: STOP_TIMEOUT_MS }).catch(() => undefined);
|
|
137
|
+
running.close();
|
|
138
|
+
await Promise.race([running.closed, delay(STOP_TIMEOUT_MS)]);
|
|
139
|
+
}
|
|
140
|
+
else if (process.platform !== 'win32' && existsSync(socket)) {
|
|
141
|
+
// A socket file nothing answers on: its daemon died without removing it.
|
|
142
|
+
rmSync(socket, { force: true });
|
|
143
|
+
}
|
|
144
|
+
await start();
|
|
145
|
+
const deadline = Date.now() + startTimeoutMs;
|
|
146
|
+
while (Date.now() < deadline) {
|
|
147
|
+
const connection = await connectDaemon(socket).catch(() => undefined);
|
|
148
|
+
if (connection) {
|
|
149
|
+
onEvent({ type: 'daemon', event: reason ? 'restarted' : 'started', pid: connection.hello.pid, log, ...(reason && { reason }) });
|
|
150
|
+
return connection;
|
|
151
|
+
}
|
|
152
|
+
await delay(50);
|
|
153
|
+
}
|
|
154
|
+
throw new DaemonFailure('daemon_unavailable', `The jevway daemon did not start within ${startTimeoutMs / 1000} s`, `See ${log}, or run jevway serve to watch it start`);
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
// The daemon inherits the environment already loaded; an --env-file flag would only log a missing .env in its state dir.
|
|
158
|
+
const daemonExecArgv = () => process.execArgv.filter((arg) => !arg.startsWith('--env-file'));
|
|
159
|
+
/**
|
|
160
|
+
* Starts `node <entry> serve` detached in `stateDir`, its output appended to `daemon.log` (moved to `daemon.log.1`
|
|
161
|
+
* past 5 MB). Returns at once; the caller waits for the socket.
|
|
162
|
+
*/
|
|
163
|
+
export const spawnDaemon = ({ entry, stateDir, env, execArgv = daemonExecArgv() }) => {
|
|
164
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
165
|
+
const log = logPath(stateDir);
|
|
166
|
+
if ((statSync(log, { throwIfNoEntry: false })?.size ?? 0) > 5_000_000)
|
|
167
|
+
renameSync(log, `${log}.1`);
|
|
168
|
+
const fd = openSync(log, 'a', 0o600);
|
|
169
|
+
try {
|
|
170
|
+
spawn(process.execPath, [...execArgv, entry, 'serve'], { detached: true, stdio: ['ignore', fd, fd], cwd: stateDir, env, windowsHide: true }).unref();
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
closeSync(fd);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { homedir, tmpdir, userInfo } from 'node:os';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
/** Where the daemon keeps its socket and log: `$JEVWAY_STATE_DIR`, else `$XDG_RUNTIME_DIR/jevway`, else `~/.cache/jevway`. */
|
|
5
|
+
export const stateDir = (env = process.env, home = homedir()) => env.JEVWAY_STATE_DIR?.trim() || (env.XDG_RUNTIME_DIR?.trim() ? join(env.XDG_RUNTIME_DIR, 'jevway') : join(home, '.cache', 'jevway'));
|
|
6
|
+
// Unix socket paths are capped at 104 bytes on macOS (108 on Linux), counting the terminating NUL.
|
|
7
|
+
const SOCKET_PATH_BYTES = 103;
|
|
8
|
+
const stateDigest = (dir) => createHash('sha256').update(`${userInfo().username}:${dir}`).digest('hex').slice(0, 16);
|
|
9
|
+
/**
|
|
10
|
+
* `<state>/daemon.sock`, or `<tmp>/jevway-<digest>.sock` when the state dir is too deep for a Unix socket; on Windows
|
|
11
|
+
* a named pipe. One state dir always maps to one socket.
|
|
12
|
+
*/
|
|
13
|
+
export const socketPath = (dir, platform = process.platform, tmp = tmpdir()) => {
|
|
14
|
+
if (platform === 'win32')
|
|
15
|
+
return `\\\\.\\pipe\\jevway-${stateDigest(dir)}`;
|
|
16
|
+
const inState = join(dir, 'daemon.sock');
|
|
17
|
+
return Buffer.byteLength(inState) <= SOCKET_PATH_BYTES ? inState : join(tmp, `jevway-${stateDigest(dir)}.sock`);
|
|
18
|
+
};
|
|
19
|
+
export const logPath = (dir) => join(dir, 'daemon.log');
|
|
20
|
+
/**
|
|
21
|
+
* The environment a daemon is started with: the caller's, with `JEVWAY_REPLAY_DIR` made absolute (the daemon runs in
|
|
22
|
+
* its state dir).
|
|
23
|
+
*/
|
|
24
|
+
export const daemonEnv = (env, cwd) => env.JEVWAY_REPLAY_DIR?.trim() ? { ...env, JEVWAY_REPLAY_DIR: resolve(cwd, env.JEVWAY_REPLAY_DIR.trim()) } : env;
|
|
25
|
+
// What a daemon reads from its own environment rather than from each request. Keys count only as set or not.
|
|
26
|
+
const KEYS = ['TYPESAFE_API_KEY', 'OPENROUTER_API_KEY'];
|
|
27
|
+
const SETTINGS = ['CHROME_PATH', 'JEVWAY_ALLOWED_HOSTS', 'JEVWAY_BLOCKED_HOSTS', 'JEVWAY_REPLAY', 'JEVWAY_REPLAY_DIR'];
|
|
28
|
+
/**
|
|
29
|
+
* A digest of the settings a daemon reads from its own environment, so a client can tell when a running daemon was
|
|
30
|
+
* started with other ones. Holds nothing derived from a key's value, only whether each key is set.
|
|
31
|
+
*/
|
|
32
|
+
export const envFingerprint = (env) => createHash('sha256')
|
|
33
|
+
.update(JSON.stringify([...KEYS.map((name) => Boolean(env[name]?.trim())), ...SETTINGS.map((name) => env[name]?.trim() ?? '')]))
|
|
34
|
+
.digest('hex')
|
|
35
|
+
.slice(0, 16);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const sendLine = (socket, message) => {
|
|
2
|
+
if (!socket.destroyed && socket.writable)
|
|
3
|
+
socket.write(`${JSON.stringify(message)}\n`);
|
|
4
|
+
};
|
|
5
|
+
/** Calls `onMessage` with each JSON line the socket receives; a line that does not parse is dropped. */
|
|
6
|
+
export const readLines = (socket, onMessage) => {
|
|
7
|
+
let buffer = '';
|
|
8
|
+
socket.setEncoding('utf8');
|
|
9
|
+
socket.on('data', (chunk) => {
|
|
10
|
+
buffer += chunk;
|
|
11
|
+
let newline = buffer.indexOf('\n');
|
|
12
|
+
while (newline >= 0) {
|
|
13
|
+
const line = buffer.slice(0, newline).trim();
|
|
14
|
+
buffer = buffer.slice(newline + 1);
|
|
15
|
+
newline = buffer.indexOf('\n');
|
|
16
|
+
if (!line)
|
|
17
|
+
continue;
|
|
18
|
+
let message;
|
|
19
|
+
try {
|
|
20
|
+
message = JSON.parse(line);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
onMessage(message);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
};
|