jevway 0.1.0 → 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.
Files changed (52) hide show
  1. package/INSTALL-FOR-AGENTS.md +32 -38
  2. package/README.md +193 -150
  3. package/dist/cli/daemon-call.js +78 -0
  4. package/dist/cli/dispatch.js +74 -0
  5. package/dist/cli/flags.js +152 -0
  6. package/dist/cli/help.js +46 -0
  7. package/dist/cli/journey-command.js +162 -0
  8. package/dist/cli/look-command.js +47 -0
  9. package/dist/cli/main.js +64 -0
  10. package/dist/cli/output.js +27 -0
  11. package/dist/cli/run-command.js +173 -0
  12. package/dist/cli/session-commands.js +132 -0
  13. package/dist/daemon/client.js +175 -0
  14. package/dist/daemon/paths.js +35 -0
  15. package/dist/daemon/protocol.js +28 -0
  16. package/dist/daemon/server.js +320 -0
  17. package/dist/jev/replay.js +7 -3
  18. package/dist/jev/retry.js +29 -0
  19. package/dist/journeys/journey-doc.js +2 -1
  20. package/dist/journeys/journey-session.js +14 -3
  21. package/dist/journeys/render-viewer.js +163 -150
  22. package/dist/mcp/instructions.js +18 -0
  23. package/dist/mcp/journey-tools.js +117 -0
  24. package/dist/mcp/results.js +53 -0
  25. package/dist/mcp/server.js +50 -0
  26. package/dist/mcp/task-tools.js +197 -0
  27. package/dist/mcp.js +12 -337
  28. package/dist/output/layout.js +50 -0
  29. package/dist/output/manifest.js +80 -0
  30. package/dist/output/run-folder.js +38 -0
  31. package/dist/recording/describe-step.js +66 -0
  32. package/dist/{task → recording}/recording-page.js +164 -18
  33. package/dist/{task → recording}/recording.js +33 -91
  34. package/dist/recording/run-page.js +155 -0
  35. package/dist/session/session-service.js +193 -0
  36. package/dist/setup/atomic-write.js +13 -0
  37. package/dist/setup/clients.js +180 -0
  38. package/dist/setup/command-output.js +15 -0
  39. package/dist/setup/doctor-command.js +16 -7
  40. package/dist/setup/doctor.js +17 -2
  41. package/dist/setup/env-file.js +22 -0
  42. package/dist/setup/setup-command.js +104 -0
  43. package/dist/setup/setup-wizard.js +216 -0
  44. package/dist/setup/skill-command.js +101 -0
  45. package/dist/setup/terminal-prompter.js +51 -0
  46. package/dist/setup/unified-diff.js +69 -0
  47. package/dist/task/present.js +90 -0
  48. package/dist/task/run-task.js +74 -11
  49. package/dist/task/start-task.js +21 -5
  50. package/package.json +12 -7
  51. package/skill/jevway/SKILL.md +50 -14
  52. package/dist/cli.js +0 -112
@@ -2,7 +2,11 @@ import { execFile } from 'node:child_process';
2
2
  import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { promisify } from 'node:util';
5
- import { BASE_STYLE, clock, dollars, escapeHtml, fact, FONT_LINK, playerMarkup, plural, PLAYER_SCRIPT, seconds, statusWord, thousands, } from './recording-page.js';
5
+ import { safeSlug, stepFileName } from '../output/layout.js';
6
+ import { describeStep } from './describe-step.js';
7
+ import { escapeHtml } from './recording-page.js';
8
+ import { renderRunPage } from './run-page.js';
9
+ export { describeStep, escapeHtml };
6
10
  const VIDEO = 'run.mp4';
7
11
  // The video shows what changed, not how long anyone waited: no frame holds longer than this.
8
12
  const LONGEST_FRAME_S = 1.5;
@@ -17,41 +21,26 @@ const SHORTEST_FRAME_S = 1 / 30;
17
21
  export const frameDurations = (times) => times.map((at, i) => i === times.length - 1 ? LONGEST_FRAME_S : Math.min(LONGEST_FRAME_S, Math.max(SHORTEST_FRAME_S, times[i + 1] - at)));
18
22
  /** Seconds into the encoded video at which the wall-clock moment `at` appears. */
19
23
  export const videoOffset = (times, durations, at) => times.reduce((offset, time, i) => (time < at ? offset + durations[i] : offset), 0);
20
- // Row labels carry a whole table row; a sentence needs its first words.
21
- const shorten = (label, max = 70) => {
22
- const text = label.replace(/\s+/g, ' ').trim();
23
- return text.length > max ? `${text.slice(0, max).replace(/\s+\S*$/, '')}…` : text;
24
- };
25
- /**
26
- * A step as one plain sentence for people reading a recording.
27
- *
28
- * @example
29
- * describeStep({ operation: 'TYPE_TEXT', element: 'Search traces', valueKey: 'search_query' })
30
- * // 'Type the "search_query" value into "Search traces"'
31
- */
32
- export const describeStep = ({ operation, element = '', valueKey, option }) => {
33
- const target = `"${shorten(element)}"`;
24
+ // A step shot's name: the verb and what it acts on, short enough to read in a file list.
25
+ const stepWords = ({ operation, element = '', valueKey, fileKey, option }) => {
34
26
  switch (operation) {
35
27
  case 'CLICK':
36
- return `Click ${target}`;
37
28
  case 'CLICK_AT':
38
- return `Click the control at ${target} by its on-screen position`;
29
+ return `click ${element}`;
39
30
  case 'TYPE_TEXT':
40
- return `Type the "${valueKey}" value into ${target}`;
31
+ return `type ${valueKey ?? element}`;
32
+ case 'UPLOAD':
33
+ return `upload ${fileKey ?? element}`;
41
34
  case 'SELECT':
42
- return option ? `Choose "${option}" in ${target}` : `Choose an option in ${target}`;
35
+ return `select ${option ?? element}`;
43
36
  case 'PRESS_ENTER':
44
- return `Press Enter in ${target} to submit it`;
45
37
  case 'PRESS_ESCAPE':
46
- return 'Press Escape to close the open menu or popup';
47
38
  case 'SCROLL_DOWN':
48
- return 'Scroll down to reveal more of the page';
49
39
  case 'SCROLL_UP':
50
- return 'Scroll back up';
51
40
  case 'WAIT':
52
- return 'Wait for the page to finish updating';
41
+ return operation.replace('_', ' ');
53
42
  default:
54
- return `${operation} ${target}`;
43
+ return `finished ${operation}`;
55
44
  }
56
45
  };
57
46
  /**
@@ -60,12 +49,14 @@ export const describeStep = ({ operation, element = '', valueKey, option }) => {
60
49
  * and an `index.html` viewer. Separate from what the model sees.
61
50
  *
62
51
  * @example
63
- * const task = new BrowserTask(tab, ask, { goal, recordDir: 'runs/langfuse' });
64
- * await task.run(); // runs/langfuse/run.mp4, 01.jpg … index.html
52
+ * const task = new BrowserTask(tab, ask, { goal, output: { dir: 'runs/langfuse', record: true } });
53
+ * await task.run(); // runs/langfuse/run.mp4, steps/01-click-traces.jpg … index.html
65
54
  */
66
55
  export class Recording {
67
56
  steps = [];
68
57
  summaries = [];
58
+ /** What each call was asked, scrubbed, one per summary. */
59
+ asked = [];
69
60
  #dir;
70
61
  #frames = [];
71
62
  /** The page size the screencast reports (CSS pixels): the run page and the reports shape the video's frame by it. */
@@ -96,7 +87,8 @@ export class Recording {
96
87
  }
97
88
  async step(tab, page, intent, target) {
98
89
  const marks = target ? [{ label: intent.operation, rect: target }] : [];
99
- const file = `${String(this.steps.length + 1).padStart(2, '0')}.jpg`;
90
+ const file = stepFileName(this.steps.length + 1, safeSlug(stepWords(intent), this.#scrub));
91
+ mkdirSync(join(this.#dir, 'steps'), { recursive: true });
100
92
  writeFileSync(join(this.#dir, file), Buffer.from(await tab.markedScreenshot(marks, page, { sharp: true }), 'base64'));
101
93
  this.steps.push({
102
94
  file,
@@ -109,7 +101,7 @@ export class Recording {
109
101
  }
110
102
  /** The action shot just taken never ran (the page moved first); the retry takes its place. */
111
103
  discardLast() {
112
- this.steps.pop();
104
+ this.#remove(this.steps.pop());
113
105
  }
114
106
  /** The action shot just taken never ran: its target couldn't be reached. Repeats of the same miss fold into one. */
115
107
  markUnreachable() {
@@ -118,26 +110,34 @@ export class Recording {
118
110
  return;
119
111
  if (previous?.unreachable && previous.description === last.description) {
120
112
  previous.attempts = (previous.attempts ?? 1) + 1;
121
- this.steps.pop();
113
+ this.#remove(this.steps.pop());
122
114
  return;
123
115
  }
124
116
  last.unreachable = true;
125
117
  last.attempts = 1;
126
118
  }
119
+ // A shot named for a step that never ran would sit in the folder with no step to explain it.
120
+ #remove(step) {
121
+ if (step)
122
+ rmSync(join(this.#dir, step.file), { force: true });
123
+ }
127
124
  // A session can run several times (missing value, confirmation); each run ends with its own final shot,
128
125
  // and the video is re-encoded to cover every run so far.
129
- async finish(tab, summary) {
126
+ async finish(tab, summary, asked) {
130
127
  const page = await tab.observe();
131
128
  await this.step(tab, page, { operation: summary.status });
132
129
  this.steps.at(-1).description = `Finished: ${summary.status}. ${summary.reason}`;
133
130
  this.summaries.push(summary);
131
+ if (asked)
132
+ this.asked.push({ goal: this.#scrub(asked.goal), ...(asked.doneWhen && { done_when: this.#scrub(asked.doneWhen) }) });
134
133
  await tab.send('Page.stopScreencast').catch(() => undefined);
135
134
  this.#stopListening?.();
136
135
  this.#stopListening = undefined;
137
136
  const video = (await this.#encode()) ?? null;
138
137
  const viewport = this.#viewport;
139
- writeFileSync(join(this.#dir, 'summary.json'), JSON.stringify({ summaries: this.summaries, video, steps: this.steps, ...(viewport && { viewport }) }, null, 1));
140
- writeFileSync(join(this.#dir, 'index.html'), renderSteps(this.steps, this.summaries, video, viewport));
138
+ const record = { summaries: this.summaries, video, steps: this.steps, ...(viewport && { viewport }), ...(this.asked.length && { asked: this.asked }) };
139
+ writeFileSync(join(this.#dir, 'summary.json'), JSON.stringify(record, null, 1));
140
+ writeFileSync(join(this.#dir, 'index.html'), renderRunPage(record));
141
141
  }
142
142
  // Frames → H.264 through ffmpeg's concat list, each frame held for its duration. Without ffmpeg: no video.
143
143
  async #encode() {
@@ -172,61 +172,3 @@ export class Recording {
172
172
  }
173
173
  }
174
174
  }
175
- export { escapeHtml };
176
- // What a run's calls cost, summed: requests, input tokens, time, and the estimate unless a call has none.
177
- const costLine = (summaries) => {
178
- const sum = (pick) => summaries.reduce((total, s) => total + pick(s), 0);
179
- const cost = summaries.reduce((total, s) => (total === null || s.jev.est_cost_usd == null ? null : total + s.jev.est_cost_usd), 0);
180
- return `${plural(sum((s) => s.jev.requests), 'Jev request')} · ${thousands(sum((s) => s.jev.input_tokens))} input tokens · ${seconds(sum((s) => s.elapsed_ms))} · ${cost === null ? 'est. cost unknown' : `est. ${dollars(cost)}`}`;
181
- };
182
- // epoch seconds → "2026-11-01 18:39 UTC"
183
- const recordedAt = (at) => `${new Date(at * 1000).toISOString().slice(0, 16).replace('T', ' ')} UTC`;
184
- /**
185
- * A run's own page (`index.html` in its record folder), in the reports' layout: how it ended, then the screencast with
186
- * its steps on a timeline and a strip kept in sync, then the facts. One self-contained file next to its shots and
187
- * video; it reads without JS (the script only syncs the player).
188
- *
189
- * @example
190
- * writeFileSync('runs/a/index.html', renderSteps(steps, summaries, 'run.mp4', { width: 1120, height: 780 }));
191
- */
192
- export const renderSteps = (steps, summaries, video = null, viewport) => {
193
- const last = summaries.at(-1);
194
- const ending = last ? `Finished: ${statusWord(last.status)}` : 'jevway run';
195
- const actions = summaries.reduce((total, s) => total + s.steps.length, 0);
196
- 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>`;
197
- const player = steps.length
198
- ? playerMarkup({ dir: '', video, steps, label: 'this run', featured: true, viewport, bar, preload: 'metadata' })
199
- : '<div class="screen quiet"><p class="small">No step was recorded.</p></div>';
200
- const facts = [
201
- ...(summaries.length ? [fact('Calls', `${escapeHtml(summaries.map((s) => statusWord(s.status)).join(' → '))} · ${plural(actions, 'action')}`)] : []),
202
- ...(last ? [fact('Final reason', escapeHtml(last.reason)), fact('Final address', `<span class="address">${escapeHtml(last.url)}</span>`), fact('Page title', escapeHtml(last.title))] : []),
203
- ...(summaries.length ? [fact('Cost', `<span class="num">${costLine(summaries)}</span>`)] : []),
204
- fact('Files', `<a href="summary.json">summary.json</a>${video ? ` · <a href="${escapeHtml(video)}">${escapeHtml(video)}</a>` : ''}`),
205
- ];
206
- return `<!doctype html>
207
- <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
208
- <title>${escapeHtml(`jevway run: ${last ? statusWord(last.status) : 'recorded'}`)}</title>
209
- <link rel="preconnect" href="https://api.fontshare.com" crossorigin>
210
- <link rel="stylesheet" href="${FONT_LINK}">
211
- <style>${BASE_STYLE}
212
- .run-page { padding-bottom: 4rem; }
213
- .run-page .facts { margin-top: 4rem; }
214
- </style>
215
- </head><body>
216
- <main class="wrapper run-page">
217
- <header class="job-head grid4">
218
- <p class="meta">jevway run<br>${steps.length ? recordedAt(steps[0].at) : ''}</p>
219
- <h1 class="job-title c2s2">${escapeHtml(ending)}</h1>
220
- <p class="meta c4 end">${plural(summaries.length, 'call')} · ${plural(actions, 'action')}</p>
221
- ${last ? `<p class="small story c2s2">${escapeHtml(last.reason)}</p>` : ''}
222
- </header>
223
- <div class="run player current">
224
- ${player}
225
- </div>
226
- <dl class="facts">${facts.join('\n')}</dl>
227
- </main>
228
- <footer><div class="wrapper"><span>Recorded by jevway for people to read; the model never sees these shots.</span><span>${plural(steps.length, 'step')}${video ? ` · ${escapeHtml(video)}` : ' · no video (needs ffmpeg)'}</span></div></footer>
229
- ${PLAYER_SCRIPT}
230
- </body></html>
231
- `;
232
- };
@@ -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,193 @@
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 { lookAt } from '../task/look.js';
6
+ import { storyLine } from '../task/present.js';
7
+ /** A session id the service does not hold: it ended, expired, or never existed. `code` is for structured CLI errors. */
8
+ export class UnknownSession extends Error {
9
+ code = 'unknown_session';
10
+ }
11
+ // 3600000 → '1 h', 1800000 → '30 min'.
12
+ const idleWords = (ms) => (ms % 3_600_000 === 0 ? `${ms / 3_600_000} h` : `${Math.round(ms / 60_000)} min`);
13
+ /**
14
+ * The sessions behind every adapter (the MCP server, the CLI daemon): task sessions and journeys by opaque UUID, each
15
+ * with its folder under the output root, calls that wait for a session's still-running call, and errors that say what
16
+ * to do next. Transport-free: adapters translate parameters in and present results out.
17
+ *
18
+ * @example
19
+ * const sessions = createSessionService(createRunner('auto'));
20
+ * const first = await sessions.task({ goal: 'Book a stay', url: 'https://stays.example' }, { onEvent: console.error });
21
+ * presentSummary(first); // { session, status: 'needs_input', next: 'Add the missing values …', story, files, … }
22
+ * await sessions.task({ goal: 'Book a stay', session: first.session, values: { email: 'ada@example.com' } });
23
+ * await sessions.end(first.session);
24
+ */
25
+ export const createSessionService = (runner, options = {}) => {
26
+ const env = options.env ?? process.env;
27
+ const cwd = options.cwd ?? process.cwd();
28
+ const root = resolve(cwd, options.outDir ?? outRoot(env, cwd));
29
+ const now = options.now ?? (() => new Date());
30
+ const newId = options.newId ?? randomUUID;
31
+ const sessions = new Map();
32
+ const journeys = new Map();
33
+ const lifetime = `when the server restarts${options.idleMs ? ` or after ${idleWords(options.idleMs)} idle` : ''}`;
34
+ const findTask = (id) => {
35
+ const entry = sessions.get(id);
36
+ if (!entry)
37
+ throw new UnknownSession(`Unknown session "${id}". Sessions end ${lifetime}; start a new one with \`url\` (or \`tab\`).`);
38
+ entry.lastUsedAt = now();
39
+ return entry;
40
+ };
41
+ const findJourney = (id) => {
42
+ const entry = journeys.get(id);
43
+ if (!entry)
44
+ throw new UnknownSession(`Unknown journey "${id}". Journeys end when finished or ${lifetime}; start a new one with its \`url\`.`);
45
+ entry.lastUsedAt = now();
46
+ return entry;
47
+ };
48
+ const startTask = async ({ session: _session, record, recordDir, ...request }) => {
49
+ const id = newId();
50
+ const at = now();
51
+ const values = request.values ?? {};
52
+ const secretKeys = request.secretKeys ?? [];
53
+ const scrub = scrubberFor(values, secretKeys);
54
+ const runDir = recordDir ? resolve(cwd, recordDir) : join(ensureOutRoot(root), 'runs', runFolderName({ at, goal: request.goal, id, scrub }));
55
+ const task = await runner.start({ ...request, output: { dir: runDir, record: Boolean(record || recordDir) } });
56
+ const entry = { task, ownsTab: Boolean(request.url), busy: false, goal: scrub(request.goal), values, secretKeys, runDir, startedAt: at, lastUsedAt: at };
57
+ sessions.set(id, entry);
58
+ return { id, entry };
59
+ };
60
+ const continueTask = async (id, request) => {
61
+ const entry = findTask(id);
62
+ await entry.running;
63
+ // Only what a later call may change: start-only fields (url, tab, coordinate clicks, recording) stay as started.
64
+ entry.task.update({
65
+ goal: request.goal,
66
+ values: request.values,
67
+ secretKeys: request.secretKeys,
68
+ files: request.files,
69
+ doneWhen: request.doneWhen,
70
+ checks: request.checks,
71
+ maxSteps: request.maxSteps,
72
+ maxSeconds: request.maxSeconds,
73
+ allowedHosts: request.allowedHosts,
74
+ blockedHosts: request.blockedHosts,
75
+ confirmIrreversible: request.confirmIrreversible,
76
+ confirmTarget: request.confirmTarget,
77
+ });
78
+ entry.values = { ...entry.values, ...request.values };
79
+ entry.secretKeys = [...new Set([...entry.secretKeys, ...(request.secretKeys ?? [])])];
80
+ entry.goal = scrubberFor(entry.values, entry.secretKeys)(request.goal);
81
+ return { id, entry };
82
+ };
83
+ const journeysRoot = () => env.JEVWAY_JOURNEYS_DIR ? resolve(cwd, env.JEVWAY_JOURNEYS_DIR) : join(ensureOutRoot(root), 'journeys');
84
+ const journeyReply = async (id, journey, report) => ({
85
+ journey: id,
86
+ report: report,
87
+ preview: await journey.preview(),
88
+ });
89
+ return {
90
+ /** The output root this service writes under. */
91
+ outDir: root,
92
+ /**
93
+ * Runs one call: a new session (with `url` or `tab`) or the next call of `session`, which first waits for that
94
+ * session's still-running call. `signal` cancels this call (the run ends `blocked` at its next check).
95
+ */
96
+ async task(request, { signal, onEvent } = {}) {
97
+ const { id, entry } = request.session ? await continueTask(request.session, request) : await startTask(request);
98
+ // A listener is the adapter's (progress, NDJSON): it never decides how the run goes.
99
+ const tell = (event) => {
100
+ try {
101
+ onEvent?.(event);
102
+ }
103
+ catch {
104
+ // ignored
105
+ }
106
+ };
107
+ tell({ type: 'started', session: id });
108
+ entry.busy = true;
109
+ const running = entry.task.run({ signal, onStep: (step, n, url) => tell({ type: 'step', session: id, n, story: storyLine(step), url }) });
110
+ entry.running = running.catch(() => undefined);
111
+ try {
112
+ const summary = await running;
113
+ entry.status = summary.status;
114
+ entry.url = summary.url;
115
+ tell({ type: 'finished', session: id, status: summary.status });
116
+ const call = entry.task.lastCall;
117
+ return { session: id, summary, ...(call && { files: call.files }), ...(call?.screenshot && { screenshot: call.screenshot }) };
118
+ }
119
+ finally {
120
+ entry.busy = false;
121
+ entry.lastUsedAt = now();
122
+ }
123
+ },
124
+ /** 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 a URL opened and closed again. */
125
+ async look({ session, tab, url }) {
126
+ if ([session, tab, url].filter(Boolean).length !== 1)
127
+ throw new Error('Pass exactly one of session, tab or url.');
128
+ if (session)
129
+ return lookAt(await findTask(session).task.look());
130
+ return lookAt(await runner.look({ url, tab }));
131
+ },
132
+ /** Forgets a session and closes the tabs it opened (its first tab only when it started from `url`), unless `keepTab`. */
133
+ async end(session, { keepTab = false } = {}) {
134
+ const entry = findTask(session);
135
+ sessions.delete(session);
136
+ if (!keepTab)
137
+ await entry.task.closeTabs({ keepFirst: !entry.ownsTab });
138
+ return { session, run_dir: entry.runDir };
139
+ },
140
+ /** Every open task session and journey, oldest first, with what it is doing and where its files are. */
141
+ listSessions() {
142
+ const tasks = [...sessions].map(([session, entry]) => ({
143
+ session,
144
+ kind: 'task',
145
+ goal: entry.goal,
146
+ ...(entry.status && { status: entry.status }),
147
+ ...(entry.url && { url: entry.url }),
148
+ running: entry.busy,
149
+ started_at: entry.startedAt.toISOString(),
150
+ last_used_at: entry.lastUsedAt.toISOString(),
151
+ run_dir: entry.runDir,
152
+ }));
153
+ const maps = [...journeys].map(([session, entry]) => ({
154
+ session,
155
+ kind: 'journey',
156
+ url: entry.url,
157
+ started_at: entry.startedAt.toISOString(),
158
+ last_used_at: entry.lastUsedAt.toISOString(),
159
+ run_dir: entry.outDir,
160
+ }));
161
+ return [...tasks, ...maps].sort((a, b) => a.started_at.localeCompare(b.started_at));
162
+ },
163
+ /** Opens `url` on an emulated phone and maps its first screen, into `<root>/journeys/<stamp>_<host>_<id4>/` (or `outDir`). */
164
+ async journeyStart({ url, device, intent, values, stayWithin, outDir }) {
165
+ const id = newId();
166
+ const at = now();
167
+ const dir = outDir ? resolve(cwd, outDir) : join(journeysRoot(), journeyFolderName({ at, host: new URL(url).host, id }));
168
+ const journey = await runner.startJourney({ startUrl: url, device, intent, values, stayWithin, outDir: dir });
169
+ journeys.set(id, { journey, url, outDir: dir, startedAt: at, lastUsedAt: at });
170
+ return journeyReply(id, journey, journey.report());
171
+ },
172
+ async journeyStep(id, input) {
173
+ const { journey } = findJourney(id);
174
+ return journeyReply(id, journey, await journey.step(input));
175
+ },
176
+ async journeyAutopilot(id, input) {
177
+ const { journey } = findJourney(id);
178
+ return journeyReply(id, journey, await journey.autopilot(input));
179
+ },
180
+ journeyAnnotate(id, screen, notes, relabel) {
181
+ findJourney(id).journey.annotate(screen, notes, relabel);
182
+ },
183
+ /** Writes the final map, closes the journey's tab unless `keepTab`, and forgets the journey. */
184
+ async journeyFinish(id, { keepTab = false } = {}) {
185
+ const { journey, outDir } = findJourney(id);
186
+ const summary = await journey.finish({ closeTab: !keepTab });
187
+ journeys.delete(id);
188
+ return { ...summary, manifest: join(outDir, 'manifest.json') };
189
+ },
190
+ /** Closes the browser connection (a launched browser closes with it). */
191
+ close: () => runner.close(),
192
+ };
193
+ };
@@ -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
+ };