@runalabs/rill-cli 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +35 -16
  2. package/dist/cli.js +963 -506
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -5,80 +5,9 @@ var __export = (target, all) => {
5
5
  __defProp(target, name, { get: all[name], enumerable: true });
6
6
  };
7
7
 
8
- // src/cli.ts
9
- import { spawn as spawn6 } from "child_process";
10
- import { Command } from "commander";
11
- import { hostname as hostname3 } from "os";
12
- import { basename, dirname as dirname2 } from "path";
13
- import { readdir, rm as rm2, stat as stat2 } from "fs/promises";
14
-
15
- // ../recorder/src/client.ts
16
- import { request } from "http";
17
- import { spawn } from "child_process";
18
-
19
- // ../recorder/src/paths.ts
20
- import { homedir, tmpdir } from "os";
8
+ // src/remote-encoding.ts
9
+ import { open, readFile as readFile2 } from "fs/promises";
21
10
  import { join } from "path";
22
- var rillDirectory = () => process.env.RILL_HOME ?? join(homedir(), ".rill");
23
- var daemonSocketPath = () => process.env.RILL_SOCKET ?? join(rillDirectory(), "recorder.sock");
24
- var daemonStatePath = () => join(rillDirectory(), "sessions.json");
25
- var recordingTempRoot = () => process.env.RILL_TEMP ?? join(tmpdir(), "rill-recordings");
26
-
27
- // ../recorder/src/client.ts
28
- async function ensureDaemon(cliEntryPath) {
29
- try {
30
- await callDaemon("GET", "/health");
31
- return;
32
- } catch {
33
- }
34
- const child = spawn(process.execPath, [cliEntryPath, "daemon"], { detached: true, stdio: "ignore", env: process.env });
35
- child.unref();
36
- const deadline = Date.now() + 1e4;
37
- while (Date.now() < deadline) {
38
- try {
39
- await callDaemon("GET", "/health");
40
- return;
41
- } catch {
42
- await new Promise((resolve) => setTimeout(resolve, 100));
43
- }
44
- }
45
- throw new Error("The local recorder service did not start.");
46
- }
47
- function startRecording(input) {
48
- return callDaemon("POST", "/sessions", input);
49
- }
50
- function stopRecording(recordingId) {
51
- return callDaemon("POST", `/sessions/${encodeURIComponent(recordingId)}/stop`, {});
52
- }
53
- function recordingStatus(recordingId) {
54
- return callDaemon("GET", `/sessions/${encodeURIComponent(recordingId)}`);
55
- }
56
- function callDaemon(method, path, body) {
57
- return new Promise((resolve, reject) => {
58
- const payload = body === void 0 ? void 0 : Buffer.from(JSON.stringify(body));
59
- const outgoing = request({ socketPath: daemonSocketPath(), method, path, headers: payload ? { "Content-Type": "application/json", "Content-Length": payload.length } : void 0 }, (incoming) => {
60
- const chunks = [];
61
- incoming.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
62
- incoming.on("end", () => {
63
- try {
64
- const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
65
- if ((incoming.statusCode ?? 500) >= 400) reject(new Error(value.error?.message ?? `Recorder returned ${incoming.statusCode}.`));
66
- else resolve(value);
67
- } catch (error51) {
68
- reject(error51);
69
- }
70
- });
71
- });
72
- outgoing.once("error", reject);
73
- if (payload) outgoing.write(payload);
74
- outgoing.end();
75
- });
76
- }
77
-
78
- // ../recorder/src/daemon.ts
79
- import { createServer as createServer2 } from "http";
80
- import { mkdir as mkdir3, readFile, unlink, writeFile as writeFile2 } from "fs/promises";
81
- import { dirname, join as join4 } from "path";
82
11
 
83
12
  // ../../node_modules/zod/v4/classic/external.js
84
13
  var external_exports = {};
@@ -14594,192 +14523,543 @@ function date4(params) {
14594
14523
  // ../../node_modules/zod/v4/classic/external.js
14595
14524
  config(en_default());
14596
14525
 
14597
- // ../contracts/src/recording.ts
14598
- var cursorAppearanceSchema = external_exports.object({
14599
- enabled: external_exports.boolean().default(true),
14600
- color: external_exports.string().regex(/^#[0-9a-fA-F]{6}$/).default("#6366f1")
14601
- });
14602
- var recordingStatusSchema = external_exports.enum([
14603
- "draft",
14604
- "recording",
14605
- "uploading",
14606
- "processing",
14607
- "ready",
14608
- "failed",
14609
- "revoked"
14610
- ]);
14611
- var recordingSchema = external_exports.object({
14612
- id: external_exports.string(),
14613
- title: external_exports.string(),
14614
- description: external_exports.string().default(""),
14615
- status: recordingStatusSchema,
14616
- sourceUrl: external_exports.string().nullable(),
14617
- createdAt: external_exports.string().datetime(),
14618
- durationSeconds: external_exports.number().nonnegative(),
14619
- width: external_exports.number().int().positive(),
14620
- height: external_exports.number().int().positive(),
14621
- shareSlug: external_exports.string().nullable(),
14622
- shareUrl: external_exports.string().nullable(),
14623
- thumbnailUrl: external_exports.string().nullable(),
14624
- playbackUrl: external_exports.string().nullable(),
14625
- expiresAt: external_exports.string().datetime().nullable(),
14626
- diagnostics: external_exports.object({
14627
- available: external_exports.boolean(),
14628
- bodyCaptureEnabled: external_exports.boolean(),
14629
- consoleErrors: external_exports.number().int().nonnegative(),
14630
- failedRequests: external_exports.number().int().nonnegative(),
14631
- httpErrors: external_exports.number().int().nonnegative(),
14632
- truncated: external_exports.boolean()
14633
- })
14526
+ // ../contracts/src/encoding.ts
14527
+ var ENCODING_PART_BYTES = 4 * 1024 * 1024;
14528
+ var ENCODING_MAX_BYTES = 1024 * 1024 * 1024;
14529
+ var frameManifestSchema = external_exports.object({
14530
+ version: external_exports.literal(1),
14531
+ durationSeconds: external_exports.number().positive().max(600),
14532
+ width: external_exports.number().int().min(320).max(3840),
14533
+ height: external_exports.number().int().min(240).max(2160),
14534
+ frames: external_exports.array(external_exports.object({ timestamp: external_exports.number().nonnegative().finite(), size: external_exports.number().int().positive().max(ENCODING_PART_BYTES) }).strict()).min(1).max(36e3)
14535
+ }).strict().superRefine((m, ctx) => {
14536
+ if (m.frames.reduce((n, f) => n + f.size, 0) > ENCODING_MAX_BYTES) ctx.addIssue({ code: "custom", message: "Frame upload exceeds the trial byte limit." });
14537
+ for (let i = 1; i < m.frames.length; i++) if (m.frames[i].timestamp < m.frames[i - 1].timestamp) ctx.addIssue({ code: "custom", message: "Frame timestamps must be ordered." });
14538
+ if (m.frames.at(-1).timestamp - m.frames[0].timestamp > m.durationSeconds + 1) ctx.addIssue({ code: "custom", message: "Frame timestamps exceed capture duration." });
14634
14539
  });
14635
- var agentOutcomeSchema = external_exports.object({
14636
- outcome: external_exports.enum(["walkthrough", "passed", "failed", "inconclusive"]),
14637
- summary: external_exports.string().min(1).max(1e3),
14638
- evidence: external_exports.string().max(1e3)
14639
- }).strict();
14640
14540
 
14641
- // ../recorder/src/cursor-overlay.ts
14642
- function installCursorOverlay(options) {
14643
- const scope = window;
14644
- if (scope[options.key]) return;
14645
- let host;
14646
- const controller = new AbortController();
14647
- const animations = /* @__PURE__ */ new Set();
14648
- const cleanup = () => {
14649
- controller.abort();
14650
- for (const animation of animations) animation.cancel();
14651
- host?.remove();
14652
- delete scope[options.key];
14653
- };
14654
- scope[options.key] = cleanup;
14655
- const mount = () => {
14656
- if (controller.signal.aborted || host || !document.documentElement) return;
14657
- host = document.createElement("div");
14658
- host.setAttribute("data-rill-cursor", "");
14659
- host.setAttribute("aria-hidden", "true");
14660
- host.style.cssText = "all:initial!important;position:fixed!important;inset:0!important;width:100%!important;height:100%!important;pointer-events:none!important;z-index:2147483647!important;";
14661
- const root = host.attachShadow({ mode: "closed" });
14662
- const style = document.createElement("style");
14663
- style.textContent = `
14664
- :host, * { pointer-events: none !important; }
14665
- .pointer { position:absolute; left:0; top:0; display:none; width:24px; height:28px; }
14666
- .halo { position:absolute; left:-12px; top:-12px; width:26px; height:26px; border-radius:50%; background:${options.color}; opacity:.22; }
14667
- svg { position:absolute; left:-2px; top:-2px; width:24px; height:28px; filter:drop-shadow(0 1px 2px #0008); }
14668
- .pulse { position:absolute; width:32px; height:32px; box-sizing:border-box; border:2px solid ${options.color}; border-radius:50%; }
14669
- `;
14670
- const pointer = document.createElement("div");
14671
- pointer.className = "pointer";
14672
- pointer.innerHTML = '<div class="halo"></div><svg viewBox="0 0 24 28" xmlns="http://www.w3.org/2000/svg"><path d="M3 2 L3 22 L8 17 L12 25 L16 23 L12 15 L20 15 Z" fill="white" stroke="#171717" stroke-width="1.5" stroke-linejoin="round"/></svg>';
14673
- root.append(style, pointer);
14674
- document.documentElement.appendChild(host);
14675
- const listenerOptions = { capture: true, passive: true, signal: controller.signal };
14676
- const hide = () => {
14677
- pointer.style.display = "none";
14678
- };
14679
- const move = (event) => {
14680
- if (event.pointerType === "touch") return;
14681
- if (event.target instanceof HTMLIFrameElement || event.target instanceof HTMLFrameElement) {
14682
- hide();
14683
- return;
14684
- }
14685
- pointer.style.display = "block";
14686
- pointer.style.transform = `translate(${event.clientX}px, ${event.clientY}px)`;
14687
- };
14688
- document.addEventListener("pointermove", move, listenerOptions);
14689
- document.addEventListener("pointerover", move, listenerOptions);
14690
- document.addEventListener("pointerdown", (event) => {
14691
- if (event.pointerType === "touch") return;
14692
- move(event);
14693
- const pulse = document.createElement("div");
14694
- pulse.className = "pulse";
14695
- pulse.style.left = `${event.clientX - 16}px`;
14696
- pulse.style.top = `${event.clientY - 16}px`;
14697
- root.insertBefore(pulse, pointer);
14698
- const animation = pulse.animate([
14699
- { transform: "scale(.4)", opacity: 0.9 },
14700
- { transform: "scale(1.8)", opacity: 0 }
14701
- ], { duration: 400, easing: "ease-out" });
14702
- animations.add(animation);
14703
- void animation.finished.catch(() => void 0).finally(() => {
14704
- animations.delete(animation);
14705
- pulse.remove();
14706
- });
14707
- }, listenerOptions);
14708
- document.addEventListener("pointerout", (event) => {
14709
- if (!event.relatedTarget) hide();
14710
- }, listenerOptions);
14711
- window.addEventListener("blur", hide, { signal: controller.signal });
14712
- document.addEventListener("visibilitychange", () => {
14713
- if (document.hidden) hide();
14714
- }, { signal: controller.signal });
14715
- };
14716
- if (document.documentElement) mount();
14717
- else document.addEventListener("DOMContentLoaded", mount, { once: true, signal: controller.signal });
14718
- }
14719
- async function attachCursorOverlay(page, cdp, input) {
14720
- const appearance = cursorAppearanceSchema.parse(input ?? {});
14721
- if (!appearance.enabled) return async () => {
14722
- };
14723
- const key = `__rillCursor_${crypto.randomUUID().replaceAll("-", "")}`;
14724
- const source = `(${installCursorOverlay.toString()})(${JSON.stringify({ ...appearance, key })})`;
14725
- await cdp.send("Page.enable");
14726
- const { identifier } = await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source });
14727
- let stopped = false;
14728
- const removeFromFrame = (frame) => frame.evaluate((key2) => {
14729
- const cleanup2 = window[key2];
14730
- if (typeof cleanup2 === "function") cleanup2();
14731
- }, key).catch(() => void 0);
14732
- const onNavigation = (frame) => {
14733
- if (stopped) return;
14734
- void frame.evaluate(source).then(() => {
14735
- if (stopped) return removeFromFrame(frame);
14736
- }).catch(() => void 0);
14737
- };
14738
- page.on("framenavigated", onNavigation);
14739
- const cleanup = async () => {
14740
- stopped = true;
14741
- page.off("framenavigated", onNavigation);
14742
- await cdp.send("Page.removeScriptToEvaluateOnNewDocument", { identifier }).catch(() => void 0);
14743
- await Promise.all(page.frames().map(removeFromFrame));
14744
- };
14745
- try {
14746
- await Promise.all(page.frames().map((frame) => frame.evaluate(source)));
14747
- return cleanup;
14748
- } catch (error51) {
14749
- await cleanup();
14750
- throw error51;
14541
+ // src/control-plane.ts
14542
+ import { createReadStream } from "fs";
14543
+ import { readFile, stat } from "fs/promises";
14544
+ import { Upload } from "tus-js-client";
14545
+ var ControlPlaneError = class extends Error {
14546
+ constructor(code, message, recovery, details, httpStatus) {
14547
+ super(`${code}: ${message}${recovery ? `: ${recovery}` : ""}`);
14548
+ this.code = code;
14549
+ this.recovery = recovery;
14550
+ this.details = details;
14551
+ this.httpStatus = httpStatus;
14552
+ this.name = "ControlPlaneError";
14751
14553
  }
14752
- }
14753
-
14754
- // ../recorder/src/session.ts
14755
- import { chromium } from "playwright-core";
14756
- import { spawn as spawn3 } from "child_process";
14757
- import { createServer } from "net";
14758
- import { mkdir as mkdir2, mkdtemp, rm } from "fs/promises";
14759
- import { join as join3 } from "path";
14760
- import { tmpdir as tmpdir2 } from "os";
14761
-
14762
- // ../recorder/src/bundle.ts
14763
- import { createGzip } from "zlib";
14764
- import { createWriteStream } from "fs";
14765
- import { once } from "events";
14766
- async function writeBundle(bundle, outputPath) {
14767
- const output = createWriteStream(outputPath, { mode: 384 });
14768
- const gzip = createGzip({ level: 9 });
14769
- gzip.pipe(output);
14770
- const { console: consoleEvents, network, timeline, ...manifest } = bundle;
14771
- gzip.write(`${JSON.stringify({ kind: "manifest", ...manifest })}
14772
- `);
14773
- for (const event of consoleEvents) gzip.write(`${JSON.stringify({ kind: "console", ...event })}
14774
- `);
14775
- for (const event of network) gzip.write(`${JSON.stringify({ kind: "network", ...event })}
14776
- `);
14777
- for (const event of timeline) gzip.write(`${JSON.stringify({ kind: "timeline", ...event })}
14778
- `);
14779
- gzip.end();
14780
- await once(output, "close");
14781
- }
14782
-
14554
+ };
14555
+ var ControlPlaneHttpError = class extends Error {
14556
+ constructor(httpStatus) {
14557
+ super(`Request failed (${httpStatus}).`);
14558
+ this.httpStatus = httpStatus;
14559
+ this.name = "ControlPlaneHttpError";
14560
+ }
14561
+ };
14562
+ var ControlPlaneClient = class {
14563
+ constructor(apiUrl, token) {
14564
+ this.apiUrl = apiUrl;
14565
+ this.token = token;
14566
+ }
14567
+ encodingCapabilities() {
14568
+ return this.request("/api/encoding/capabilities");
14569
+ }
14570
+ beginEncoding(id, manifest) {
14571
+ return this.request(`/api/encoding/${encodeURIComponent(id)}`, { method: "POST", body: JSON.stringify(manifest) });
14572
+ }
14573
+ encodingStatus(id) {
14574
+ return this.request(`/api/encoding/${encodeURIComponent(id)}`);
14575
+ }
14576
+ uploadEncodingPart(id, part, bytes) {
14577
+ return this.request(`/api/encoding/${encodeURIComponent(id)}/parts/${part}`, { method: "PUT", headers: { "Content-Type": "application/octet-stream", "Content-Length": String(bytes.length) }, body: new Uint8Array(bytes), signal: AbortSignal.timeout(12e4) });
14578
+ }
14579
+ completeEncoding(id) {
14580
+ return this.request(`/api/encoding/${encodeURIComponent(id)}/complete`, { method: "POST" });
14581
+ }
14582
+ retryEncoding(id) {
14583
+ return this.request(`/api/encoding/${encodeURIComponent(id)}/retry`, { method: "POST" });
14584
+ }
14585
+ createCloudRun(input) {
14586
+ return this.request("/api/cloud/runs", { method: "POST", body: JSON.stringify(input) });
14587
+ }
14588
+ cloudRun(id) {
14589
+ return this.request(`/api/cloud/runs/${encodeURIComponent(id)}`);
14590
+ }
14591
+ cancelCloudRun(id) {
14592
+ return this.request(`/api/cloud/runs/${encodeURIComponent(id)}/cancel`, { method: "POST" });
14593
+ }
14594
+ health() {
14595
+ return this.request("/api/health", {}, false);
14596
+ }
14597
+ agentSession() {
14598
+ return this.request("/api/agent/session", { signal: AbortSignal.timeout(5e3) });
14599
+ }
14600
+ reportCliCommand(input) {
14601
+ return this.request("/api/analytics/cli-command", { method: "POST", body: JSON.stringify(input) });
14602
+ }
14603
+ createRecording(input) {
14604
+ return this.request("/api/recordings", { method: "POST", body: JSON.stringify(input) });
14605
+ }
14606
+ markRecordingStarted(recordingId, input) {
14607
+ return this.request(`/api/recordings/${recordingId}/local-started`, {
14608
+ method: "POST",
14609
+ body: JSON.stringify(input)
14610
+ });
14611
+ }
14612
+ markRecordingStopped(recordingId, input) {
14613
+ return this.request(`/api/recordings/${recordingId}/local-stopped`, {
14614
+ method: "POST",
14615
+ body: JSON.stringify(input)
14616
+ });
14617
+ }
14618
+ submitRecordingFeedback(recordingId, input) {
14619
+ return this.request(recordingId ? `/api/recordings/${recordingId}/feedback` : "/api/feedback", {
14620
+ method: "POST",
14621
+ body: JSON.stringify(input)
14622
+ });
14623
+ }
14624
+ provisionVideo(recordingId, input) {
14625
+ return this.request(`/api/recordings/${recordingId}/video-upload`, { method: "POST", body: JSON.stringify(input) });
14626
+ }
14627
+ recordingStatus(recordingId) {
14628
+ return this.request(`/api/recordings/${recordingId}`);
14629
+ }
14630
+ cancelRecording(recordingId, reason = "cancelled_by_agent") {
14631
+ return this.request(`/api/recordings/${recordingId}/cancel`, {
14632
+ method: "POST",
14633
+ body: JSON.stringify({ reason })
14634
+ });
14635
+ }
14636
+ async waitUntilPlayable(recordingId, timeoutMs = 10 * 6e4) {
14637
+ const deadline = Date.now() + timeoutMs;
14638
+ let intervalMs = 1e3;
14639
+ while (Date.now() < deadline) {
14640
+ const recording = await this.recordingStatus(recordingId);
14641
+ if (recording.status === "ready" && recording.shareUrl) {
14642
+ return { ...recording, shareUrl: new URL(recording.shareUrl, this.apiUrl).toString() };
14643
+ }
14644
+ if (recording.status === "failed") throw new Error(`processing_failed: ${recording.failure_message ?? "Cloudflare Stream could not process the recording."}`);
14645
+ process.stderr.write(`\rprocessing ${recording.status.padEnd(12)}`);
14646
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
14647
+ intervalMs = Math.min(5e3, Math.round(intervalMs * 1.4));
14648
+ }
14649
+ throw new Error("processing_failed: Timed out waiting for the recording to become playable. Run `rill record status <recording-id>` to continue checking.");
14650
+ }
14651
+ async uploadVideo(uploadUrl, filePath) {
14652
+ const file2 = await stat(filePath);
14653
+ if (uploadUrl.startsWith(this.apiUrl)) {
14654
+ const response = await fetch(uploadUrl, { method: "PUT", headers: { "Content-Length": String(file2.size) }, body: createReadStream(filePath), duplex: "half" });
14655
+ if (!response.ok) throw new Error(`Video upload failed (${response.status}).`);
14656
+ return;
14657
+ }
14658
+ await new Promise((resolve, reject) => {
14659
+ const upload = new Upload(createReadStream(filePath), {
14660
+ uploadUrl,
14661
+ uploadSize: file2.size,
14662
+ retryDelays: [0, 1e3, 3e3, 5e3, 1e4],
14663
+ removeFingerprintOnSuccess: true,
14664
+ onError: reject,
14665
+ onSuccess: () => resolve(),
14666
+ onProgress: (sent, total) => process.stderr.write(`\ruploading ${Math.round(sent / total * 100)}%`)
14667
+ });
14668
+ upload.start();
14669
+ });
14670
+ process.stderr.write("\n");
14671
+ }
14672
+ async uploadDiagnostics(recordingId, filePath, summary) {
14673
+ const body = await readFile(filePath);
14674
+ return this.request(`/api/recordings/${recordingId}/diagnostics`, { method: "PUT", headers: { "Content-Encoding": "gzip", "Content-Type": "application/x-ndjson", "Content-Length": String(body.length), "X-Rill-Summary": JSON.stringify(summary) }, body });
14675
+ }
14676
+ listRecordings() {
14677
+ return this.request("/api/recordings");
14678
+ }
14679
+ createDeviceAuthorization(input) {
14680
+ return this.request("/api/device/authorizations", { method: "POST", body: JSON.stringify(input) }, false);
14681
+ }
14682
+ pollDeviceAuthorization(deviceCode) {
14683
+ return this.request("/api/device/token", { method: "POST", body: JSON.stringify({ deviceCode }) }, false);
14684
+ }
14685
+ inspect(shareUrl) {
14686
+ if (/^rec_[a-zA-Z0-9_-]+$/.test(shareUrl)) return this.request(`/api/recordings/${encodeURIComponent(shareUrl)}`);
14687
+ const url2 = new URL(shareUrl);
14688
+ const slug = url2.pathname.split("/").filter(Boolean).at(-1);
14689
+ if (!slug) throw new Error("The share URL does not contain a slug.");
14690
+ return fetch(`${url2.origin}/api/public/shares/${encodeURIComponent(slug)}/context`, { headers: { Accept: "application/json" } }).then(async (response) => {
14691
+ if (!response.ok) throw new Error(`Could not inspect the share link (${response.status}).`);
14692
+ return response.json();
14693
+ });
14694
+ }
14695
+ async request(path, init = {}, authenticated = true) {
14696
+ const headers = new Headers(init.headers);
14697
+ if (init.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
14698
+ if (authenticated) {
14699
+ const token = typeof this.token === "function" ? await this.token() : this.token;
14700
+ if (!token) throw new Error("authentication_required: Run `rill login` or set RILL_TOKEN.");
14701
+ headers.set("Authorization", `Bearer ${token}`);
14702
+ }
14703
+ const response = await fetch(`${this.apiUrl}${path}`, { ...init, headers });
14704
+ const value = await response.json().catch(() => null);
14705
+ if (!response.ok && value?.error?.code && value.error.message) {
14706
+ throw new ControlPlaneError(value.error.code, value.error.message, value.error.recovery, value.error.details, response.status);
14707
+ }
14708
+ if (!response.ok) throw new ControlPlaneHttpError(response.status);
14709
+ return value;
14710
+ }
14711
+ };
14712
+
14713
+ // src/remote-encoding.ts
14714
+ async function withFrameUploadRetry(upload, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
14715
+ for (let attempt = 0; ; attempt++) {
14716
+ try {
14717
+ return await upload();
14718
+ } catch (error51) {
14719
+ const status = error51 instanceof ControlPlaneError || error51 instanceof ControlPlaneHttpError ? error51.httpStatus : void 0;
14720
+ const transient = status !== void 0 ? status === 408 || status === 429 || status >= 500 : error51 instanceof TypeError || error51 instanceof DOMException && ["TimeoutError", "AbortError"].includes(error51.name);
14721
+ if (!transient || attempt >= 7) throw error51;
14722
+ const delay = Math.min(15e3, 1e3 * 2 ** attempt);
14723
+ process.stderr.write(`
14724
+ Frame upload interrupted; retrying in ${delay / 1e3}s.
14725
+ `);
14726
+ await sleep(delay);
14727
+ }
14728
+ }
14729
+ }
14730
+ async function submitFrameCapture(client, id, directory) {
14731
+ const manifest = frameManifestSchema.parse(JSON.parse(await readFile2(join(directory, "capture-manifest.json"), "utf8")));
14732
+ let status = await client.beginEncoding(id, manifest);
14733
+ if (status.status === "failed") status = await client.retryEncoding(id);
14734
+ if (status.status !== "uploading") {
14735
+ if (["expired", "cancelled"].includes(status.status)) throw new Error(`Capture ${status.status}; it cannot be retried.`);
14736
+ return status;
14737
+ }
14738
+ const file2 = await open(join(directory, "frames.bin"), "r");
14739
+ try {
14740
+ const total = manifest.frames.reduce((n, f) => n + f.size, 0);
14741
+ if ((await file2.stat()).size !== total) throw new Error("Capture bytes do not match the manifest.");
14742
+ const uploaded = new Set(status.uploadedParts);
14743
+ for (let part = 0; part < Math.ceil(total / ENCODING_PART_BYTES); part++) {
14744
+ if (uploaded.has(part)) continue;
14745
+ const bytes = Buffer.alloc(Math.min(ENCODING_PART_BYTES, total - part * ENCODING_PART_BYTES));
14746
+ let offset = 0;
14747
+ while (offset < bytes.length) {
14748
+ const r = await file2.read(bytes, offset, bytes.length - offset, part * ENCODING_PART_BYTES + offset);
14749
+ if (!r.bytesRead) throw new Error("Incomplete capture bytes.");
14750
+ offset += r.bytesRead;
14751
+ }
14752
+ await withFrameUploadRetry(() => client.uploadEncodingPart(id, part, bytes));
14753
+ process.stderr.write(`\ruploading frames ${Math.round(Math.min(total, (part + 1) * ENCODING_PART_BYTES) / total * 100)}%`);
14754
+ }
14755
+ } finally {
14756
+ await file2.close();
14757
+ }
14758
+ return client.completeEncoding(id);
14759
+ }
14760
+ async function waitForEncoding(client, id, timeoutMs = 30 * 6e4) {
14761
+ const deadline = Date.now() + timeoutMs;
14762
+ while (Date.now() < deadline) {
14763
+ const status = await client.encodingStatus(id);
14764
+ if (status.status === "ready") return { ...status, ...await client.recordingStatus(id), recordingId: id };
14765
+ if (["failed", "expired", "cancelled"].includes(status.status)) throw new Error(`${status.error ?? status.status}. Retry with rill record retry ${id}; capture expires ${status.expiresAt}.`);
14766
+ process.stderr.write(`\r${status.status.padEnd(20)}`);
14767
+ await new Promise((r) => setTimeout(r, 2e3));
14768
+ }
14769
+ throw new Error(`Encoding continues remotely. Check rill record status ${id}.`);
14770
+ }
14771
+
14772
+ // src/cli.ts
14773
+ import { spawn as spawn6 } from "child_process";
14774
+ import { hostname as hostname4 } from "os";
14775
+ import { Command } from "commander";
14776
+ import { basename, dirname as dirname2 } from "path";
14777
+ import { readdir as readdir2, rm as rm5, stat as stat4 } from "fs/promises";
14778
+
14779
+ // ../recorder/src/client.ts
14780
+ import { request } from "http";
14781
+ import { spawn } from "child_process";
14782
+
14783
+ // ../recorder/src/paths.ts
14784
+ import { homedir, tmpdir } from "os";
14785
+ import { join as join2 } from "path";
14786
+ var rillDirectory = () => process.env.RILL_HOME ?? join2(homedir(), ".rill");
14787
+ var daemonSocketPath = () => process.env.RILL_SOCKET ?? join2(rillDirectory(), "recorder-v2.sock");
14788
+ var daemonStatePath = () => join2(rillDirectory(), "sessions-v2.json");
14789
+ var recordingTempRoot = () => process.env.RILL_TEMP ?? join2(tmpdir(), "rill-recordings");
14790
+
14791
+ // ../recorder/src/client.ts
14792
+ async function ensureDaemon(cliEntryPath) {
14793
+ try {
14794
+ await callDaemon("GET", "/health");
14795
+ return;
14796
+ } catch {
14797
+ }
14798
+ const child = spawn(process.execPath, [cliEntryPath, "daemon"], { detached: true, stdio: "ignore", env: process.env });
14799
+ child.unref();
14800
+ const deadline = Date.now() + 1e4;
14801
+ while (Date.now() < deadline) {
14802
+ try {
14803
+ await callDaemon("GET", "/health");
14804
+ return;
14805
+ } catch {
14806
+ await new Promise((resolve) => setTimeout(resolve, 100));
14807
+ }
14808
+ }
14809
+ throw new Error("The local recorder service did not start.");
14810
+ }
14811
+ function startRecording(input) {
14812
+ return callDaemon("POST", "/sessions", input);
14813
+ }
14814
+ function stopRecording(recordingId) {
14815
+ return callDaemon("POST", `/sessions/${encodeURIComponent(recordingId)}/stop`, {});
14816
+ }
14817
+ function recordingStatus(recordingId) {
14818
+ return callDaemon("GET", `/sessions/${encodeURIComponent(recordingId)}`);
14819
+ }
14820
+ function callDaemon(method, path, body) {
14821
+ return new Promise((resolve, reject) => {
14822
+ const payload = body === void 0 ? void 0 : Buffer.from(JSON.stringify(body));
14823
+ const outgoing = request({ socketPath: daemonSocketPath(), method, path, headers: payload ? { "Content-Type": "application/json", "Content-Length": payload.length } : void 0 }, (incoming) => {
14824
+ const chunks = [];
14825
+ incoming.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
14826
+ incoming.on("end", () => {
14827
+ try {
14828
+ const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
14829
+ if ((incoming.statusCode ?? 500) >= 400) reject(new Error(value.error?.message ?? `Recorder returned ${incoming.statusCode}.`));
14830
+ else resolve(value);
14831
+ } catch (error51) {
14832
+ reject(error51);
14833
+ }
14834
+ });
14835
+ });
14836
+ outgoing.once("error", reject);
14837
+ if (payload) outgoing.write(payload);
14838
+ outgoing.end();
14839
+ });
14840
+ }
14841
+
14842
+ // ../recorder/src/retention.ts
14843
+ import { readdir, readFile as readFile3, rm } from "fs/promises";
14844
+ import { join as join3 } from "path";
14845
+ async function purgeExpiredRemoteCaptures(root, now = Date.now()) {
14846
+ const removed = [];
14847
+ for (const id of await readdir(root).catch(() => [])) {
14848
+ if (!/^rec_[a-zA-Z0-9_-]+$/.test(id)) continue;
14849
+ const directory = join3(root, id);
14850
+ try {
14851
+ const marker = JSON.parse(await readFile3(join3(directory, "remote-capture.json"), "utf8"));
14852
+ if (typeof marker.expiresAt !== "number" || marker.expiresAt > now) continue;
14853
+ await rm(directory, { recursive: true, force: true });
14854
+ removed.push(id);
14855
+ } catch {
14856
+ }
14857
+ }
14858
+ return removed;
14859
+ }
14860
+ async function hasRetainedRemoteCaptures(root) {
14861
+ for (const id of await readdir(root).catch(() => [])) {
14862
+ if (!/^rec_[a-zA-Z0-9_-]+$/.test(id)) continue;
14863
+ try {
14864
+ const marker = JSON.parse(await readFile3(join3(root, id, "remote-capture.json"), "utf8"));
14865
+ if (typeof marker.expiresAt === "number" && Number.isFinite(marker.expiresAt)) return true;
14866
+ } catch {
14867
+ }
14868
+ }
14869
+ return false;
14870
+ }
14871
+
14872
+ // ../recorder/src/daemon.ts
14873
+ import { createServer as createServer2 } from "http";
14874
+ import { mkdir as mkdir4, readFile as readFile5, unlink, writeFile as writeFile4, rename, stat as stat2 } from "fs/promises";
14875
+ import { dirname, join as join7 } from "path";
14876
+
14877
+ // ../contracts/src/recording.ts
14878
+ var cursorAppearanceSchema = external_exports.object({
14879
+ enabled: external_exports.boolean().default(true),
14880
+ color: external_exports.string().regex(/^#[0-9a-fA-F]{6}$/).default("#6366f1")
14881
+ });
14882
+ var recordingStatusSchema = external_exports.enum([
14883
+ "draft",
14884
+ "recording",
14885
+ "uploading",
14886
+ "processing",
14887
+ "ready",
14888
+ "failed",
14889
+ "revoked"
14890
+ ]);
14891
+ var recordingSchema = external_exports.object({
14892
+ id: external_exports.string(),
14893
+ title: external_exports.string(),
14894
+ description: external_exports.string().default(""),
14895
+ status: recordingStatusSchema,
14896
+ sourceUrl: external_exports.string().nullable(),
14897
+ createdAt: external_exports.string().datetime(),
14898
+ durationSeconds: external_exports.number().nonnegative(),
14899
+ width: external_exports.number().int().positive(),
14900
+ height: external_exports.number().int().positive(),
14901
+ shareSlug: external_exports.string().nullable(),
14902
+ shareUrl: external_exports.string().nullable(),
14903
+ thumbnailUrl: external_exports.string().nullable(),
14904
+ playbackUrl: external_exports.string().nullable(),
14905
+ expiresAt: external_exports.string().datetime().nullable(),
14906
+ diagnostics: external_exports.object({
14907
+ available: external_exports.boolean(),
14908
+ bodyCaptureEnabled: external_exports.boolean(),
14909
+ consoleErrors: external_exports.number().int().nonnegative(),
14910
+ failedRequests: external_exports.number().int().nonnegative(),
14911
+ httpErrors: external_exports.number().int().nonnegative(),
14912
+ truncated: external_exports.boolean()
14913
+ })
14914
+ });
14915
+ var agentOutcomeSchema = external_exports.object({
14916
+ outcome: external_exports.enum(["walkthrough", "passed", "failed", "inconclusive"]),
14917
+ summary: external_exports.string().min(1).max(1e3),
14918
+ evidence: external_exports.string().max(1e3)
14919
+ }).strict();
14920
+
14921
+ // ../recorder/src/cursor-overlay.ts
14922
+ function installCursorOverlay(options) {
14923
+ const scope = window;
14924
+ if (scope[options.key]) return;
14925
+ let host;
14926
+ const controller = new AbortController();
14927
+ const animations = /* @__PURE__ */ new Set();
14928
+ const cleanup = () => {
14929
+ controller.abort();
14930
+ for (const animation of animations) animation.cancel();
14931
+ host?.remove();
14932
+ delete scope[options.key];
14933
+ };
14934
+ scope[options.key] = cleanup;
14935
+ const mount = () => {
14936
+ if (controller.signal.aborted || host || !document.documentElement) return;
14937
+ host = document.createElement("div");
14938
+ host.setAttribute("data-rill-cursor", "");
14939
+ host.setAttribute("aria-hidden", "true");
14940
+ host.style.cssText = "all:initial!important;position:fixed!important;inset:0!important;width:100%!important;height:100%!important;pointer-events:none!important;z-index:2147483647!important;";
14941
+ const root = host.attachShadow({ mode: "closed" });
14942
+ const style = document.createElement("style");
14943
+ style.textContent = `
14944
+ :host, * { pointer-events: none !important; }
14945
+ .pointer { position:absolute; left:0; top:0; display:none; width:24px; height:28px; }
14946
+ .halo { position:absolute; left:-12px; top:-12px; width:26px; height:26px; border-radius:50%; background:${options.color}; opacity:.22; }
14947
+ svg { position:absolute; left:-2px; top:-2px; width:24px; height:28px; filter:drop-shadow(0 1px 2px #0008); }
14948
+ .pulse { position:absolute; width:32px; height:32px; box-sizing:border-box; border:2px solid ${options.color}; border-radius:50%; }
14949
+ `;
14950
+ const pointer = document.createElement("div");
14951
+ pointer.className = "pointer";
14952
+ pointer.innerHTML = '<div class="halo"></div><svg viewBox="0 0 24 28" xmlns="http://www.w3.org/2000/svg"><path d="M3 2 L3 22 L8 17 L12 25 L16 23 L12 15 L20 15 Z" fill="white" stroke="#171717" stroke-width="1.5" stroke-linejoin="round"/></svg>';
14953
+ root.append(style, pointer);
14954
+ document.documentElement.appendChild(host);
14955
+ const listenerOptions = { capture: true, passive: true, signal: controller.signal };
14956
+ const hide = () => {
14957
+ pointer.style.display = "none";
14958
+ };
14959
+ const move = (event) => {
14960
+ if (event.pointerType === "touch") return;
14961
+ if (event.target instanceof HTMLIFrameElement || event.target instanceof HTMLFrameElement) {
14962
+ hide();
14963
+ return;
14964
+ }
14965
+ pointer.style.display = "block";
14966
+ pointer.style.transform = `translate(${event.clientX}px, ${event.clientY}px)`;
14967
+ };
14968
+ document.addEventListener("pointermove", move, listenerOptions);
14969
+ document.addEventListener("pointerover", move, listenerOptions);
14970
+ document.addEventListener("pointerdown", (event) => {
14971
+ if (event.pointerType === "touch") return;
14972
+ move(event);
14973
+ const pulse = document.createElement("div");
14974
+ pulse.className = "pulse";
14975
+ pulse.style.left = `${event.clientX - 16}px`;
14976
+ pulse.style.top = `${event.clientY - 16}px`;
14977
+ root.insertBefore(pulse, pointer);
14978
+ const animation = pulse.animate([
14979
+ { transform: "scale(.4)", opacity: 0.9 },
14980
+ { transform: "scale(1.8)", opacity: 0 }
14981
+ ], { duration: 400, easing: "ease-out" });
14982
+ animations.add(animation);
14983
+ void animation.finished.catch(() => void 0).finally(() => {
14984
+ animations.delete(animation);
14985
+ pulse.remove();
14986
+ });
14987
+ }, listenerOptions);
14988
+ document.addEventListener("pointerout", (event) => {
14989
+ if (!event.relatedTarget) hide();
14990
+ }, listenerOptions);
14991
+ window.addEventListener("blur", hide, { signal: controller.signal });
14992
+ document.addEventListener("visibilitychange", () => {
14993
+ if (document.hidden) hide();
14994
+ }, { signal: controller.signal });
14995
+ };
14996
+ if (document.documentElement) mount();
14997
+ else document.addEventListener("DOMContentLoaded", mount, { once: true, signal: controller.signal });
14998
+ }
14999
+ async function attachCursorOverlay(page, cdp, input) {
15000
+ const appearance = cursorAppearanceSchema.parse(input ?? {});
15001
+ if (!appearance.enabled) return async () => {
15002
+ };
15003
+ const key = `__rillCursor_${crypto.randomUUID().replaceAll("-", "")}`;
15004
+ const source = `(${installCursorOverlay.toString()})(${JSON.stringify({ ...appearance, key })})`;
15005
+ await cdp.send("Page.enable");
15006
+ const { identifier } = await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source });
15007
+ let stopped = false;
15008
+ const removeFromFrame = (frame) => frame.evaluate((key2) => {
15009
+ const cleanup2 = window[key2];
15010
+ if (typeof cleanup2 === "function") cleanup2();
15011
+ }, key).catch(() => void 0);
15012
+ const onNavigation = (frame) => {
15013
+ if (stopped) return;
15014
+ void frame.evaluate(source).then(() => {
15015
+ if (stopped) return removeFromFrame(frame);
15016
+ }).catch(() => void 0);
15017
+ };
15018
+ page.on("framenavigated", onNavigation);
15019
+ const cleanup = async () => {
15020
+ stopped = true;
15021
+ page.off("framenavigated", onNavigation);
15022
+ await cdp.send("Page.removeScriptToEvaluateOnNewDocument", { identifier }).catch(() => void 0);
15023
+ await Promise.all(page.frames().map(removeFromFrame));
15024
+ };
15025
+ try {
15026
+ await Promise.all(page.frames().map((frame) => frame.evaluate(source)));
15027
+ return cleanup;
15028
+ } catch (error51) {
15029
+ await cleanup();
15030
+ throw error51;
15031
+ }
15032
+ }
15033
+
15034
+ // ../recorder/src/session.ts
15035
+ import { chromium } from "playwright-core";
15036
+ import { spawn as spawn3 } from "child_process";
15037
+ import { createServer } from "net";
15038
+ import { mkdir as mkdir3, mkdtemp, rm as rm2, writeFile as writeFile3 } from "fs/promises";
15039
+ import { join as join6 } from "path";
15040
+ import { tmpdir as tmpdir2 } from "os";
15041
+
15042
+ // ../recorder/src/bundle.ts
15043
+ import { createGzip } from "zlib";
15044
+ import { createWriteStream } from "fs";
15045
+ import { once } from "events";
15046
+ async function writeBundle(bundle, outputPath) {
15047
+ const output = createWriteStream(outputPath, { mode: 384 });
15048
+ const gzip = createGzip({ level: 9 });
15049
+ gzip.pipe(output);
15050
+ const { console: consoleEvents, network, timeline, ...manifest } = bundle;
15051
+ gzip.write(`${JSON.stringify({ kind: "manifest", ...manifest })}
15052
+ `);
15053
+ for (const event of consoleEvents) gzip.write(`${JSON.stringify({ kind: "console", ...event })}
15054
+ `);
15055
+ for (const event of network) gzip.write(`${JSON.stringify({ kind: "network", ...event })}
15056
+ `);
15057
+ for (const event of timeline) gzip.write(`${JSON.stringify({ kind: "timeline", ...event })}
15058
+ `);
15059
+ gzip.end();
15060
+ await once(output, "close");
15061
+ }
15062
+
14783
15063
  // ../recorder/src/environment.ts
14784
15064
  import { existsSync } from "fs";
14785
15065
  function findChromeExecutable() {
@@ -14835,27 +15115,70 @@ function redactJson(value, depth = 0) {
14835
15115
  return value;
14836
15116
  }
14837
15117
 
14838
- // ../recorder/src/screencast.ts
14839
- import { mkdir, writeFile } from "fs/promises";
15118
+ // ../recorder/src/encoding.ts
15119
+ import { open as open2, writeFile, mkdir } from "fs/promises";
14840
15120
  import { spawn as spawn2 } from "child_process";
14841
- import { join as join2 } from "path";
14842
- import { once as once2 } from "events";
14843
- function createFfconcatManifest(frames, expectedDurationSeconds) {
14844
- if (!frames.length) throw new Error("No video frames were captured.");
14845
- const orderedFrames = frames.toSorted((left, right) => left.timestamp - right.timestamp);
14846
- const firstTimestamp = orderedFrames[0].timestamp;
14847
- const lines = ["ffconcat version 1.0"];
14848
- for (let index = 0; index < orderedFrames.length; index += 1) {
14849
- const frame = orderedFrames[index];
14850
- const next = orderedFrames[index + 1];
14851
- const elapsedAtFrame = Math.max(0, frame.timestamp - firstTimestamp);
14852
- const duration3 = next ? Math.max(1 / 60, next.timestamp - frame.timestamp) : Math.max(1 / 60, expectedDurationSeconds - elapsedAtFrame);
14853
- lines.push(`file '${frame.path.replaceAll("'", "'\\''")}'`, `duration ${duration3.toFixed(6)}`);
14854
- }
14855
- lines.push(`file '${orderedFrames.at(-1).path.replaceAll("'", "'\\''")}'`);
14856
- return `${lines.join("\n")}
14857
- `;
15121
+ import { join as join4 } from "path";
15122
+ var active = 0;
15123
+ var waiting = [];
15124
+ function encodingConcurrency() {
15125
+ const n = Number(process.env.RILL_ENCODING_CONCURRENCY ?? 2);
15126
+ if (!Number.isInteger(n) || n < 1 || n > 8) throw new Error("Invalid RILL_ENCODING_CONCURRENCY");
15127
+ return n;
15128
+ }
15129
+ async function withEncodingSlot(work) {
15130
+ if (active >= encodingConcurrency()) await new Promise((resolve) => waiting.push(resolve));
15131
+ else active++;
15132
+ try {
15133
+ return await work();
15134
+ } finally {
15135
+ const next = waiting.shift();
15136
+ if (next) next();
15137
+ else active--;
15138
+ }
15139
+ }
15140
+ async function encodeFrames(directory, manifest, output, signal) {
15141
+ frameManifestSchema.parse(manifest);
15142
+ return withEncodingSlot(async () => {
15143
+ signal?.throwIfAborted();
15144
+ const lines = ["ffconcat version 1.0"];
15145
+ for (let i = 0; i < manifest.frames.length; i++) {
15146
+ const next = manifest.frames[i + 1];
15147
+ const duration3 = next ? next.timestamp - manifest.frames[i].timestamp : manifest.durationSeconds - (manifest.frames[i].timestamp - manifest.frames[0].timestamp);
15148
+ lines.push(`file 'frame-${String(i).padStart(6, "0")}.jpg'`, `duration ${Math.max(1 / 60, duration3).toFixed(6)}`);
15149
+ }
15150
+ lines.push(`file 'frame-${String(manifest.frames.length - 1).padStart(6, "0")}.jpg'`);
15151
+ await writeFile(join4(directory, "frames.ffconcat"), lines.join("\n") + "\n", { mode: 384 });
15152
+ const threads = Number(process.env.RILL_FFMPEG_THREADS ?? 1);
15153
+ if (!Number.isInteger(threads) || threads < 1 || threads > 16) throw new Error("Invalid RILL_FFMPEG_THREADS");
15154
+ await new Promise((resolve, reject) => {
15155
+ const child = spawn2(process.env.RILL_FFMPEG ?? "ffmpeg", ["-hide_banner", "-loglevel", "error", "-protocol_whitelist", "file", "-f", "concat", "-safe", "1", "-i", "frames.ffconcat", "-vsync", "vfr", "-pix_fmt", "yuv420p", "-c:v", "libvpx-vp9", "-deadline", "good", "-cpu-used", "4", "-threads", String(threads), "-b:v", "0", "-crf", "32", "-y", output], { cwd: directory, stdio: ["ignore", "ignore", "pipe"] });
15156
+ let stderr = "";
15157
+ child.stderr.on("data", (chunk) => {
15158
+ stderr = (stderr + String(chunk)).slice(-2e3);
15159
+ });
15160
+ const abort = () => child.kill("SIGKILL");
15161
+ const timer = setTimeout(abort, 20 * 6e4);
15162
+ signal?.addEventListener("abort", abort, { once: true });
15163
+ const clean = () => {
15164
+ clearTimeout(timer);
15165
+ signal?.removeEventListener("abort", abort);
15166
+ };
15167
+ child.once("error", (error51) => {
15168
+ clean();
15169
+ reject(error51);
15170
+ });
15171
+ child.once("exit", (code) => {
15172
+ clean();
15173
+ code === 0 ? resolve() : reject(new Error(`ffmpeg failed (${code}): ${stderr}`));
15174
+ });
15175
+ });
15176
+ });
14858
15177
  }
15178
+
15179
+ // ../recorder/src/screencast.ts
15180
+ import { mkdir as mkdir2, writeFile as writeFile2, readFile as readFile4, open as open3 } from "fs/promises";
15181
+ import { join as join5 } from "path";
14859
15182
  var ScreencastRecorder = class {
14860
15183
  constructor(session, directory, width, height) {
14861
15184
  this.session = session;
@@ -14867,13 +15190,24 @@ var ScreencastRecorder = class {
14867
15190
  pendingWrites = /* @__PURE__ */ new Set();
14868
15191
  started = false;
14869
15192
  frameCounter = 0;
15193
+ frameBytes = 0;
15194
+ captureError;
14870
15195
  async start() {
14871
- await mkdir(this.directory, { recursive: true, mode: 448 });
15196
+ await mkdir2(this.directory, { recursive: true, mode: 448 });
14872
15197
  this.session.on("Page.screencastFrame", (payload) => {
14873
- const path = join2(this.directory, `frame-${String(this.frameCounter).padStart(6, "0")}.jpg`);
15198
+ const bytes = Buffer.from(payload.data, "base64");
15199
+ if (this.frameCounter >= 36e3 || this.frameBytes + bytes.length > ENCODING_MAX_BYTES || bytes.length > ENCODING_PART_BYTES) {
15200
+ this.captureError = new Error("Capture exceeds the remote encoding frame or byte limit.");
15201
+ void this.session.send("Page.screencastFrameAck", { sessionId: payload.sessionId }).catch(() => void 0);
15202
+ return;
15203
+ }
15204
+ this.frameBytes += bytes.length;
15205
+ const path = join5(this.directory, `frame-${String(this.frameCounter).padStart(6, "0")}.jpg`);
14874
15206
  this.frameCounter += 1;
14875
- const write = writeFile(path, Buffer.from(payload.data, "base64"), { mode: 384 }).then(() => {
15207
+ const write = writeFile2(path, bytes, { mode: 384 }).then(() => {
14876
15208
  this.frames.push({ path, timestamp: payload.metadata.timestamp ?? Date.now() / 1e3 });
15209
+ }).catch((error51) => {
15210
+ this.captureError = error51 instanceof Error ? error51 : new Error(String(error51));
14877
15211
  }).finally(() => {
14878
15212
  this.pendingWrites.delete(write);
14879
15213
  void this.session.send("Page.screencastFrameAck", { sessionId: payload.sessionId }).catch(() => void 0);
@@ -14884,21 +15218,27 @@ var ScreencastRecorder = class {
14884
15218
  await this.session.send("Page.startScreencast", { format: "jpeg", quality: 82, maxWidth: this.width, maxHeight: this.height, everyNthFrame: 1 });
14885
15219
  this.started = true;
14886
15220
  }
14887
- async stop(outputPath, expectedDurationSeconds) {
15221
+ async stop(outputPath, expectedDurationSeconds, captureOnly = false) {
14888
15222
  if (this.started) await this.session.send("Page.stopScreencast").catch(() => void 0);
14889
15223
  await new Promise((resolve) => setTimeout(resolve, 120));
14890
15224
  await Promise.all(this.pendingWrites);
15225
+ if (this.captureError) throw this.captureError;
14891
15226
  if (!this.frames.length) throw new Error("No video frames were captured.");
14892
- const manifestPath = join2(this.directory, "frames.ffconcat");
14893
- await writeFile(manifestPath, createFfconcatManifest(this.frames, expectedDurationSeconds), { mode: 384 });
14894
- const ffmpeg = process.env.RILL_FFMPEG ?? "ffmpeg";
14895
- const processHandle = spawn2(ffmpeg, ["-hide_banner", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", manifestPath, "-vsync", "vfr", "-pix_fmt", "yuv420p", "-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "32", "-y", outputPath], { stdio: ["ignore", "ignore", "pipe"] });
14896
- let stderr = "";
14897
- processHandle.stderr.on("data", (chunk) => {
14898
- stderr += String(chunk);
14899
- });
14900
- const [exitCode] = await once2(processHandle, "exit");
14901
- if (exitCode !== 0) throw new Error(`ffmpeg could not finalize the recording: ${stderr.trim()}`);
15227
+ const ordered = this.frames.toSorted((a, b) => a.timestamp - b.timestamp);
15228
+ const frames = [];
15229
+ const bundle = await open3(join5(this.directory, "frames.bin"), "w", 384);
15230
+ try {
15231
+ for (const frame of ordered) {
15232
+ const bytes = await readFile4(frame.path);
15233
+ await bundle.writeFile(bytes);
15234
+ frames.push({ timestamp: frame.timestamp, size: bytes.length });
15235
+ }
15236
+ } finally {
15237
+ await bundle.close();
15238
+ }
15239
+ const manifest = frameManifestSchema.parse({ version: 1, durationSeconds: Math.min(600, expectedDurationSeconds), width: this.width, height: this.height, frames });
15240
+ await writeFile2(join5(this.directory, "capture-manifest.json"), JSON.stringify(manifest), { mode: 384 });
15241
+ if (!captureOnly) await encodeFrames(this.directory, manifest, outputPath);
14902
15242
  return { frameCount: this.frames.length, outputPath };
14903
15243
  }
14904
15244
  };
@@ -14932,6 +15272,7 @@ var RecordingSession = class _RecordingSession {
14932
15272
  bodyBytes = 0;
14933
15273
  truncated = false;
14934
15274
  constructor(options, runtime) {
15275
+ this.encoding = options.encoding ?? "local";
14935
15276
  this.recordingId = options.recordingId;
14936
15277
  this.cdpUrl = runtime.cdpUrl;
14937
15278
  this.browser = runtime.browser;
@@ -14945,10 +15286,11 @@ var RecordingSession = class _RecordingSession {
14945
15286
  static async start(options) {
14946
15287
  const width = options.width ?? 1280;
14947
15288
  const height = options.height ?? 720;
14948
- await mkdir2(options.outputDirectory, { recursive: true, mode: 448 });
15289
+ await mkdir3(options.outputDirectory, { recursive: true, mode: 448 });
15290
+ if (options.encoding === "remote") await writeFile3(join6(options.outputDirectory, "remote-capture.json"), JSON.stringify({ expiresAt: Date.now() + 864e5 }), { mode: 384 });
14949
15291
  const runtime = options.cdpUrl ? await connectAttached(options.cdpUrl, options.outputDirectory) : await launchManaged({ headed: options.headed ?? false, width, height, outputDirectory: options.outputDirectory, proxyServer: options.proxyServer });
14950
15292
  const cdpSession = await runtime.context.newCDPSession(runtime.page);
14951
- const screencast = new ScreencastRecorder(cdpSession, join3(options.outputDirectory, "frames"), width, height);
15293
+ const screencast = new ScreencastRecorder(cdpSession, join6(options.outputDirectory, "frames"), width, height);
14952
15294
  const session = new _RecordingSession(options, { ...runtime, screencast });
14953
15295
  try {
14954
15296
  await session.attachDiagnostics(options.captureNetworkBodies ?? false);
@@ -14980,8 +15322,8 @@ var RecordingSession = class _RecordingSession {
14980
15322
  if (this.maxTimer) clearTimeout(this.maxTimer);
14981
15323
  const durationSeconds = Math.max(0.1, (Date.now() - this.startedAt.getTime()) / 1e3);
14982
15324
  const outputDirectory = this.outputDirectory;
14983
- const videoPath = join3(outputDirectory, "recording.webm");
14984
- const diagnosticsPath = join3(outputDirectory, "diagnostics-v1.ndjson.gz");
15325
+ const videoPath = join6(outputDirectory, "recording.webm");
15326
+ const diagnosticsPath = join6(outputDirectory, "diagnostics-v1.ndjson.gz");
14985
15327
  const bundle = {
14986
15328
  schemaVersion: 1,
14987
15329
  capturePolicyVersion: "safe-default-v1",
@@ -14998,21 +15340,22 @@ var RecordingSession = class _RecordingSession {
14998
15340
  try {
14999
15341
  const safeBundle = JSON.parse(this.secretValues.reduce((text, secret) => text.split(JSON.stringify(secret).slice(1, -1)).join("[redacted]"), JSON.stringify(bundle)));
15000
15342
  await writeBundle(safeBundle, diagnosticsPath);
15001
- await this.screencast.stop(videoPath, durationSeconds);
15343
+ await this.screencast.stop(videoPath, durationSeconds, this.encoding === "remote");
15002
15344
  } finally {
15003
15345
  await this.removeCursorOverlay?.();
15004
15346
  if (this.browserProcess) {
15005
15347
  await this.browser.close().catch(() => void 0);
15006
15348
  await terminateBrowser(this.browserProcess);
15007
- if (this.profileDirectory) await rm(this.profileDirectory, { recursive: true, force: true }).catch(() => void 0);
15349
+ if (this.profileDirectory) await rm2(this.profileDirectory, { recursive: true, force: true }).catch(() => void 0);
15008
15350
  }
15009
15351
  }
15010
15352
  const consoleErrors = this.consoleEvents.filter((event) => event.severity === "error").length;
15011
15353
  const failedRequests = this.networkEvents.filter((event) => event.failureReason).length;
15012
15354
  const httpErrors = this.networkEvents.filter((event) => (event.status ?? 0) >= 400).length;
15013
- return { recordingId: this.recordingId, videoPath, diagnosticsPath, durationSeconds, stoppedReason: reason, diagnostics: { consoleErrors, failedRequests, httpErrors, truncated: this.truncated, bodyCaptureEnabled: this.captureNetworkBodies } };
15355
+ return { recordingId: this.recordingId, videoPath, ...this.encoding === "remote" ? { frameDirectory: join6(outputDirectory, "frames") } : {}, diagnosticsPath, durationSeconds, stoppedReason: reason, diagnostics: { consoleErrors, failedRequests, httpErrors, truncated: this.truncated, bodyCaptureEnabled: this.captureNetworkBodies } };
15014
15356
  }
15015
15357
  captureNetworkBodies = false;
15358
+ encoding = "local";
15016
15359
  outputDirectory = "";
15017
15360
  async attachDiagnostics(captureNetworkBodies) {
15018
15361
  this.captureNetworkBodies = captureNetworkBodies;
@@ -15061,34 +15404,34 @@ var RecordingSession = class _RecordingSession {
15061
15404
  this.consoleEvents.push(event);
15062
15405
  pushTimeline({ offsetMs: event.offsetMs, type: "exception", label: event.text.slice(0, 160), severity: "error" });
15063
15406
  });
15064
- this.page.on("request", (request2) => {
15407
+ this.page.on("request", (request3) => {
15065
15408
  if (this.diagnosticsPaused) return;
15066
15409
  if (this.networkEvents.length >= 5e3) {
15067
15410
  this.truncated = true;
15068
15411
  return;
15069
15412
  }
15070
15413
  const started = offset();
15071
- this.requestStart.set(request2, started);
15072
- const event = { id: `net_${this.networkEvents.length + 1}`, offsetMs: started, method: request2.method(), url: sanitizeUrl(request2.url()), resourceType: request2.resourceType() };
15073
- if (captureNetworkBodies && request2.postData()) {
15074
- const redacted = redactBody(request2.postData(), request2.headers()["content-type"]);
15414
+ this.requestStart.set(request3, started);
15415
+ const event = { id: `net_${this.networkEvents.length + 1}`, offsetMs: started, method: request3.method(), url: sanitizeUrl(request3.url()), resourceType: request3.resourceType() };
15416
+ if (captureNetworkBodies && request3.postData()) {
15417
+ const redacted = redactBody(request3.postData(), request3.headers()["content-type"]);
15075
15418
  event.requestBody = redacted.body;
15076
15419
  event.bodyOmittedReason = redacted.omittedReason;
15077
15420
  if (redacted.body) this.bodyBytes += Buffer.byteLength(redacted.body);
15078
15421
  }
15079
15422
  this.networkEvents.push(event);
15080
- this.requestEvents.set(request2, event);
15423
+ this.requestEvents.set(request3, event);
15081
15424
  });
15082
15425
  this.page.on("response", (response) => this.updateResponse(response));
15083
- this.page.on("requestfailed", (request2) => {
15084
- const event = this.requestEvents.get(request2);
15426
+ this.page.on("requestfailed", (request3) => {
15427
+ const event = this.requestEvents.get(request3);
15085
15428
  if (!event) return;
15086
- event.failureReason = request2.failure()?.errorText ?? "request_failed";
15087
- event.durationMs = Math.max(0, offset() - (this.requestStart.get(request2) ?? offset()));
15429
+ event.failureReason = request3.failure()?.errorText ?? "request_failed";
15430
+ event.durationMs = Math.max(0, offset() - (this.requestStart.get(request3) ?? offset()));
15088
15431
  pushTimeline({ offsetMs: event.offsetMs, type: "network", label: `${event.method} ${event.url} \u2192 ${event.failureReason}`, severity: "error" });
15089
15432
  });
15090
- this.page.on("requestfinished", async (request2) => {
15091
- const response = await request2.response();
15433
+ this.page.on("requestfinished", async (request3) => {
15434
+ const response = await request3.response();
15092
15435
  if (response) await this.captureResponseBody(response);
15093
15436
  });
15094
15437
  }
@@ -15121,7 +15464,7 @@ var RecordingSession = class _RecordingSession {
15121
15464
  };
15122
15465
  async function launchManaged(options) {
15123
15466
  const port = await getAvailablePort();
15124
- const profileDirectory = await mkdtemp(join3(tmpdir2(), "rill-chrome-"));
15467
+ const profileDirectory = await mkdtemp(join6(tmpdir2(), "rill-chrome-"));
15125
15468
  const executable = findChromeExecutable();
15126
15469
  const args = [
15127
15470
  `--remote-debugging-port=${port}`,
@@ -15150,7 +15493,7 @@ async function launchManaged(options) {
15150
15493
  return { cdpUrl, browser, context, page, browserProcess, profileDirectory };
15151
15494
  } catch (error51) {
15152
15495
  await terminateBrowser(browserProcess);
15153
- await rm(profileDirectory, { recursive: true, force: true }).catch(() => void 0);
15496
+ await rm2(profileDirectory, { recursive: true, force: true }).catch(() => void 0);
15154
15497
  throw error51;
15155
15498
  }
15156
15499
  }
@@ -15201,17 +15544,22 @@ var sessions = /* @__PURE__ */ new Map();
15201
15544
  var lastActivity = Date.now();
15202
15545
  async function runDaemon() {
15203
15546
  const socketPath = daemonSocketPath();
15204
- await mkdir3(dirname(socketPath), { recursive: true, mode: 448 });
15205
- await mkdir3(recordingTempRoot(), { recursive: true, mode: 448 });
15547
+ await mkdir4(dirname(socketPath), { recursive: true, mode: 448 });
15548
+ await mkdir4(recordingTempRoot(), { recursive: true, mode: 448 });
15206
15549
  await restoreRecoverableSessions();
15550
+ await purgeExpiredCaptures();
15207
15551
  await unlink(socketPath).catch((error51) => {
15208
15552
  if (error51.code !== "ENOENT") throw error51;
15209
15553
  });
15210
- const server = createServer2((request2, response) => void route(request2, response));
15554
+ const server = createServer2((request3, response) => void route(request3, response));
15211
15555
  server.listen(socketPath, () => process.platform !== "win32" && import("fs/promises").then(({ chmod }) => chmod(socketPath, 384)));
15212
15556
  const idleTimer = setInterval(() => {
15213
- const hasActiveSession = [...sessions.values()].some((entry) => entry.state === "recording" || entry.state === "finalizing");
15214
- if (!hasActiveSession && Date.now() - lastActivity > 5 * 6e4) server.close(() => process.exit(0));
15557
+ void (async () => {
15558
+ await purgeExpiredCaptures();
15559
+ const hasActiveSession = [...sessions.values()].some((entry) => entry.state === "recording" || entry.state === "finalizing");
15560
+ const hasRetainedCapture = await hasRetainedRemoteCaptures(recordingTempRoot());
15561
+ if (!hasActiveSession && !hasRetainedCapture && Date.now() - lastActivity > 5 * 6e4) server.close(() => process.exit(0));
15562
+ })().catch(() => void 0);
15215
15563
  }, 3e4);
15216
15564
  idleTimer.unref();
15217
15565
  const close = () => {
@@ -15222,16 +15570,16 @@ async function runDaemon() {
15222
15570
  process.once("SIGTERM", close);
15223
15571
  process.once("SIGINT", close);
15224
15572
  }
15225
- async function route(request2, response) {
15573
+ async function route(request3, response) {
15226
15574
  lastActivity = Date.now();
15227
- const url2 = new URL(request2.url ?? "/", "http://daemon.local");
15228
- if (request2.method === "GET" && url2.pathname === "/health") return json2(response, 200, { ok: true, pid: process.pid });
15229
- if (request2.method === "POST" && url2.pathname === "/sessions") {
15575
+ const url2 = new URL(request3.url ?? "/", "http://daemon.local");
15576
+ if (request3.method === "GET" && url2.pathname === "/health") return json2(response, 200, { ok: true, pid: process.pid });
15577
+ if (request3.method === "POST" && url2.pathname === "/sessions") {
15230
15578
  if ([...sessions.values()].filter((entry) => entry.state === "recording").length >= 2) return json2(response, 429, { error: { code: "quota_exceeded", message: "This recorder already has two active sessions." } });
15231
15579
  try {
15232
- const input = await readJson(request2);
15233
- if (!input.recordingId) return json2(response, 400, { error: { code: "validation_failed", message: "recordingId is required." } });
15234
- const outputDirectory = join4(recordingTempRoot(), input.recordingId);
15580
+ const input = await readJson(request3);
15581
+ if (!/^rec_[a-zA-Z0-9_-]+$/.test(input.recordingId)) return json2(response, 400, { error: { code: "validation_failed", message: "recordingId is required." } });
15582
+ const outputDirectory = join7(recordingTempRoot(), input.recordingId);
15235
15583
  const session = await RecordingSession.start({ ...input, outputDirectory });
15236
15584
  const status = session.status();
15237
15585
  const entry = { session, base: { recordingId: status.recordingId, cdpUrl: status.cdpUrl, startedAt: status.startedAt }, state: "recording" };
@@ -15248,8 +15596,8 @@ async function route(request2, response) {
15248
15596
  if (match) {
15249
15597
  const entry = sessions.get(decodeURIComponent(match[1]));
15250
15598
  if (!entry) return json2(response, 404, { error: { code: "recording_not_found", message: "The local recording session was not found." } });
15251
- if (request2.method === "GET" && !match[2]) return json2(response, 200, serializeEntry(entry));
15252
- if (request2.method === "POST" && match[2] === "stop") {
15599
+ if (request3.method === "GET" && !match[2]) return json2(response, 200, serializeEntry(entry));
15600
+ if (request3.method === "POST" && match[2] === "stop") {
15253
15601
  if (entry.state === "ready") return json2(response, 200, serializeEntry(entry));
15254
15602
  await finalizeEntry(entry, "agent_request");
15255
15603
  return json2(response, entry.result ? 200 : 500, serializeEntry(entry));
@@ -15275,7 +15623,7 @@ function serializeEntry(entry) {
15275
15623
  }
15276
15624
  async function restoreRecoverableSessions() {
15277
15625
  try {
15278
- const value = JSON.parse(await readFile(daemonStatePath(), "utf8"));
15626
+ const value = JSON.parse(await readFile5(daemonStatePath(), "utf8"));
15279
15627
  for (const [id, entry] of Object.entries(value)) {
15280
15628
  if (entry.status !== "ready" && entry.status !== "failed") continue;
15281
15629
  sessions.set(id, { base: { recordingId: entry.recordingId, cdpUrl: entry.cdpUrl, startedAt: entry.startedAt }, state: entry.status, result: entry.result, error: entry.error });
@@ -15285,13 +15633,15 @@ async function restoreRecoverableSessions() {
15285
15633
  }
15286
15634
  async function persistState() {
15287
15635
  const state = Object.fromEntries([...sessions.entries()].map(([id, entry]) => [id, serializeEntry(entry)]));
15288
- await writeFile2(daemonStatePath(), `${JSON.stringify(state, null, 2)}
15636
+ const temporary = daemonStatePath() + "." + crypto.randomUUID() + ".tmp";
15637
+ await writeFile4(temporary, `${JSON.stringify(state, null, 2)}
15289
15638
  `, { mode: 384 });
15639
+ await rename(temporary, daemonStatePath());
15290
15640
  }
15291
- async function readJson(request2) {
15641
+ async function readJson(request3) {
15292
15642
  const chunks = [];
15293
15643
  let size = 0;
15294
- for await (const chunk of request2) {
15644
+ for await (const chunk of request3) {
15295
15645
  const buffer = Buffer.from(chunk);
15296
15646
  size += buffer.length;
15297
15647
  if (size > 1e6) throw new Error("Request body exceeds 1 MB.");
@@ -15303,147 +15653,16 @@ function json2(response, status, value) {
15303
15653
  response.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
15304
15654
  response.end(JSON.stringify(value));
15305
15655
  }
15306
-
15307
- // src/control-plane.ts
15308
- import { createReadStream } from "fs";
15309
- import { readFile as readFile2, stat } from "fs/promises";
15310
- import { Upload } from "tus-js-client";
15311
- var ControlPlaneError = class extends Error {
15312
- constructor(code, message, recovery, details) {
15313
- super(`${code}: ${message}${recovery ? `: ${recovery}` : ""}`);
15314
- this.code = code;
15315
- this.recovery = recovery;
15316
- this.details = details;
15317
- this.name = "ControlPlaneError";
15318
- }
15319
- };
15320
- var ControlPlaneClient = class {
15321
- constructor(apiUrl, token) {
15322
- this.apiUrl = apiUrl;
15323
- this.token = token;
15324
- }
15325
- createCloudRun(input) {
15326
- return this.request("/api/cloud/runs", { method: "POST", body: JSON.stringify(input) });
15327
- }
15328
- cloudRun(id) {
15329
- return this.request(`/api/cloud/runs/${encodeURIComponent(id)}`);
15330
- }
15331
- cancelCloudRun(id) {
15332
- return this.request(`/api/cloud/runs/${encodeURIComponent(id)}/cancel`, { method: "POST" });
15333
- }
15334
- health() {
15335
- return this.request("/api/health", {}, false);
15336
- }
15337
- agentSession() {
15338
- return this.request("/api/agent/session", { signal: AbortSignal.timeout(5e3) });
15339
- }
15340
- reportCliCommand(input) {
15341
- return this.request("/api/analytics/cli-command", { method: "POST", body: JSON.stringify(input) });
15342
- }
15343
- createRecording(input) {
15344
- return this.request("/api/recordings", { method: "POST", body: JSON.stringify(input) });
15345
- }
15346
- markRecordingStarted(recordingId, input) {
15347
- return this.request(`/api/recordings/${recordingId}/local-started`, {
15348
- method: "POST",
15349
- body: JSON.stringify(input)
15350
- });
15351
- }
15352
- markRecordingStopped(recordingId, input) {
15353
- return this.request(`/api/recordings/${recordingId}/local-stopped`, {
15354
- method: "POST",
15355
- body: JSON.stringify(input)
15356
- });
15357
- }
15358
- submitRecordingFeedback(recordingId, input) {
15359
- return this.request(recordingId ? `/api/recordings/${recordingId}/feedback` : "/api/feedback", {
15360
- method: "POST",
15361
- body: JSON.stringify(input)
15362
- });
15363
- }
15364
- provisionVideo(recordingId, input) {
15365
- return this.request(`/api/recordings/${recordingId}/video-upload`, { method: "POST", body: JSON.stringify(input) });
15366
- }
15367
- recordingStatus(recordingId) {
15368
- return this.request(`/api/recordings/${recordingId}`);
15369
- }
15370
- cancelRecording(recordingId, reason = "cancelled_by_agent") {
15371
- return this.request(`/api/recordings/${recordingId}/cancel`, {
15372
- method: "POST",
15373
- body: JSON.stringify({ reason })
15374
- });
15375
- }
15376
- async waitUntilPlayable(recordingId, timeoutMs = 10 * 6e4) {
15377
- const deadline = Date.now() + timeoutMs;
15378
- let intervalMs = 1e3;
15379
- while (Date.now() < deadline) {
15380
- const recording = await this.recordingStatus(recordingId);
15381
- if (recording.status === "ready" && recording.shareUrl) {
15382
- return { ...recording, shareUrl: new URL(recording.shareUrl, this.apiUrl).toString() };
15383
- }
15384
- if (recording.status === "failed") throw new Error(`processing_failed: ${recording.failure_message ?? "Cloudflare Stream could not process the recording."}`);
15385
- process.stderr.write(`\rprocessing ${recording.status.padEnd(12)}`);
15386
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
15387
- intervalMs = Math.min(5e3, Math.round(intervalMs * 1.4));
15388
- }
15389
- throw new Error("processing_failed: Timed out waiting for the recording to become playable. Run `rill record status <recording-id>` to continue checking.");
15390
- }
15391
- async uploadVideo(uploadUrl, filePath) {
15392
- const file2 = await stat(filePath);
15393
- if (uploadUrl.startsWith(this.apiUrl)) {
15394
- const response = await fetch(uploadUrl, { method: "PUT", headers: { "Content-Length": String(file2.size) }, body: createReadStream(filePath), duplex: "half" });
15395
- if (!response.ok) throw new Error(`Video upload failed (${response.status}).`);
15396
- return;
15397
- }
15398
- await new Promise((resolve, reject) => {
15399
- const upload = new Upload(createReadStream(filePath), {
15400
- uploadUrl,
15401
- uploadSize: file2.size,
15402
- retryDelays: [0, 1e3, 3e3, 5e3, 1e4],
15403
- removeFingerprintOnSuccess: true,
15404
- onError: reject,
15405
- onSuccess: () => resolve(),
15406
- onProgress: (sent, total) => process.stderr.write(`\ruploading ${Math.round(sent / total * 100)}%`)
15407
- });
15408
- upload.start();
15409
- });
15410
- process.stderr.write("\n");
15411
- }
15412
- async uploadDiagnostics(recordingId, filePath, summary) {
15413
- const body = await readFile2(filePath);
15414
- return this.request(`/api/recordings/${recordingId}/diagnostics`, { method: "PUT", headers: { "Content-Encoding": "gzip", "Content-Type": "application/x-ndjson", "Content-Length": String(body.length), "X-Rill-Summary": JSON.stringify(summary) }, body });
15415
- }
15416
- createDeviceAuthorization(input) {
15417
- return this.request("/api/device/authorizations", { method: "POST", body: JSON.stringify(input) }, false);
15418
- }
15419
- pollDeviceAuthorization(deviceCode) {
15420
- return this.request("/api/device/token", { method: "POST", body: JSON.stringify({ deviceCode }) }, false);
15421
- }
15422
- inspect(shareUrl) {
15423
- const url2 = new URL(shareUrl);
15424
- const slug = url2.pathname.split("/").filter(Boolean).at(-1);
15425
- if (!slug) throw new Error("The share URL does not contain a slug.");
15426
- return fetch(`${url2.origin}/api/public/shares/${encodeURIComponent(slug)}/context`, { headers: { Accept: "application/json" } }).then(async (response) => {
15427
- if (!response.ok) throw new Error(`Could not inspect the share link (${response.status}).`);
15428
- return response.json();
15429
- });
15430
- }
15431
- async request(path, init = {}, authenticated = true) {
15432
- const headers = new Headers(init.headers);
15433
- if (init.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
15434
- if (authenticated) {
15435
- if (!this.token) throw new Error("authentication_required: Run `rill login` or set RILL_TOKEN.");
15436
- headers.set("Authorization", `Bearer ${this.token}`);
15437
- }
15438
- const response = await fetch(`${this.apiUrl}${path}`, { ...init, headers });
15439
- const value = await response.json().catch(() => null);
15440
- if (!response.ok && value?.error?.code && value.error.message) {
15441
- throw new ControlPlaneError(value.error.code, value.error.message, value.error.recovery, value.error.details);
15442
- }
15443
- if (!response.ok) throw new Error(`Request failed (${response.status}).`);
15444
- return value;
15656
+ async function purgeExpiredCaptures() {
15657
+ const removed = await purgeExpiredRemoteCaptures(recordingTempRoot());
15658
+ for (const id of removed) sessions.delete(id);
15659
+ let changed = removed.length > 0;
15660
+ for (const [id, entry] of sessions) if (entry.result?.frameDirectory && !await stat2(entry.result.frameDirectory).catch(() => null)) {
15661
+ sessions.delete(id);
15662
+ changed = true;
15445
15663
  }
15446
- };
15664
+ if (changed) await persistState();
15665
+ }
15447
15666
 
15448
15667
  // src/analytics.ts
15449
15668
  var commandCategories = /* @__PURE__ */ new Map([
@@ -15475,6 +15694,153 @@ function cliFailureCode(error51) {
15475
15694
  return message.match(/^([a-z][a-z0-9_]{0,79}):/)?.[1] ?? "unexpected_error";
15476
15695
  }
15477
15696
 
15697
+ // src/agent-session.ts
15698
+ import { hostname as hostname3, homedir as homedir2 } from "os";
15699
+ import { mkdir as mkdir5, rm as rm4, stat as stat3 } from "fs/promises";
15700
+ import { join as join8 } from "path";
15701
+ import { createHash, randomBytes } from "crypto";
15702
+ var JWT_GRANT = "urn:ietf:params:oauth:grant-type:jwt-bearer";
15703
+ var CLAIM_GRANT = "urn:workos:agent-auth:grant-type:claim";
15704
+ var AgentAuthError = class extends Error {
15705
+ constructor(code, message) {
15706
+ super(`${code}: ${message}`);
15707
+ this.code = code;
15708
+ }
15709
+ };
15710
+ function parseAuthorization(value) {
15711
+ if (!value?.startsWith("{")) return null;
15712
+ const saved = JSON.parse(value);
15713
+ if (saved.version !== 1 || typeof saved.anonymous !== "boolean") throw new Error("Saved Rill authorization is invalid. Run rill connect --email <your-email>.");
15714
+ return saved;
15715
+ }
15716
+ async function request2(apiUrl, path, body, form = false) {
15717
+ const url2 = new URL(apiUrl);
15718
+ if (url2.protocol !== "https:" && !["localhost", "127.0.0.1", "[::1]"].includes(url2.hostname)) throw new Error("Agent authentication requires HTTPS.");
15719
+ const response = await fetch(`${apiUrl}${path}`, {
15720
+ method: "POST",
15721
+ redirect: "error",
15722
+ signal: AbortSignal.timeout(15e3),
15723
+ headers: { "Content-Type": form ? "application/x-www-form-urlencoded" : "application/json" },
15724
+ body: form ? new URLSearchParams(Object.entries(body).map(([key, value2]) => [key, String(value2)])) : JSON.stringify(body)
15725
+ });
15726
+ const value = await response.json();
15727
+ if (!response.ok) throw new AgentAuthError(value.error ?? "authentication_failed", value.error_description ?? "Could not authorize Rill.");
15728
+ return value;
15729
+ }
15730
+ function withTokens(saved, tokens) {
15731
+ return {
15732
+ ...saved,
15733
+ accessToken: tokens.access_token,
15734
+ accessExpiresAt: Date.now() + tokens.expires_in * 1e3,
15735
+ ...tokens.identity_assertion ? { identityAssertion: tokens.identity_assertion, assertionExpires: tokens.assertion_expires } : {}
15736
+ };
15737
+ }
15738
+ var save = (apiUrl, value) => saveCredential(apiUrl, JSON.stringify(value));
15739
+ var pending = /* @__PURE__ */ new Map();
15740
+ async function loadAgentCredential(apiUrl) {
15741
+ if (process.env.RILL_TOKEN) return process.env.RILL_TOKEN;
15742
+ const inFlight = pending.get(apiUrl);
15743
+ if (inFlight) return inFlight;
15744
+ const promise2 = (async () => {
15745
+ const raw = await loadStoredCredential(apiUrl);
15746
+ const saved = parseAuthorization(raw);
15747
+ if (!saved) return raw;
15748
+ if (saved.accessToken && (saved.accessExpiresAt ?? 0) > Date.now() + 6e4) return saved.accessToken;
15749
+ if (!saved.identityAssertion && saved.anonymous && saved.registrationToken) return null;
15750
+ if (!saved.identityAssertion) throw new Error("Connection is awaiting approval. Run rill connect --email <your-email>.");
15751
+ const tokens = await request2(apiUrl, "/oauth2/token", { grant_type: JWT_GRANT, assertion: saved.identityAssertion }, true);
15752
+ await save(apiUrl, withTokens(saved, tokens));
15753
+ return tokens.access_token;
15754
+ })();
15755
+ pending.set(apiUrl, promise2);
15756
+ try {
15757
+ return await promise2;
15758
+ } finally {
15759
+ pending.delete(apiUrl);
15760
+ }
15761
+ }
15762
+ async function ensureAnonymousCredential(apiUrl) {
15763
+ const root = join8(homedir2(), ".rill", "auth-locks");
15764
+ await mkdir5(root, { recursive: true, mode: 448 });
15765
+ const lock = join8(root, createHash("sha256").update(apiUrl).digest("hex"));
15766
+ const deadline = Date.now() + 3e4;
15767
+ while (true) {
15768
+ try {
15769
+ await mkdir5(lock, { mode: 448 });
15770
+ break;
15771
+ } catch (error51) {
15772
+ if (error51.code !== "EEXIST") throw error51;
15773
+ const info = await stat3(lock).catch(() => null);
15774
+ if (info && Date.now() - info.mtimeMs > 12e4) {
15775
+ await rm4(lock, { recursive: true, force: true });
15776
+ continue;
15777
+ }
15778
+ if (Date.now() > deadline) throw new Error("Another Rill process is setting up authorization. Retry shortly.");
15779
+ await new Promise((resolve) => setTimeout(resolve, 200));
15780
+ }
15781
+ }
15782
+ try {
15783
+ return await createAnonymousCredential(apiUrl);
15784
+ } finally {
15785
+ await rm4(lock, { recursive: true, force: true });
15786
+ }
15787
+ }
15788
+ async function createAnonymousCredential(apiUrl) {
15789
+ const existing = await loadAgentCredential(apiUrl);
15790
+ if (existing) return existing;
15791
+ const stored = parseAuthorization(await loadStoredCredential(apiUrl));
15792
+ const initial = stored?.registrationToken ? stored : { version: 1, anonymous: true, registrationToken: randomBytes(32).toString("hex") };
15793
+ await save(apiUrl, initial);
15794
+ const registered = await request2(apiUrl, "/agent/identity", { type: "anonymous", analytics_enabled: process.env.RILL_ANONYMOUS_ANALYTICS !== "0" && process.env.DO_NOT_TRACK !== "1", registration_token: initial.registrationToken, name: `${hostname3()} \xB7 ${process.platform}` });
15795
+ const saved = { ...initial, registrationToken: void 0, identityAssertion: registered.identity_assertion, assertionExpires: registered.assertion_expires, claimToken: registered.claim_token };
15796
+ await save(apiUrl, saved);
15797
+ process.stderr.write("Recording anonymously: three recordings, up to five minutes each. Recordings and share links expire after seven days. Run rill connect --email <your-email> to keep them.\n");
15798
+ return await loadAgentCredential(apiUrl);
15799
+ }
15800
+ async function connectAgent(apiUrl, email3) {
15801
+ if (process.env.RILL_TOKEN) throw new Error("Unset RILL_TOKEN before connecting a local identity.");
15802
+ let saved = parseAuthorization(await loadStoredCredential(apiUrl));
15803
+ let claim;
15804
+ if (saved?.claimToken && saved.anonymous) {
15805
+ try {
15806
+ const result = await request2(apiUrl, "/agent/identity/claim", { claim_token: saved.claimToken, email: email3 });
15807
+ claim = result.claim_attempt;
15808
+ } catch (error51) {
15809
+ if (!(error51 instanceof AgentAuthError) || error51.code !== "claim_expired") throw error51;
15810
+ }
15811
+ }
15812
+ if (!claim) {
15813
+ await save(apiUrl, saved ?? { version: 1, anonymous: true });
15814
+ const result = await request2(apiUrl, "/agent/identity", { type: "service_auth", login_hint: email3, name: `${hostname3()} \xB7 ${process.platform}` });
15815
+ saved = { version: 1, anonymous: true, claimToken: result.claim_token };
15816
+ await save(apiUrl, saved);
15817
+ claim = result.claim;
15818
+ }
15819
+ process.stderr.write(`Open ${claim.verification_uri}
15820
+ Sign in as ${email3} and enter ${claim.user_code}. Approve only the request you started.
15821
+ `);
15822
+ const deadline = Date.now() + claim.expires_in * 1e3;
15823
+ let interval = claim.interval;
15824
+ while (Date.now() < deadline) {
15825
+ await new Promise((resolve) => setTimeout(resolve, interval * 1e3));
15826
+ try {
15827
+ const tokens = await request2(apiUrl, "/oauth2/token", { grant_type: CLAIM_GRANT, claim_token: saved.claimToken }, true);
15828
+ const connected = withTokens({ version: 1, anonymous: false }, tokens);
15829
+ await save(apiUrl, connected);
15830
+ return { status: "authenticated", expiresAt: connected.assertionExpires, apiUrl };
15831
+ } catch (error51) {
15832
+ if (error51 instanceof AgentAuthError && error51.code === "expired_token") throw new AgentAuthError("expired_token", "The approval attempt expired. Run rill connect again to request a fresh code using the same saved claim token.");
15833
+ if (error51 instanceof AgentAuthError && error51.code === "authorization_pending") continue;
15834
+ if (error51 instanceof AgentAuthError && error51.code === "slow_down") {
15835
+ interval += 5;
15836
+ continue;
15837
+ }
15838
+ throw error51;
15839
+ }
15840
+ }
15841
+ throw new AgentAuthError("expired_token", "Connection request expired. Run rill connect again to restart the approval attempt using the same saved claim token.");
15842
+ }
15843
+
15478
15844
  // src/keychain.ts
15479
15845
  import { spawn as spawn4 } from "child_process";
15480
15846
  var service = "rill";
@@ -15489,15 +15855,20 @@ async function saveCredential(apiUrl, token) {
15489
15855
  }
15490
15856
  throw new Error("OS keychain storage is not supported on this platform yet. Use RILL_TOKEN.");
15491
15857
  }
15492
- async function loadCredential(apiUrl) {
15493
- if (process.env.RILL_TOKEN) return process.env.RILL_TOKEN;
15858
+ function loadCredential(apiUrl) {
15859
+ return loadAgentCredential(apiUrl);
15860
+ }
15861
+ async function loadStoredCredential(apiUrl) {
15494
15862
  try {
15495
- if (process.platform === "darwin") return (await run("security", ["find-generic-password", "-a", apiUrl, "-s", service, "-w"])).trim();
15496
- if (process.platform === "linux") return (await run("secret-tool", ["lookup", "service", service, "api-url", apiUrl])).trim();
15497
- } catch {
15498
- return null;
15863
+ if (process.platform === "darwin") return (await run("security", ["find-generic-password", "-a", apiUrl, "-s", service, "-w"])).trim() || null;
15864
+ if (process.platform === "linux") return (await run("secret-tool", ["lookup", "service", service, "api-url", apiUrl])).trim() || null;
15865
+ } catch (error51) {
15866
+ const code = error51.exitCode;
15867
+ if (process.platform === "darwin" && code === 44) return null;
15868
+ if (process.platform === "linux" && code === 1 && !error51.message.trim()) return null;
15869
+ throw new Error("Cannot read the OS credential store. Unlock your keychain or enable Secret Service; your identity has not been replaced.");
15499
15870
  }
15500
- return null;
15871
+ throw new Error("Local authorization requires macOS Keychain or Linux Secret Service.");
15501
15872
  }
15502
15873
  function run(command, args, input) {
15503
15874
  return new Promise((resolve, reject) => {
@@ -15507,7 +15878,7 @@ function run(command, args, input) {
15507
15878
  child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk)));
15508
15879
  child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk)));
15509
15880
  child.once("error", reject);
15510
- child.once("exit", (code) => code === 0 ? resolve(Buffer.concat(stdout).toString("utf8")) : reject(new Error(Buffer.concat(stderr).toString("utf8").trim() || `${command} exited with ${code}.`)));
15881
+ child.once("exit", (code) => code === 0 ? resolve(Buffer.concat(stdout).toString("utf8")) : reject(Object.assign(new Error(Buffer.concat(stderr).toString("utf8").trim()), { exitCode: code })));
15511
15882
  if (input) child.stdin.end(input);
15512
15883
  else child.stdin.end();
15513
15884
  });
@@ -15597,6 +15968,8 @@ var CLI_VERSION = packageJson.version;
15597
15968
 
15598
15969
  // src/doctor.ts
15599
15970
  var defaultDependencies = {
15971
+ encoding: "remote",
15972
+ encodingCapabilities: (apiUrl, token) => new ControlPlaneClient(apiUrl, token).encodingCapabilities(),
15600
15973
  nodeVersion: process.version,
15601
15974
  cliVersion: CLI_VERSION,
15602
15975
  chromePath: findChromeExecutable(),
@@ -15639,7 +16012,7 @@ async function runDoctor(apiUrl, overrides = {}) {
15639
16012
  } else {
15640
16013
  const token = await dependencies.loadCredential(apiUrl);
15641
16014
  if (!token) {
15642
- checks.push({ name: "authentication", status: "fail", message: "No Rill agent credential is available.", recovery: `Run \`rill --api-url ${apiUrl} login\` or set RILL_TOKEN.` });
16015
+ checks.push({ name: "authentication", status: health.anonymousAccess ? "warn" : "fail", message: health.anonymousAccess ? "The first recording will create an anonymous identity in your OS credential store. Anonymous recordings expire after seven days." : "No Rill agent credential is available.", recovery: `Run \`rill --api-url ${apiUrl} login\` or set RILL_TOKEN.` });
15643
16016
  } else {
15644
16017
  try {
15645
16018
  const session = await dependencies.agentSession(apiUrl, token);
@@ -15651,7 +16024,18 @@ async function runDoctor(apiUrl, overrides = {}) {
15651
16024
  }
15652
16025
  }
15653
16026
  checks.push(await executableCheck("chrome", dependencies.chromePath, ["--version"], dependencies.commandVersion, "Install Google Chrome or Chromium, or set RILL_CHROME_PATH to its executable."));
15654
- checks.push(await executableCheck("ffmpeg", dependencies.ffmpegPath, ["-version"], dependencies.commandVersion, process.platform === "darwin" ? "Install ffmpeg with `brew install ffmpeg`, or set RILL_FFMPEG." : "Install ffmpeg using the system package manager, or set RILL_FFMPEG."));
16027
+ if (dependencies.encoding === "local") {
16028
+ checks.push(await executableCheck("ffmpeg", dependencies.ffmpegPath, ["-version"], dependencies.commandVersion, process.platform === "darwin" ? "Install ffmpeg with `brew install ffmpeg`, or set RILL_FFMPEG." : "Install ffmpeg using the system package manager, or set RILL_FFMPEG."));
16029
+ } else {
16030
+ checks.push({ name: "ffmpeg", status: "skip", message: "Remote encoding does not require local FFmpeg." });
16031
+ try {
16032
+ const token = await dependencies.loadCredential(apiUrl);
16033
+ if (!(token ? (await dependencies.encodingCapabilities(apiUrl, token)).enabled : health?.anonymousAccess && health.anonymousRemoteEncoding)) throw new Error("Remote encoding is not enabled for this workspace.");
16034
+ checks.push({ name: "encoding", status: "pass", message: token ? "Remote encoding is enabled." : "Remote encoding is available for the first anonymous recording." });
16035
+ } catch (error51) {
16036
+ checks.push({ name: "encoding", status: "fail", message: errorMessage(error51), recovery: "Remote encoding must be enabled on the server before using the default recording workflow." });
16037
+ }
16038
+ }
15655
16039
  return { schemaVersion: 1, status: checks.some((check2) => check2.status === "fail") ? "error" : "ok", apiUrl, checks, ...runFeedbackFields(feedbackEnabled) };
15656
16040
  }
15657
16041
  async function executableCheck(name, path, args, run2, recovery) {
@@ -15760,20 +16144,31 @@ function controlPlaneUrl(environmentUrl) {
15760
16144
  }
15761
16145
 
15762
16146
  // src/cli.ts
16147
+ async function removeLocalCapture(id) {
16148
+ await ensureDaemon(process.argv[1]);
16149
+ const local = await recordingStatus(id).catch(() => null);
16150
+ if (local?.result?.frameDirectory) await rm5(dirname2(local.result.videoPath), { recursive: true, force: true });
16151
+ }
16152
+ function watchEncoding(apiUrl, id) {
16153
+ const child = spawn6(process.execPath, [process.argv[1], "--api-url", apiUrl, "encoding-watch", id], { detached: true, stdio: "ignore", env: { ...process.env } });
16154
+ child.on("error", () => void 0);
16155
+ child.unref();
16156
+ }
15763
16157
  var program = new Command();
15764
16158
  program.name("rill").description("Record, inspect, and share agent browser runs.").version(CLI_VERSION).option("--api-url <url>", "Rill control plane", controlPlaneUrl(process.env.RILL_API_URL)).option("--pretty", "Pretty-print JSON output", false).option("--json", "Output cloud run results as JSON", false);
15765
16159
  var activeTelemetry = null;
15766
16160
  program.command("daemon", { hidden: true }).action(() => runDaemon());
15767
- program.command("doctor").description("Validate Rill prerequisites before recording.").action(async () => {
16161
+ program.command("doctor").description("Validate Rill prerequisites before recording.").option("--encoding <mode>", "remote or local", "remote").action(async (commandOptions) => {
15768
16162
  const options = program.opts();
15769
- const result = await runDoctor(options.apiUrl);
16163
+ const result = await runDoctor(options.apiUrl, { encoding: commandOptions.encoding });
15770
16164
  emit(result, options.pretty);
15771
16165
  if (result.status === "error") process.exitCode = 1;
15772
16166
  });
15773
16167
  async function authorize(quiet = false) {
15774
16168
  const options = program.opts();
16169
+ if (parseAuthorization(await loadStoredCredential(options.apiUrl))?.anonymous) throw new Error("Use rill connect --email <your-email> to keep and claim this anonymous identity before using account access.");
15775
16170
  const client = new ControlPlaneClient(options.apiUrl, null);
15776
- const authorization = await client.createDeviceAuthorization({ deviceName: `${hostname3()} \xB7 ${process.platform}` });
16171
+ const authorization = await client.createDeviceAuthorization({ deviceName: `${hostname4()} \xB7 ${process.platform}` });
15777
16172
  process.stderr.write(`Open ${authorization.verificationUri} and enter ${authorization.userCode}
15778
16173
  `);
15779
16174
  const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open";
@@ -15792,7 +16187,11 @@ async function authorize(quiet = false) {
15792
16187
  }
15793
16188
  throw new Error("Device authorization expired.");
15794
16189
  }
15795
- program.command("login").description("Authorize Rill once.").action(async () => {
16190
+ program.command("connect").description("Connect this agent once, with renewable account access.").requiredOption("--email <email>", "Email of the Rill account approving access").action(async (input) => {
16191
+ const options = program.opts();
16192
+ emit({ schemaVersion: 1, ...await connectAgent(options.apiUrl, input.email) }, options.pretty);
16193
+ });
16194
+ program.command("login").description("Authorize Rill using the existing device login flow.").action(async () => {
15796
16195
  await authorize();
15797
16196
  });
15798
16197
  var record2 = program.command("record").description("Run a cloud browser from a URL and prompt, or manage a local recording.").argument("[url]").option("-p, --prompt <prompt>").option("--expectation <text>").option("--account <name>", "Saved website test account").option("--id <uuid>", "Retry a submission with the same run ID").option("--no-wait").action(async (url2, commandOptions) => {
@@ -15822,16 +16221,19 @@ var record2 = program.command("record").description("Run a cloud browser from a
15822
16221
  var cloudRun = program.command("run").description("Retrieve or cancel a cloud run.");
15823
16222
  for (const action of ["status", "cancel"]) cloudRun.command(action).argument("<run-id>").action(async (id) => {
15824
16223
  const options = program.opts();
15825
- const client = new ControlPlaneClient(options.apiUrl, await loadCredential(options.apiUrl));
16224
+ const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
15826
16225
  const run2 = await (action === "status" ? client.cloudRun(id) : client.cancelCloudRun(id));
15827
16226
  if (options.json || options.pretty) emit(customerRun(run2), options.pretty);
15828
16227
  else process.stdout.write(formatCloudRun(run2));
15829
16228
  });
15830
- record2.command("start").option("--url <url>").option("--title <title>").option("--description <description>", "").option("--headed").option("--cdp-url <url>").option("--width <pixels>", "", "1280").option("--height <pixels>", "", "720").option("--max-duration <seconds>", "", "600").action(async (commandOptions) => {
16229
+ record2.command("start").option("--encoding <mode>", "remote or local", "remote").option("--url <url>").option("--title <title>").option("--description <description>", "").option("--headed").option("--cdp-url <url>").option("--width <pixels>", "", "1280").option("--height <pixels>", "", "720").option("--max-duration <seconds>", "", "600").action(async (commandOptions) => {
15831
16230
  const options = program.opts();
15832
- const token = await loadCredential(options.apiUrl);
15833
- const client = new ControlPlaneClient(options.apiUrl, token);
16231
+ await ensureAnonymousCredential(options.apiUrl);
16232
+ const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
15834
16233
  const result = await withRunFeedback(() => client.agentSession(), async (rememberRecording) => {
16234
+ if (!["local", "remote"].includes(commandOptions.encoding)) throw new Error("Encoding must be remote or local.");
16235
+ if (commandOptions.encoding === "remote" && !(await client.encodingCapabilities()).enabled) throw new Error("Remote encoding is unavailable for this workspace. Use --encoding local.");
16236
+ if (!Number.isFinite(Number(commandOptions.maxDuration)) || Number(commandOptions.maxDuration) < 1 || Number(commandOptions.maxDuration) > 600) throw new Error("Recording duration must be between 1 and 600 seconds.");
15835
16237
  const width = Number(commandOptions.width);
15836
16238
  const height = Number(commandOptions.height);
15837
16239
  const remote = await client.createRecording({ title: commandOptions.title ?? `Browser recording \xB7 ${(/* @__PURE__ */ new Date()).toISOString()}`, description: commandOptions.description, sourceUrl: commandOptions.url ?? null, width, height });
@@ -15839,7 +16241,7 @@ record2.command("start").option("--url <url>").option("--title <title>").option(
15839
16241
  const local = await startRecordingWithCompensation(remote, {
15840
16242
  startLocal: async () => {
15841
16243
  await ensureDaemon(process.argv[1]);
15842
- return startRecording({ recordingId: remote.recordingId, url: commandOptions.url, cdpUrl: commandOptions.cdpUrl, headed: Boolean(commandOptions.headed), width, height, maxDurationSeconds: Number(commandOptions.maxDuration), captureNetworkBodies: remote.bodyCaptureEnabled, cursorAppearance: remote.cursorAppearance });
16244
+ return startRecording({ encoding: commandOptions.encoding, recordingId: remote.recordingId, url: commandOptions.url, cdpUrl: commandOptions.cdpUrl, headed: Boolean(commandOptions.headed), width, height, maxDurationSeconds: Math.min(Number(commandOptions.maxDuration), remote.maxDurationSeconds ?? 600), captureNetworkBodies: remote.bodyCaptureEnabled, cursorAppearance: remote.cursorAppearance });
15843
16245
  },
15844
16246
  cancelRemote: (recordingId, reason) => client.cancelRecording(recordingId, reason)
15845
16247
  });
@@ -15850,7 +16252,7 @@ record2.command("start").option("--url <url>").option("--title <title>").option(
15850
16252
  headed: Boolean(commandOptions.headed),
15851
16253
  attached: Boolean(commandOptions.cdpUrl)
15852
16254
  }).catch(() => void 0);
15853
- return { schemaVersion: 1, recordingId: remote.recordingId, status: local.status, cdpUrl: local.cdpUrl, bodyCaptureEnabled: remote.bodyCaptureEnabled, maxDurationSeconds: Number(commandOptions.maxDuration) };
16255
+ return { schemaVersion: 1, recordingId: remote.recordingId, status: local.status, cdpUrl: local.cdpUrl, bodyCaptureEnabled: remote.bodyCaptureEnabled, anonymous: remote.anonymous ?? false, expiresAt: remote.expiresAt ?? null, maxDurationSeconds: Math.min(Number(commandOptions.maxDuration), remote.maxDurationSeconds ?? 600) };
15854
16256
  });
15855
16257
  emit(result, options.pretty);
15856
16258
  });
@@ -15859,31 +16261,73 @@ record2.command("stop").argument("<recording-id>").option("--no-wait").option("-
15859
16261
  await ensureDaemon(process.argv[1]);
15860
16262
  const local = await stopRecording(recordingId);
15861
16263
  if (!local.result) throw new Error(local.error ?? "The recorder did not produce an artifact.");
15862
- const token = await loadCredential(options.apiUrl);
15863
- const client = new ControlPlaneClient(options.apiUrl, token);
16264
+ const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
15864
16265
  const stopped = await client.markRecordingStopped(recordingId, {
15865
16266
  durationSeconds: local.result.durationSeconds,
15866
16267
  stoppedReason: local.result.stoppedReason,
15867
16268
  diagnosticsAvailable: true,
15868
16269
  requestFeedback: commandOptions.feedback
15869
16270
  }).catch(() => null);
15870
- const video = await stat2(local.result.videoPath);
16271
+ if (local.result.frameDirectory) {
16272
+ await client.uploadDiagnostics(recordingId, local.result.diagnosticsPath, local.result.diagnostics);
16273
+ const accepted = await submitFrameCapture(client, recordingId, local.result.frameDirectory);
16274
+ const result2 = commandOptions.wait && record2.opts().wait !== false ? await waitForEncoding(client, recordingId) : accepted;
16275
+ emit({ schemaVersion: 1, ...result2, ...recordingFeedbackFields(recordingId, stopped?.feedbackRequested ?? false) }, options.pretty);
16276
+ if (result2.status === "ready") await rm5(dirname2(local.result.videoPath), { recursive: true, force: true });
16277
+ else watchEncoding(options.apiUrl, recordingId);
16278
+ return;
16279
+ }
16280
+ const video = await stat4(local.result.videoPath);
15871
16281
  const provisioned = await client.provisionVideo(recordingId, { sizeBytes: video.size, filename: basename(local.result.videoPath), uploadKind: "recorded_browser" });
15872
16282
  await Promise.all([client.uploadVideo(provisioned.uploadUrl, local.result.videoPath), client.uploadDiagnostics(recordingId, local.result.diagnosticsPath, local.result.diagnostics)]);
15873
- const remote = commandOptions.wait === false ? null : await client.waitUntilPlayable(recordingId);
16283
+ const remote = commandOptions.wait === false || record2.opts().wait === false ? null : await client.waitUntilPlayable(recordingId);
15874
16284
  if (remote) process.stderr.write("\n");
15875
16285
  const result = {
15876
16286
  schemaVersion: 1,
15877
16287
  recordingId,
15878
16288
  status: remote ? "ready" : "processing",
15879
16289
  shareUrl: remote?.shareUrl ?? null,
16290
+ expiresAt: remote?.expires_at ?? null,
15880
16291
  durationSeconds: local.result.durationSeconds,
15881
16292
  stoppedReason: local.result.stoppedReason,
15882
16293
  diagnostics: local.result.diagnostics,
15883
16294
  ...recordingFeedbackFields(recordingId, stopped?.feedbackRequested ?? false)
15884
16295
  };
15885
16296
  emit(result, options.pretty);
15886
- if (remote) await rm2(dirname2(local.result.videoPath), { recursive: true, force: true });
16297
+ if (remote) await rm5(dirname2(local.result.videoPath), { recursive: true, force: true });
16298
+ });
16299
+ record2.command("retry").argument("<recording-id>").option("--no-wait").action(async (recordingId, commandOptions) => {
16300
+ const options = program.opts();
16301
+ const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
16302
+ const current = await client.encodingStatus(recordingId).catch((error51) => {
16303
+ if (error51 instanceof ControlPlaneError && error51.code === "recording_not_found") return null;
16304
+ throw error51;
16305
+ });
16306
+ let accepted = current;
16307
+ if (!current || current.status === "uploading") {
16308
+ await ensureDaemon(process.argv[1]);
16309
+ const local = await recordingStatus(recordingId);
16310
+ if (!local.result?.frameDirectory) throw new Error("Local capture is unavailable.");
16311
+ await client.uploadDiagnostics(recordingId, local.result.diagnosticsPath, local.result.diagnostics);
16312
+ accepted = await submitFrameCapture(client, recordingId, local.result.frameDirectory);
16313
+ } else if (current.status === "failed") accepted = await client.retryEncoding(recordingId);
16314
+ if (!accepted) throw new Error("Encoding job is unavailable.");
16315
+ if (["expired", "cancelled"].includes(accepted.status)) throw new Error(`Recording ${accepted.status}.`);
16316
+ const result = commandOptions.wait && record2.opts().wait !== false ? await waitForEncoding(client, recordingId) : accepted;
16317
+ emit({ schemaVersion: 1, ...result }, options.pretty);
16318
+ if (result.status === "ready") await removeLocalCapture(recordingId);
16319
+ else watchEncoding(options.apiUrl, recordingId);
16320
+ });
16321
+ program.command("encoding-watch", { hidden: true }).argument("<recording-id>").action(async (id) => {
16322
+ if (!/^rec_[a-zA-Z0-9_-]+$/.test(id)) throw new Error("Invalid recording ID");
16323
+ const options = program.opts();
16324
+ const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
16325
+ const result = await waitForEncoding(client, id, 864e5);
16326
+ if (result.status === "ready") {
16327
+ await ensureDaemon(process.argv[1]);
16328
+ const local = await recordingStatus(id).catch(() => null);
16329
+ if (local?.result?.frameDirectory) await rm5(dirname2(local.result.videoPath), { recursive: true, force: true });
16330
+ }
15887
16331
  });
15888
16332
  var feedback = program.command("feedback").description("Submit a short Rill product review for a recorded run.");
15889
16333
  feedback.command("submit").argument("[recording-id]", "Omit only when setup failed before a recording was created").requiredOption("--outcome <outcome>", "succeeded, failed, or abandoned").option("--helped <text>", "What went well or helped during setup and the run", "").option("--friction <text>", "What created friction during the run", "").option("--improvement <text>", "Possible Rill improvements, prioritized by usefulness", "").action(async (recordingId, commandOptions) => {
@@ -15892,7 +16336,7 @@ feedback.command("submit").argument("[recording-id]", "Omit only when setup fail
15892
16336
  if (![commandOptions.helped, commandOptions.friction, commandOptions.improvement].some((value) => value.trim())) throw new Error("validation_failed: Include at least one short feedback answer.");
15893
16337
  const options = program.opts();
15894
16338
  const token = await loadCredential(options.apiUrl);
15895
- const client = new ControlPlaneClient(options.apiUrl, token);
16339
+ const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
15896
16340
  await client.submitRecordingFeedback(recordingId ?? null, {
15897
16341
  outcome: commandOptions.outcome,
15898
16342
  whatHelped: commandOptions.helped,
@@ -15901,10 +16345,23 @@ feedback.command("submit").argument("[recording-id]", "Omit only when setup fail
15901
16345
  });
15902
16346
  emit(feedbackSubmissionReceipt(recordingId ?? null), options.pretty);
15903
16347
  });
16348
+ record2.command("list").description("List recordings visible to this connection.").action(async () => {
16349
+ const options = program.opts();
16350
+ emit(await new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl)).listRecordings(), options.pretty);
16351
+ });
15904
16352
  record2.command("status").argument("<recording-id>").action(async (recordingId) => {
15905
16353
  const options = program.opts();
15906
16354
  const token = await loadCredential(options.apiUrl);
15907
- const client = new ControlPlaneClient(options.apiUrl, token);
16355
+ const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
16356
+ try {
16357
+ const encoding = await client.encodingStatus(recordingId);
16358
+ const remote = encoding.status === "ready" ? await client.recordingStatus(recordingId) : {};
16359
+ emit({ schemaVersion: 1, ...encoding, ...remote, recordingId, source: "remote" }, options.pretty);
16360
+ if (encoding.status === "ready") await removeLocalCapture(recordingId);
16361
+ return;
16362
+ } catch (error51) {
16363
+ if (!(error51 instanceof Error) || !error51.message.includes("recording_not_found")) throw error51;
16364
+ }
15908
16365
  const status = await remoteAwareRecordingStatus(recordingId, {
15909
16366
  localStatus: async (id) => {
15910
16367
  await ensureDaemon(process.argv[1]);
@@ -15917,42 +16374,42 @@ record2.command("status").argument("<recording-id>").action(async (recordingId)
15917
16374
  record2.command("cancel").argument("<recording-id>").description("Cancel an active recording and release its remote quota slot.").action(async (recordingId) => {
15918
16375
  const options = program.opts();
15919
16376
  const token = await loadCredential(options.apiUrl);
15920
- const client = new ControlPlaneClient(options.apiUrl, token);
16377
+ const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
15921
16378
  emit(await client.cancelRecording(recordingId), options.pretty);
15922
16379
  });
15923
16380
  program.command("upload").argument("<video-file>").option("--title <title>").option("--no-wait").action(async (videoFile, commandOptions) => {
15924
16381
  const options = program.opts();
15925
- const token = await loadCredential(options.apiUrl);
15926
- const client = new ControlPlaneClient(options.apiUrl, token);
15927
- const file2 = await stat2(videoFile);
16382
+ await ensureAnonymousCredential(options.apiUrl);
16383
+ const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
16384
+ const file2 = await stat4(videoFile);
15928
16385
  const recording = await client.createRecording({ title: commandOptions.title ?? basename(videoFile), sourceUrl: null });
15929
16386
  const upload = await client.provisionVideo(recording.recordingId, { sizeBytes: file2.size, filename: basename(videoFile), uploadKind: "existing_video" });
15930
16387
  await client.uploadVideo(upload.uploadUrl, videoFile);
15931
16388
  const remote = commandOptions.wait === false ? null : await client.waitUntilPlayable(recording.recordingId);
15932
16389
  if (remote) process.stderr.write("\n");
15933
- emit({ schemaVersion: 1, recordingId: recording.recordingId, status: remote ? "ready" : "processing", shareUrl: remote?.shareUrl ?? null, diagnosticsAvailable: false }, options.pretty);
16390
+ emit({ schemaVersion: 1, recordingId: recording.recordingId, status: remote ? "ready" : "processing", shareUrl: remote?.shareUrl ?? null, expiresAt: remote?.expires_at ?? recording.expiresAt ?? null, diagnosticsAvailable: false }, options.pretty);
15934
16391
  });
15935
16392
  program.command("inspect").argument("<share-url>").action(async (shareUrl) => {
15936
16393
  const options = program.opts();
15937
- emit(await new ControlPlaneClient(options.apiUrl, null).inspect(shareUrl), options.pretty);
16394
+ emit(await new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl)).inspect(shareUrl), options.pretty);
15938
16395
  });
15939
16396
  var drafts = program.command("drafts");
15940
16397
  drafts.command("list").action(async () => {
15941
16398
  const options = program.opts();
15942
16399
  const root = process.env.RILL_TEMP ?? `${process.env.TMPDIR ?? "/tmp"}/rill-recordings`;
15943
- const names = await readdir(root).catch(() => []);
16400
+ const names = await readdir2(root).catch(() => []);
15944
16401
  emit({ schemaVersion: 1, drafts: names }, options.pretty);
15945
16402
  });
15946
16403
  drafts.command("purge").action(async () => {
15947
16404
  const options = program.opts();
15948
16405
  const root = process.env.RILL_TEMP ?? `${process.env.TMPDIR ?? "/tmp"}/rill-recordings`;
15949
- const names = await readdir(root).catch(() => []);
16406
+ const names = await readdir2(root).catch(() => []);
15950
16407
  let purged = 0;
15951
16408
  for (const name of names) {
15952
16409
  const path = `${root}/${name}`;
15953
- const metadata = await stat2(path);
16410
+ const metadata = await stat4(path);
15954
16411
  if (Date.now() - metadata.mtimeMs > 24 * 60 * 60 * 1e3) {
15955
- await rm2(path, { recursive: true, force: true });
16412
+ await rm5(path, { recursive: true, force: true });
15956
16413
  purged += 1;
15957
16414
  }
15958
16415
  }
@@ -15964,7 +16421,7 @@ program.hook("preAction", async (_command, actionCommand) => {
15964
16421
  const options = program.opts();
15965
16422
  const token = await loadCredential(options.apiUrl).catch(() => null);
15966
16423
  if (!token) return;
15967
- const client = new ControlPlaneClient(options.apiUrl, token);
16424
+ const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
15968
16425
  await startCliTelemetry(client, command);
15969
16426
  });
15970
16427
  async function startCliTelemetry(client, command) {