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,155 @@
1
+ import { BASE_STYLE, clock, dollars, embeddedJson, escapeHtml as escape, fact, FONT_STYLESHEET, GENERATOR_META, indexLabel, JEVWAY_VERSION, pathOf, playerMarkup, plural, PLAYER_SCRIPT, seconds, sentencesOf, statusLabel, statusTone, statusWord, storyOfRecording, thousands, } from './recording-page.js';
2
+ const total = (summaries, pick) => summaries.reduce((sum, s) => sum + pick(s), 0);
3
+ // Unknown when any call has no estimate (a model without a published rate).
4
+ const estimatedCost = (summaries) => summaries.reduce((sum, s) => (sum === null || s.jev.est_cost_usd == null ? null : sum + s.jev.est_cost_usd), 0);
5
+ // epoch seconds → "2026-11-01 18:39 UTC"
6
+ const utc = (at) => `${new Date(at * 1000).toISOString().slice(0, 16).replace('T', ' ')} UTC`;
7
+ const RUN_STYLE = `
8
+ .run-top { padding-block: 2.5rem 0; display: grid; gap: 1.75rem; }
9
+ .run-top .label { padding-top: 0.5rem; }
10
+ .done-when { margin-top: 0.75rem; }
11
+ .outcome-line { font-size: 1.125rem; line-height: 1.625rem; overflow-wrap: anywhere; }
12
+ .status { display: inline-flex; align-items: center; gap: 0.5rem; font-weight: 500; margin-right: 0.5rem; }
13
+ .status::before { content: ""; flex: none; width: 0.625rem; height: 0.625rem; border-radius: 99px; background: var(--muted-foreground); }
14
+ .status.held::before { background: var(--held); } .status.reached::before { background: var(--reached); } .status.missed::before { background: var(--missed); }
15
+ .glance { border-top: 1px solid var(--muted); padding-top: 1.25rem; grid-template-columns: repeat(2, minmax(0, 1fr)); row-gap: 1.25rem; }
16
+ @media (min-width: 64rem) { .glance { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
17
+ .glance dt { margin-bottom: 0.25rem; }
18
+ .glance dd { font-size: 1.125rem; line-height: 1.5rem; font-weight: 500; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
19
+ .glance dd .small { display: block; font-weight: 400; }
20
+ .run-top .facts .fact { padding-block: 0.875rem; }
21
+ .block { margin-top: 4rem; }
22
+ .block-head { margin-bottom: 1.5rem; }
23
+ #recording .screen { margin-top: 0; }
24
+ .call + .call { margin-top: 1.5rem; }
25
+ .call-head { padding-top: 0.875rem; }
26
+ .call-asked { margin-top: 0.25rem; }
27
+ .story { border-bottom: 1px solid var(--muted); }
28
+ .entry { display: grid; grid-template-columns: 2.25rem minmax(0, 1fr); gap: 0.125rem 0.75rem; padding: 0.875rem 0.75rem 0.875rem 0.5rem; border-top: 1px solid var(--muted); scroll-margin-top: 4rem; }
29
+ .entry .n { padding-top: 0.2rem; }
30
+ .entry .sentence { font-size: 1rem; line-height: 1.5rem; overflow-wrap: anywhere; }
31
+ .entry .links { grid-column: 2; display: flex; flex-wrap: wrap; gap: 0.125rem 1rem; font-size: 0.75rem; line-height: 1.25rem; color: var(--muted-foreground); }
32
+ .entry .links > * { min-width: 0; }
33
+ .entry .where { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
34
+ .entry .links a { text-decoration: underline; text-decoration-color: var(--muted); text-underline-offset: 3px; }
35
+ .entry .links a:hover { color: var(--foreground); text-decoration-color: currentColor; }
36
+ .entry.unshot .sentence { color: var(--muted-foreground); }
37
+ .entry.ended .sentence { font-weight: 500; }
38
+ .entry:target, .entry[aria-current="step"] { box-shadow: inset 2px 0 0 var(--foreground); background: color-mix(in oklch, var(--muted) 35%, transparent); }
39
+ .entry[aria-current="step"] .n { color: var(--foreground); }
40
+ .files dt { text-transform: none; letter-spacing: 0; font-family: var(--font-mono); }
41
+ .files dd a { text-decoration: underline; text-decoration-color: var(--muted); text-underline-offset: 3px; }
42
+ .run-page { padding-bottom: 4rem; }
43
+ `;
44
+ /** One line of a call's story: its number and sentence, then its time in the video, its shot file and its address. */
45
+ const storyEntry = ({ sentence, shot }, steps, video) => {
46
+ if (shot === undefined)
47
+ return `<li class="entry unshot"><span class="n meta num" aria-hidden="true">·</span><p class="sentence">${escape(sentence)}</p></li>`;
48
+ const step = steps[shot];
49
+ const id = `s${shot + 1}`;
50
+ const name = step.file.split('/').pop();
51
+ const links = [
52
+ ...(video && step.video_at !== undefined ? [`<a class="at num" data-seek="${shot}" href="#${id}" aria-label="Play from step ${shot + 1}, at ${clock(step.video_at)}">${clock(step.video_at)}</a>`] : []),
53
+ `<a href="${escape(step.file)}">${escape(name)}</a>`,
54
+ `<span class="where">${escape(pathOf(step.url))}</span>`,
55
+ ];
56
+ const ended = step.operation === step.operation.toLowerCase();
57
+ return `<li class="entry${ended ? ' ended' : ''}" id="${id}" data-shot="${shot}"><span class="n meta num">${indexLabel(shot)}</span><p class="sentence">${escape(sentence)}</p><p class="links">${links.join('')}</p></li>`;
58
+ };
59
+ const callStories = (stories, record) => {
60
+ const several = stories.length > 1;
61
+ return stories
62
+ .map((story, n) => {
63
+ const asked = record.asked?.[n];
64
+ const before = record.asked?.[n - 1];
65
+ const differs = n > 0 && asked && before && (asked.goal !== before.goal || asked.done_when !== before.done_when);
66
+ const status = story.status ?? record.summaries[n]?.status;
67
+ const head = several ? `<h3 class="meta">Call ${n + 1}${status ? ` · ${escape(statusWord(status))}` : ''}</h3>` : '';
68
+ return `<div class="call grid4"><div class="call-head">${head}${differs ? `<p class="small call-asked">Asked: ${escape(asked.goal)}${asked.done_when ? `<br>Done when: ${escape(asked.done_when)}` : ''}</p>` : ''}</div><ol class="story c2s3">${story.entries.map((entry) => storyEntry(entry, record.steps, record.video)).join('')}</ol></div>`;
69
+ })
70
+ .join('\n');
71
+ };
72
+ /**
73
+ * The page for a recorded run folder, from its `summary.json` record. The top says what was asked, how the run ended
74
+ * and why, when, how long, what it cost, and where it started and ended; then the screencast (or the first shot); then
75
+ * each call's story, every step linking its shot file and its video time (`#s<n>`, also a deep link); then the
76
+ * details and the folder's files. The record itself is embedded as `#jevway-summary`.
77
+ *
78
+ * @example
79
+ * writeFileSync('runs/a/index.html', renderRunPage(JSON.parse(readFileSync('runs/a/summary.json', 'utf8'))));
80
+ */
81
+ export const renderRunPage = (record) => {
82
+ const { summaries, steps, video, viewport } = record;
83
+ const last = summaries.at(-1);
84
+ const goal = record.asked?.[0]?.goal;
85
+ const doneWhen = record.asked?.[0]?.done_when;
86
+ // Actions that ran: a step told back to Jev as not done (`not_executed`) is in the story, not in the count.
87
+ const actions = total(summaries, (s) => s.steps.filter((step) => !step.not_executed).length);
88
+ const cost = estimatedCost(summaries);
89
+ const stories = storyOfRecording(steps, summaries);
90
+ const outcome = last ? statusLabel(last.status) : 'Recorded';
91
+ const title = `${outcome}${goal ? `: ${goal}` : ''} · jevway run`;
92
+ const bar = `<div class="player-bar grid4"><p class="meta c1s3">${plural(steps.length, 'step')}${video ? ' · pick one to play from there' : ''}</p><span class="meta clock c4 end">${video ? clock(0) : ''}</span></div>`;
93
+ const player = steps.length
94
+ ? playerMarkup({ dir: '', video, steps, label: 'this run', featured: true, viewport, bar, preload: 'metadata', sentences: sentencesOf(stories) })
95
+ : '<div class="screen quiet"><p class="small">No step was recorded.</p></div>';
96
+ const glance = [
97
+ ['Started', steps.length ? utc(steps[0].at) : 'unknown'],
98
+ ['Took', summaries.length ? seconds(total(summaries, (s) => s.elapsed_ms)) : 'unknown'],
99
+ ['Cost', `${cost === null ? 'est. cost unknown' : `est. ${dollars(cost)}`}<span class="small">${plural(total(summaries, (s) => s.jev.requests), 'Jev request')}</span>`],
100
+ ['Steps', `${plural(summaries.length, 'call')} · ${plural(actions, 'action')}`],
101
+ ];
102
+ const header = `<header class="wrapper run-top">
103
+ <div class="grid4"><p class="meta label">Asked</p><div class="c2s3"><h1 class="job-title">${goal ? escape(goal) : 'Goal not recorded'}</h1>${doneWhen ? `<p class="small done-when"><span class="kv">Done when</span> ${escape(doneWhen)}</p>` : ''}${goal ? '' : '<p class="small done-when">This run was recorded before jevway kept the goal in its folder.</p>'}</div></div>
104
+ <div class="grid4"><p class="meta label">Outcome</p><p class="c2s3 outcome-line">${last ? `<span class="status ${statusTone(last.status)}">${escape(outcome)}</span> ${escape(last.reason)}` : 'No call finished.'}</p></div>
105
+ <dl class="glance grid4">${glance.map(([label, value]) => `<div><dt class="meta">${label}</dt><dd>${value}</dd></div>`).join('')}</dl>
106
+ <dl class="facts">${[
107
+ ...(steps.length ? [fact('Start page', `<span class="address">${escape(steps[0].url)}</span>`)] : []),
108
+ ...(last ? [fact('End page', `<span class="address">${escape(last.url)}</span>${last.title ? ` · ${escape(last.title)}` : ''}`)] : []),
109
+ ].join('\n')}</dl>
110
+ </header>`;
111
+ const details = [
112
+ ...(summaries.length ? [fact('Calls', `${escape(summaries.map((s) => statusWord(s.status)).join(' → '))} · ${plural(actions, 'action')}`)] : []),
113
+ ...(last ? [fact('Final page', `${escape(last.title)}<br><span class="address">${escape(last.url)}</span>`)] : []),
114
+ ...(summaries.length
115
+ ? [fact('Cost', `<span class="num">${plural(total(summaries, (s) => s.jev.requests), 'Jev request')} · ${thousands(total(summaries, (s) => s.jev.input_tokens))} input tokens · ${seconds(total(summaries, (s) => s.elapsed_ms))} · ${cost === null ? 'est. cost unknown' : `est. ${dollars(cost)}`}</span>`)]
116
+ : []),
117
+ ...(last?.jev.model ? [fact('Model', escape(last.jev.model))] : []),
118
+ ...(viewport ? [fact('Viewport', `<span class="num">${viewport.width} × ${viewport.height}</span> CSS pixels`)] : []),
119
+ ];
120
+ const files = [
121
+ fact('<a href="summary.json">summary.json</a>', "Each call's summary and the recorded steps: the same data this page embeds as <code>#jevway-summary</code>"),
122
+ fact('<a href="manifest.json">manifest.json</a>', 'Every file in this folder, with what it is'),
123
+ ...(video ? [fact(`<a href="${escape(video)}">${escape(video)}</a>`, 'Screencast of the whole session, idle stretches shortened')] : []),
124
+ fact('steps/', `${plural(steps.length, 'shot')}: one before each action, its target outlined, and one as each call ended; each step above links its own`),
125
+ ];
126
+ return `<!doctype html>
127
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
128
+ <title>${escape(title)}</title>
129
+ <meta name="description" content="${escape(`${goal ?? 'A jevway run'}: ${outcome.toLowerCase()}${last ? `. ${last.reason}` : ''}`)}">
130
+ ${GENERATOR_META}
131
+ <meta name="color-scheme" content="light dark">
132
+ <link rel="alternate" type="application/json" href="summary.json" title="This run as data">
133
+ ${FONT_STYLESHEET}
134
+ <style>${BASE_STYLE}${RUN_STYLE}</style>
135
+ </head><body>
136
+ <nav class="top" aria-label="This page"><div class="wrapper"><span>jevway · run</span><div class="links"><a href="#steps">Steps</a><a href="#details">Details</a><a href="#files">Files</a></div></div></nav>
137
+ ${header}
138
+ <main class="wrapper run-page">
139
+ <div class="run player current" data-step-links>
140
+ <section id="recording" class="block" aria-labelledby="recording-title"><h2 class="block-head meta" id="recording-title">Recording</h2>
141
+ ${player}
142
+ </section>
143
+ <section id="steps" class="block" aria-labelledby="steps-title"><h2 class="section-title block-head" id="steps-title">Steps</h2>
144
+ ${callStories(stories, record)}
145
+ </section>
146
+ </div>
147
+ <section id="details" class="block" aria-labelledby="details-title"><h2 class="section-title block-head" id="details-title">Details</h2><dl class="facts">${details.join('\n')}</dl></section>
148
+ <section id="files" class="block" aria-labelledby="files-title"><h2 class="section-title block-head" id="files-title">Files</h2><dl class="facts files">${files.join('\n')}</dl></section>
149
+ </main>
150
+ <footer><div class="wrapper"><span>Recorded by jevway ${escape(JEVWAY_VERSION)} for people to read; the model never sees these shots.</span><span>${plural(steps.length, 'step')}${video ? ` · ${escape(video)}` : ' · no video (recording one needs ffmpeg)'}</span></div></footer>
151
+ ${embeddedJson('jevway-summary', record)}
152
+ ${PLAYER_SCRIPT}
153
+ </body></html>
154
+ `;
155
+ };
@@ -0,0 +1,213 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { join, resolve } from 'node:path';
3
+ import { scrubberFor } from '../jev/secrets.js';
4
+ import { ensureOutRoot, journeyFolderName, outRoot, runFolderName } from '../output/layout.js';
5
+ import { writeLookPdf, writeRunPdf } from '../output/run-folder.js';
6
+ import { lookAt } from '../task/look.js';
7
+ import { storyLine } from '../task/present.js';
8
+ import { checkTotpSecrets, totpMasks } from '../task/totp.js';
9
+ /** A session id the service does not hold: it ended, expired, or never existed. `code` is for structured CLI errors. */
10
+ export class UnknownSession extends Error {
11
+ code = 'unknown_session';
12
+ }
13
+ // 3600000 → '1 h', 1800000 → '30 min'.
14
+ const idleWords = (ms) => (ms % 3_600_000 === 0 ? `${ms / 3_600_000} h` : `${Math.round(ms / 60_000)} min`);
15
+ /**
16
+ * The sessions behind every adapter (the MCP server, the CLI daemon): task sessions and journeys by opaque UUID, each
17
+ * with its folder under the output root, calls that wait for a session's still-running call, and errors that say what
18
+ * to do next. Transport-free: adapters translate parameters in and present results out.
19
+ *
20
+ * @example
21
+ * const sessions = createSessionService(createRunner('auto'));
22
+ * const first = await sessions.task({ goal: 'Book a stay', url: 'https://stays.example' }, { onEvent: console.error });
23
+ * presentSummary(first); // { session, status: 'needs_input', next: 'Add the missing values …', story, files, … }
24
+ * await sessions.task({ goal: 'Book a stay', session: first.session, values: { email: 'ada@example.com' } });
25
+ * await sessions.end(first.session);
26
+ */
27
+ export const createSessionService = (runner, options = {}) => {
28
+ const env = options.env ?? process.env;
29
+ const cwd = options.cwd ?? process.cwd();
30
+ const root = resolve(cwd, options.outDir ?? outRoot(env, cwd));
31
+ const now = options.now ?? (() => new Date());
32
+ const newId = options.newId ?? randomUUID;
33
+ const sessions = new Map();
34
+ const journeys = new Map();
35
+ const lifetime = `when the server restarts${options.idleMs ? ` or after ${idleWords(options.idleMs)} idle` : ''}`;
36
+ const findTask = (id) => {
37
+ const entry = sessions.get(id);
38
+ if (!entry)
39
+ throw new UnknownSession(`Unknown session "${id}". Sessions end ${lifetime}; start a new one with \`url\` (or \`tab\`).`);
40
+ entry.lastUsedAt = now();
41
+ return entry;
42
+ };
43
+ const findJourney = (id) => {
44
+ const entry = journeys.get(id);
45
+ if (!entry)
46
+ throw new UnknownSession(`Unknown journey "${id}". Journeys end when finished or ${lifetime}; start a new one with its \`url\`.`);
47
+ entry.lastUsedAt = now();
48
+ return entry;
49
+ };
50
+ const startTask = async ({ session: _session, record, recordDir, ...request }) => {
51
+ const id = newId();
52
+ const at = now();
53
+ const values = request.values ?? {};
54
+ // An authenticator key is secret for the session, even if a later call gives it a plain value.
55
+ const secretKeys = [...(request.secretKeys ?? []), ...Object.keys(request.totp ?? {})];
56
+ // A bad authenticator secret is refused before a tab opens.
57
+ const moreSecrets = totpMasks(checkTotpSecrets(request.totp, request.values));
58
+ const scrub = scrubberFor(values, secretKeys, moreSecrets);
59
+ const runDir = recordDir ? resolve(cwd, recordDir) : join(ensureOutRoot(root), 'runs', runFolderName({ at, goal: request.goal, id, scrub }));
60
+ const task = await runner.start({ ...request, output: { dir: runDir, record: Boolean(record || recordDir) } });
61
+ const entry = { task, ownsTab: Boolean(request.url), busy: false, goal: scrub(request.goal), values, secretKeys, moreSecrets, runDir, startedAt: at, lastUsedAt: at };
62
+ sessions.set(id, entry);
63
+ return { id, entry };
64
+ };
65
+ const continueTask = async (id, request) => {
66
+ const entry = findTask(id);
67
+ await entry.running;
68
+ // Only what a later call may change: start-only fields (url, tab, coordinate clicks, recording) stay as started.
69
+ entry.task.update({
70
+ goal: request.goal,
71
+ values: request.values,
72
+ secretKeys: request.secretKeys,
73
+ files: request.files,
74
+ totp: request.totp,
75
+ doneWhen: request.doneWhen,
76
+ checks: request.checks,
77
+ maxSteps: request.maxSteps,
78
+ maxSeconds: request.maxSeconds,
79
+ waitForPersonSeconds: request.waitForPersonSeconds,
80
+ allowedHosts: request.allowedHosts,
81
+ blockedHosts: request.blockedHosts,
82
+ confirmIrreversible: request.confirmIrreversible,
83
+ confirmTarget: request.confirmTarget,
84
+ });
85
+ entry.values = { ...entry.values, ...request.values };
86
+ entry.secretKeys = [...new Set([...entry.secretKeys, ...(request.secretKeys ?? []), ...Object.keys(request.totp ?? {})])];
87
+ entry.moreSecrets.push(...totpMasks(checkTotpSecrets(request.totp)));
88
+ entry.goal = scrubberFor(entry.values, entry.secretKeys, entry.moreSecrets)(request.goal);
89
+ return { id, entry };
90
+ };
91
+ const journeysRoot = () => env.JEVWAY_JOURNEYS_DIR ? resolve(cwd, env.JEVWAY_JOURNEYS_DIR) : join(ensureOutRoot(root), 'journeys');
92
+ const journeyReply = async (id, journey, report) => ({
93
+ journey: id,
94
+ report: report,
95
+ preview: await journey.preview(),
96
+ });
97
+ return {
98
+ /** The output root this service writes under. */
99
+ outDir: root,
100
+ /**
101
+ * Runs one call: a new session (with `url` or `tab`) or the next call of `session`, which first waits for that
102
+ * session's still-running call. `signal` cancels this call (the run ends `blocked` at its next check).
103
+ */
104
+ async task(request, { signal, onEvent } = {}) {
105
+ const { id, entry } = request.session ? await continueTask(request.session, request) : await startTask(request);
106
+ // A listener is the adapter's (progress, NDJSON): it never decides how the run goes.
107
+ const tell = (event) => {
108
+ try {
109
+ onEvent?.(event);
110
+ }
111
+ catch {
112
+ // ignored
113
+ }
114
+ };
115
+ tell({ type: 'started', session: id });
116
+ entry.busy = true;
117
+ const running = entry.task.run({
118
+ signal,
119
+ onStep: (step, n, url) => tell({ type: 'step', session: id, n, story: storyLine(step), url }),
120
+ onWait: ({ message, seconds_left }, url) => tell({ type: 'waiting', session: id, message, seconds_left, url }),
121
+ });
122
+ entry.running = running.catch(() => undefined);
123
+ try {
124
+ const summary = await running;
125
+ entry.status = summary.status;
126
+ entry.url = summary.url;
127
+ tell({ type: 'finished', session: id, status: summary.status });
128
+ const call = entry.task.lastCall;
129
+ return { session: id, summary, ...(call && { files: call.files }), ...(call?.screenshot && { screenshot: call.screenshot }) };
130
+ }
131
+ finally {
132
+ entry.busy = false;
133
+ entry.lastUsedAt = now();
134
+ }
135
+ },
136
+ /**
137
+ * Reads a page with no model call and no input: a session's tab (its secret values masked), an open tab by URL part, or
138
+ * a URL opened and closed again. With `pdf`, the page is saved as a PDF too: in the session's run folder
139
+ * (`looks/NN-<title>.pdf`), else in a look folder of its own (`<root>/looks/<stamp>_<host>_<id4>/page.pdf`).
140
+ */
141
+ async look({ session, tab, url, pdf }) {
142
+ if ([session, tab, url].filter(Boolean).length !== 1)
143
+ throw new Error('Pass exactly one of session, tab or url.');
144
+ if (session) {
145
+ const entry = findTask(session);
146
+ const looked = await entry.task.look({ pdf });
147
+ return { ...lookAt(looked), ...(looked.pdf && { pdf: writeRunPdf({ dir: entry.runDir, pdf: looked.pdf, title: looked.title }) }) };
148
+ }
149
+ const looked = await runner.look({ url, tab, pdf });
150
+ return { ...lookAt(looked), ...(looked.pdf && { pdf: writeLookPdf({ root, pdf: looked.pdf, url: looked.url, at: now(), id: newId() }) }) };
151
+ },
152
+ /** Forgets a session and closes the tabs it opened (its first tab only when it started from `url`), unless `keepTab`. */
153
+ async end(session, { keepTab = false } = {}) {
154
+ const entry = findTask(session);
155
+ sessions.delete(session);
156
+ if (!keepTab)
157
+ await entry.task.closeTabs({ keepFirst: !entry.ownsTab });
158
+ return { session, run_dir: entry.runDir };
159
+ },
160
+ /** Every open task session and journey, oldest first, with what it is doing and where its files are. */
161
+ listSessions() {
162
+ const tasks = [...sessions].map(([session, entry]) => ({
163
+ session,
164
+ kind: 'task',
165
+ goal: entry.goal,
166
+ ...(entry.status && { status: entry.status }),
167
+ ...(entry.url && { url: entry.url }),
168
+ running: entry.busy,
169
+ started_at: entry.startedAt.toISOString(),
170
+ last_used_at: entry.lastUsedAt.toISOString(),
171
+ run_dir: entry.runDir,
172
+ }));
173
+ const maps = [...journeys].map(([session, entry]) => ({
174
+ session,
175
+ kind: 'journey',
176
+ url: entry.url,
177
+ started_at: entry.startedAt.toISOString(),
178
+ last_used_at: entry.lastUsedAt.toISOString(),
179
+ run_dir: entry.outDir,
180
+ }));
181
+ return [...tasks, ...maps].sort((a, b) => a.started_at.localeCompare(b.started_at));
182
+ },
183
+ /** Opens `url` on an emulated phone and maps its first screen, into `<root>/journeys/<stamp>_<host>_<id4>/` (or `outDir`). */
184
+ async journeyStart({ url, device, intent, values, stayWithin, outDir }) {
185
+ const id = newId();
186
+ const at = now();
187
+ const dir = outDir ? resolve(cwd, outDir) : join(journeysRoot(), journeyFolderName({ at, host: new URL(url).host, id }));
188
+ const journey = await runner.startJourney({ startUrl: url, device, intent, values, stayWithin, outDir: dir });
189
+ journeys.set(id, { journey, url, outDir: dir, startedAt: at, lastUsedAt: at });
190
+ return journeyReply(id, journey, journey.report());
191
+ },
192
+ async journeyStep(id, input) {
193
+ const { journey } = findJourney(id);
194
+ return journeyReply(id, journey, await journey.step(input));
195
+ },
196
+ async journeyAutopilot(id, input) {
197
+ const { journey } = findJourney(id);
198
+ return journeyReply(id, journey, await journey.autopilot(input));
199
+ },
200
+ journeyAnnotate(id, screen, notes, relabel) {
201
+ findJourney(id).journey.annotate(screen, notes, relabel);
202
+ },
203
+ /** Writes the final map, closes the journey's tab unless `keepTab`, and forgets the journey. */
204
+ async journeyFinish(id, { keepTab = false } = {}) {
205
+ const { journey, outDir } = findJourney(id);
206
+ const summary = await journey.finish({ closeTab: !keepTab });
207
+ journeys.delete(id);
208
+ return { ...summary, manifest: join(outDir, 'manifest.json') };
209
+ },
210
+ /** Closes the browser connection (a launched browser closes with it). */
211
+ close: () => runner.close(),
212
+ };
213
+ };
@@ -0,0 +1,13 @@
1
+ import { chmodSync, mkdirSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ /**
4
+ * Writes `text` to a temporary file beside `path` and renames it into place, so an interrupted setup leaves the file
5
+ * either as it was or complete. `mode` applies to the result even where the file existed before.
6
+ */
7
+ export const writeFileAtomic = (path, text, mode = 0o644) => {
8
+ mkdirSync(dirname(path), { recursive: true });
9
+ const temporary = `${path}.jevway-${process.pid}.tmp`;
10
+ writeFileSync(temporary, text, { mode });
11
+ chmodSync(temporary, mode);
12
+ renameSync(temporary, path);
13
+ };
@@ -0,0 +1,180 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { delimiter, join } from 'node:path';
3
+ import { envFilePath, shownEnvFilePath } from './env-file.js';
4
+ import { unifiedDiff } from './unified-diff.js';
5
+ const SERVER = { command: 'npx', args: ['-y', '-p', 'jevway', 'jevway-mcp'] };
6
+ const xdgConfig = ({ home, env }) => env.XDG_CONFIG_HOME?.trim() || join(home, '.config');
7
+ const claudeFolder = ({ home, env }) => env.CLAUDE_CONFIG_DIR?.trim() || join(home, '.claude');
8
+ const claudeJson = (machine) => machine.env.CLAUDE_CONFIG_DIR?.trim() ? join(machine.env.CLAUDE_CONFIG_DIR.trim(), '.claude.json') : join(machine.home, '.claude.json');
9
+ const codexFolder = ({ home, env }) => env.CODEX_HOME?.trim() || join(home, '.codex');
10
+ const openCodeFolder = (machine) => join(xdgConfig(machine), 'opencode');
11
+ const readText = (path) => (existsSync(path) ? readFileSync(path, 'utf8') : undefined);
12
+ const readJson = (path) => {
13
+ try {
14
+ const value = JSON.parse(readText(path) ?? '');
15
+ return value && typeof value === 'object' ? value : undefined;
16
+ }
17
+ catch {
18
+ return undefined;
19
+ }
20
+ };
21
+ // A file that is not plain JSON (JSONC with comments) still counts when it names a jevway key.
22
+ const namesJevway = (path, container) => {
23
+ const parsed = readJson(path);
24
+ if (parsed)
25
+ return Boolean(parsed[container]?.jevway);
26
+ return /"jevway"\s*:/.test(readText(path) ?? '');
27
+ };
28
+ const hasClaudePlugin = (machine) => Object.keys(readJson(join(claudeFolder(machine), 'plugins', 'installed_plugins.json'))?.plugins ?? {}).some((id) => id.startsWith('jevway@'));
29
+ const openCodeFile = (machine) => {
30
+ const json = join(openCodeFolder(machine), 'opencode.json');
31
+ const jsonc = join(openCodeFolder(machine), 'opencode.jsonc');
32
+ return !existsSync(json) && existsSync(jsonc) ? jsonc : json;
33
+ };
34
+ /** The clients setup registers jevway with, in the order it offers them. */
35
+ export const CLIENTS = [
36
+ {
37
+ name: 'claude-code',
38
+ label: 'Claude Code',
39
+ binary: 'claude',
40
+ markers: (machine) => [claudeFolder(machine), claudeJson(machine)],
41
+ skillsDir: (machine) => join(claudeFolder(machine), 'skills'),
42
+ hasSkillBundled: hasClaudePlugin,
43
+ isConfigured: (machine) => Boolean(readJson(claudeJson(machine))?.mcpServers?.jevway) || hasClaudePlugin(machine),
44
+ registration: { kind: 'command', argv: ['claude', 'mcp', 'add', 'jevway', '-s', 'user', '--', SERVER.command, ...SERVER.args] },
45
+ },
46
+ {
47
+ name: 'codex',
48
+ label: 'Codex',
49
+ binary: 'codex',
50
+ markers: (machine) => [codexFolder(machine)],
51
+ skillsDir: (machine) => join(codexFolder(machine), 'skills'),
52
+ isConfigured: (machine) => /^\s*\[mcp_servers\.(?:jevway|"jevway")\]/m.test(readText(join(codexFolder(machine), 'config.toml')) ?? ''),
53
+ registration: {
54
+ kind: 'command',
55
+ argv: ['codex', 'mcp', 'add', 'jevway', '--', SERVER.command, ...SERVER.args],
56
+ writes: {
57
+ file: (machine) => join(codexFolder(machine), 'config.toml'),
58
+ snippet: `[mcp_servers.jevway]\ncommand = "${SERVER.command}"\nargs = [${SERVER.args.map((arg) => `"${arg}"`).join(', ')}]\n`,
59
+ },
60
+ },
61
+ },
62
+ {
63
+ name: 'cursor',
64
+ label: 'Cursor',
65
+ binary: 'cursor',
66
+ markers: ({ home }) => [join(home, '.cursor')],
67
+ skillsDir: ({ home }) => join(home, '.cursor', 'skills'),
68
+ isConfigured: ({ home }) => namesJevway(join(home, '.cursor', 'mcp.json'), 'mcpServers'),
69
+ registration: { kind: 'config', file: ({ home }) => join(home, '.cursor', 'mcp.json'), container: 'mcpServers', entry: SERVER },
70
+ },
71
+ {
72
+ name: 'opencode',
73
+ label: 'OpenCode',
74
+ binary: 'opencode',
75
+ markers: (machine) => [openCodeFolder(machine)],
76
+ skillsDir: (machine) => join(openCodeFolder(machine), 'skills'),
77
+ isConfigured: (machine) => namesJevway(openCodeFile(machine), 'mcp'),
78
+ registration: {
79
+ kind: 'config',
80
+ file: openCodeFile,
81
+ container: 'mcp',
82
+ entry: { type: 'local', command: [SERVER.command, ...SERVER.args], enabled: true },
83
+ newFile: { $schema: 'https://opencode.ai/config.json' },
84
+ },
85
+ },
86
+ ];
87
+ /** Names `--client` accepts: the known clients, and `other` for any stdio MCP client. */
88
+ export const CLIENT_NAMES = [...CLIENTS.map(({ name }) => name), 'other'];
89
+ /**
90
+ * Whether `binary` is an executable file in one of `PATH`'s folders. Scans the folders itself, never a shell, so a
91
+ * shell function or alias of the same name does not count.
92
+ */
93
+ export const onPathIn = (path = process.env.PATH ?? '') => (binary) => path
94
+ .split(delimiter)
95
+ .filter(Boolean)
96
+ .some((folder) => (process.platform === 'win32' ? ['.exe', '.cmd', ''] : ['']).some((extension) => existsSync(join(folder, binary + extension))));
97
+ /** `path` with the home folder written as `~`, for messages. */
98
+ export const shownPath = (path, home) => (path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path);
99
+ /**
100
+ * The clients installed on this machine (command on PATH or config present), and whether each already has a
101
+ * jevway server.
102
+ */
103
+ export const detectClients = (machine) => CLIENTS.filter((client) => machine.onPath(client.binary) || client.markers(machine).some(existsSync)).map((client) => ({
104
+ client,
105
+ configured: client.isConfigured(machine),
106
+ }));
107
+ const indentOf = (text) => /^([ \t]+)\S/m.exec(text)?.[1] ?? ' ';
108
+ const snippetFor = ({ container, entry }) => JSON.stringify({ [container]: { jevway: entry } }, null, 2);
109
+ /**
110
+ * What setup would do to register jevway with `client`: run its `mcp add` command, or add only the `jevway` entry to
111
+ * its JSON config (the file's own indent kept, shown as a unified diff). A file that is not plain JSON, or whose server
112
+ * list is not an object, is never edited: the change is `manual`, with the snippet to add by hand.
113
+ *
114
+ * @example
115
+ * plannedChange(cursor, machine)
116
+ * // { kind: 'edit', file: '/Users/ada/.cursor/mcp.json', shownFile: '~/.cursor/mcp.json', next: '{…}', diff: '--- …' }
117
+ */
118
+ export const plannedChange = (client, machine) => {
119
+ const { registration } = client;
120
+ if (registration.kind === 'command')
121
+ return { kind: 'command', argv: registration.argv, command: registration.argv.join(' ') };
122
+ const file = registration.file(machine);
123
+ const shownFile = shownPath(file, machine.home);
124
+ const before = readText(file);
125
+ const manual = (reason) => ({ kind: 'manual', file, shownFile, reason, snippet: snippetFor(registration) });
126
+ let config;
127
+ try {
128
+ config = before?.trim() ? JSON.parse(before) : { ...registration.newFile };
129
+ }
130
+ catch {
131
+ return manual(`${shownFile} is not plain JSON (comments or trailing commas), so setup leaves it alone`);
132
+ }
133
+ if (!config || typeof config !== 'object' || Array.isArray(config))
134
+ return manual(`${shownFile} does not hold a JSON object`);
135
+ const servers = config[registration.container] ?? {};
136
+ if (typeof servers !== 'object' || Array.isArray(servers))
137
+ return manual(`"${registration.container}" in ${shownFile} is not an object`);
138
+ const next = `${JSON.stringify({ ...config, [registration.container]: { ...servers, jevway: registration.entry } }, null, before ? indentOf(before) : 2)}\n`;
139
+ return { kind: 'edit', file, shownFile, before, next, diff: unifiedDiff(before, next, shownFile) };
140
+ };
141
+ /**
142
+ * The exact command or config snippet that registers jevway with a client, and where it goes; for `other`, the
143
+ * server's command and args.
144
+ *
145
+ * @example
146
+ * printedRegistration('codex', machine)
147
+ * // { client: 'codex', kind: 'command', command: 'codex mcp add jevway -- npx …', file: '~/.codex/config.toml', snippet: '[mcp_servers.jevway]…' }
148
+ */
149
+ export const printedRegistration = (name, machine) => {
150
+ const client = CLIENTS.find((candidate) => candidate.name === name);
151
+ if (!client)
152
+ return { client: 'other', kind: 'config', snippet: JSON.stringify(SERVER, null, 2) };
153
+ const { registration } = client;
154
+ if (registration.kind === 'command') {
155
+ const command = registration.argv.join(' ');
156
+ return registration.writes
157
+ ? { client: client.name, kind: 'command', command, file: shownPath(registration.writes.file(machine), machine.home), snippet: registration.writes.snippet }
158
+ : { client: client.name, kind: 'command', command, snippet: command };
159
+ }
160
+ return { client: client.name, kind: 'config', file: shownPath(registration.file(machine), machine.home), snippet: snippetFor(registration) };
161
+ };
162
+ /**
163
+ * `printedRegistration` as text: a `#` line saying what to do and where, then the command or snippet.
164
+ *
165
+ * @example
166
+ * renderPrinted(printedRegistration('cursor', machine))
167
+ * // '# Cursor: merge into "mcpServers" in ~/.cursor/mcp.json (keep the other servers)\n{\n "mcpServers": …'
168
+ */
169
+ export const renderPrinted = ({ client, kind, command, file, snippet }, env = process.env) => {
170
+ const label = CLIENTS.find(({ name }) => name === client)?.label;
171
+ const keyNote = `# The key stays in ${shownEnvFilePath(envFilePath(env))}, which jevway reads itself; never put it in this config.`;
172
+ if (!label)
173
+ return `# Any stdio MCP client: add a server named "jevway" that runs\n${snippet}\n${keyNote}\n`;
174
+ if (kind === 'command') {
175
+ const alternative = file && snippet !== command ? `# or add this to ${file} yourself:\n${snippet}` : '';
176
+ return `# ${label}: run\n${command}\n${alternative}${keyNote}\n`;
177
+ }
178
+ const container = Object.keys(JSON.parse(snippet))[0];
179
+ return `# ${label}: merge into "${container}" in ${file} (keep the other servers)\n${snippet}\n${keyNote}\n`;
180
+ };
@@ -0,0 +1,15 @@
1
+ /** Writes `text` to stdout and waits until it is flushed: on macOS a piped stdout is asynchronous. */
2
+ export const print = (text) => new Promise((resolve) => process.stdout.write(text, () => resolve()));
3
+ /** Prints `value` as indented JSON on stdout. */
4
+ export const printJson = (value) => print(`${JSON.stringify(value, null, 2)}\n`);
5
+ /**
6
+ * Prints the structured error agents read (`{ ok: false, error: { code, message, hint } }`) on stdout and returns
7
+ * `exitCode`, so a command can `return printError(…)`.
8
+ *
9
+ * @example
10
+ * return printError(2, 'usage', 'jevway skill: --install needs a folder', 'jevway skill --install ~/.claude/skills');
11
+ */
12
+ export const printError = async (exitCode, code, message, hint) => {
13
+ await printJson({ ok: false, error: { code, message, hint } });
14
+ return exitCode;
15
+ };