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,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
+ };
@@ -0,0 +1,339 @@
1
+ import { chmodSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { createConnection, createServer } from 'node:net';
3
+ import { dirname, join } from 'node:path';
4
+ import { InboxError } from '../inbox/inbox.js';
5
+ import { createInboxRegistry } from '../inbox/inbox-registry.js';
6
+ import { ensureOutRoot } from '../output/layout.js';
7
+ import { createSessionService, UnknownSession } from '../session/session-service.js';
8
+ import { presentSummary } from '../task/present.js';
9
+ import { readLines, sendLine } from './protocol.js';
10
+ /** Another daemon already answers on the socket. */
11
+ export class DaemonRunning extends Error {
12
+ code = 'daemon_running';
13
+ }
14
+ const STOP_WAIT_MS = 10_000;
15
+ // 3600000 → '1 h', 1800000 → '30 min'.
16
+ const idleWords = (ms) => (ms % 3_600_000 === 0 ? `${ms / 3_600_000} h` : `${Math.round(ms / 60_000)} min`);
17
+ const listen = (server, path) => new Promise((resolve, reject) => {
18
+ server.once('error', reject);
19
+ server.listen(path, () => {
20
+ server.off('error', reject);
21
+ resolve();
22
+ });
23
+ });
24
+ const answers = (path) => new Promise((resolve) => {
25
+ const probe = createConnection(path);
26
+ probe.once('connect', () => {
27
+ probe.destroy();
28
+ resolve(true);
29
+ });
30
+ probe.once('error', () => resolve(false));
31
+ });
32
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms).unref());
33
+ const errorBody = (error) => {
34
+ if (error instanceof UnknownSession) {
35
+ return { code: 'unknown_session', message: error.message, hint: 'List the open ones with jevway sessions, or start one with jevway run --session new --url <url> --goal <goal>' };
36
+ }
37
+ if (error instanceof InboxError)
38
+ return { code: error.code, message: error.message, hint: error.hint };
39
+ return { code: 'failed', message: error instanceof Error ? error.message : String(error), hint: 'The message says what failed; jevway doctor checks the key and the browser.' };
40
+ };
41
+ /**
42
+ * Serves the session service over a local socket until stopped or idle: one runner per browser/model choice, one
43
+ * service per output root, sessions found by id in whichever service holds them, and `sessions.json` kept in each
44
+ * output root. A socket file nothing answers on is replaced; one another daemon answers on is `DaemonRunning`.
45
+ *
46
+ * @example
47
+ * const daemon = await startDaemon({ socket, version, fingerprint, idleMs: 3_600_000, runnerFor, log });
48
+ * await daemon.closed;
49
+ */
50
+ export const startDaemon = async ({ socket, version, fingerprint, idleMs, runnerFor, log, inboxes = createInboxRegistry() }) => {
51
+ const runners = new Map();
52
+ const services = new Map();
53
+ const connections = new Set();
54
+ const controllers = new Set();
55
+ const pending = new Set();
56
+ const busy = new Map();
57
+ const startedAt = new Date();
58
+ let lastActivity = Date.now();
59
+ let stopping;
60
+ let markClosed;
61
+ const closed = new Promise((resolve) => (markClosed = resolve));
62
+ const lifetime = `Sessions end when the daemon stops${idleMs ? ` or after ${idleWords(idleMs)} idle` : ''}`;
63
+ const unknown = (id, kind) => new UnknownSession(kind === 'session'
64
+ ? `Unknown session "${id}". ${lifetime}; start a new one with --session new and --url (or --tab).`
65
+ : `Unknown journey "${id}". Journeys end when finished, or as sessions do: ${lifetime.toLowerCase()}; start a new one with jevway journey start --url <url>.`);
66
+ const allSessions = () => [...services.values()].flatMap(({ service }) => service.listSessions());
67
+ const holderOf = (id, kind) => {
68
+ for (const holder of services.values())
69
+ if (holder.service.listSessions().some(({ session }) => session === id))
70
+ return holder;
71
+ throw unknown(id, kind);
72
+ };
73
+ const serviceFor = (choice, place) => {
74
+ const runnerKey = JSON.stringify([choice.browser, choice.model ?? null, choice.screenshots ?? null]);
75
+ let runner = runners.get(runnerKey);
76
+ if (!runner)
77
+ runners.set(runnerKey, (runner = runnerFor(choice)));
78
+ const key = JSON.stringify([runnerKey, place.outDir, place.journeysDir ?? null]);
79
+ let holder = services.get(key);
80
+ if (!holder) {
81
+ const env = place.journeysDir ? { JEVWAY_JOURNEYS_DIR: place.journeysDir } : {};
82
+ holder = { service: createSessionService(runner, { outDir: place.outDir, cwd: place.cwd, env, idleMs: idleMs || undefined }), outDir: place.outDir };
83
+ services.set(key, holder);
84
+ }
85
+ return holder;
86
+ };
87
+ /** Rewrites `<root>/sessions.json` in every output root this daemon writes to. */
88
+ const writeSessionFiles = () => {
89
+ const byRoot = new Map();
90
+ for (const { service, outDir } of services.values())
91
+ byRoot.set(outDir, [...(byRoot.get(outDir) ?? []), ...service.listSessions()]);
92
+ for (const [root, sessions] of byRoot) {
93
+ try {
94
+ ensureOutRoot(root);
95
+ sessions.sort((a, b) => a.started_at.localeCompare(b.started_at));
96
+ writeFileSync(join(root, 'sessions.json'), `${JSON.stringify({ updated_at: new Date().toISOString(), sessions }, null, 1)}\n`);
97
+ }
98
+ catch {
99
+ // A folder that can't be written never fails a call.
100
+ }
101
+ }
102
+ };
103
+ const endOne = ({ service }, { session, kind }, keepTab = false) => kind === 'journey' ? service.journeyFinish(session, { keepTab }) : service.end(session, { keepTab });
104
+ const hello = () => ({
105
+ version,
106
+ fingerprint,
107
+ pid: process.pid,
108
+ sessions: allSessions().length,
109
+ idle_timeout_s: idleMs ? idleMs / 1000 : null,
110
+ started_at: startedAt.toISOString(),
111
+ });
112
+ const withoutPreview = ({ journey, report }) => ({ journey, ...report });
113
+ const handlers = {
114
+ hello: () => hello(),
115
+ async task({ request, detail, runner, place }, { signal, emit, track }) {
116
+ const { service } = request.session ? holderOf(request.session, 'session') : serviceFor(runner, place);
117
+ const result = await service.task(request, {
118
+ signal,
119
+ onEvent: (event) => {
120
+ if (event.type === 'started') {
121
+ track(event.session);
122
+ writeSessionFiles();
123
+ }
124
+ emit(event);
125
+ },
126
+ });
127
+ writeSessionFiles();
128
+ return presentSummary(result, { detail });
129
+ },
130
+ async look({ session, pdf }) {
131
+ return { session, ...(await holderOf(session, 'session').service.look({ session, pdf: pdf === true })) };
132
+ },
133
+ async end({ session, keepTab }) {
134
+ const holder = holderOf(session, 'session');
135
+ const info = holder.service.listSessions().find((entry) => entry.session === session);
136
+ const ended = await endOne(holder, info, keepTab);
137
+ writeSessionFiles();
138
+ return info.kind === 'journey' ? { journey: session, ...ended } : ended;
139
+ },
140
+ sessions: () => ({ daemon: hello(), sessions: allSessions().sort((a, b) => a.started_at.localeCompare(b.started_at)) }),
141
+ async 'journey.start'({ request, runner, place }) {
142
+ const reply = await serviceFor(runner, place).service.journeyStart(request);
143
+ writeSessionFiles();
144
+ return withoutPreview(reply);
145
+ },
146
+ async 'journey.step'({ journey, input }) {
147
+ return withoutPreview(await holderOf(journey, 'journey').service.journeyStep(journey, input));
148
+ },
149
+ async 'journey.autopilot'({ journey, input }) {
150
+ return withoutPreview(await holderOf(journey, 'journey').service.journeyAutopilot(journey, input));
151
+ },
152
+ 'journey.annotate'({ journey, screen, notes, relabel }) {
153
+ holderOf(journey, 'journey').service.journeyAnnotate(journey, screen, notes, relabel);
154
+ return { journey, screen, annotated: true };
155
+ },
156
+ async 'journey.finish'({ journey, keepTab }) {
157
+ const summary = await holderOf(journey, 'journey').service.journeyFinish(journey, { keepTab });
158
+ writeSessionFiles();
159
+ return { journey, ...summary };
160
+ },
161
+ // Inboxes are named `inbox`, never `session`, so the idle sweep never mistakes one for a busy session.
162
+ 'inbox.create': ({ provider }) => inboxes.create({ provider }),
163
+ async 'inbox.wait'({ inbox, from, subject, timeoutS }, { signal, emit }) {
164
+ const address = inboxes.addressOf(inbox);
165
+ const message = await inboxes.wait(inbox, {
166
+ from,
167
+ subject,
168
+ timeoutMs: timeoutS * 1000,
169
+ signal,
170
+ onPoll: (elapsedMs) => emit({ type: 'inbox_wait', address, elapsed_s: Math.round(elapsedMs / 1000), timeout_s: timeoutS }),
171
+ });
172
+ return { message: message ?? null };
173
+ },
174
+ 'inbox.list': ({ inbox }, { signal }) => inboxes.list(inbox, { signal }),
175
+ 'inbox.read': ({ inbox, id }, { signal }) => inboxes.read(inbox, id, { signal }),
176
+ };
177
+ // Ending (not destroying) lets the last replies reach their clients first.
178
+ const finish = () => {
179
+ for (const connection of connections) {
180
+ connection.end();
181
+ setTimeout(() => connection.destroy(), 1_000).unref();
182
+ }
183
+ markClosed();
184
+ };
185
+ const shutdown = (reason) => (stopping ??= (async () => {
186
+ clearInterval(timer);
187
+ server.close();
188
+ for (const controller of controllers)
189
+ controller.abort();
190
+ await Promise.race([Promise.allSettled([...pending]), delay(STOP_WAIT_MS)]);
191
+ let ended = 0;
192
+ for (const holder of services.values()) {
193
+ for (const info of holder.service.listSessions()) {
194
+ try {
195
+ await endOne(holder, info);
196
+ ended++;
197
+ }
198
+ catch {
199
+ // A tab that is already gone is ended all the same.
200
+ }
201
+ }
202
+ }
203
+ for (const runner of runners.values())
204
+ await runner.close().catch(() => undefined);
205
+ writeSessionFiles();
206
+ log({ event: 'stopped', reason, ended });
207
+ return ended;
208
+ })());
209
+ const handle = async (socket, mine, { id, method, params = {} }) => {
210
+ if (method === 'stop') {
211
+ const ended = await shutdown('stop');
212
+ sendLine(socket, { id, result: { stopped: true, ended } });
213
+ finish();
214
+ return;
215
+ }
216
+ if (stopping) {
217
+ sendLine(socket, { id, error: { code: 'daemon_unavailable', message: 'The daemon is stopping.', hint: 'Run the command again: it starts a new daemon.' } });
218
+ return;
219
+ }
220
+ const handler = handlers[method];
221
+ if (!handler) {
222
+ sendLine(socket, { id, error: { code: 'unknown_method', message: `Unknown method "${method}"`, hint: 'The daemon and this jevway may differ in version: run jevway stop.' } });
223
+ return;
224
+ }
225
+ const controller = new AbortController();
226
+ mine.set(id, controller);
227
+ controllers.add(controller);
228
+ const tracked = [];
229
+ const track = (session) => {
230
+ tracked.push(session);
231
+ busy.set(session, (busy.get(session) ?? 0) + 1);
232
+ };
233
+ for (const session of [params.session, params.journey, params.request?.session])
234
+ if (typeof session === 'string')
235
+ track(session);
236
+ const began = Date.now();
237
+ lastActivity = began;
238
+ let failed;
239
+ const work = (async () => {
240
+ try {
241
+ const result = await handler(params, { signal: controller.signal, emit: (event) => sendLine(socket, { id, event }), track });
242
+ sendLine(socket, { id, result });
243
+ }
244
+ catch (error) {
245
+ const body = errorBody(error);
246
+ failed = body.code;
247
+ sendLine(socket, { id, error: body });
248
+ }
249
+ })();
250
+ pending.add(work);
251
+ await work;
252
+ pending.delete(work);
253
+ mine.delete(id);
254
+ controllers.delete(controller);
255
+ for (const session of tracked) {
256
+ const left = (busy.get(session) ?? 1) - 1;
257
+ if (left > 0)
258
+ busy.set(session, left);
259
+ else
260
+ busy.delete(session);
261
+ }
262
+ lastActivity = Date.now();
263
+ log({ event: 'request', method, id, ...(tracked[0] && { session: tracked[0] }), ms: lastActivity - began, ...(failed && { error: failed }) });
264
+ };
265
+ const onConnection = (socket) => {
266
+ connections.add(socket);
267
+ const mine = new Map();
268
+ readLines(socket, (message) => {
269
+ if (typeof message?.cancel === 'number')
270
+ mine.get(message.cancel)?.abort();
271
+ else if (typeof message?.id === 'number' && typeof message?.method === 'string')
272
+ void handle(socket, mine, message);
273
+ });
274
+ // A client that goes away cancels its running calls, as a cancelled MCP request does.
275
+ socket.on('close', () => {
276
+ connections.delete(socket);
277
+ for (const controller of mine.values())
278
+ controller.abort();
279
+ });
280
+ socket.on('error', () => undefined);
281
+ };
282
+ let sweeping = false;
283
+ const sweep = async () => {
284
+ if (sweeping || stopping)
285
+ return;
286
+ sweeping = true;
287
+ try {
288
+ const cutoff = Date.now() - idleMs;
289
+ let changed = false;
290
+ for (const holder of services.values()) {
291
+ for (const info of holder.service.listSessions()) {
292
+ if (info.running || busy.has(info.session) || Date.parse(info.last_used_at) > cutoff)
293
+ continue;
294
+ await endOne(holder, info).catch(() => undefined);
295
+ changed = true;
296
+ log({ event: 'idle_end', session: info.session, kind: info.kind });
297
+ }
298
+ }
299
+ if (changed)
300
+ writeSessionFiles();
301
+ if (!allSessions().length && !pending.size && lastActivity <= cutoff) {
302
+ await shutdown('idle');
303
+ finish();
304
+ }
305
+ }
306
+ finally {
307
+ sweeping = false;
308
+ }
309
+ };
310
+ if (process.platform !== 'win32')
311
+ mkdirSync(dirname(socket), { recursive: true, mode: 0o700 });
312
+ let server = createServer(onConnection);
313
+ try {
314
+ await listen(server, socket);
315
+ }
316
+ catch (error) {
317
+ if (error.code !== 'EADDRINUSE')
318
+ throw error;
319
+ if (await answers(socket))
320
+ throw new DaemonRunning(`A jevway daemon already answers on ${socket}`);
321
+ // A socket file left by a daemon that died: nothing answers on it.
322
+ rmSync(socket, { force: true });
323
+ server = createServer(onConnection);
324
+ await listen(server, socket);
325
+ }
326
+ if (process.platform !== 'win32')
327
+ chmodSync(socket, 0o600);
328
+ const timer = idleMs > 0 ? setInterval(() => void sweep(), Math.min(Math.max(idleMs / 4, 25), 60_000)) : undefined;
329
+ timer?.unref();
330
+ log({ event: 'started', pid: process.pid, version, socket, idle_timeout_s: idleMs ? idleMs / 1000 : null });
331
+ return {
332
+ socket,
333
+ closed,
334
+ async stop() {
335
+ await shutdown('stopped');
336
+ finish();
337
+ },
338
+ };
339
+ };
@@ -0,0 +1,43 @@
1
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { ensureOutRoot } from '../output/layout.js';
4
+ import { inboxFrom } from './inbox.js';
5
+ const ADDRESS = /^[a-z0-9._+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i;
6
+ /** `<root>/inboxes/<address>.env`; anything that is not a plain address is refused, so it never escapes the folder. */
7
+ export const inboxFilePath = (root, address) => {
8
+ if (!ADDRESS.test(address) || address.includes('..'))
9
+ throw new Error(`"${address}" is not an email address`);
10
+ return join(root, 'inboxes', `${address.toLowerCase()}.env`);
11
+ };
12
+ /**
13
+ * Writes the one-shot CLI's credentials as the standalone script's `ADDR=` / `PASS=` / `API=` lines, mode 600 in a
14
+ * 700 folder, under an output root whose `.gitignore` is `*`. Returns the path.
15
+ */
16
+ export const writeInboxFile = (root, inbox) => {
17
+ ensureOutRoot(root);
18
+ const path = inboxFilePath(root, inbox.address);
19
+ mkdirSync(join(root, 'inboxes'), { recursive: true, mode: 0o700 });
20
+ chmodSync(join(root, 'inboxes'), 0o700);
21
+ writeFileSync(path, `ADDR=${inbox.address}\nPASS=${inbox.password}\nAPI=${inbox.api}\n`, { mode: 0o600 });
22
+ // `mode` only applies when the file is created.
23
+ chmodSync(path, 0o600);
24
+ return path;
25
+ };
26
+ /** The inbox stored for `address` under `root`, or undefined when there is no file. */
27
+ export const readInboxFile = (root, address) => {
28
+ let text;
29
+ try {
30
+ text = readFileSync(inboxFilePath(root, address), 'utf8');
31
+ }
32
+ catch {
33
+ return undefined;
34
+ }
35
+ const fields = Object.fromEntries(text
36
+ .split('\n')
37
+ .map((line) => /^(\w+)=(.*)$/.exec(line.trim()))
38
+ .filter((match) => match !== null)
39
+ .map(([, key, value]) => [key, value]));
40
+ if (!fields.ADDR || !fields.PASS || !fields.API)
41
+ return undefined;
42
+ return inboxFrom({ address: fields.ADDR, password: fields.PASS, api: fields.API });
43
+ };
@@ -0,0 +1,33 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { createInbox, InboxError, listMessages, PROVIDERS, providerOf, readMessage, waitForMessage, } from './inbox.js';
3
+ /**
4
+ * The inboxes one process holds in memory (the MCP server, or the CLI daemon), each named by a UUID and found by
5
+ * that id or its address. They end with the process.
6
+ *
7
+ * @example
8
+ * const inboxes = createInboxRegistry();
9
+ * const { inbox, address } = await inboxes.create();
10
+ * await inboxes.wait(inbox, { from: 'staylist', timeoutMs: 120_000 });
11
+ */
12
+ export const createInboxRegistry = (client = {}) => {
13
+ const inboxes = new Map();
14
+ const find = (handle) => {
15
+ const inbox = inboxes.get(handle) ?? [...inboxes.values()].find(({ address }) => address === handle.toLowerCase());
16
+ if (inbox)
17
+ return inbox;
18
+ throw new InboxError('unknown_inbox', `Unknown inbox "${handle}": inboxes live in memory and end when this jevway process (or its daemon) stops.`, 'Create a new one with jevway_inbox_create (CLI: jevway inbox create) and sign up again with its address.');
19
+ };
20
+ return {
21
+ async create({ provider } = {}) {
22
+ const inbox = await createInbox({ ...client, ...(provider && { api: PROVIDERS[provider] }) });
23
+ const id = randomUUID();
24
+ inboxes.set(id, inbox);
25
+ return { inbox: id, address: inbox.address, provider: providerOf(inbox.api) };
26
+ },
27
+ /** The inbox's address; throws `unknown_inbox`. */
28
+ addressOf: (handle) => find(handle).address,
29
+ wait: async (handle, options) => waitForMessage(find(handle), { ...client, ...options }),
30
+ list: async (handle, options = {}) => listMessages(find(handle), { ...client, ...options }),
31
+ read: async (handle, id, options = {}) => readMessage(find(handle), id, { ...client, ...options }),
32
+ };
33
+ };