#!/usr/bin/env node
// -----------------------------------------------------------------------------
//  yorecord — share a video your agent already has, as a link a human can open.
//
//  ONE FILE, ZERO DEPENDENCIES, and both are deliberate.
//
//  Zero dependencies because this program holds a credential on a developer's machine:
//  every package it pulled in would be another account able to take that credential over.
//
//  One file because of how it is delivered. It is served from yorecord.com — the same
//  domain the videos are on — so installing it is one curl, with no package manager, no
//  registry and no account in the trust chain. That matters more than it sounds: a careful
//  agent refused an earlier version of this feature because it could not vouch for the
//  source, and "read this one dependency-free file, served by the domain you are uploading
//  to" is a far easier thing to justify than "let a package manager fetch something".
//  (No line count here on purpose: the last one said 400 and the file had grown to 2057.)
//
//  Two modes:
//    node yorecord.mjs --mcp                 stdio MCP server (claude mcp add / codex mcp add)
//    node yorecord.mjs share <file.mp4>      plain CLI, for CI and harnesses without MCP
//
//  Read it before you run it. That is the point of shipping it as one readable file.
// -----------------------------------------------------------------------------

import { spawn } from 'node:child_process';
import { createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { readFile, stat, mkdir, mkdtemp, writeFile, chmod, rm } from 'node:fs/promises';
import { cpus, homedir, loadavg, tmpdir } from 'node:os';
import { join } from 'node:path';
import { createInterface } from 'node:readline';
import { pathToFileURL } from 'node:url';

// YoRecord share — the logic behind the MCP tool. No I/O of its own: the caller injects
// fetch, the filesystem and the clock, which is what makes all of this testable.
//
// WHY AN MCP SERVER AT ALL, given three curl commands already work.
//
// On 2026-08-19 a well-aligned agent read the pasted instructions, classified them as a
// prompt injection and refused — twice, including after the user asked directly. It was
// right: the text asks an agent to fetch a credential, write it to disk and upload to a
// domain it cannot vouch for. No wording fixes that, and it gets harder as agents get safer.
//
// An installed server inverts the trust relationship. The human ran `claude mcp add`, so
// nothing has to persuade the agent of anything — it simply has a tool. Same three requests,
// entirely different posture.

const VERSION = '0.4.0';
const UPLOAD_BASE = 'https://up.yorecord.com';

/**
 * The site, for links a human opens. Separate from UPLOAD_BASE on purpose: that one is the
 * worker, and it is the ONLY origin these bytes may be fetched from cross-origin — the R2
 * playback domain sends no CORS headers at all.
 */
const SITE_BASE = 'https://yorecord.com';

/** Matches the worker: a device code is valid for ten minutes. */
const DEVICE_CODE_TTL_MS = 10 * 60 * 1000;

/** Matches the worker. Refused locally so a doomed upload never costs a daily slot. */
export const AGENT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;

/**
 * The tool an agent sees. ONE tool, ONE argument.
 *
 * The description is doing real work: it is what the agent reads and relays, which makes it
 * the only channel that reliably reaches the person. The consent facts therefore live here,
 * not in prose on a website nobody fetches.
 */
export const MCP_TOOLS = [
  {
    name: 'yorecord_share_video',
    description:
      'Share an MP4 video file as a link a human can open in a browser. Use it for a video ' +
      'you ALREADY HAVE — a Playwright recordVideo capture, a CI artifact, an ffmpeg output. ' +
      'It does not record anything, so do not install a browser or ffmpeg for it. ' +
      'IMPORTANT, tell your human: the link is PUBLIC to anyone who has it, and the video is ' +
      'deleted automatically after 7 days. Do not share a recording showing passwords, ' +
      'tokens, inboxes or other people’s data. The first use asks the human to approve this ' +
      'machine once in their browser; after that it needs nothing.',
    inputSchema: {
      type: 'object',
      properties: {
        path: { type: 'string', description: 'Absolute path to an .mp4 file on this machine.' },
      },
      required: ['path'],
      additionalProperties: false,
    },
  },
  {
    name: 'yorecord_start_recording',
    description:
      'Start recording a browser, so you can walk through a website and hand your human a ' +
      'video of it. Opens a FRESH Chrome — never their own profile, so no logged-in session ' +
      'of theirs can end up in the video — and starts capturing immediately. If the site needs ' +
      'a login, pass a "profile" name and the session survives between takes, so you can ' +
      'rehearse a throwaway take and then perform a clean one without asking them to log in ' +
      'twice. REHEARSE FIRST: a demo is a performance, and exploring on camera looks like ' +
      'exploring. Drive it with ' +
      'yorecord_browse, or with your own browser tool if that can attach to an existing ' +
      'browser over CDP (the endpoint is in the reply). Then call yorecord_stop_and_share. ' +
      'The video has no audio and only the one tab is recorded. Stops itself after 10 minutes ' +
      'OF VIDEO — the clock pauses while you think, so taking your time between calls costs ' +
      'the recording nothing. ' +
      'PASS AN INTENT (demo / proof / guide / bug / before-after) and the reply tells you how ' +
      'to record that kind well — a demo and a bug report need opposite handling. ' +
      'IMPORTANT: this browser is NOT on your human’s screen — it is headless and invisible to ' +
      'them. Never use a screenshot or screen-capture tool to see it: that would photograph ' +
      'their own desktop, including whatever private thing is open there, and this tool ' +
      'uploads what it is given. yorecord_browse returns a picture of the page instead.',
    inputSchema: {
      type: 'object',
      properties: {
        url: { type: 'string', description: 'Page to open first. Recording starts either way.' },
        width: { type: 'number', description: 'Viewport width, default 1280.' },
        port: { type: 'number', description: 'CDP port to offer your own browser tool, default 9222.' },
        intent: {
          type: 'string',
          enum: ['demo', 'proof', 'guide', 'bug', 'before-after'],
          description:
            'What the video is FOR. The reply then tells you how to record that kind well — ' +
            'they need genuinely different handling. demo: a performance someone will watch. ' +
            'proof: show your human what you built. guide: teach them a task. bug: a ' +
            'reproduction. before-after: a fix working. Pass one.',
        },
        fps: {
          type: 'number',
          description:
            'Frames per second, 5-30, default 10. Raise it to 24 or 30 for a demo someone will ' +
            'actually watch. The drawn pointer now animates continuously, so a page sitting ' +
            'still still produces smooth video — you do not have to keep it busy yourself.',
        },
        profile: {
          type: 'string',
          description:
            'Reuse a named browser profile instead of a throwaway one, so a human logs in ONCE ' +
            'per app rather than once per take. Use it to rehearse: record a scrappy take, ' +
            'discard it, then perform the real one without asking them to log in again. Letters, ' +
            'digits, dot, dash and underscore only. Omit it and you get a clean throwaway, which ' +
            'is the right default for anything you did not log into.',
        },
        rehearse: {
          type: 'boolean',
          description:
            'Rehearse instead of recording: same browser, same profile, same page outline, ' +
            'but NO video is captured and nothing can be uploaded. Use it to make a script ' +
            'pass before you perform it — exploring on camera is what makes a walkthrough ' +
            'look like exploring. Rehearse and then record WITHOUT restarting me: the logged-in ' +
            'session is carried in memory for this session only, so a restart sends your human ' +
            'back to the login screen.',
        },
        pauseBetweenCalls: {
          type: 'boolean',
          description:
            'Default true: the capture clock STOPS while you think, so the gap between two ' +
            'yorecord_browse calls is not in the video. Pass false only if the page animates ' +
            'by itself between your calls and you need that on camera — a progress bar, a ' +
            'long-running job, a live chart. It costs about 17 seconds of frozen frame per ' +
            'call to leave off.',
        },
        height: { type: 'number', description: 'Viewport height, default 720.' },
        headless: { type: 'boolean', description: 'Default true. Pass false to show the window to your human.' },
      },
      additionalProperties: false,
    },
  },
  {
    name: 'yorecord_browse',
    description:
      'Drive the browser that is being recorded, one small batch of actions at a time. Each ' +
      'action is one of: {"goto":"url"}, {"click":"visible text or CSS selector"}, ' +
      '{"type":"text","into":"selector"}, {"press":"Enter"}, {"scroll":600}, {"wait":1000}. ' +
      'The reply contains a PICTURE of the page plus a numbered list of everything you can ' +
      'act on, so choose the next batch from that rather than guessing — click by ref, e.g. ' +
      '{"click":"@7"}, which is exact where a label is a guess, and stays exact between calls. ' +
      'TAKE YOUR TIME: the recording clock STOPS between calls once the page settles, so ' +
      'thinking costs the video nothing. Pace the walkthrough with {"wait":1200} INSIDE a ' +
      'batch, which the video keeps, rather than by splitting it into more calls. Never use a screenshot or ' +
      'screen-capture tool to look at this browser: it is not on your human’s screen, so you ' +
      'would photograph their own desktop instead. A pointer is drawn into the video for every ' +
      'move and click, because a screen capture contains no real cursor.',
    inputSchema: {
      type: 'object',
      properties: {
        actions: { type: 'array', description: 'Up to 20 actions, performed in order.', items: { type: 'object' } },
      },
      required: ['actions'],
      additionalProperties: false,
    },
  },
  {
    name: 'yorecord_replay',
    description:
      'Perform a whole walkthrough from a script, in one call, with no thinking in between. ' +
      'This is how you record a watchable tutorial: write the script, rehearse it with ' +
      'yorecord_start_recording({rehearse:true}) until it passes, then start a real recording ' +
      'and replay the SAME script. A script is {"title":"…","steps":[{"say":"what this step ' +
      'shows","actions":[…],"hold":1200,"chapter":"optional"}]} — actions are exactly what ' +
      'yorecord_browse takes. "say" becomes an on-screen caption for as long as the step runs ' +
      '(there is no audio, so this is the only way the video explains itself), "hold" is how ' +
      'long the finished screen stays up, and "chapter" marks a section — use it a few times, ' +
      'not once per step. I STOP at the first action that fails and tell you which step, so ' +
      'fix the script and replay it again rather than patching around it mid-take.',
    inputSchema: {
      type: 'object',
      properties: {
        script: {
          type: 'object',
          description: 'The shot list itself. Pass this or "path", not both.',
        },
        path: {
          type: 'string',
          description:
            'A .json file holding the script, so a human can read and edit it before you ' +
            'perform it. Keep it in your working directory, beside the code it documents.',
        },
      },
      additionalProperties: false,
    },
  },
  {
    name: 'yorecord_stop_and_share',
    description:
      'Stop recording, encode the video and UPLOAD it, returning a link. IMPORTANT, tell ' +
      'your human first: the link is PUBLIC to anyone who has it and the video is deleted ' +
      'after 7 days, so do not upload a walkthrough showing passwords, tokens, inboxes or ' +
      'other people’s data. If the upload cannot happen the video is kept on disk and the ' +
      'PASS edit:true FOR ANYTHING A HUMAN WILL WATCH: it uploads nothing, writes the video ' +
      'and its zooms and captions to disk, and tells you where — your human drops both on ' +
      'yorecord.com/import, reviews them in the editor and publishes from there. Uploading ' +
      'first would put the whole recording on a public URL before anyone had looked at it. ' +
      'reply says where. Pass discard:true to stop WITHOUT uploading — use that for a ' +
      'practice take, or anything you would not publish; the file is kept locally and the ' +
      'reply says where, so you can look at it or share it later.',
    inputSchema: {
      type: 'object',
      properties: {
        edit: {
          type: 'boolean',
          description:
            'Stop and hand the recording over for EDITING instead of publishing it. Uploads ' +
            'nothing. Writes the video and an edit.json holding the zooms, captions and ' +
            'chapters, and tells you both paths; your human drops them on ' +
            'yorecord.com/import, reviews the result in the editor and shares it themselves. ' +
            'This is the right default for a walkthrough of anything real, because the video ' +
            'shows everything that was on screen and a share link is public.',
        },
        discard: {
          type: 'boolean',
          description: 'Stop and clean up without uploading anything. The video stays on disk.',
        },
      },
      additionalProperties: false,
    },
  },
];

/**
 * Name to handler. The dispatcher used to compare against one string, which meant a tool
 * could be listed and unroutable at the same time — an "Unknown tool" the human cannot
 * diagnose.
 */
export const TOOL_HANDLERS = {
  yorecord_share_video: handleShareVideo,
  yorecord_start_recording: handleStartRecording,
  yorecord_replay: handleReplay,
  yorecord_browse: handleBrowse,
  yorecord_stop_and_share: handleStopAndShare,
};

/** An ISO-BMFF file carries its size in bytes 0-3 and the type `ftyp` in bytes 4-7. */
export function looksLikeMp4Head(head) {
  return Boolean(head) && head.length >= 8 && head[4] === 0x66 && head[5] === 0x74 && head[6] === 0x79 && head[7] === 0x70;
}

const ok = (text) => ({ text });
const fail = (text) => ({ text, isError: true });

/**
 * An MCP tool result's content blocks.
 *
 * The image is why this exists. An agent driving a browser it cannot see took a full-screen
 * screenshot to orient itself and captured the human's desktop - their chats and a
 * confidential PDF - because we gave it no picture and it had another tool that could. We
 * already hold the latest frame for the encoder, so handing it over costs nothing.
 */
export function toolContent({ text, imageJpegBase64 }) {
  const content = [{ type: 'text', text }];
  if (imageJpegBase64) content.push({ type: 'image', data: imageJpegBase64, mimeType: 'image/jpeg' });
  return content;
}

const PUBLIC_NOTE = 'This link is public to anyone who has it, and the video is deleted after 7 days.';

/**
 * Run the whole flow: approve if needed, then upload.
 *
 * Ordering is deliberate — every local check happens BEFORE any network call, because a
 * refusal that costs a daily slot is a refusal that costs the person a share.
 */
export async function handleShareVideo(args, deps) {
  // Only what THIS function needs; the token and the upload live in the two helpers below.
  const { readFile, statSize } = deps;
  const path = typeof args?.path === 'string' ? args.path : '';
  if (!path) return fail('Give me the path to an .mp4 file.');

  // 1. Size, from stat: do not read 50 MiB into memory only to reject it.
  let size;
  try {
    size = await statSize(path);
  } catch {
    return fail(`I could not find a file at ${path}. Check the path — nothing was uploaded.`);
  }
  if (size > AGENT_MAX_UPLOAD_BYTES) {
    return fail(
      `That file is ${(size / 1024 / 1024).toFixed(1)} MB, and the limit is 50 MiB. ` +
        'Shorten the recording, or lower its frame rate. Nothing was uploaded.',
    );
  }

  // 2. Bytes, not the file extension: the worker checks this too, and being refused after a
  // 40 MB upload is a poor way to learn it.
  const bytes = await readFile(path);
  if (!looksLikeMp4Head(bytes)) {
    return fail(
      `${path} is not an MP4 (no ftyp box). Convert it first, for example: ` +
        'ffmpeg -y -i input.webm -c:v libx264 -pix_fmt yuv420p out.mp4',
    );
  }

  // 3. A token, or a human.
  const granted = await ensureToken(deps);
  if (!granted.token) return ok(granted.message);

  // 4. Upload.
  const result = await uploadMp4({ bytes, token: granted.token, deps });
  if (!result.ok) return fail(result.message);
  return ok(`Shared: ${result.url}\n${PUBLIC_NOTE}`);
}

/**
 * A stored token, or a message telling the human how to grant one.
 *
 * Waiting for a person is NOT an error — see approveThisMachine below.
 */
async function ensureToken(deps) {
  const {
    readToken,
    writeToken,
    fetch,
    sleep,
    maxPolls = 5,
    readPending = async () => null,
    writePending = async () => {},
    clearPending = async () => {},
    now = () => Date.now(),
  } = deps;
  const existing = await readToken();
  if (existing) return { token: existing };
  const approved = await approveThisMachine({ fetch, sleep, maxPolls, readPending, writePending, clearPending, now });
  if (!approved.token) return { message: approved.message };
  await writeToken(approved.token);
  await clearPending();
  return { token: approved.token };
}

/**
 * The one PUT. Both the share tool and the recorder go through here, so the upload contract
 * has a single home — the alternative is two copies that drift, which is exactly how the
 * subtitle script came to be POSTing to a service that had been removed weeks earlier.
 */
async function uploadMp4({ bytes, token, deps }) {
  const { fetch, randomUuid } = deps;
  const uid = randomUuid();
  let res;
  try {
    res = await fetch(`${UPLOAD_BASE}/agent-shares/${uid}/rendered.mp4`, {
      method: 'PUT',
      headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'video/mp4' },
      body: bytes,
      // Generous, because this is up to 50 MiB on whatever connection the machine has - but
      // bounded, because a stalled socket would otherwise hold the tool call open for ever.
      signal: AbortSignal.timeout(5 * 60 * 1000),
    });
  } catch {
    // An allowlisted container answers 403 host_not_allowed, and a laptop on a train answers
    // nothing at all. Reporting it IS the answer; looking for another route is not.
    return {
      ok: false,
      message: `I could not reach ${UPLOAD_BASE}. Tell your human the network blocked it; nothing was uploaded.`,
    };
  }
  const body = await res.json().catch(() => ({}));
  if (res.status === 401 || res.status === 403) {
    // Deliberately NOT deleting the stored token: if this is a transient fault, throwing away
    // their approval would cost them a click for nothing.
    return {
      ok: false,
      message:
        `The upload was refused (${String(res.status)}): ${String(body.error ?? 'unknown token')}. ` +
        'Ask your human to approve this machine again, then retry.',
    };
  }
  if (!res.ok) {
    // Relayed verbatim: the 429 message names when the quota resets, and paraphrasing it
    // loses the one fact the person needs.
    return { ok: false, message: `The upload was refused (${String(res.status)}): ${String(body.error ?? 'no detail')}` };
  }
  return { ok: true, uid, url: String(body.url) };
}

/**
 * The device flow. Returns a token, or a message for the human explaining what to do.
 *
 * Waiting for a person is NOT an error: an error makes agents retry or abandon, when the
 * correct behaviour is to show a URL and wait.
 */
async function approveThisMachine({ fetch, sleep, maxPolls, readPending, writePending, clearPending, now }) {
  const mint = async () => {
    let started;
    try {
      started = await (await fetch(`${UPLOAD_BASE}/agent-tokens/device`, { method: 'POST' })).json();
    } catch {
      return null;
    }
    if (!started?.device_code || !started?.verification_url) return null;
    const record = {
      deviceCode: started.device_code,
      userCode: started.user_code,
      verificationUrl: started.verification_url,
      intervalMs: Math.max(0, Number(started.interval_seconds ?? 3) * 1000),
      createdAtMs: now(),
    };
    await writePending(record);
    return record;
  };

  // FIRST, resume anything already pending. Minting a new code on every call is the bug that
  // made this unusable: the human approved one code while the tool asked about the next, three
  // times over, with no error to explain it. A rotating code is also indistinguishable from a
  // device-code phishing attempt, which one agent correctly said out loud.
  let pending = await readPending();
  if (pending && now() - Number(pending.createdAtMs ?? 0) >= DEVICE_CODE_TTL_MS) {
    await clearPending();
    pending = null;
  }
  // `remint` is spent the first time a resumed code turns out to be dead, so a stale record
  // costs the human nothing: they are handed a fresh code in the SAME call rather than being
  // told to ask again.
  let remint = Boolean(pending);
  if (!pending) {
    pending = await mint();
    if (!pending) {
      return { message: `I could not reach ${UPLOAD_BASE} to request access, or it returned no code.` };
    }
  }

  for (let i = 0; i < maxPolls; i++) {
    await sleep(Math.max(0, Number(pending.intervalMs ?? 3000)));
    let poll;
    try {
      poll = await (
        await fetch(`${UPLOAD_BASE}/agent-tokens/device?device_code=${pending.deviceCode}`)
      ).json();
    } catch {
      // Bounded, then report. Polling through a network fault hides the fault.
      return {
        message: `The network dropped while waiting for approval. Open ${pending.verificationUrl} and ask me again.`,
      };
    }
    if (poll.status === 'approved' && poll.token) return { token: poll.token };
    if (poll.status !== 'pending') {
      await clearPending();
      if (remint) {
        remint = false;
        pending = await mint();
        if (!pending) return { message: `I could not reach ${UPLOAD_BASE} to request a new code.` };
        continue;
      }
      return { message: `That approval code is ${String(poll.status)}. Ask me again and I will start a new one.` };
    }
  }

  return {
    message:
      `Waiting for approval — nothing has been uploaded yet. Ask your human to open ` +
      `${pending.verificationUrl} and press Approve; the code shown should be ` +
      `${String(pending.userCode)}. ${PUBLIC_NOTE} Then ask me to share again, and I will ` +
      `resume THIS approval rather than starting a new one.`,
  };
}

// -----------------------------------------------------------------------------
//  RECORDING
//
//  Sharing a file the agent already had only helped agents that were already recording -
//  a Playwright test writing recordVideo, in a repo. It did nothing for "go to this site
//  and walk me through it", because nothing was capturing anything.
//
//  The shape of this was chosen by measurement, not by preference. Docs/plans/
//  2026-08-19-agent-recording-plan.md has the four probes; the three that decided it:
//
//  1. Chrome serves CDP over a PIPE and over a PORT at the same time. So we launch and
//     record the browser over the pipe, and leave the port open for the agent's OWN browser
//     tool to attach to. Real Playwright, via connectOverCDP, drove the page we were
//     recording, used our tab, and its browser.close() did NOT kill the browser - so the
//     agent's tool disconnecting cannot destroy a recording. The pipe also needs no
//     WebSocket, which means Node 18 works, not just Node 22.
//  2. A screencast is PAINT-DRIVEN: 58 fps on an animated page, 2 frames in 3 seconds on a
//     static one. Encoding one output frame per captured frame gives a video that runs ~10x
//     fast. So the encoder is fed on a wall-clock schedule from the latest held frame -
//     the same FCR rule, and the same reason, as lib/export/exportWorker.worker.ts.
//  3. A mid-recording viewport change alters the JPEG geometry, which image2pipe refuses.
//     A fixed scale+pad output makes ffmpeg reinitialise its filter graph and carry on.
// -----------------------------------------------------------------------------

/** Matches app/recorder/page.tsx. Pro does not lift it and neither does this. */
export const RECORD_MAX_SECONDS = 600;

/**
 * Default 10 fps: a walkthrough, not a game capture, and 41 KB for 6 s at 720p.
 *
 * It used to be the CEILING, hard-coded, and a demo recorded through it measured 0.37 UNIQUE
 * frames per second — a slideshow in a video container. The frame rate cannot fix a still
 * screen (the capture is paint-driven, so a static form emits almost nothing), but it should
 * not be the thing standing in the way either.
 */
const RECORD_FPS = 10;

/** 5 to 30. Above 30 the encoder is padding duplicates of a page that is not repainting. */
export function clampFps(fps) {
  const n = Number(fps);
  if (!Number.isFinite(n)) return RECORD_FPS;
  return Math.min(30, Math.max(5, Math.round(n)));
}

/**
 * How many frames a video of this KIND deserves.
 *
 * Measured through the production selector on a settings form at rest, scored with
 * mpdecimate on the encoded MP4: 10 fps gives 2.60 unique fps, 30 fps gives 6.48. That 2.5x
 * is the largest lever in the capture path — considerably larger than the ~10% the cursor
 * fix buys on the same realistic beat — and a flat default of 10 was spending it on every
 * video regardless of whether a person was ever going to watch one.
 *
 * The tool already knows. `intent` was being used only to choose a playbook, and a demo and
 * a bug report genuinely want opposite things: a demo is a performance and wants smoothness;
 * a reproduction wants legibility and often runs long, where smoothness buys nothing and
 * bytes cost something real.
 *
 * The bytes are the reason this is graded rather than simply raised. Full-screen animated
 * noise — the worst case for JPEG, and what a video-heavy page approaches — reached 52.9 MB
 * in NINE SECONDS at 30 fps, past the 50 MiB share cap. A realistic UI at the same rate is
 * 88 KB for ten seconds. The spread is ~600x, so the exposure is full-motion content and not
 * an admin panel; and the size guard already refuses cleanly, keeping the file and naming it.
 */
export function defaultFpsForIntent(intent) {
  return (
    {
      demo: 30, // a performance someone will sit and watch
      'before-after': 30, // a fix working; the motion IS the evidence
      guide: 24, // teaching a task — smooth enough to follow, cheaper than a demo
      proof: 15, // showing your human what you built; glanceable
      bug: 10, // a reproduction: legible, often long, and smoothness adds nothing
    }[String(intent)] ?? RECORD_FPS
  );
}

/**
 * An explicit `fps` always wins over the intent's default — the agent may know something
 * about the page that the intent does not carry, and the argument would be a lie otherwise.
 */
export function resolveFps({ fps, intent }) {
  return fps === undefined || fps === null || fps === '' ? defaultFpsForIntent(intent) : clampFps(fps);
}

/**
 * How much unencoded video may sit in memory before frames are genuinely dropped.
 *
 * 32 MiB is roughly three seconds of 720p JPEGs. Below it, keep feeding: ffmpeg at
 * `veryfast` is far quicker than the capture, so a queue this deep means something else
 * stalled and will recover.
 */
const ENCODER_QUEUE_LIMIT_BYTES = 32 * 1024 * 1024;

/**
 * How long to wait for Chrome to answer on the pipe.
 *
 * Six seconds was enough on an idle box and nowhere near enough on a working one: measured on
 * this machine, while another session ran the E2E suite (load 7.3 on 4 cores, 1 GB free),
 * Chrome had not finished starting - and the failure named the pipe, which is not where anyone
 * should have looked. A developer machine under load is a normal state.
 */
export const BROWSER_START_TIMEOUT_MS = 45_000;

/**
 * What to say when it runs out of time. Names the load when the load is the likely cause, and
 * stays quiet about it when the machine is idle - inventing a load problem is the same mistake
 * as blaming the pipe.
 */
export function browserStartTimeoutMessage({ seconds, loadavg1, cores }) {
  const busy = loadavg1 > cores * 0.9;
  return (
    `Chrome did not finish starting within ${String(seconds)}s, so nothing was recorded. ` +
    (busy
      ? `This machine is busy — load average ${loadavg1.toFixed(1)} across ${String(cores)} cores — ` +
        'which is the usual reason. Try again when it is quieter.'
      : 'Try again; if it keeps happening, check that Chrome runs on this machine at all.')
  );
}

/** CDP over a pipe frames each message with a trailing NUL. */
export function encodeCdpMessage(msg) {
  return `${JSON.stringify(msg)}\0`;
}

/**
 * Reassemble CDP messages from a byte stream.
 *
 * A 9 KB base64 JPEG does not arrive in one chunk, and a UTF-8 character can straddle a
 * boundary — so the buffer is kept as BYTES and only decoded once a complete frame is in
 * hand. Decoding per chunk corrupts any page title with an accent in it.
 */
export function createCdpFramer() {
  let buf = Buffer.alloc(0);
  return {
    push(chunk) {
      buf = Buffer.concat([buf, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
      const out = [];
      let i;
      while ((i = buf.indexOf(0)) !== -1) {
        const raw = buf.subarray(0, i).toString('utf8');
        buf = buf.subarray(i + 1);
        try {
          out.push(JSON.parse(raw));
        } catch {
          // A frame we cannot parse is not worth ending the recording over.
        }
      }
      return out;
    },
  };
}

/**
 * How many frames to write now, so that total frames == elapsed wall clock * fps.
 *
 * `maxBurst` bounds one tick's allocation after a stall. The debt is not lost: a tick may
 * emit a whole second's worth while only one frame is due, so the count converges.
 */
export function framesToEmit({ elapsedMs, emitted, fps, maxBurst = fps }) {
  const want = Math.floor((elapsedMs / 1000) * fps);
  return Math.max(0, Math.min(want - emitted, maxBurst));
}

export function chromeLaunchArgs({ profileDir, port, width, height, headless, isRoot }) {
  return [
    // Ours.
    '--remote-debugging-pipe',
    // Theirs: the agent's own browser tool attaches here. Loopback only — an open debug
    // port on a shared or CI network is remote code execution against this machine.
    `--remote-debugging-port=${port}`,
    '--remote-debugging-address=127.0.0.1',
    // A FRESH profile, never the human's. A walkthrough recorded in their own Chrome could
    // capture a Slack, Gmail or Jira session, and the uploaded link is public.
    `--user-data-dir=${profileDir}`,
    ...(headless
      ? ['--headless=new']
      : // Headful mode exists so a HUMAN can interact — to log in, usually, on camera. A
        // window that opens unfocused on their second display is that mode failing, and it
        // did: the field human had to be told where to look while the clock ran.
        ['--window-position=0,0']),
    // As uid 0 Chrome refuses to start with its sandbox on, so this is the difference between
    // a browser and no browser - not a security choice to weigh. It is measured behaviour of
    // containerised agents (the Hermes image runs as root), and it never appears otherwise.
    ...(isRoot ? ['--no-sandbox'] : []),
    '--no-first-run',
    '--no-default-browser-check',
    '--disable-gpu',
    '--disable-extensions',
    '--hide-crash-restore-bubble',
    // One geometry for the whole recording, which is what the encoder needs.
    `--window-size=${width},${height}`,
    'about:blank',
  ];
}

export function ffmpegArgs({ fps, width, height, outPath }) {
  return [
    '-hide_banner', '-loglevel', 'error', '-y',
    '-f', 'image2pipe', '-framerate', String(fps), '-i', 'pipe:0',
    // Fixed output geometry. Probe 4 changed the viewport mid-recording; without this the
    // encode ends there with "changing frame properties is not supported".
    '-vf',
    `scale=${width}:${height}:force_original_aspect_ratio=decrease,` +
      `pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,format=yuv420p`,
    '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '26', '-g', String(fps * 2),
    // Playable before it is fully downloaded, which is what the share viewer needs.
    '-movflags', '+faststart',
    outPath,
  ];
}

const CHROME_CANDIDATES = [
  '/usr/bin/google-chrome',
  '/usr/bin/google-chrome-stable',
  '/opt/google/chrome/chrome',
  '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
  '/usr/bin/chromium',
  '/usr/bin/chromium-browser',
  '/snap/bin/chromium',
  '/Applications/Chromium.app/Contents/MacOS/Chromium',
];

/** First real browser on disk, or null. `CHROME_PATH` wins if it is set and exists. */
export function findChromeBinary(exists, envPath) {
  if (envPath && exists(envPath)) return envPath;
  for (const p of CHROME_CANDIDATES) if (exists(p)) return p;
  return null;
}

/**
 * Everything that must be true BEFORE the human's walkthrough, not after it.
 *
 * There is deliberately no fallback encoder. CLAUDE.md's rule, learned when a size floor
 * silently downgraded every ffmpeg load for 100% of users: a graceful fallback turns a
 * failure into a success signal. The only fallback available here is a WebM nobody can open
 * in Slack, and an error carrying an install line is worth more than that.
 */
export function startPreflight({ hasFfmpeg, chromePath, alreadyRecording }) {
  if (alreadyRecording) {
    return {
      ok: false,
      message:
        'I am already recording. Call yorecord_stop_and_share to finish that one first — ' +
        'starting a second would throw away what is captured so far.',
    };
  }
  if (!hasFfmpeg) {
    return {
      ok: false,
      message:
        'I need ffmpeg to turn the captured frames into an MP4, and it is not on this ' +
        'machine. `sudo apt install ffmpeg` on Debian or Ubuntu, `brew install ffmpeg` on ' +
        'macOS, then ask me again. Nothing has been recorded and nothing was uploaded.',
    };
  }
  if (!chromePath) {
    return {
      ok: false,
      message:
        'I could not find Chrome or Chromium on this machine — I looked for google-chrome, ' +
        'chromium and chromium-browser in the usual places. Set CHROME_PATH to the binary ' +
        'if it lives somewhere else. Nothing has been recorded.',
    };
  }
  return { ok: true };
}

/**
 * The cursor the capture does not contain.
 *
 * A screencast is a compositor capture, so there is no pointer in it — the same gap
 * Playwright's recordVideo has. This draws one, following the synthetic mouse events the
 * driver dispatches, which probe 3 confirmed page scripts can see.
 *
 * IT IS ALSO THE METRONOME, and that was missed for the life of the feature. The screencast
 * emits a frame when a layer commits, so its rate is the rate of the fastest-changing layer
 * on the page. On a settings form at rest, nothing else is changing — the drawn cursor IS
 * that layer, and it was moved in 14 discrete jumps per glide, so the page committed 14
 * frames per glide and none between them. That is the whole of the "0.37 unique fps"
 * indictment in the 2026-08-20 trace: a self-inflicted ceiling, read as a property of the
 * transport. Measured 2026-08-28, real page at rest, driver and beat unchanged:
 *
 *     left/top + transition:none      7.8 fps, worst freeze 1547 ms
 *     rAF-eased transform            39.2 fps, worst freeze  859 ms
 *
 * Scope it honestly: this is decisive on a page AT REST and does nothing on a page that
 * animates itself (45.0 vs 42.8 fps on a marketing page), because there the page is already
 * the fastest-changing layer. The class that failed is the class at rest.
 */
export const CURSOR_OVERLAY_SOURCE = `
(() => {
  const ID = '__yr_cursor';
  let dot = null;
  const make = () => {
    if (!document.documentElement) return null;
    const d = document.createElement('div');
    d.id = ID;
    // Hidden from the accessibility tree, so it never appears in a snapshot the agent reads.
    d.setAttribute('aria-hidden', 'true');
    // pointer-events:none is load-bearing: at this z-index an overlay that accepted events
    // would swallow every click the agent tries to make.
    // Centred by MARGIN, positioned by TRANSFORM, so moving it composites instead of
    // laying out — see the block comment above for why that is the whole point.
    d.style.cssText = 'position:fixed;left:0;top:0;margin:-8px 0 0 -8px;width:16px;height:16px;' +
      'border-radius:50%;background:rgba(255,255,255,.95);box-shadow:0 0 0 2px rgba(0,0,0,.55),0 2px 6px rgba(0,0,0,.4);' +
      'pointer-events:none;z-index:2147483647;will-change:transform;transform:translate(-100px,-100px);';
    document.documentElement.appendChild(d);
    return d;
  };
  const alive = () => {
    // Re-attach when the document is replaced under us. Probe 3: after Playwright's
    // setContent the overlay was gone, because that swaps the document without a
    // navigation, so the on-new-document script never re-runs.
    if (!dot || !dot.isConnected) dot = document.getElementById(ID) || make();
    return dot;
  };
  // Created EAGERLY, parked off screen. Creating it on the first mousemove instead left the
  // page with no cursor at all until something moved, and put a DOM insert in the middle of
  // the first glide - both of which the self-test caught.
  if (document.readyState === 'loading') addEventListener('DOMContentLoaded', () => alive(), { once: true });
  else alive();
  let tx = -100, ty = -100, cx = -100, cy = -100, placed = false;
  addEventListener('mousemove', (e) => {
    tx = e.clientX; ty = e.clientY;
    // The first event places the dot outright. Easing in from off-screen would drag it
    // across the whole page before the first action.
    if (!placed) { cx = tx; cy = ty; placed = true; }
  }, true);
  // ONE rAF chain, started once, never cancelled. It carries on committing while the dot
  // settles, which is what keeps frames arriving through the pause AFTER a glide ends —
  // and it converges, so a genuinely idle page stops painting instead of burning a core.
  (function follow() {
    const d = alive();
    if (d) {
      cx += (tx - cx) * 0.28;
      cy += (ty - cy) * 0.28;
      d.style.transform = 'translate(' + cx.toFixed(2) + 'px,' + cy.toFixed(2) + 'px)';
    }
    requestAnimationFrame(follow);
  })();
  addEventListener('mousedown', (e) => {
    const d = alive();
    if (!d) return;
    const r = document.createElement('div');
    r.setAttribute('aria-hidden', 'true');
    r.style.cssText = 'position:fixed;left:' + e.clientX + 'px;top:' + e.clientY + 'px;' +
      'width:14px;height:14px;border-radius:50%;border:2px solid rgba(255,255,255,.95);' +
      'pointer-events:none;z-index:2147483646;transform:translate(-50%,-50%) scale(1);opacity:.9;' +
      'transition:transform .45s ease-out,opacity .45s ease-out;';
    document.documentElement.appendChild(r);
    requestAnimationFrame(() => {
      r.style.transform = 'translate(-50%,-50%) scale(4)';
      r.style.opacity = '0';
    });
    setTimeout(() => r.remove(), 600);
  }, true);
})();`;

/**
 * An element's label, WITHOUT reading a secret out of it.
 *
 * Injected into both page-side scripts. The outline used `el.value` directly, so a filled
 * password field was reported as its own contents - and this tool asks the human to log in on
 * camera, because the recording browser holds none of their sessions. Their password then
 * travelled into the agent's transcript, and into the steps sidecar, which is uploaded beside
 * a public video.
 *
 * The type alone is not enough: a "reveal password" toggle flips type to text, and one-time
 * codes and API tokens are routinely type=text or type=tel.
 */
export const SAFE_LABEL_SOURCE = `
  window.__yrSafeLabel = (el) => {
    const attr = (n) => el.getAttribute(n) || '';
    const looksSecret =
      el.type === 'password' ||
      /current-password|new-password|one-time-code|cc-number|cc-csc/i.test(attr('autocomplete')) ||
      /pass|secret|token|otp|pin|cvc|cvv|card|auth/i.test(attr('name') + ' ' + attr('id') + ' ' + attr('data-testid'));
    const spoken = attr('aria-label') || attr('placeholder') || attr('title');
    if (looksSecret) {
      // Say the field is there and whether it is filled, never what is in it. That is what an
      // agent needs in order to type into it; the contents are not.
      return (spoken || 'password') + (el.value ? ' (hidden, filled)' : ' (hidden, empty)');
    }
    const value = typeof el.value === 'string' ? el.value : '';
    return (attr('aria-label') || value || el.innerText || attr('placeholder') || attr('title') || '')
      .replace(/\\s+/g, ' ')
      .trim();
  };
`;

/**
 * What is on the page, for an agent with no browser tool of its own.
 *
 * Not an accessibility tree: a short list of the things a person could click or type into,
 * each with a selector this file can act on. Bounded, because a long page would otherwise
 * flood the agent's context with markup instead of leaving room for its own reasoning.
 */
export const PAGE_OUTLINE_SOURCE = `
(() => {
  ${SAFE_LABEL_SOURCE}
  // Every role a real design system builds a control out of, not just the native tags. MUI,
  // Radix, Headless UI, Ant Design and shadcn/ui all render a dropdown as div[role="combobox"],
  // and its absence here made the Rossum Role field invisible while its own OPTIONS were
  // listed. The aria-haspopup pair catches the older MUI shape, which predates the role.
  const SEL = 'a[href],button,input,textarea,select,summary,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[role="option"],[role="combobox"],[role="listbox"],[role="switch"],[role="checkbox"],[role="radio"],[role="menuitemcheckbox"],[role="menuitemradio"],[role="spinbutton"],[role="slider"],[aria-haspopup="listbox"],[aria-haspopup="menu"],[contenteditable="true"],[onclick]';
  const seen = [];
  // Refs are HANDLES, not positions. They used to be a counter reassigned from zero on every
  // outline, so anything appearing or vanishing above an element renumbered it: a toast
  // dismissed itself between two calls and every ref below it shifted by one, so a click on
  // @12 meaning "Users" would have landed on a different link and said nothing. Whatever
  // number an element already carries is the number it keeps; only new elements are given
  // one, counting on from the highest handed out so far.
  let nextRef = 0;
  for (const tagged of document.querySelectorAll('[data-yr-ref]')) {
    const n = Number(tagged.getAttribute('data-yr-ref'));
    if (n > nextRef) nextRef = n;
  }
  for (const el of document.querySelectorAll(SEL)) {
    if (el.id === '__yr_cursor') continue;
    const r = el.getBoundingClientRect();
    // Only what the human can actually see: an agent clicking a hidden element produces a
    // recording in which nothing happens.
    if (r.width < 2 || r.height < 2) continue;
    if (r.bottom < 0 || r.top > innerHeight * 3) continue;
    const style = getComputedStyle(el);
    if (style.visibility === 'hidden' || style.display === 'none' || Number(style.opacity) === 0) continue;
    let label = window.__yrSafeLabel(el).slice(0, 70);
    if (!label) {
      // An icon button with no accessible name used to be SKIPPED, which made the gear that
      // opens queue settings invisible - and four selector guesses followed, all misses. The
      // agent can see it in the picture, so give it something to aim at: whatever the page
      // offers, plus the fact that it is an icon.
      const hint =
        el.getAttribute('data-testid') ||
        el.getAttribute('data-cy') ||
        el.querySelector('[data-testid]')?.getAttribute('data-testid') ||
        el.querySelector('svg')?.getAttribute('data-testid') ||
        (el.className?.toString?.() || '').split(/\\s+/).find((c) => /icon|btn|button/i.test(c)) ||
        '';
      const isIconish = el.querySelector('svg,img,i') || /INPUT|TEXTAREA|SELECT/.test(el.tagName);
      if (!isIconish) continue;
      label = hint ? '(icon: ' + String(hint).slice(0, 40) + ')' : '(icon, no label)';
    }
    // A stable handle. Repeating a label back to us is a guess: on a real application labels
    // repeat, and they truncate.
    let ref = Number(el.getAttribute('data-yr-ref'));
    if (!ref) {
      nextRef += 1;
      ref = nextRef;
      el.setAttribute('data-yr-ref', String(ref));
    }
    seen.push({
      ref,
      tag: el.tagName.toLowerCase(),
      type: el.getAttribute('type') || undefined,
      label,
      inView: r.top >= 0 && r.bottom <= innerHeight,
    });
  }
  const shown = seen.slice(0, 150);
  return JSON.stringify({
    url: location.href.slice(0, 300),
    title: document.title.slice(0, 200),
    scrollY: Math.round(scrollY),
    scrollHeight: Math.round(document.documentElement.scrollHeight),
    elements: shown,
    notShown: seen.length - shown.length,
  });
})();`;

/**
 * Ten seconds per step. Longer waits are legitimate, but they should be explicit calls.
 */
export const MAX_WAIT_MS = 10_000;

/**
 * How many actions may go in one batch, whether that batch is a browse call or a script step.
 *
 * One number, because they are the same idea. The browse cap exists so the agent sees what
 * happened before choosing the next batch; the step cap exists so a failing step is small
 * enough to read. Two literals for one ceiling is how they drift.
 */
export const MAX_ACTIONS_PER_CALL = 20;

/**
 * How many times the recorded cap a session may occupy in wall-clock time.
 *
 * Only reachable because the capture clock now pauses. It bounds an ABANDONED recording, not
 * a slow one: three times the cap is roughly six minutes of video plus twenty-four minutes of
 * thinking, which no real walkthrough spends.
 */
export const WALL_CLOCK_BACKSTOP = 3;

/**
 * How long the page must go without painting before the clock stops between calls.
 *
 * The pause cannot simply begin when a browse call returns. A page that renders late — a
 * fetch resolving, a lazy image, our own click ripple finishing — would have that paint
 * dropped, and an agent driving the browser over CDP itself (which the start reply invites)
 * would be filmed as a frozen screen for as long as it worked.
 *
 * So a paint RESUMES the clock and quiet re-stops it. The screencast is paint-driven, so an
 * idle page delivers nothing at all: the arrival of a frame is evidence, not noise. The cost
 * is this much recorded quiet per boundary rather than the measured ~17s.
 */
export const IDLE_PAUSE_MS = 1200;

/**
 * What to say when a click opened a tab we are not recording.
 *
 * A link that opens a new tab reported SUCCESS while the page never changed, because the
 * screencast is attached to one target. Silence there cost ten minutes of a session.
 */
export const NEW_TAB_HINT =
  'that opened a NEW TAB, which is not being recorded — I am still recording the first one. ' +
  'Go to the same place in this tab instead, or ask your human whether the new tab is what the ' +
  'video should show.';

/**
 * A wait longer than the cap is shortened; say so.
 *
 * `{"wait":20000}` was quietly served as 10000, and the agent planned around a wait that never
 * happened. Silent truncation is the same defect class as a click that reports success.
 */
export function waitNotice(requestedMs) {
  return requestedMs > MAX_WAIT_MS
    ? ` (you asked for ${String(requestedMs)}ms; I wait at most ${String(MAX_WAIT_MS)}ms per step, so call me again to wait longer)`
    : '';
}

/**
 * Labelled timestamps for the sidecar, in the shape the WORKER requires: a root array of
 * `{t, label, status}`.
 *
 * It used to be `{steps:[{t,label,ok}]}`, which the worker rejected with 422 every single
 * time - and the client never looked at the response, so a recording reported success with
 * its sidecar thrown away. Both sides had passing tests; neither crossed the boundary.
 * scripts/yorecord/stepsBoundary.test.mjs now does.
 *
 * Labels are page-derived text, so they are trimmed to the worker's 120-character limit and
 * stripped of control characters HERE: the worker rejects the whole sidecar over one bad
 * label, which would lose every step of a walkthrough to a single long link.
 */
export function stepsSidecar(actions) {
  return actions.map((a) => ({
    t: Math.round(a.atMs / 100) / 10,
    label: String(a.label)
      // eslint-disable-next-line no-control-regex
      .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ')
      .trim()
      .slice(0, 120),
    status: a.ok ? 'pass' : 'fail',
  }));
}

/**
 * Zooms derived from where the clicks actually landed.
 *
 * The coordinates were always computed — `found.x`/`found.y`, to aim the glide — and always
 * thrown away, because `record()` kept only `{label, atMs, ok}`. So the one signal that
 * cannot be recovered from pixels was discarded at the only point it existed.
 * Docs/plans/2026-04-18-zoom-segments-design.md:27 notes the schema shipped manual-first
 * "designed so click-capture auto-zoom layers on without a data-model change".
 *
 * Output is the editor's own `ZoomSegment` (lib/editDocument/types.ts): source-time seconds,
 * x/y normalised 0..1 of the frame, scale 1..3. The defaults mirror the shipped constants —
 * ZOOM_DEFAULT_SCALE 1.8, ZOOM_MIN_DURATION 1.0 — which cannot be imported here, because
 * this file is a zero-dependency .mjs served to agents and that module is TypeScript.
 *
 * A second click inside a running zoom EXTENDS it rather than queueing another. Queueing
 * makes the camera arrive after the thing it was meant to show, and the 2026-08-20 trace's
 * own prescription was "one cut, not eight".
 */
export function zoomSegmentsFromActions(actions, opts = {}) {
  const leadSec = (opts.leadMs ?? 500) / 1000;
  const holdSec = (opts.holdMs ?? 2000) / 1000;
  const scale = opts.scale ?? 1.8;
  const minDuration = 1.0;
  const out = [];
  for (const a of actions ?? []) {
    // A failed click has coordinates and did nothing. Zooming on it points the camera at
    // the one place where nothing happens.
    if (!a || a.ok !== true) continue;
    const vw = Number(a.viewport?.width);
    const vh = Number(a.viewport?.height);
    if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !(vw > 0) || !(vh > 0)) continue;
    const at = Number(a.atMs ?? 0) / 1000;
    const prev = out[out.length - 1];
    if (prev && at - leadSec < prev.endSource) {
      prev.endSource = Number(Math.max(prev.endSource, at + holdSec).toFixed(3));
      continue;
    }
    const startSource = Math.max(0, at - leadSec);
    out.push({
      id: `auto-click-${out.length + 1}`,
      startSource: Number(startSource.toFixed(3)),
      endSource: Number(Math.max(startSource + minDuration, at + holdSec).toFixed(3)),
      x: Math.min(1, Math.max(0, a.x / vw)),
      y: Math.min(1, Math.max(0, a.y / vh)),
      scale,
    });
  }
  return out;
}

/**
 * Where Chrome's `--user-data-dir` goes.
 *
 * The default is a throwaway, and that is a SECURITY property rather than an implementation
 * detail: a walkthrough recorded in the human's own Chrome could contain their logged-in
 * mail, and this tool uploads what it is given. That default does not change.
 *
 * A NAMED profile is the opt-in, and it exists because both traces record authentication as
 * a PER-TAKE cost — "each discarded take costs one manual login because the fresh Chrome
 * keeps no session", three logins in one session. That is what forces a dry run to itself be
 * a recording, and it is why both agents recorded their own groping instead of rehearsing
 * first and then performing once. Measured 2026-08-28: cookies and localStorage survive a
 * full Chrome restart in the same directory; a fresh profile starts empty.
 *
 * It is still never the human's own profile. It is one this tool owns, one per app.
 */
export function profileDirFor({ profile, tmpDir, configDir }) {
  if (profile === undefined || profile === null || profile === '') return { dir: tmpDir, persistent: false };
  const name = String(profile);
  // The name arrives in a tool call and becomes a path, so it is agent-supplied input on a
  // filesystem argument. A traversal would aim --user-data-dir at an arbitrary directory.
  if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name) || name.includes('..')) {
    throw new Error(
      `profile "${name.slice(0, 40)}" is not a usable name — use letters, digits, dot, dash or underscore, ` +
        'starting with a letter or digit.',
    );
  }
  return { dir: join(configDir, 'profiles', name), persistent: true };
}

/**
 * Where the recorder may navigate.
 *
 * `stop_and_share` PUBLISHES what is on screen, which turns navigation into a publication
 * route: an agent talked into `{"goto":"file:///home/you/.config/yorecord/token"}` by a page
 * it was visiting would upload that file to a public URL. `chrome://` and `devtools://`
 * expose the browser's own internals for the same price.
 */
/**
 * The session cookies a named profile would otherwise lose, and nothing else.
 *
 * Chrome keeps a cookie with no Expires in memory and drops it when the browser exits, which
 * is correct behaviour and exactly what a named profile is supposed to defeat. Persistent
 * cookies are left alone: the profile already keeps them, and restoring a saved copy would
 * overwrite whatever the site had since updated.
 */
export function sessionCookiesToCarry(cookies) {
  return (cookies ?? [])
    .filter((c) => c && c.session === true && c.name)
    .map((c) => ({
      name: c.name,
      value: c.value ?? '',
      domain: c.domain,
      path: c.path || '/',
      ...(c.secure ? { secure: true } : {}),
      ...(c.httpOnly ? { httpOnly: true } : {}),
      ...(c.sameSite ? { sameSite: c.sameSite } : {}),
    }));
}

/**
 * Where the profile stands, said out loud at START rather than discovered a round trip later.
 *
 * The field failure was silent: the tool opened a logged-out browser and said nothing, so the
 * agent found the login wall on camera, in the take it had already started.
 */
export function profileNotice({ name, existed, restored }) {
  if (!name) return '';
  if (!existed) {
    return `Profile "${name}" is new, so nothing is logged in yet — your human will have to sign in on camera this take. Discard it and the next take reuses the session.`;
  }
  if (restored > 0) {
    return `Profile "${name}" reused, carrying ${String(restored)} session cookie(s) from the last take in this run, plus whatever it had saved to disk.`;
  }
  return `Profile "${name}" reused, but no session cookies were carried over — if this app signs you out when the browser closes, your human may still have to log in. Check the first screenshot before you go further.`;
}

/**
 * Retry a CDP call that timed out, and only one that timed out.
 *
 * The 4000ms deadline on input dispatch is deliberate — a 15s default cost a whole action in
 * the field — but until now nothing stood behind it, so a renderer busy for one moment failed
 * the action and made the agent spend a round trip (26s and 35s of recording, twice in one
 * take) discovering what a retry fixes.
 *
 * A timeout is the only error worth repeating. "No node with given id found" will not fix
 * itself, and three deadlines spent reaching the same answer are three deadlines of video.
 */
export async function retryOnTimeout(fn, { attempts = 3, sleep, waitMs = 600 } = {}) {
  let last;
  for (let attempt = 1; attempt <= attempts; attempt++) {
    try {
      return await fn(attempt);
    } catch (e) {
      last = e;
      if (!/timed out/i.test(String(e?.message ?? e))) throw e;
      if (attempt < attempts) await sleep(waitMs * attempt);
    }
  }
  throw last;
}

/** How much of any one string a trace line keeps. Enough to identify it, not to reproduce it. */
export const TRACE_MAX_STRING = 300;

/**
 * What a trace line may carry.
 *
 * The action log is redacted at source — see SAFE_LABEL_SOURCE — and a firehose of raw CDP
 * would put every bit of that back. This tool asks a human to log in on camera and types on
 * their behalf, so three things never reach the file: what was typed, cookie values, and
 * screencast frames, which are 4500 a take, ~100 MB, and a picture of whatever was on screen.
 */
export function redactForTrace(method, params) {
  if (!params || typeof params !== 'object') return params;
  if (/cookies$/i.test(method)) {
    const n = Array.isArray(params.cookies) ? params.cookies.length : 0;
    return { cookies: `${String(n)} cookie(s), values not traced` };
  }
  if (method === 'Input.insertText') return { textLength: String(params.text ?? '').length };
  if (method === 'Page.screencastFrame') {
    return { dataBytes: String(params.data ?? '').length, sessionId: params.sessionId, metadata: params.metadata };
  }
  const shorten = (v) => {
    if (typeof v === 'string' && v.length > TRACE_MAX_STRING) {
      return `${v.slice(0, TRACE_MAX_STRING)}… (${String(v.length)} chars, truncated)`;
    }
    if (Array.isArray(v)) return v.slice(0, 20).map(shorten);
    if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, shorten(x)]));
    return v;
  };
  return shorten(params);
}

/**
 * One event, on one line.
 *
 * BOTH clocks, always. Wall time says how long the agent took and video time says what
 * reached the file; the gap between them is the whole subject of the field report, and one
 * clock cannot express it. One JSON object per line so a recording killed mid-take still
 * parses up to the break.
 */
export function traceEvent({ kind, wallMs, videoMs, data }) {
  // Coerced, because a clock can legitimately not be running yet: the trace opens before
  // rec.t0 is assigned, so the earliest lines have no video clock at all. NaN serialises to
  // null, which reads fine and makes every later sum over the file NaN.
  const n = (x) => (Number.isFinite(x) ? Math.round(x) : 0);
  return { k: kind, t: n(wallMs), v: n(videoMs), ...(data ?? {}) };
}

/**
 * Why the video is shorter than the session.
 *
 * The first question anyone asks now that the clock pauses, and without an answer in the
 * reply itself the honest fix reads exactly like a bug.
 */
export function captureSummary({ recordedMs, wallMs, pauses, pausedMs }) {
  const s = (ms) => Math.round(ms / 1000);
  if (!pauses) return `${String(s(recordedMs))}s of video.`;
  return (
    `${String(s(recordedMs))}s of video out of ${String(s(wallMs))}s at the browser — I stopped the ` +
    `clock ${String(pauses)} time(s), ${String(s(pausedMs))}s in total, while nothing was happening on screen.`
  );
}

/**
 * Where a recording's trace lives, and how it is written.
 *
 * ALWAYS on. The value of a trace is entirely in having it for the take you did not expect to
 * fail — a flag gets turned on one take too late, which is how the 2026-08-31 report came to
 * be assembled by hand from reply text.
 *
 * Deliberately NOT beside the video: outDir is deleted on a successful upload and on a
 * discard with nothing worth keeping, and those are two of the cases most worth reading
 * afterwards. It also outlives the profile, which is deleted outright.
 *
 * A stream rather than sync appends: this is written at CDP-message frequency, and a
 * synchronous write per message would pace the recorder against the disk. The cost is that a
 * hard crash loses the last few lines.
 */
export function openTrace({ dir, id, now, t0, recFor }) {
  let stream = null;
  try {
    mkdirSync(dir, { recursive: true, mode: 0o700 });
    stream = createWriteStream(join(dir, `${id}.jsonl`), { flags: 'a', mode: 0o600 });
    // A trace must never be the reason a recording ends. Losing it is a bad afternoon;
    // throwing here would lose a walkthrough that cannot be repeated.
    stream.on('error', () => {
      stream = null;
    });
  } catch {
    stream = null;
  }
  return {
    path: stream ? join(dir, `${id}.jsonl`) : null,
    write(kind, data) {
      if (!stream) return;
      const wallMs = now() - t0;
      const rec = recFor();
      try {
        stream.write(`${JSON.stringify(traceEvent({ kind, wallMs, videoMs: rec ? recordedElapsedMs(rec, now()) : wallMs, data }))}\n`);
      } catch {
        /* a trace line is never worth an exception */
      }
    },
    close() {
      try {
        stream?.end();
      } catch {
        /* already gone */
      }
      stream = null;
    },
  };
}

/**
 * A trace, read back into the table that made the field report legible.
 *
 * One row per browse call: how long the agent spent, how much of that reached the video, and
 * the gap between two calls with the part that was still filmed named separately. That last
 * column is the one to watch — it is the capture gate's own report card, and a number that
 * creeps up means the page is painting between calls when nobody thinks it is.
 *
 * Tolerant of a truncated file. A killed recording leaves no stop line, and that is exactly
 * when someone wants to read it.
 */
export function summariseTrace(events) {
  const calls = [];
  const failures = [];
  let open = null;
  let pausedInGap = 0;
  let last = null;

  for (const e of events ?? []) {
    if (!e || typeof e.k !== 'string') continue;
    last = e;
    if (e.k === 'browse.in') {
      const prev = calls[calls.length - 1];
      const thinkMs = prev ? e.t - prev.endWallMs : null;
      open = {
        call: calls.length + 1,
        actions: Array.isArray(e.actions) ? e.actions.length : 0,
        startWallMs: e.t,
        startVideoMs: e.v,
        ...(thinkMs === null ? {} : { thinkMs, filmedWhileThinkingMs: Math.max(0, thinkMs - pausedInGap) }),
      };
      pausedInGap = 0;
    } else if (e.k === 'resume') {
      pausedInGap += e.pausedForMs ?? 0;
    } else if (e.k === 'action' && e.ok === false) {
      failures.push({ call: open?.call ?? calls.length + 1, label: e.label, detail: e.detail, videoMs: e.v });
    } else if (e.k === 'browse.out' && open) {
      calls.push({
        ...open,
        endWallMs: e.t,
        wallMs: e.t - open.startWallMs,
        videoMs: e.v - open.startVideoMs,
        elements: e.elements ?? null,
        captured: e.captured ?? null,
        emitted: e.emitted ?? null,
      });
      open = null;
    }
  }

  const stop = [...(events ?? [])].reverse().find((e) => e?.k === 'stop');
  const wallMs = stop?.t ?? last?.t ?? 0;
  const videoMs = stop?.recordedMs ?? last?.v ?? 0;
  return {
    calls,
    failures,
    totals: {
      wallMs,
      videoMs,
      pauses: stop?.pauses ?? calls.length,
      pausedMs: stop?.pausedMs ?? 0,
      deadAirRemovedPct: wallMs > 0 ? Math.round((1 - videoMs / wallMs) * 100) : 0,
      truncated: !stop,
    },
  };
}

/** The same summary, as the table a person reads. */
export function formatTraceSummary(s) {
  const ms = (n) => (n === null || n === undefined ? '—' : `${(n / 1000).toFixed(1)}s`);
  const rows = s.calls.map(
    (c) =>
      `  ${String(c.call).padStart(3)}  ${String(c.actions).padStart(4)}  ${ms(c.wallMs).padStart(8)}  ` +
      `${ms(c.videoMs).padStart(8)}  ${ms(c.thinkMs).padStart(8)}  ${ms(c.filmedWhileThinkingMs).padStart(9)}  ` +
      `${String(c.elements ?? '—').padStart(5)}`,
  );
  return [
    `call  acts  wall      video     think     filmed     refs`,
    ...rows,
    '',
    `${ms(s.totals.videoMs)} of video from ${ms(s.totals.wallMs)} at the browser ` +
      `(${String(s.totals.deadAirRemovedPct)}% not filmed, ${String(s.totals.pauses)} pauses totalling ${ms(s.totals.pausedMs)})` +
      `${s.totals.truncated ? '  — TRUNCATED, no stop line' : ''}`,
    ...(s.failures.length
      ? ['', 'failed actions:', ...s.failures.map((f) => `  call ${String(f.call)} @${ms(f.videoMs)}  ${f.label} — ${f.detail ?? ''}`)]
      : []),
  ].join('\n');
}

/**
 * How many steps a shot list may hold.
 *
 * Matched to the worker's own `MAX_STEPS` for the steps sidecar, because every step becomes
 * a sidecar label and an `edit.json` caption, and a bound that disagrees across that boundary
 * is a 422 discovered at upload time rather than at authoring time.
 */
export const MAX_SCRIPT_STEPS = 200;

/** A step with no `hold` still has to breathe. The default is the opinion. */
export const DEFAULT_HOLD_MS = 1200;

/**
 * Read a shot list, or say exactly which step is wrong.
 *
 * This is the artefact field report #2(a) asked for: somewhere a rehearsal can put the action
 * sequence it discovered, so the recorded take replays it instead of re-deriving it at model
 * latency with the clock running.
 *
 * Every rejection names the step. A fifteen-step script refused as "invalid script" is a
 * bisect the agent pays for in round trips.
 */
export function parseScript(value) {
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
    throw new Error('a script is an object with a steps array, for example {"steps":[{"say":"…","actions":[{"goto":"https://…"}]}]}');
  }
  const steps = value.steps;
  if (!Array.isArray(steps) || steps.length === 0) {
    throw new Error('a script is an object with a steps array, and it needs at least one step');
  }
  if (steps.length > MAX_SCRIPT_STEPS) {
    throw new Error(`at most ${String(MAX_SCRIPT_STEPS)} steps in one script; this one has ${String(steps.length)}`);
  }
  return {
    ...(value.title ? { title: String(value.title).slice(0, 120) } : {}),
    steps: steps.map((step, i) => {
      const at = `step ${String(i + 1)}`;
      if (!step || typeof step !== 'object') throw new Error(`${at} is not an object`);
      if (!Array.isArray(step.actions) || step.actions.length === 0) {
        throw new Error(`${at} has no actions — every step needs at least one, for example {"click":"Save"}`);
      }
      if (step.actions.length > MAX_ACTIONS_PER_CALL) {
        throw new Error(`${at} has ${String(step.actions.length)} actions; ${String(MAX_ACTIONS_PER_CALL)} is the most in one step`);
      }
      return {
        ...(step.say ? { say: String(step.say).slice(0, 120) } : {}),
        ...(step.chapter ? { chapter: String(step.chapter).slice(0, 120) } : {}),
        actions: step.actions,
        // Clamped to the same ceiling as a {"wait"} inside a browse call. A hold is the same
        // thing by another name, and two different ceilings for one idea is a trap.
        hold: Math.min(MAX_WAIT_MS, Math.max(0, Number(step.hold ?? DEFAULT_HOLD_MS) || 0)),
      };
    }),
  };
}

/**
 * Perform a whole shot list, with no model in the loop.
 *
 * Sequencing only — `runOne` performs, the clock is injected — so the rule that matters here
 * is testable without a browser: STOP AT THE FIRST FAILURE. `yorecord_browse` deliberately
 * continues, because the agent reads the whole batch and decides what to do. A recorded take
 * is the other case: in the 2026-09-02 re-run one call lost four of its eleven actions to
 * targets that no longer matched and the recorder kept rolling on a take that was already
 * ruined. An exploration continues; a performance stops.
 *
 * `ran` holds COMPLETED steps only, with their spans on the recorded clock, which is what
 * captionsFromSteps and chaptersFromSteps consume. A failed take is going to be re-run, so
 * its partial spans describe a video that will not exist.
 */
export async function runScriptSteps(steps, { runOne, sleep, elapsedMs }) {
  const ran = [];
  for (let i = 0; i < steps.length; i++) {
    const step = steps[i];
    const startMs = elapsedMs();
    for (const action of step.actions) {
      if (!(await runOne(action))) return { ran, failedStep: i + 1, failedAction: action };
    }
    // The hold is inside the step's span on purpose: the sentence stays on screen while the
    // viewer looks at what just happened, which is the whole reason the hold exists.
    if (step.hold) await sleep(step.hold);
    ran.push({
      ...(step.say ? { say: step.say } : {}),
      ...(step.chapter ? { chapter: step.chapter } : {}),
      startMs,
      endMs: elapsedMs(),
    });
  }
  return { ran, failedStep: null };
}

/** Milliseconds as SRT time. `SubtitleEntry` carries strings, not numbers. */
export function msToSrtTime(ms) {
  const t = Math.max(0, Math.round(ms));
  const pad = (n, w) => String(n).padStart(w, '0');
  return (
    `${pad(Math.floor(t / 3_600_000), 2)}:${pad(Math.floor(t / 60_000) % 60, 2)}:` +
    `${pad(Math.floor(t / 1000) % 60, 2)},${pad(t % 1000, 3)}`
  );
}

/**
 * The captions a replayed script produces.
 *
 * The recorder captures no audio at all, so nothing is transcribed and Whisper is never
 * involved. The caption is the step's own sentence, held for exactly as long as the step ran
 * — and because the tool owns both the wall clock and the recorded clock, that span is exact
 * rather than estimated. Tutorial captions are signposts, not dialogue.
 */
export function captionsFromSteps(steps) {
  const out = [];
  for (const s of steps ?? []) {
    if (!s?.say) continue;
    out.push({
      id: out.length + 1,
      startTime: msToSrtTime(s.startMs),
      endTime: msToSrtTime(s.endMs),
      text: String(s.say),
    });
  }
  return out;
}

/**
 * Chapters, only where the script asked for one.
 *
 * Never one per step. An eleven-step walkthrough with eleven chapters has no structure; it
 * has a second copy of the caption track in a narrower column.
 */
export function chaptersFromSteps(steps) {
  const out = [];
  for (const s of steps ?? []) {
    if (!s?.chapter) continue;
    out.push({ id: `auto-chapter-${String(out.length + 1)}`, start: Math.round(s.startMs / 1000), title: String(s.chapter) });
  }
  return out;
}

/**
 * The edit document a recording hands to the editor.
 *
 * Shaped like a `LibraryEntry`, not like an `EditDocument`, because that is what the editor
 * actually hydrates from — it reads the entry field by field into the store. So the carrier
 * for "an already-edited video" is this plus the MP4, written into IndexedDB the way
 * /dev/seed already demonstrates. Nothing in the editor changes.
 *
 * Everything here comes from data the recording already holds. `zoomSegmentsFromActions` has
 * been exported and tested since 2026-08-28 with no caller at all; this is its caller.
 */
export function editDocumentSidecar(rec, { recordedMs } = {}) {
  const ran = rec?.scriptRun?.ran ?? [];
  return {
    version: 1,
    ...(rec?.scriptRun?.title ? { title: String(rec.scriptRun.title).slice(0, 120) } : {}),
    durationSec: Number(((recordedMs ?? 0) / 1000).toFixed(3)),
    width: rec?.width ?? 1280,
    height: rec?.height ?? 720,
    zoomSegments: zoomSegmentsFromActions(rec?.actions),
    // Empty for a take driven by yorecord_browse rather than a script, and that is not an
    // error: browse stays a first-class way to record. It simply has no sentences to show.
    captions: captionsFromSteps(ran),
    chapters: chaptersFromSteps(ran),
    // The editor's own defaults, stated rather than left to chance, because a caption the
    // human cannot read is the same as no caption.
    subtitleStyle: 'bold-white',
    subtitlePosition: 'bottom',
    subtitleBackground: 'full',
  };
}

export function isAllowedNavigationUrl(url) {
  if (typeof url !== 'string' || url.length === 0) return false;
  if (url.trim().toLowerCase() === 'about:blank') return true;
  let parsed;
  try {
    parsed = new URL(url);
  } catch {
    return false;
  }
  return parsed.protocol === 'http:' || parsed.protocol === 'https:';
}

/**
 * Frames genuinely lost, computed ONCE at the end.
 *
 * The previous counter incremented inside the emit loop without advancing `emitted`, so the
 * same frames were counted again on every stalled tick and then written anyway when the queue
 * drained - over-reporting deferred frames while never reporting the only real loss, which is
 * whatever shortfall is left when recording stops.
 */
export function droppedFrames({ elapsedMs, emitted, fps }) {
  return Math.max(0, Math.floor((elapsedMs / 1000) * fps) - emitted);
}

/**
 * How much VIDEO exists, which is not how long the tool has been running.
 *
 * A browse call returns, and then nothing happens on screen for as long as the model takes to
 * read a 4KB outline and a 1280x720 screenshot and write the next call. Measured over a real
 * 14-step walkthrough: ~17s per boundary, 271s of a 396s recording — 68% of the video was a
 * frozen page. The worst single one is the tail, 38s of motionless screen welded to the end
 * because the agent had to think about whether to upload.
 *
 * So the capture clock stops between calls. Everything that describes the video reads this
 * and not the wall clock: the FCR pacer (freeze it and it asks for no frames, with no second
 * flag to keep in sync), the cap, the "Recording for Ns" line, droppedFrames — which would
 * otherwise invent fps frames of loss for every paused second — and the atMs each action is
 * stamped with, which is what the steps sidecar and the zoom segments point at.
 */
export function recordedElapsedMs(rec, nowMs) {
  const paused = (rec.pausedMs ?? 0) + (rec.pausedAt ? nowMs - rec.pausedAt : 0);
  return Math.max(0, nowMs - rec.t0 - paused);
}

/** Stop the capture clock. Idempotent: a second call must not restart the span. */
export function pauseCapture(rec, nowMs) {
  if (rec.pauseBetweenCalls === false || rec.pausedAt) return;
  rec.pausedAt = nowMs;
  rec.pauseCount = (rec.pauseCount ?? 0) + 1;
  rec.trace?.write('pause', {});
}

/**
 * Between calls, follow the page: record while it paints, stop when it goes quiet.
 *
 * Only between calls. Inside one, a still screen is usually deliberate — a guide holding a
 * form for two seconds so the viewer can read it — and clipping that would break the one
 * thing the pacing advice exists for.
 */
export function idleGate(rec, nowMs, quietMs = IDLE_PAUSE_MS) {
  if (!rec.betweenCalls) return;
  if (nowMs - (rec.lastFrameAt ?? nowMs) >= quietMs) pauseCapture(rec, nowMs);
  else resumeCapture(rec, nowMs);
}

/** Start it again, banking whatever the pause cost. */
export function resumeCapture(rec, nowMs) {
  if (!rec.pausedAt) return;
  const forMs = nowMs - rec.pausedAt;
  rec.pausedMs = (rec.pausedMs ?? 0) + forMs;
  rec.pausedAt = null;
  rec.trace?.write('resume', { pausedForMs: Math.round(forMs) });
}

/**
 * How to record well, per intent.
 *
 * These are procedure, not mechanics, and procedure is what the field evidence showed going
 * wrong. An agent asked for a one-minute product demo produced three unrehearsed 600-second
 * takes and cut eight fragments out of the third at 2x — measured afterwards at 0.37 UNIQUE
 * frames per second. Its own post-mortem: "The order was wrong."
 *
 * Note what makes the demo advice possible at all: `discard`. Before that existed, every
 * exploratory take cost a public link, so "rehearse first" was not advice anybody could
 * follow — and the agent that needed it invented a workaround instead, publishing a black
 * clip three times to escape the tool's only exit.
 */
const UNIVERSAL = `Always:
- Drive with yorecord_browse, not with raw CDP or your own script. The pointer in the video is
  drawn by us and it only follows OUR actions — drive it yourself and things happen on screen
  with no visible cause, which reads as broken. (Measured: 6 of 8 segments of one demo had no
  pointer at all for exactly this reason.)
- Click by ref (@7) from the list in the browse reply. A label is a guess; refs are exact.
- Look at the picture in every browse reply. Never use a screenshot or screen-capture tool:
  this browser is headless and invisible, so you would photograph your human's own desktop.
- The browser has NONE of your human's logins. Anything gated needs them to sign in while
  recording — so think before you record, and before you upload, whether that is acceptable.
- Look at the whole frame before you upload, not just the thing you are demonstrating. A
  sidebar full of customer names, a notification, an open document — the link is public, and
  the video shows everything that was on screen.
- There is no audio. Nothing you say is in the video.
- 10 minutes maximum, then it stops itself. Ask before you upload; the link is public.`;

export const PLAYBOOKS = {
  demo: `INTENT: a demo someone will watch. Treat it as a performance.

0. ASK FIRST, before you start recording: the exact URL of the screen you need, and the name
   of the thing you will demonstrate. Reconnaissance inside a recording is expensive - the
   browser holds none of your human's logins, so every take costs them a sign-in - and they
   usually know the answer. Guessing routes under a running clock is what sinks these.
1. Rehearse, and throw the rehearsal away. Record, explore, find every ref and selector, then
   stop with yorecord_stop_and_share({discard:true}). Nothing is published, and there is no
   time pressure on a take you are going to discard - restart it as often as you like.
2. Write the action list down before the real take — every click as a ref or selector, in
   order, with nothing left to discover.
3. Perform it ONCE, straight through. Any hesitation in the take is in the video.
4. Hover the target before you click it, and let the pointer travel. The drawn pointer eases
   to where you send it rather than jumping, so the travel is real motion in the video — and
   the pacer holds the last frame, so a still page still fills its frame rate. You do not
   have to invent movement.
5. Do NOT speed the result up afterwards. 2x on a sparse capture halves an already-thin frame
   density and fixes nothing. Cut once if you must remove a long wait; never eight times.
6. If the result still judders, that is the ceiling of a screencast of a static page. For
   something customer-facing, hand your human the verified action list and let them record it.

${UNIVERSAL}`,

  proof: `INTENT: show your human what you built or changed. Evidence, not cinema.

1. Do not rehearse. Record the thing once, straight through, and stop.
2. Keep it short — under a minute. Nobody watches proof twice.
3. Go where the change is visible and demonstrate it. Skip the tour.
4. Polish is wasted here. A slightly clumsy 40-second clip that shows the feature working is
   worth more than a rehearsed two-minute one.
5. Say in your reply what the video shows, so they can decide not to watch it.

${UNIVERSAL}`,

  guide: `INTENT: teach your human how to do something. They will follow along.

1. ONE action per beat, and pause after each — but pace INSIDE one call, with {"wait":1200}
   between the actions, not by making a separate call per beat. The video holds your waits;
   it does not hold the gaps between calls, so a beat costs a wait and nothing else.
2. There is no audio, so pace is your only explanation: hover the thing, wait, then click it.
   A viewer who cannot see what you clicked has learned nothing.
3. Wait for each screen to settle and hold it long enough to read — a second or two after the
   page stops changing.
4. Follow the order a person would, including the navigation. Do not jump straight to a deep
   URL you discovered; they cannot.
5. Explore first on a discarded take if you do not already know the exact path.
6. Write the steps out in your reply as well. The video shows; the text is what they search
   later.

${UNIVERSAL}`,

  bug: `INTENT: record a reproduction. The failure is the point.

1. Start from a clean state and take the shortest path to the failure.
2. KEEP RECORDING PAST THE FAILURE. Show the error, the broken layout, the spinner that never
   stops — for several seconds. A repro that cuts at the moment of failure omits the evidence
   it exists for.
3. Do NOT tidy up, retry, or fix anything on camera. Reload and repeat once if it is flaky,
   which is itself worth recording.
4. Do not rehearse: an unrehearsed take is more credible here, and a rehearsed one can hide
   the timing that causes the bug.
5. Name in your reply what should have happened, so the video has a claim to compare against.

${UNIVERSAL}`,

  'before-after': `INTENT: show a fix working. Two runs, one take.

1. Both runs in ONE take, along the SAME path, so nothing looks like a different flow.
2. Broken first, then fixed. Reach the failure, hold it long enough to register, then switch
   the thing that changed — the flag, the branch, the build — and walk the identical path.
3. Use the identical inputs both times. A different value invites the question "is that even
   the same test?".
4. Rehearse the path on a discarded take: doing it twice inside the cap needs the route known.
5. Say in your reply where the switch happens, in seconds, so a reviewer can jump to it.

${UNIVERSAL}`,
};

/** The playbook for an intent, or the universal rules plus the menu. */
export function playbookFor(intent) {
  const known = PLAYBOOKS[String(intent)];
  if (known) return known;
  return `No intent given, so here are the rules that always apply. Pass intent to
yorecord_start_recording for the rest: "demo" (a performance someone will watch), "proof"
(show what you built), "guide" (teach a task), "bug" (a reproduction), "before-after" (a fix
working).

${UNIVERSAL}`;
}

// -- The moving parts ---------------------------------------------------------
// Everything above is a decision and is tested without a browser. What follows drives a
// real Chrome, so it is deliberately thin: launch, attach, pace, encode, upload, clean up.
// `npm run test:record` exercises it against a real browser and a real ffmpeg.

const KEYS = {
  Enter: { code: 'Enter', key: 'Enter', vk: 13, text: '\r' },
  Tab: { code: 'Tab', key: 'Tab', vk: 9, text: '\t' },
  Escape: { code: 'Escape', key: 'Escape', vk: 27 },
  Backspace: { code: 'Backspace', key: 'Backspace', vk: 8 },
  Delete: { code: 'Delete', key: 'Delete', vk: 46 },
  Space: { code: 'Space', key: ' ', vk: 32, text: ' ' },
  ArrowDown: { code: 'ArrowDown', key: 'ArrowDown', vk: 40 },
  ArrowUp: { code: 'ArrowUp', key: 'ArrowUp', vk: 38 },
  ArrowLeft: { code: 'ArrowLeft', key: 'ArrowLeft', vk: 37 },
  ArrowRight: { code: 'ArrowRight', key: 'ArrowRight', vk: 39 },
  // Field report: `{"press":"End"}` was answered with "unsupported key" while trying to reach
  // the bottom of a long settings form, after scrolling had already timed out twice.
  End: { code: 'End', key: 'End', vk: 35 },
  Home: { code: 'Home', key: 'Home', vk: 36 },
  PageDown: { code: 'PageDown', key: 'PageDown', vk: 34 },
  PageUp: { code: 'PageUp', key: 'PageUp', vk: 33 },
};

/** For the tests, and for the error message when an agent asks for something else. */
export const SUPPORTED_KEYS = Object.keys(KEYS);

/** Open a CDP connection over Chrome's pipe (fds 3 and 4) and return a `send`. */
function connectOverPipe(child, onEvent, trace) {
  const framer = createCdpFramer();
  const pending = new Map();
  let nextId = 1;
  child.stdio[4].on('data', (chunk) => {
    for (const msg of framer.push(chunk)) {
      if (msg.id && pending.has(msg.id)) {
        const { resolve, reject } = pending.get(msg.id);
        pending.delete(msg.id);
        trace?.write('cdp.recv', {
          id: msg.id,
          ...(msg.error ? { error: String(msg.error.message ?? JSON.stringify(msg.error)) } : {}),
        });
        msg.error ? reject(new Error(`${msg.error.message ?? JSON.stringify(msg.error)}`)) : resolve(msg.result);
      } else if (msg.method) {
        trace?.write('cdp.event', { method: msg.method, params: redactForTrace(msg.method, msg.params) });
        onEvent(msg);
      }
    }
  });
  const send = (method, params = {}, sessionId, timeoutMs = 15000) =>
    new Promise((resolve, reject) => {
      const id = nextId++;
      trace?.write('cdp.send', { id, method, params: redactForTrace(method, params), sessionId });
      pending.set(id, { resolve, reject });
      try {
        child.stdio[3].write(encodeCdpMessage({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
      } catch (e) {
        pending.delete(id);
        reject(e);
        return;
      }
      setTimeout(() => {
        if (pending.delete(id)) reject(new Error(`${method} timed out after ${timeoutMs}ms`));
      }, timeoutMs);
    });
  // Fire-and-forget, for the screencast ack: awaiting it per frame would serialise the
  // capture behind our own round trips.
  const post = (method, params, sessionId) => {
    try {
      child.stdio[3].write(encodeCdpMessage({ id: nextId++, method, params, ...(sessionId ? { sessionId } : {}) }));
    } catch {
      /* the browser is gone; the caller finds out on its next send */
    }
  };
  return { send, post };
}

/**
 * Is `port` serving the browser holding `targetId`? Returns the port, or null.
 *
 * Identity, not reachability: another Chrome answering on 9222 is exactly the case worth
 * catching, and it answers a reachability check perfectly well.
 */
async function confirmDebugPort({ fetch, port, targetId }) {
  try {
    const res = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(2000) });
    if (!res.ok) return null;
    const targets = await res.json();
    return Array.isArray(targets) && targets.some((t) => t?.id === targetId) ? port : null;
  } catch {
    return null;
  }
}

export async function handleStartRecording(args, deps) {
  const {
    spawn,
    which,
    fileExists,
    makeTempDir,
    removeDir,
    readTextFile,
    env = {},
    state,
    sleep,
    now = () => Date.now(),
  } = deps;

  const width = Math.min(1920, Math.max(320, Number(args?.width) || 1280));
  const height = Math.min(1200, Math.max(240, Number(args?.height) || 720));
  const headless = args?.headless !== false;
  const fps = resolveFps({ fps: args?.fps, intent: args?.intent });
  // Default ON. The cost is real and stated in the tool description — a page that animates by
  // itself between calls has that animation clipped — but the alternative was measured at 17
  // seconds of frozen frame per step.
  const pauseBetweenCalls = args?.pauseBetweenCalls !== false;
  // A rehearsal drives the same browser and reads the same outline, and films nothing. Its
  // job is to make a script PASS, and every take that exists only to be thrown away used to
  // cost an encoder check, an MP4, and a directory left behind (field report #9).
  const rehearsing = args?.rehearse === true;

  // A recording that has already ENDED must not lock the tool. The cap fires, stopping is
  // set, and the only way out used to be an upload - so an agent overwrote its video with a
  // black clip and published that, three times, to escape.
  if (state.recording?.stopping) {
    return fail(
      `The previous recording already stopped${state.recording.capped ? ' (it hit the 10-minute cap)' : ''} ` +
        'but has not been finished off. Call yorecord_stop_and_share to publish it, or ' +
        'yorecord_stop_and_share with discard:true to throw it away without uploading. Then start again.',
    );
  }
  const verdict = startPreflight({
    // A rehearsal encodes nothing, so a machine without ffmpeg can still make a script pass.
    // The check stays for the take itself: discovering a missing encoder at STOP throws away
    // a walkthrough that cannot be repeated.
    hasFfmpeg: rehearsing ? true : await which('ffmpeg'),
    chromePath: findChromeBinary(fileExists, env.CHROME_PATH),
    alreadyRecording: Boolean(state.recording),
  });
  if (!verdict.ok) return fail(verdict.message);
  const chromePath = findChromeBinary(fileExists, env.CHROME_PATH);

  if (args?.url !== undefined && !isAllowedNavigationUrl(String(args.url))) {
    return fail(
      `I will not open ${String(args.url).slice(0, 120)} — only http and https pages. ` +
        'Sharing publishes whatever is on screen, so a local file or a browser-internal page ' +
        'is not something I will put on a public URL. Nothing has been recorded.',
    );
  }

  // The VIDEO lives outside the browser profile. It used to be inside it, and the upload's own
  // failure branch deleted the profile before reporting - so a refused upload destroyed a
  // walkthrough that cannot be repeated, while the tool promised the file was kept.
  let profile;
  try {
    profile = profileDirFor({ profile: args?.profile, tmpDir: await makeTempDir(), configDir: TOKEN_DIR });
  } catch (e) {
    return fail(`${e.message} Nothing has been recorded.`);
  }
  const profileDir = profile.dir;
  // A named profile is the point of a named profile: it must survive cleanUp, or the login
  // it exists to preserve is deleted the moment the take ends.
  const profilePersistent = profile.persistent;
  const profileName = profilePersistent ? String(args.profile) : null;
  // Read BEFORE the mkdir, or every profile looks new exactly once and then never again.
  const profileExisted = profilePersistent ? fileExists(profileDir) : false;
  if (profilePersistent) await mkdir(profileDir, { recursive: true }).catch(() => {});
  let restoredCookies = 0;
  const outDir = await makeTempDir();
  // Named after the temp directory, so a trace and the video it describes can be matched up
  // from their filenames alone — including when the reply had to keep the video on disk.
  const traceId = outDir.split('/').filter(Boolean).pop();
  const outPath = join(outDir, 'recording.mp4');
  const requestedPort = Number(args?.port) || 9222;

  const chrome = spawn(
    chromePath,
    chromeLaunchArgs({
      profileDir,
      port: requestedPort,
      width,
      height,
      headless,
      isRoot: typeof process.getuid === 'function' && process.getuid() === 0,
    }),
    {
      // fd 2 is IGNORED, not piped. A piped stderr nobody reads fills its 64 KB OS buffer on a
      // noisy site and then Chrome BLOCKS on the write: the capture freezes, and the pacer
      // cheerfully keeps encoding the last frame it saw.
      stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'],
    },
  );

  const rec = {
    chrome, profileDir, profilePersistent, outDir, outPath, width, height, headless, chromePath, fps,
    browserDied: false, encoderExit: null,
    startedAt: now(), latest: null, captured: 0, emitted: 0,
    actions: [], mouse: { x: Math.round(width / 2), y: Math.round(height / 2) },
    ffErr: '', capped: false, stopping: false,
    // The capture clock. See recordedElapsedMs: `pausedAt` is the open span, `pausedMs` the
    // closed ones. Both are read through that function and never subtracted at a call site.
    pausedMs: 0, pausedAt: null, pauseBetweenCalls, betweenCalls: true, lastFrameAt: null,
    profileName, rehearsing,
  };

  const trace = openTrace({ dir: join(TOKEN_DIR, 'traces'), id: traceId, now, t0: now(), recFor: () => rec });
  rec.trace = trace;
  trace.write('start', {
    args: { url: args?.url, width, height, fps, intent: args?.intent, headless, profile: profileName, pauseBetweenCalls },
    chromePath,
    node: process.version,
    profileExisted,
    outPath,
  });

  // EPIPE on either pipe must not become an uncaught exception. Measured on Node 22 it does
  // not - writes to a dead child's stdin are silently discarded - but that is a version
  // detail, and a crashed server takes every tool away from the agent mid-session.
  for (const fd of [3, 4]) chrome.stdio[fd]?.on('error', () => {});
  chrome.on('exit', () => {
    // Only an UNEXPECTED death. `closeBrowser` sets rec.closing before it asks Chrome to quit,
    // so without this guard every successful recording ended with "the browser closed before I
    // stopped recording" - a warning on the happy path, which is how real warnings get ignored.
    if (!rec.closing) rec.browserDied = true;
  });

  const { send, post } = connectOverPipe(chrome, (msg) => {
    if (msg.method !== 'Page.screencastFrame') return;
    rec.captured++;
    // A frame arriving means the page PAINTED. See idleGate: between calls that is the
    // evidence that resumes the clock, and quiet is what stops it again.
    rec.lastFrameAt = now();
    if (rec.betweenCalls) resumeCapture(rec, rec.lastFrameAt);
    rec.latest = Buffer.from(msg.params.data, 'base64');
    // Ack immediately. The screencast is ack-gated: Chrome sends the next frame only once
    // the previous is acknowledged, so acking on a drain loop caps the rate at one frame
    // per drain — measured, in probe 1, as exactly 1 frame.
    post('Page.screencastFrameAck', { sessionId: msg.params.sessionId }, msg.sessionId);
  }, trace);
  rec.send = send;

  try {
    // Chrome takes a moment to open the pipe. Poll rather than sleep a guessed amount.
    let targets = null;
    const startDeadline = now() + BROWSER_START_TIMEOUT_MS;
    while (!targets && now() < startDeadline) {
      targets = await send('Target.getTargets', {}, undefined, 2000).catch(() => null);
      if (!targets) await sleep(250);
    }
    if (!targets) {
      throw new Error(
        browserStartTimeoutMessage({
          seconds: Math.round(BROWSER_START_TIMEOUT_MS / 1000),
          loadavg1: loadavg()[0],
          cores: cpus().length || 1,
        }),
      );
    }

    const page = targets.targetInfos.find((t) => t.type === 'page');
    if (!page) throw new Error('Chrome started with no page to record');
    const { sessionId } = await send('Target.attachToTarget', { targetId: page.targetId, flatten: true });
    rec.sessionId = sessionId;
    rec.targetId = page.targetId;
    rec.pageCount = targets.targetInfos.filter((t) => t.type === 'page').length;

    await send('Page.enable', {}, sessionId);
    await send('Runtime.enable', {}, sessionId);
    // One geometry for the whole recording; the encoder needs it and so does the pad filter.
    await send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false }, sessionId);
    await send('Page.addScriptToEvaluateOnNewDocument', { source: CURSOR_OVERLAY_SOURCE }, sessionId);

    // Confirm the port belongs to THE BROWSER WE JUST LAUNCHED, by identity and not by hope.
    //
    // The obvious check - read DevToolsActivePort from the profile - cannot work here:
    // measured, Chrome never writes that file when `--remote-debugging-pipe` is passed
    // alongside the port. The previous code fell back to the requested port when the read
    // failed, i.e. always, so it advertised 9222 to the agent on no evidence at all. If the
    // human's own Chrome held that port, their browser is what the agent would have driven -
    // logged-in sessions and all - while we filmed a blank tab.
    //
    // So: ask the port for its target list and look for OUR target id in it.
    rec.port = await confirmDebugPort({ fetch: deps.fetch, port: requestedPort, targetId: page.targetId });

    // BEFORE the first navigation. Restoring after it would mean the take opens on a login
    // wall and only the SECOND page is authenticated, which is the same defect one step on.
    if (profileName && carriedSessionCookies.has(profileName)) {
      const cookies = carriedSessionCookies.get(profileName);
      restoredCookies = await send('Storage.setCookies', { cookies }, undefined, 4000)
        .then(() => cookies.length)
        .catch(() => 0);
    }

    // Raise it as well as place it. The flag decides where the window opens; this decides
    // whether it is the one in front.
    if (!headless) await send('Page.bringToFront', {}, sessionId, 4000).catch(() => {});

    if (args?.url) {
      await send('Page.navigate', { url: String(args.url) }, sessionId);
      await sleep(1200);
      rec.actions.push({ label: `Opened ${String(args.url)}`, atMs: 0, ok: true });
    }

    await send(
      'Page.startScreencast',
      { format: 'jpeg', quality: 70, maxWidth: width, maxHeight: height, everyNthFrame: 1 },
      sessionId,
    );

    // The screencast above runs either way — a rehearsal still needs a picture of the page
    // in every reply. What a rehearsal does NOT do is encode it.
    const ff = rehearsing
      ? null
      : spawn('ffmpeg', ffmpegArgs({ fps, width, height, outPath }), { stdio: ['pipe', 'ignore', 'pipe'] });
    ff?.stderr.on('data', (d) => {
      rec.ffErr = (rec.ffErr + String(d)).slice(-2000);
    });
    ff?.on('error', (e) => {
      rec.ffErr += `\nffmpeg failed to start: ${String(e)}`;
    });
    ff?.stdin.on('error', () => {});
    ff?.on('close', (code, signal) => {
      // Only interesting BEFORE we asked it to stop. An encoder that dies mid-recording is
      // otherwise invisible: the writes are discarded silently and the video simply stops.
      if (!rec.stopping) rec.encoderExit = signal ?? code ?? 'unknown';
    });
    rec.ff = ff;
    rec.t0 = now();

    // FCR: hold the latest frame, emit against wall clock. See probe 2.
    rec.timer = setInterval(() => {
      if (!rec.ff || !rec.latest || rec.stopping || rec.browserDied || rec.encoderExit !== null) return;
      idleGate(rec, now());
      const n = framesToEmit({ elapsedMs: recordedElapsedMs(rec, now()), emitted: rec.emitted, fps });
      // Backpressure is judged by how much is QUEUED, never by write()'s return value.
      // write() returns false as soon as the 64 KB stream buffer is full - which one 100 KB
      // frame does on its own - and it still buffers the data, so treating that as a dropped
      // frame throws away frames the encoder was willing to take. Worse, the debt was never
      // repaid: a single late tick left the pacer permanently behind, dropping nine frames in
      // every ten from then on. Measured on a real site: 1096 frames "dropped" from a 27 s
      // recording that holds 270.
      for (let i = 0; i < n; i++) {
        // Over the ceiling, stop feeding and let it drain: these frames are DEFERRED, not
        // lost, and counting them here double-counted them on every stalled tick. The real
        // loss is whatever shortfall remains at the end - droppedFrames() computes it once.
        if (rec.ff.stdin.writableLength > ENCODER_QUEUE_LIMIT_BYTES) break;
        rec.ff.stdin.write(rec.latest);
        rec.emitted++;
      }
    }, Math.round(1000 / fps));

    // The 10-minute cap, enforced rather than announced. It counts RECORDED seconds, which
    // is what it announces and what the reply reports; a wall-clock cap would now stop a
    // walkthrough at four minutes of video because the agent spent six minutes thinking.
    const stopNow = () => {
      rec.capped = true;
      // Close the BROWSER too, not just the encoder. Leaving it open until the agent happens
      // to call stop left a Chrome and a cookie-holding profile alive for the rest of the
      // session - and an agent that never calls stop left them for as long as the server ran.
      void finishCapture(rec)
        .then(() => closeBrowser(rec, removeDir))
        .catch(() => {});
    };
    // A rehearsal has no recorded clock to run out, so only the wall-clock backstop bounds
    // it. Without this it would hold a Chrome open for as long as the server lived.
    rec.capTimer = rehearsing
      ? null
      : setInterval(() => {
          if (recordedElapsedMs(rec, now()) >= RECORD_MAX_SECONDS * 1000) stopNow();
        }, 1000);
    // And a wall-clock backstop, because pausing removed the only bound on how long this
    // holds a Chrome and a cookie-holding profile open. An abandoned recording used to end
    // itself after ten minutes; without this it would not end at all.
    rec.deadline = setTimeout(stopNow, RECORD_MAX_SECONDS * WALL_CLOCK_BACKSTOP * 1000);

    state.recording = rec;
  } catch (e) {
    clearInterval(rec.timer);
    clearTimeout(rec.capTimer);
    await closeBrowser(rec, removeDir);
    await removeDir(outDir).catch(() => {});
    // The message may already be a complete explanation (see browserStartTimeoutMessage), so
    // do not staple a second "Nothing was recorded" onto it.
    const why = String(e.message ?? e);
    return fail(why.includes('nothing was recorded') ? why : `I could not start recording: ${why}. Nothing was recorded.`);
  }

  const profileLine = profileNotice({ name: profileName, existed: profileExisted, restored: restoredCookies });
  if (rehearsing) {
    return ok(
      `REHEARSING at ${width}x${height} — no video is being captured and nothing here can be ` +
        `uploaded.\n` +
        (args?.url ? `Opened ${String(args.url)}.\n` : 'The page is blank — go somewhere first.\n') +
        (profileLine ? `${profileLine}\n` : '') +
        `\nDrive it with yorecord_browse to find the refs and selectors, write them into a ` +
        `script, then replay that script with yorecord_replay until every step passes. When it ` +
        `does, call yorecord_stop_and_share, start a REAL recording with the same profile, and ` +
        `replay the same script once. Stay in this session: the logged-in browser state is held ` +
        `in memory and a restart sends your human back to the login screen.\n` +
        `\n${playbookFor(args?.intent)}`,
    );
  }
  return ok(
    `Recording now, at ${width}x${height}, ${String(fps)} fps.\n` +
      (args?.url ? `Opened ${String(args.url)}.\n` : 'The page is blank — go somewhere first.\n') +
      (profileLine ? `${profileLine}\n` : '') +
      (headless
        ? ''
        : 'A VISIBLE Chrome window has opened at the top left of the main display — tell your ' +
          'human it is there and that it is being recorded from this moment, because anything ' +
          'they do in it is in the video.\n') +
      `\nTo drive it, either:\n` +
      (rec.port
        ? `  • use your OWN browser tool if it can attach to an existing browser over CDP at ` +
          `http://127.0.0.1:${rec.port} (Playwright: connectOverCDP), or\n`
        : `  • (the debugging port could not be confirmed, so do NOT guess one — another ` +
          `browser may be holding it)\n`) +
      `  • call yorecord_browse with a list of actions.\n` +
      `\n${playbookFor(args?.intent)}\n` +
      `\nThen call yorecord_stop_and_share, which encodes and UPLOADS the video. ` +
      `${PUBLIC_NOTE} Tell your human what the recording will show before you upload it. ` +
      `Recording stops on its own after ${RECORD_MAX_SECONDS / 60} minutes. ` +
      `The mouse cursor in the video is drawn by us — a screen capture contains no real one. ` +
      `Only this one tab is recorded, so keep the walkthrough in it.`,
  );
}

/**
 * Stop capturing and let ffmpeg finish.
 *
 * Idempotent by PROMISE, not by flag. A flag made the second caller return immediately while
 * the first was still inside ffmpeg's `+faststart` rewrite — so when the 10-minute cap fired
 * and the agent called stop a moment later, the file was read mid-rewrite: a truncated upload,
 * or a spurious "not an MP4" that then deleted the recording.
 */
function finishCapture(rec) {
  rec.finishing ??= (async () => {
    rec.stopping = true;
    clearInterval(rec.timer);
    clearInterval(rec.capTimer);
    clearTimeout(rec.deadline);
    rec.endedAt = Date.now();
    await rec.send('Page.stopScreencast', {}, rec.sessionId).catch(() => {});
    await new Promise((resolve) => {
      if (!rec.ff || rec.ff.exitCode !== null || rec.ff.signalCode !== null) return resolve();
      rec.ff.once('close', resolve);
      try {
        rec.ff.stdin.end();
      } catch {
        resolve();
      }
      // A wedged encoder must not hold the tool call open forever.
      setTimeout(resolve, 30000);
    });
  })();
  return rec.finishing;
}

/**
 * Close the browser and delete its profile. Idempotent, and ordered.
 *
 * The profile is deleted only once the process has really exited: Chrome rewrites network
 * state and its cache on the way out, so a directory removed first comes back — holding
 * cookies for whatever the walkthrough visited. Closing the CDP pipe is enough on its own
 * (measured: Chrome exits 0 when the pipe closes), and SIGKILL is the backstop.
 */
/**
 * Session cookies held between takes, keyed by profile name, for this server run only.
 *
 * In memory on purpose. These are live authentication cookies; Chrome keeps its own behind
 * the OS keyring, and a plaintext sidecar of them is not something to add to a tool whose
 * whole profile design exists to keep the human's credentials out of a public video. The
 * cost is stated where it matters: a restarted server starts empty, which is exactly the
 * behaviour that exists today.
 */
const carriedSessionCookies = new Map();

function closeBrowser(rec, removeDir) {
  rec.closing ??= (async () => {
    // BEFORE Browser.close, which is the last moment the cookie store can be read. A named
    // profile exists to hold a login across takes, and Chrome drops session cookies on exit.
    if (rec.profilePersistent && rec.profileName) {
      const carried = await rec.send('Storage.getCookies', {})
        .then((r) => sessionCookiesToCarry(r?.cookies))
        .catch(() => []);
      if (carried.length) carriedSessionCookies.set(rec.profileName, carried);
    }
    await rec.send('Browser.close', {}).catch(() => {});
    await new Promise((resolve) => {
      if (rec.chrome.exitCode !== null || rec.chrome.signalCode !== null) return resolve();
      rec.chrome.once('exit', resolve);
      try {
        rec.chrome.stdio[3]?.end();
      } catch {
        /* already gone */
      }
      const hard = setTimeout(() => {
        try {
          rec.chrome.kill('SIGKILL');
        } catch {
          /* already gone */
        }
      }, 3000);
      setTimeout(() => {
        clearTimeout(hard);
        resolve();
      }, 6000);
    });
    // A NAMED profile survives. It exists to hold the login across takes, so deleting it
    // here would delete the one thing it is for — and silently, since this runs on the
    // happy path. A throwaway is still removed, and still only after the process has exited.
    if (!rec.profilePersistent) await removeDir(rec.profileDir).catch(() => {});
  })();
  return rec.closing;
}

/**
 * The one place an action is actually performed.
 *
 * Extracted so `yorecord_replay` drives the same code as `yorecord_browse` rather than
 * growing a second copy of it. Two executors would disagree within a release — the same rot
 * CLAUDE.md records for the subtitle script, which went on POSTing to a gateway that had been
 * gone for weeks because it was a hand-copy of the real transport.
 *
 * `runOne` returns whether the action succeeded. `browse` ignores that and keeps going, so
 * the agent can read the whole batch and decide; `replay` stops on the first false, because a
 * performance that has already gone wrong should not keep filming.
 */
function createActionRunner(rec, deps) {
  const { sleep, now = () => Date.now() } = deps;
  const { send } = rec;
  const evaluate = async (expression) => {
    const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, rec.sessionId);
    if (r?.exceptionDetails) throw new Error(r.exceptionDetails.text ?? 'evaluation failed');
    return r?.result?.value;
  };

  /** Move the pointer in steps, so the drawn cursor travels instead of teleporting. */
  const glideTo = async (x, y) => {
    const from = rec.mouse;
    // 14 steps rather than 8. The travel is the only continuous motion in most of these
    // videos, and a measured demo came out at 0.37 UNIQUE frames per second because the
    // screen was still between actions.
    const steps = 14;
    for (let i = 1; i <= steps; i++) {
      const nx = Math.round(from.x + ((x - from.x) * i) / steps);
      const ny = Math.round(from.y + ((y - from.y) * i) / steps);
      // A dropped intermediate move is COSMETIC — the pointer travels one frame less
      // smoothly. The click is the action. Failing the whole action because one of fourteen
      // decorative moves hit a busy renderer is what cost two round trips in the field.
      await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: nx, y: ny }, rec.sessionId, 4000)
        .catch(() => {});
      await sleep(30);
    }
    rec.mouse = { x, y };
  };

  const log = [];
  /**
   * `where` is the point the action actually landed on, in CSS pixels of the viewport.
   * It is kept because a zoom target cannot be recovered from pixels afterwards, and this is
   * the only moment it is known. `stepsSidecar` drops it, so the uploaded shape is unchanged
   * — pinned by scripts/yorecord/stepsBoundary.test.mjs.
   */
  let lastOk = true;
  const record = (label, ok, detail, where) => {
    lastOk = ok;
    rec.trace?.write('action', { label, ok, detail, ...(where ? { x: where.x, y: where.y } : {}) });
    rec.actions.push({
      label,
      // The recorded clock, because this is where in the VIDEO the action happens. The steps
      // sidecar and the zoom segments both aim at it, and a wall clock would aim them past
      // the end of a paused recording.
      atMs: recordedElapsedMs(rec, now()),
      ok,
      ...(where ? { x: where.x, y: where.y, viewport: { width: rec.width, height: rec.height } } : {}),
    });
    log.push(`${ok ? '✓' : '✗'} ${label}${detail ? ` — ${detail}` : ''}`);
  };

  const runOne = async (action) => {
    lastOk = true;
    try {
      if (action.goto) {
        if (!isAllowedNavigationUrl(String(action.goto))) {
          // Sharing publishes what is on screen, so navigation is a publication route. A page
          // the walkthrough visits must not be able to talk us into opening a local file.
          record(`Opened ${String(action.goto).slice(0, 80)}`, false, 'only http and https pages can be recorded');
          return lastOk;
        }
        await send('Page.navigate', { url: String(action.goto) }, rec.sessionId);
        await sleep(Number(action.settleMs) || 1500);
        record(`Opened ${String(action.goto)}`, true);
      } else if (action.click) {
        const found = JSON.parse(await evaluate(clickTargetSource(String(action.click))));
        if (!found?.count) {
          record(`Clicked ${String(action.click)}`, false, 'nothing visible on the page matched, so nothing was clicked');
          return lastOk;
        }
        if (found.unreachable) {
          record(
            `Clicked ${found.label || String(action.click)}`,
            false,
            `it sits at y=${String(found.y)} and the window is only ${String(found.viewportHeight)}px tall, so a click ` +
              'there would be discarded. Scroll it into view first, or press End.',
          );
          return lastOk;
        }
        if (found.blockedBy) {
          // The check whose absence produced a tick for a click that did nothing.
          //
          // What it says matters as much as that it fires. "Close it, or click that first" was
          // read by a field agent as advice to close the dialog holding its own target — which
          // would have destroyed the state it had just built. When the query matched several,
          // the honest answer is that none of them can be reached, not that one thing is in
          // the way.
          record(
            `Clicked ${found.label || String(action.click)}`,
            false,
            found.count > 1
              ? `your query matched ${String(found.count)} elements and none of the visible ones can be ` +
                `clicked where they are — "${found.blockedBy}" covers the first. Pick the one you mean ` +
                'by ref (@n) from the list below.'
              : `"${found.blockedBy}" is on top at that point, so the click would hit that instead. ` +
                'Close it, or click that first.',
          );
          return lastOk;
        }
        await sleep(250); // let scrollIntoView settle before we aim at it
        await glideTo(found.x, found.y);
        // Press and release are retried SEPARATELY. Retrying the pair would re-send a press
        // that had already landed, which the page reads as a drag and then a second click.
        for (const type of ['mousePressed', 'mouseReleased']) {
          await retryOnTimeout(
            () =>
              send(
                'Input.dispatchMouseEvent',
                { type, x: found.x, y: found.y, button: 'left', buttons: type === 'mousePressed' ? 1 : 0, clickCount: 1 },
                rec.sessionId,
                4000,
              ),
            { sleep },
          );
        }
        await sleep(Number(action.settleMs) || 700);
        // Did it open a tab we are not recording? Cheap to ask, and the alternative is an agent
        // that believes a click worked while the video shows nothing happening at all.
        const pages = await send('Target.getTargets', {}, undefined, 4000)
          .then((t) => t.targetInfos.filter((x) => x.type === 'page').length)
          .catch(() => rec.pageCount ?? 1);
        const opened = pages > (rec.pageCount ?? 1);
        rec.pageCount = Math.max(pages, rec.pageCount ?? 1);
        const notes = [];
        if (found.count > 1) notes.push(`${found.count} elements matched; clicked the first`);
        if (opened) notes.push(NEW_TAB_HINT);
        record(`Clicked ${found.label || String(action.click)}`, !opened, notes.join(' — ') || undefined, found);
      } else if (action.type !== undefined) {
        if (action.into) {
          const found = JSON.parse(await evaluate(clickTargetSource(String(action.into))));
          if (!found.count) {
            record(`Typed into ${String(action.into)}`, false, 'no such field');
            return lastOk;
          }
          await glideTo(found.x, found.y);
          for (const t of ['mousePressed', 'mouseReleased'])
            await send('Input.dispatchMouseEvent', { type: t, x: found.x, y: found.y, button: 'left', buttons: t === 'mousePressed' ? 1 : 0, clickCount: 1 }, rec.sessionId);
        }
        await send('Input.insertText', { text: String(action.type) }, rec.sessionId);
        await sleep(200);
        // Do not echo what was typed into a secret field. The agent knows the value already -
        // it supplied it - but this line also goes into the steps sidecar, which is UPLOADED
        // beside a public video, so a password typed on the human's behalf would be published.
        const intoSecret = await evaluate(
          `${SAFE_LABEL_SOURCE}\nwindow.__yrSafeLabel(document.activeElement || document.body).includes('(hidden')`,
        ).catch(() => false);
        record(
          intoSecret ? 'Typed into a hidden field (value not recorded)' : `Typed "${String(action.type).slice(0, 40)}"`,
          true,
        );
      } else if (action.press) {
        const k = KEYS[String(action.press)];
        if (!k) {
          record(`Pressed ${String(action.press)}`, false, `I can send: ${SUPPORTED_KEYS.join(', ')}`);
          return lastOk;
        }
        for (const type of ['keyDown', 'keyUp'])
          await send('Input.dispatchKeyEvent', { type, key: k.key, code: k.code, windowsVirtualKeyCode: k.vk, nativeVirtualKeyCode: k.vk, ...(k.text && type === 'keyDown' ? { text: k.text } : {}) }, rec.sessionId);
        await sleep(Number(action.settleMs) || 600);
        record(`Pressed ${k.key}`, true);
      } else if (action.scroll !== undefined) {
        const total = Number(action.scroll) || 0;
        const before = await evaluate('String(scrollY) + ":" + String(document.activeElement?.scrollTop ?? 0)');
        // Four steps, not eight, each with its own short deadline. A wheel dispatch on a heavy
        // application timed out twice at 15s in the field, which cost the whole action.
        let wheelFailed = null;
        for (let i = 0; i < 4; i++) {
          try {
            await send(
              'Input.dispatchMouseEvent',
              { type: 'mouseWheel', x: rec.mouse.x, y: rec.mouse.y, deltaX: 0, deltaY: total / 4 },
              rec.sessionId,
              4000,
            );
          } catch (e) {
            wheelFailed = String(e.message ?? e);
            break;
          }
          await sleep(70);
        }
        const after = await evaluate('String(scrollY) + ":" + String(document.activeElement?.scrollTop ?? 0)');
        if (after === before) {
          // Nothing moved: either the wheel went nowhere or the scroller is an inner element
          // that ignores it. Ask the page to scroll itself, which cannot time out on input.
          await evaluate(
            `(() => { const n = ${JSON.stringify(total)};
              const sc = [...document.querySelectorAll('*')].find((e) => e.scrollHeight > e.clientHeight + 40 && /auto|scroll/.test(getComputedStyle(e).overflowY));
              (sc ?? window).scrollBy({ top: n, behavior: 'smooth' });
            })()`,
          );
          await sleep(500);
        }
        const moved = (await evaluate('String(scrollY) + ":" + String(document.activeElement?.scrollTop ?? 0)')) !== before;
        record(
          `Scrolled ${total > 0 ? 'down' : 'up'} ${Math.abs(total)}px`,
          moved,
          moved ? undefined : `nothing moved${wheelFailed ? ` (${wheelFailed})` : ''} — the page may have no scrollable area here`,
        );
      } else if (action.wait) {
        const asked = Number(action.wait) || 0;
        const waited = Math.min(MAX_WAIT_MS, asked);
        await sleep(waited);
        record(`Waited ${String(waited)}ms${waitNotice(asked)}`, true);
      } else {
        record(`Unknown action ${JSON.stringify(action).slice(0, 60)}`, false, 'expected goto, click, type, press, scroll or wait');
      }
    } catch (e) {
      // Say what a timeout MEANS. "Input.dispatchMouseEvent timed out after 4000ms" is not
      // something an agent can act on; "the page was still loading, wait and retry" is, and
      // it is what the field agent worked out for itself a round trip later, twice.
      const why = String(e.message ?? e);
      record(
        `Action ${JSON.stringify(action).slice(0, 50)}`,
        false,
        /timed out/i.test(why)
          ? "the page's renderer stayed busy through three attempts, so it was most likely still " +
            'loading. Put a {"wait":2500} in front of this action and send it again.'
          : why,
      );
    }
    return lastOk;
  };
  // `evaluate` goes back out because reading the outline is not an action but needs the same
  // channel. Leaving it inside cost three real-browser checks: the outline's catch swallowed
  // the ReferenceError and reported "I could not read the page just now" instead.
  return { runOne, log, evaluate };
}

export async function handleBrowse(args, deps) {
  const { state, sleep, now = () => Date.now() } = deps;
  const rec = state.recording;
  if (!rec) return fail('I am not recording anything. Call yorecord_start_recording first.');
  if (rec.browserDied) {
    return fail('The browser I was recording has closed. Call yorecord_stop_and_share to see what was captured.');
  }
  if (rec.stopping) {
    return fail(
      `The recording has already stopped${rec.capped ? ` — it hit the ${RECORD_MAX_SECONDS / 60}-minute cap` : ''}. ` +
        'Call yorecord_stop_and_share; anything I did now would not be in the video.',
    );
  }
  const actions = Array.isArray(args?.actions) ? args.actions : [];
  if (actions.length === 0) return fail('Give me a list of actions, for example [{"goto":"https://example.com"}].');
  if (actions.length > MAX_ACTIONS_PER_CALL) {
    return fail(
      `${String(MAX_ACTIONS_PER_CALL)} actions at a time, at most — so you can see what happened before choosing the next.`,
    );
  }

  // Below the guards on purpose: a rejected call recorded nothing, so it must not bank a
  // resume it never used.
  rec.betweenCalls = false;
  resumeCapture(rec, now());
  rec.trace?.write('browse.in', { actions });

  const { runOne, log, evaluate } = createActionRunner(rec, deps);
  for (const action of actions) await runOne(action);

  let outline = null;
  try {
    outline = JSON.parse(await evaluate(PAGE_OUTLINE_SOURCE));
  } catch {
    /* the page may be mid-navigation; the log above is still worth returning */
  }

  const elapsed = Math.round(recordedElapsedMs(rec, now()) / 1000);
  rec.trace?.write('browse.out', {
    elements: outline?.elements?.length ?? null,
    url: outline?.url,
    captured: rec.captured,
    emitted: rec.emitted,
    queueBytes: rec.ff?.stdin?.writableLength ?? 0,
  });
  // Handed back to the idle gate before the reply is serialised, so none of the model's
  // reading time is filmed once the page settles. This is also what removes the tail: by the
  // time stop_and_share arrives the page has long gone quiet.
  rec.betweenCalls = true;
  return {
    text:
      `${log.join('\n')}\n\nRecording for ${elapsed}s of video, of ${RECORD_MAX_SECONDS}s.\n` +
      describeOutline(outline),
    // The picture of the page. See toolContent: without it, an agent that wants to look at
    // what it is doing has to find another way, and the other ways photograph the human.
    imageJpegBase64: rec.latest ? rec.latest.toString('base64') : undefined,
  };
}

/**
 * Resolve `@3` (a ref from the last outline), or a CSS selector, or visible text, to a point.
 *
 * The ref comes first and is exact. Text matching is a guess that a real application makes
 * worse - labels repeat, and they truncate at 70 characters in the outline - and the field
 * report was an agent that found the right thing and still could not name it back to us.
 */
/** What the page looks like, in the words both browse and replay report it with. */
function describeOutline(outline) {
  if (!outline) return 'I could not read the page just now — it may still be loading.';
  return (
    `Now on: ${outline.title || '(no title)'} — ${outline.url}\n` +
    `Scroll ${outline.scrollY} of ${outline.scrollHeight}px.\n` +
    `You can act on these — click by ref, e.g. {"click":"@7"}:\n` +
    outline.elements
      .map((e) => `  @${e.ref} ${e.tag}${e.type ? `[${e.type}]` : ''} "${e.label}"${e.inView ? '' : ' (off screen)'}`)
      .join('\n') +
    (outline.notShown > 0
      ? `\n  … and ${String(outline.notShown)} more not listed — scroll, or narrow the page down first`
      : '')
  );
}

/**
 * Perform a whole shot list in one call.
 *
 * The recorded twin of `yorecord_browse`. Same executor, three differences that all follow
 * from one fact — this one is being filmed:
 *
 *   it stops at the first failure, because a take that has gone wrong should not keep rolling;
 *   its waits come from a file a human reviewed, not from a model under a running clock;
 *   it keeps what it ran, so the captions and chapters are exact rather than reconstructed.
 */
export async function handleReplay(args, deps) {
  const { state, sleep, readTextFile, now = () => Date.now() } = deps;
  const rec = state.recording;
  if (!rec) return fail('I am not recording anything. Call yorecord_start_recording first — with rehearse:true if you are still making the script work.');
  if (rec.browserDied) return fail('The browser I was recording has closed. Call yorecord_stop_and_share to see what was captured.');
  if (rec.stopping) {
    return fail(
      `The recording has already stopped${rec.capped ? ` — it hit the ${RECORD_MAX_SECONDS / 60}-minute cap` : ''}. ` +
        'Call yorecord_stop_and_share; anything I did now would not be in the video.',
    );
  }
  if (args?.script && args?.path) return fail('Give me either a script or a path to one, not both.');

  let script;
  try {
    const raw = args?.path ? JSON.parse(await readTextFile(String(args.path))) : args?.script;
    script = parseScript(raw);
  } catch (e) {
    // The message from parseScript names the step. Nothing has been performed, so the take is
    // still clean and the agent can fix the file and call again.
    return fail(`I could not use that script: ${String(e.message ?? e)}. Nothing was performed.`);
  }

  rec.betweenCalls = false;
  resumeCapture(rec, now());
  rec.trace?.write('replay.in', { title: script.title, steps: script.steps.length });

  const { runOne, log, evaluate } = createActionRunner(rec, deps);
  const out = await runScriptSteps(script.steps, {
    runOne,
    sleep,
    // The RECORDED clock, so a step's span is where it sits in the video. Captions timed off
    // the wall clock would drift by exactly the thinking time the capture gate removes.
    elapsedMs: () => recordedElapsedMs(rec, now()),
  });

  // Kept for the edit document: what ran, when, and what each step said.
  rec.scriptRun = { ...(script.title ? { title: script.title } : {}), ran: out.ran };

  let outline = null;
  try {
    outline = JSON.parse(await evaluate(PAGE_OUTLINE_SOURCE));
  } catch {
    /* the page may be mid-navigation; the log is still worth returning */
  }
  const elapsed = Math.round(recordedElapsedMs(rec, now()) / 1000);
  rec.trace?.write('replay.out', {
    ranSteps: out.ran.length,
    failedStep: out.failedStep,
    elements: outline?.elements?.length ?? null,
  });
  rec.betweenCalls = true;

  const head = out.failedStep
    ? `STOPPED at step ${String(out.failedStep)} of ${String(script.steps.length)} — ` +
      `${JSON.stringify(out.failedAction).slice(0, 80)} did not work, so I did not perform the rest. ` +
      `Fix that step and replay the whole script again; ${rec.recording === false ? 'this was a rehearsal, so nothing was filmed' : 'the take so far is not worth keeping'}.`
    : `Performed all ${String(script.steps.length)} steps.` +
      (out.ran.some((r) => r.say) ? ` ${String(out.ran.filter((r) => r.say).length)} of them will show a caption.` : '');

  return {
    text: `${head}\n\n${log.join('\n')}\n\nRecording for ${elapsed}s of video, of ${RECORD_MAX_SECONDS}s.\n${describeOutline(outline)}`,
    imageJpegBase64: rec.latest ? rec.latest.toString('base64') : undefined,
  };
}

export function clickTargetSource(query) {
  return `
(() => {
  ${SAFE_LABEL_SOURCE}
  const q = ${JSON.stringify(query)};
  const norm = (s) => (s || '').replace(/\\s+/g, ' ').trim().toLowerCase();
  let els = [];
  const ref = /^@(\\d+)$/.exec(q.trim());
  if (ref) {
    const byRef = document.querySelector('[data-yr-ref="' + ref[1] + '"]');
    els = byRef ? [byRef] : [];
  }
  if (!els.length && !ref) try { els = [...document.querySelectorAll(q)]; } catch { els = []; }
  if (!els.length && !ref) {
    const all = [...document.querySelectorAll('a[href],button,input,textarea,select,label,summary,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[role="option"],[role="combobox"],[role="listbox"],[role="switch"],[role="checkbox"],[role="radio"],[role="menuitemcheckbox"],[role="menuitemradio"],[role="spinbutton"],[role="slider"],[aria-haspopup="listbox"],[aria-haspopup="menu"],[onclick]')];
    const want = norm(q);
    const text = (e) => norm(window.__yrSafeLabel(e));
    els = all.filter((e) => text(e) === want);
    if (!els.length) els = all.filter((e) => text(e).includes(want));
  }
  // Only candidates a person could actually see. Text matching found HIDDEN DUPLICATES on a
  // real application, dispatched at their coordinates, and reported success.
  const vis = els.filter((e) => {
    const r = e.getBoundingClientRect();
    if (r.width <= 1 || r.height <= 1) return false;
    const st = getComputedStyle(e);
    if (st.visibility === 'hidden' || st.display === 'none' || Number(st.opacity) === 0) return false;
    return typeof e.checkVisibility === 'function'
      ? e.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
      : true;
  });
  if (!vis.length) return JSON.stringify({ count: 0 });

  // EVERY visible candidate, not just the first. A CSS selector on a real application matches
  // several - '[aria-haspopup=\'listbox\']' matched ten in the field - and the one first in
  // document order was behind the dialog the agent had just opened. Only vis[0] was ever
  // hit-tested, so the tool reported an occlusion that was true of that element and useless
  // as advice: "close it" meant closing the dialog holding the real target.
  //
  // Bounded, because scrollIntoView runs per candidate and a query matching a hundred rows
  // would scroll the page a hundred times, on camera.
  let firstProblem = null;
  for (const el of vis.slice(0, 12)) {
    el.scrollIntoView({ block: 'center', inline: 'center' });
    const r = el.getBoundingClientRect();
    // Matching is case-insensitive, but the LABEL keeps the page's own capitalisation: it
    // goes into the steps sidecar and into what the agent tells its human, and "Clicked
    // pricing" for a link that reads "Pricing" is a small lie about the page.
    const shown = window.__yrSafeLabel(el);
    const x = Math.round(r.left + r.width / 2);
    const y = Math.round(r.top + r.height / 2);
    const label = shown.slice(0, 60);

    // A dispatched mouse event knows only a coordinate. If that coordinate is outside the
    // viewport the event is silently discarded - two clicks were lost that way, at y=1150 in
    // an 810px viewport - so say so instead of dispatching into nothing.
    if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) {
      firstProblem = firstProblem || { count: vis.length, label, unreachable: true, y, viewportHeight: innerHeight };
      continue;
    }

    // And if something else is on top at that point, a click would hit THAT. The absence of
    // this check is what produced a tick for a click that did nothing at all. A hit on our
    // own subtree, or on an ancestor that will forward the event, is not occlusion.
    const hit = document.elementFromPoint(x, y);
    if (!(hit && (hit === el || el.contains(hit) || hit.contains(el)))) {
      const b = hit ? (hit.getAttribute('aria-label') || hit.innerText || hit.tagName) : 'nothing';
      firstProblem = firstProblem || {
        count: vis.length, label, x, y,
        blockedBy: String(b).replace(/\\s+/g, ' ').trim().slice(0, 60) || 'an element with no label',
      };
      continue;
    }
    return JSON.stringify({ count: vis.length, x, y, label });
  }
  return JSON.stringify(firstProblem);
})()`;
}

export async function handleStopAndShare(args, deps) {
  const { state, readFileBytes, statSize, removeDir } = deps;
  const rec = state.recording;
  if (!rec) return fail('I am not recording anything, so there is nothing to share.');
  state.recording = null;

  await finishCapture(rec);
  // Measured to when capture ENDED, not to now. The cap may have stopped it ten minutes ago,
  // and reporting the wall clock since would describe a video that does not exist.
  const recordedMs = recordedElapsedMs(rec, rec.endedAt ?? Date.now());
  const seconds = Math.round(recordedMs / 1000);
  // Why the video is shorter than the session. Without this the honest fix reads as a bug.
  const clockLine = captureSummary({
    recordedMs,
    wallMs: (rec.endedAt ?? Date.now()) - rec.t0,
    pauses: rec.pauseCount ?? 0,
    pausedMs: rec.pausedMs ?? 0,
  });
  rec.trace?.write('stop', {
    recordedMs: Math.round(recordedMs),
    pauses: rec.pauseCount ?? 0,
    pausedMs: Math.round(rec.pausedMs ?? 0),
    captured: rec.captured,
    emitted: rec.emitted,
    capped: rec.capped,
    encoderExit: rec.encoderExit,
    ffErr: rec.ffErr ? rec.ffErr.slice(-500) : undefined,
    discard: args?.discard === true,
  });
  const lost = droppedFrames({ elapsedMs: recordedMs, emitted: rec.emitted, fps: rec.fps ?? RECORD_FPS });

  // The browser and its profile go whatever happens next; the VIDEO does not, until it is
  // safely uploaded. A walkthrough cannot be repeated, so it outlives every failure below.
  //
  // Only offer it if there is something worth opening. ffmpeg killed before it wrote a moov
  // atom leaves a stub or nothing at all, and pointing the human at that would litter their
  // /tmp with empty directories while promising a file they cannot use.
  const keepTheVideo = async () => {
    const size = await statSize(rec.outPath).catch(() => 0);
    if (size > 1000) {
      return (
        ` The recording is kept at ${rec.outPath} (${seconds}s, ${(size / 1024 / 1024).toFixed(1)} MB) — ` +
        'you can share it with yorecord_share_video once the problem is fixed.'
      );
    }
    await removeDir(rec.outDir).catch(() => {});
    return ' There was no usable video to keep.';
  };
  // A rehearsal filmed nothing, so there is nothing to publish and no branch below applies.
  // Handled before finishCapture rather than inside it: every exit from here either uploads
  // or offers a file, and a rehearsal must do neither.
  if (rec.rehearsing) {
    await finishCapture(rec);
    await closeBrowser(rec, removeDir);
    rec.trace?.write('stop', { rehearsal: true });
    rec.trace?.close();
    return ok(
      'Rehearsal over. Nothing was filmed and nothing was uploaded — that is what a rehearsal ' +
        'is for. Start a real recording with the same profile and replay the script that ' +
        'passed; do it in this session, because the logged-in browser state is held in memory ' +
        'and a restart loses it.' +
        (rec.trace?.path ? `\nTrace: ${rec.trace.path}` : ''),
    );
  }

  const cleanUp = async () => {
    await closeBrowser(rec, removeDir);
    rec.trace?.close();
  };
  /** Where to read afterwards. Named on every exit, because every exit is worth reading. */
  const traceLine = rec.trace?.path ? `\nTrace: ${rec.trace.path}` : '';

  // STOP AND HAND IT OVER WITHOUT PUBLISHING.
  //
  // The default for anything a human will look at, and the resolution of the one tension in
  // this feature: a link that opens an already-edited video requires the video to be uploaded
  // BEFORE anyone has watched it. On the field recording that would have put colleague emails
  // and customer names on a public URL for seven days, before the human could trim them.
  //
  // So the bytes stay here. The human opens /import, drops both files, reviews the edit in
  // the editor with the zooms and captions already applied, and publishes from there — with
  // their own quota, 31-day retention and a revoke key, all of which an agent share lacks.
  if (args?.edit === true) {
    await cleanUp();
    const size = await statSize(rec.outPath).catch(() => 0);
    if (size <= 1000) {
      await removeDir(rec.outDir).catch(() => {});
      return ok(`Stopped and uploaded nothing. ${clockLine}\nThere was no usable video to keep.${traceLine}`);
    }
    const editPath = join(rec.outDir, 'edit.json');
    const doc = editDocumentSidecar(rec, { recordedMs });
    const wrote = await deps.writeTextFile(editPath, JSON.stringify(doc, null, 2)).then(() => true).catch(() => false);
    return ok(
      `Stopped. ${clockLine} NOTHING was uploaded.\n` +
        `Video: ${rec.outPath} (${(size / 1024 / 1024).toFixed(1)} MB)\n` +
        (wrote
          ? `Edits: ${editPath} — ${String(doc.zoomSegments.length)} zoom(s), ` +
            `${String(doc.captions.length)} caption(s), ${String(doc.chapters.length)} chapter(s)\n` +
            `\nTell your human to open ${SITE_BASE}/import and drop BOTH files in. The editor ` +
            `opens with the zooms and captions already on the timeline, and they publish from ` +
            `there once they have watched it — which is the point, because the video shows ` +
            `everything that was on screen.`
          : `I could not write the edit document, so the video imports unedited.`) +
        traceLine,
    );
  }

  // STOP WITHOUT PUBLISHING.
  //
  // Without this the lifecycle had exactly one exit and it uploaded. An agent that had used
  // its three takes for exploration overwrote each video with a one-second black clip and
  // shared THAT, three times, purely to unlock the tool - so three junk links are public for
  // seven days, and the real recordings were nearly lost to a workaround.
  if (args?.discard === true) {
    await cleanUp();
    const size = await statSize(rec.outPath).catch(() => 0);
    if (size > 1000) {
      return ok(
        `Stopped and uploaded NOTHING. ${clockLine}\nThe video is at ${rec.outPath} ` +
          `(${(size / 1024 / 1024).toFixed(1)} MB) if you want to look at it or share it later ` +
          `with yorecord_share_video. You can start a new recording now.${traceLine}`,
      );
    }
    await removeDir(rec.outDir).catch(() => {});
    return ok(`Stopped and uploaded nothing. ${clockLine}\nThere was no usable video to keep.${traceLine}`);
  }

  // A process that died mid-recording produces a video that just stops, and used to be
  // reported as a complete success. On Node 22 neither death raises anything at all: writes to
  // a dead child's stdin are silently discarded, so nothing surfaces without this check.
  if (rec.encoderExit !== null) {
    await cleanUp();
    return fail(
      `ffmpeg stopped after ${seconds}s of recording (${String(rec.encoderExit)}), so the video ` +
        `is incomplete or missing. ${rec.ffErr ? `It said: ${rec.ffErr.slice(-300)}` : 'It said nothing.'} ` +
        `Nothing was uploaded.${await keepTheVideo()}`,
    );
  }

  let bytes;
  try {
    const size = await statSize(rec.outPath);
    if (!size) throw new Error('the encoder produced an empty file');
    if (size > AGENT_MAX_UPLOAD_BYTES) {
      await cleanUp();
      return fail(
        `The recording came to ${(size / 1024 / 1024).toFixed(1)} MB and the limit is 50 MiB. ` +
          `Record a shorter walkthrough, or pass a lower fps — this page paints a lot and frame ` +
          `rate costs bytes roughly in proportion (${String(rec.fps)} was used here). ` +
          `Nothing was uploaded.${await keepTheVideo()}`,
      );
    }
    bytes = await readFileBytes(rec.outPath);
  } catch (e) {
    await cleanUp();
    return fail(
      `I recorded ${seconds}s but could not produce a video: ${String(e.message ?? e)}. ` +
        (rec.ffErr ? `ffmpeg said: ${rec.ffErr.slice(-400)}` : 'ffmpeg said nothing at all.') +
        ' Nothing was uploaded.' +
        (rec.browserDied ? ' The browser had already closed, which is the likely cause.' : ''),
    );
  }

  if (!looksLikeMp4Head(bytes)) {
    await cleanUp();
    return fail(
      `What came out of the encoder is not an MP4. ffmpeg said: ${rec.ffErr.slice(-400) || '(nothing)'}` +
        (await keepTheVideo()),
    );
  }

  const token = await ensureToken(deps);
  if (!token.token) {
    // The video is kept, not thrown away: the human can approve and the agent can share the
    // file directly. Losing a walkthrough to an approval prompt would be unforgivable.
    //
    // The BROWSER still goes. Leaving it running here leaked a Chrome and a cookie-holding
    // profile with no handle to either, because state.recording is already null.
    await cleanUp();
    return ok(
      `${token.message}\n\nThe recording is safe at ${rec.outPath} (${seconds}s, ` +
        `${(bytes.length / 1024 / 1024).toFixed(1)} MB). Once approved, call ` +
        `yorecord_share_video with that path.`,
    );
  }

  const result = await uploadMp4({ bytes, token: token.token, deps });
  await cleanUp();
  // Only NOW is the video expendable. It used to live inside the profile directory that
  // cleanUp deletes, so a refused upload - the tenth of the day, an expired token, a blocked
  // network - destroyed the walkthrough while reporting the refusal.
  if (!result.ok) return fail(`${result.message}${await keepTheVideo()}`);
  await removeDir(rec.outDir).catch(() => {});

  const notes = [];
  if (rec.actions.length > 0) {
    // The sidecar: labelled timestamps, stored but not yet displayed by the viewer.
    //
    // The RESPONSE is checked. Ignoring it hid a 422 on every single upload for the whole life
    // of the feature - a rejection is a resolved fetch, so `.catch()` never saw it.
    const res = await deps
      .fetch(`${UPLOAD_BASE}/agent-shares/${result.uid}/steps.json`, {
        method: 'PUT',
        headers: { Authorization: `Bearer ${token.token}`, 'Content-Type': 'application/json' },
        body: JSON.stringify(stepsSidecar(rec.actions)),
      })
      .catch(() => null);
    if (!res || !res.ok) {
      const why = res ? `${String(res.status)} ${String((await res.json().catch(() => ({}))).error ?? '')}` : 'no response';
      notes.push(`The step list could not be attached (${why.trim()}), but the video is fine.`);
    }
  }

  // The edit document: what the EDITOR opens with. Uploaded on the same terms as the steps
  // sidecar — same 64 KiB cap, same ownership check, no daily slot — and its response is
  // checked for the same reason that one's is.
  let editLink = null;
  {
    const editRes = await deps
      .fetch(`${UPLOAD_BASE}/agent-shares/${result.uid}/edit.json`, {
        method: 'PUT',
        headers: { Authorization: `Bearer ${token.token}`, 'Content-Type': 'application/json' },
        body: JSON.stringify(editDocumentSidecar(rec, { recordedMs })),
      })
      .catch(() => null);
    if (editRes?.ok) {
      editLink = `${SITE_BASE}/import?uid=${result.uid}&p=agent`;
    } else {
      const why = editRes
        ? `${String(editRes.status)} ${String((await editRes.json().catch(() => ({}))).error ?? '')}`
        : 'no response';
      // Never block the video on its sidecar. The video is the thing that cannot be repeated.
      notes.push(`The zooms and captions could not be attached (${why.trim()}), so the link opens an unedited video.`);
    }
  }

  if (rec.capped) notes.push(`It hit the ${RECORD_MAX_SECONDS / 60}-minute cap and stopped itself.`);
  // One frame of slack: capture stops BETWEEN ticks, so the tick that was due when we stopped
  // is always outstanding. Reporting that as a loss would put a scary number on every healthy
  // recording, which is how a real warning gets ignored.
  if (lost > 1) notes.push(`${lost} frames are missing because the encoder fell behind.`);
  if (rec.captured === 0) notes.push('The page never painted, so the video may be blank.');
  if (rec.browserDied) notes.push('The browser closed before I stopped recording, so the video may end early.');

  return ok(
    `Shared: ${result.url}\n${clockLine} ${(bytes.length / 1024 / 1024).toFixed(1)} MB, ` +
      `${rec.width}x${rec.height}, no audio.\n${PUBLIC_NOTE}` +
      (editLink ? `\nTo EDIT it — zooms, captions and chapters already on the timeline: ${editLink}` : '') +
      (notes.length ? `\n${notes.join(' ')}` : '') +
      traceLine,
  );
}

// -- CLI --------------------------------------------------------------------

// `yorecord share <file.mp4>` — the same logic as the MCP tool, for anything with a shell.
//
// It exists because MCP is not everywhere: CI has none, and neither does a harness that can
// only run a command. Sharing one implementation means there is one behaviour to reason
// about, and the MCP tests and these tests cover the same code.


/**
 * Parse a trace, skipping whatever the last line turned into.
 *
 * A recording killed mid-take leaves a half-written final line, and that is precisely the
 * file someone wants to read. Throwing on it would make the tool useless in its best case.
 */
export function readTraceFile(file) {
  return readFileSync(file, 'utf8')
    .split('\n')
    .filter(Boolean)
    .map((l) => {
      try {
        return JSON.parse(l);
      } catch {
        return null;
      }
    })
    .filter(Boolean);
}

/** The most recently modified trace, which is nearly always the one you mean. */
function latestTracePath() {
  const dir = join(TOKEN_DIR, 'traces');
  try {
    return readdirSync(dir)
      .filter((f) => f.endsWith('.jsonl'))
      .map((f) => ({ f: join(dir, f), m: statSync(join(dir, f)).mtimeMs }))
      .sort((a, b) => b.m - a.m)[0]?.f ?? null;
  } catch {
    return null;
  }
}

const USAGE = `yorecord — share a video your agent already has, as a link a human can open.

Usage:
  yorecord share <file.mp4>
  yorecord trace [file.jsonl]     read back what a recording did; no argument = the latest

The first run prints a URL for your human to approve, once per machine. Links are PUBLIC to
anyone who has them and are deleted after 7 days. MP4 only, 50 MiB max, 10 uploads a day.

Every recording writes a trace to ~/.config/yorecord/traces/ (or $YORECORD_CONFIG_DIR).`;

/**
 * Pure-ish entry point: dependencies and the output sink are injected, so the tests never
 * touch a disk or a network. Returns the process exit code.
 */
export async function runCli(argv, deps, write) {
  const [command, path] = argv;

  if (!command || command === 'help' || command === '--help') {
    write(USAGE);
    return 0;
  }

  // Reading a trace is a LOCAL, offline command: no token, no network, nothing to approve.
  // It lives here rather than in the repo's scripts because debugging happens on the machine
  // that holds the trace, and that machine has only this one file.
  if (command === 'trace') {
    const file = path ?? latestTracePath();
    if (!file) {
      write(`No traces found in ${join(TOKEN_DIR, 'traces')}. Record something first.`);
      return 1;
    }
    let events;
    try {
      events = readTraceFile(file);
    } catch (e) {
      write(`Could not read ${file}: ${String(e.message ?? e)}`);
      return 1;
    }
    write(`${file}\n\n${formatTraceSummary(summariseTrace(events))}`);
    return 0;
  }

  if (command !== 'share') {
    write(`Unknown command: ${String(command)}\n\n${USAGE}`);
    return 1;
  }

  const out = await handleShareVideo({ path }, deps);

  if (out.isError) {
    write(out.text);
    return 1;
  }

  // A pending approval is not a success: nothing was shared, and a CI job with nobody to
  // click must not pass. The message still tells the human exactly what to do.
  const url = /https:\/\/yorecord\.com\/view\?[^\s]+/.exec(out.text)?.[0];
  if (!url) {
    write(out.text);
    return 1;
  }

  // The URL alone on the LAST line, so `| tail -1` works without parsing prose.
  write(out.text.replace(`Shared: ${url}\n`, '').trim());
  write(url);
  return 0;
}

// -- Real-world wiring --------------------------------------------------------
// Every decision above is pure and injected; this part is the only code that touches a
// disk, a clock or a network, and it is deliberately dull.

/**
 * Where the token and the pending approval live.
 *
 * A containerised agent runs with `$HOME=/root`, which is usually not a persistent mount - so
 * the human's approval was thrown away on every restart and they were asked to click Approve
 * again, for ever. Measured on a real Hermes install, where only /opt/data survives.
 */
export function configDirFrom(env, home) {
  const override = String(env?.YORECORD_CONFIG_DIR ?? '').trim();
  return override.length > 0 ? override : join(home, '.config', 'yorecord');
}

const TOKEN_DIR = configDirFrom(process.env, homedir());
const TOKEN_PATH = join(TOKEN_DIR, 'token');
const PENDING_PATH = join(TOKEN_DIR, 'pending.json');

export const realDeps = {
  readToken: async () => {
    try {
      const t = (await readFile(TOKEN_PATH, 'utf8')).trim();
      return t.length > 0 ? t : null;
    } catch {
      return null;
    }
  },
  writeToken: async (token) => {
    await mkdir(TOKEN_DIR, { recursive: true, mode: 0o700 });
    await writeFile(TOKEN_PATH, token, { mode: 0o600 });
    // Set explicitly as well as at create time: an existing file keeps its old mode.
    await chmod(TOKEN_PATH, 0o600);
  },
  // The pending approval is kept on disk so the NEXT call resumes it. Without this, every
  // call minted a new code while the human was approving the previous one - three codes in a
  // row in a real trace, with no way for the user to win.
  readPending: async () => {
    try {
      return JSON.parse(await readFile(PENDING_PATH, 'utf8'));
    } catch {
      return null;
    }
  },
  writePending: async (record) => {
    await mkdir(TOKEN_DIR, { recursive: true, mode: 0o700 });
    await writeFile(PENDING_PATH, JSON.stringify(record), { mode: 0o600 });
  },
  clearPending: async () => {
    try {
      await rm(PENDING_PATH);
    } catch {
      /* already gone */
    }
  },
  readFile: async (p) => new Uint8Array(await readFile(p)),
  readFileBytes: async (p) => new Uint8Array(await readFile(p)),
  statSize: async (p) => (await stat(p)).size,
  fetch: (...a) => fetch(...a),
  randomUuid: () => crypto.randomUUID(),
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),

  // Recording.
  spawn,
  fileExists: (p) => existsSync(p),
  // No process spawned to answer "is ffmpeg installed": walking PATH is cheaper and cannot
  // hang. `.exe` covers Windows, where PATH is semicolon-separated.
  which: async (cmd) => {
    const sep = process.platform === 'win32' ? ';' : ':';
    const names = process.platform === 'win32' ? [`${cmd}.exe`, cmd] : [cmd];
    return (process.env.PATH ?? '')
      .split(sep)
      .some((dir) => dir && names.some((n) => existsSync(join(dir, n))));
  },
  makeTempDir: () => mkdtemp(join(tmpdir(), 'yorecord-')),
  // maxRetries is the documented remedy for the ENOTEMPTY/EBUSY race a shutting-down Chrome
  // creates: it rewrites cache files while the delete walks the tree, so the contents go and
  // the empty directory stays. Measured as a pile of empty /tmp/yorecord-* folders.
  removeDir: (p) => rm(p, { recursive: true, force: true, maxRetries: 5, retryDelay: 150 }),
  readTextFile: (p) => readFile(p, 'utf8'),
  writeTextFile: (p, text) => writeFile(p, text, 'utf8'),
  env: process.env,
  // One recording per server process. The state is here rather than at module scope so a
  // test can hand in its own.
  state: {},
};

/** MCP over stdio: read a line, dispatch, write a line. */
export function serveMcp(deps = realDeps, stdin = process.stdin, stdout = process.stdout) {
  const send = (msg) => stdout.write(`${JSON.stringify(msg)}\n`);
  const result = (id, value) => send({ jsonrpc: '2.0', id, result: value });

  const dispatch = async (msg) => {
    const { id, method, params } = msg;
    // Notifications carry no id and expect no reply — checked FIRST, so a notification-shaped
    // `initialize` does not get answered with `id: undefined`.
    if (id === undefined) return undefined;
    if (method === 'initialize') {
      return result(id, {
        protocolVersion: '2024-11-05',
        capabilities: { tools: {}, prompts: {} },
        serverInfo: { name: 'yorecord', version: VERSION },
      });
    }
    if (method === 'tools/list') return result(id, { tools: MCP_TOOLS });

    // MCP prompts cost the user nothing - no second install - and in Claude Code they show up
    // as slash commands. They carry PROCEDURE, which is the part a tool description cannot:
    // a description is read on every call and has to stay short.
    if (method === 'prompts/list') {
      return result(id, {
        prompts: Object.keys(PLAYBOOKS).map((name) => ({
          name,
          description: `How to record a ${name.replace('-', '/')} video well, and what to avoid.`,
        })),
      });
    }
    if (method === 'prompts/get') {
      const name = String(params?.name ?? '');
      if (!PLAYBOOKS[name]) {
        return send({
          jsonrpc: '2.0',
          id,
          error: { code: -32602, message: `Unknown prompt: ${name}. Try ${Object.keys(PLAYBOOKS).join(', ')}.` },
        });
      }
      return result(id, {
        description: `Record a ${name} video with yorecord`,
        messages: [
          {
            role: 'user',
            content: {
              type: 'text',
              text:
                `Record a video with yorecord, following this playbook. Ask me what to record ` +
                `if it is not already clear.\n\n${PLAYBOOKS[name]}`,
            },
          },
        ],
      });
    }
    if (method === 'tools/call') {
      const handler = TOOL_HANDLERS[params?.name];
      if (!handler) {
        return result(id, {
          content: [{ type: 'text', text: `Unknown tool: ${String(params?.name)}` }],
          isError: true,
        });
      }
      // Never throw at the protocol layer: an agent shows tool output to its human, and a
      // stack trace is a worse answer than a sentence.
      try {
        const out = await handler(params?.arguments ?? {}, deps);
        return result(id, {
          content: toolContent({ text: out.text, imageJpegBase64: out.imageJpegBase64 }),
          isError: Boolean(out.isError),
        });
      } catch (e) {
        return result(id, { content: [{ type: 'text', text: `yorecord failed: ${String(e)}` }], isError: true });
      }
    }
    return send({ jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${String(method)}` } });
  };

  createInterface({ input: stdin }).on('line', (line) => {
    const text = line.trim();
    if (!text) return;
    let msg;
    try {
      msg = JSON.parse(text);
    } catch {
      return; // A malformed line is not worth killing the server over.
    }
    void dispatch(msg);
  });
}

// Run only when executed directly, never when imported by a test.
//
// Compared by RESOLVED URL, not by filename. Matching `endsWith('yorecord.mjs')` meant the
// same bytes under any other name did nothing at all - no server, no CLI, no error, exit 0 -
// and an MCP client reports that as "failed to connect" with nothing to read. People rename
// downloads, and browsers add .download.
const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (invokedDirectly) {
  if (process.argv.includes('--mcp')) {
    serveMcp();
  } else {
    const code = await runCli(process.argv.slice(2), realDeps, (line) => process.stdout.write(`${line}\n`));
    process.exit(code);
  }
}
