@runalabs/rill-cli 0.1.14 → 0.1.15

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 +29 -2
  2. package/dist/cli.js +300 -105
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -124,7 +124,7 @@ rill record status <recording-id>
124
124
 
125
125
  Replace the placeholders with start's actual values. This snippet verifies attachment only until you add your flow assertions. Keep the directory for retries and remove it when finished. Playwright documents the [connected-browser disconnect behavior](https://playwright.dev/docs/api/class-browser#browser-close).
126
126
 
127
- Capture follows the first page selected at recording start. Keep the flow in that page: new tabs and popups do not receive complete video and diagnostic coverage. Keep the recorded page open until Rill stops.
127
+ Capture follows the page selected at recording start. Keep the flow in that page: new tabs and popups do not receive complete video and diagnostic coverage. Keep the recorded page open until Rill stops.
128
128
 
129
129
  ### Recording options
130
130
 
@@ -135,12 +135,32 @@ Capture follows the first page selected at recording start. Keep the flow in tha
135
135
  | `--title <title>` | Name the run; defaults to a timestamped title. |
136
136
  | `--description <text>` | Describe what the run demonstrates. |
137
137
  | `--headed` | Show the managed browser; default is headless. |
138
- | `--cdp-url <url>` | Attach to an existing Chromium browser and record its first open page. Navigate that page with your browser tool. |
138
+ | `--cdp-url <url>` | Attach to an existing Chromium CDP endpoint. Select a tab when several are open. |
139
+ | `--tab-id <id>` | Record the stable target ID returned by `record tabs`; requires `--cdp-url`. |
139
140
  | `--width <pixels>` / `--height <pixels>` | Managed viewport; defaults to 1280 × 720. |
140
141
  | `--max-duration <seconds>` | Automatically finalize local capture; defaults to 600. Still call `record stop` to upload. |
141
142
 
142
143
  Rill closes browsers it launches during finalization. Attached browsers remain open.
143
144
 
145
+ ### Record a tab you already use
146
+
147
+ Your Chromium browser must already expose a CDP endpoint; Rill cannot attach to an ordinary unexposed tab. Keep the endpoint local or otherwise access-controlled. This does not launch a second browser, switch tabs automatically or provide Safari/Firefox support.
148
+
149
+ ```bash
150
+ rill record tabs --cdp-url http://127.0.0.1:9222
151
+ rill record start --cdp-url http://127.0.0.1:9222 --tab-id <tab-id> --title "Checkout review"
152
+ # Continue your flow in that exact tab, then finalize/upload:
153
+ rill record stop <recording-id>
154
+ ```
155
+
156
+ Discovery requires no Rill sign-in and returns `tabId`, title and a sanitized URL. Query values, credentials and fragments are removed; embedded data/file URLs are omitted. Tab titles can still contain private content. Start returns the selected `tabId` at the top level and in `browserAttachment`. If only one page is open, `--tab-id` is optional. Several pages without a selection, or a closed/missing ID, fail instead of recording a different page. A tab ID remains tied to that tab across navigation; relist after closing it.
157
+
158
+ Keep automation on the selected target. For Playwright, inspect each page with `context.newCDPSession(page)`, call `Target.getTargetInfo`, match `targetInfo.targetId` to the returned `tabId`, and detach that inspection session. Do not assume `context.pages()[0]` is the recorded tab. `--url` deliberately navigates the selected tab when supplied; omit it to record the page as it is. Capture stays on this tab; popups and other tabs are not fully covered.
159
+
160
+ An older running recorder service cannot honor tab selection. Start detects this and fails before sending capture instructions. Finish active recordings first; the service exits after five idle minutes when it has no retained recoverable captures. For an immediate manual restart, inspect the local `/health` response over the configured Rill socket, verify its recorder PID, and terminate only that idle recorder process. Restarting it interrupts active captures; this release does not restart it automatically.
161
+
162
+ The CDP connection and disconnect behavior follow the [Playwright browser API](https://playwright.dev/docs/api/class-browser#browser-close).
163
+
144
164
  ## Set up a coding agent
145
165
 
146
166
  Install and authenticate the CLI where the agent runs. No separate skill installation is required. Optionally save the following workflow in your agent's project instructions or skill setup; it is also available in the [public agent instructions](https://userill.dev/docs/cli#agents):
@@ -187,6 +207,7 @@ Use `rill --help` or `<command> --help` for supported flags.
187
207
  | `rill connect --email <email> [--no-wait]` | Start or resume account approval. |
188
208
  | `rill connect status <authorization-id>` | Poll a saved approval once; obey `retryAfterSeconds`. |
189
209
  | `rill doctor` | Check prerequisites, API compatibility, and authentication. |
210
+ | `rill record tabs --cdp-url <url>` | List existing Chromium tabs without starting a recording. |
190
211
  | `rill record start [options]` | Start a managed or attached browser recording. |
191
212
  | `rill record stop <recording-id>` | Finalize and upload; supports `--no-wait` and `--no-feedback`. |
192
213
  | `rill record status <recording-id>` | Read remote state first, then local capture for unsubmitted recordings; local completion is `captured`. |
@@ -343,3 +364,9 @@ Run `rill whoami` (or `rill --pretty whoami`) to verify your current identity. J
343
364
  `record start` and active local `record status` return `startedAt`, `stopsAt`, `maxDurationSeconds`, `elapsedSeconds`, and `remainingSeconds`. Use the remaining time to budget retries. After automatic capture stop, status reports `captured` with the stop/upload command; zero remaining time does not mean playback is ready. Fields may be absent for recovered drafts or an older daemon.
344
365
 
345
366
  Remote frame uploads write accepted/total MiB, transfer speed and an approximate remaining transfer time to stderr. Estimates use this attempt's observed transfers, including retry delays, and exclude previously uploaded bytes. They do not estimate queue or encoding time. JSON stdout stays machine readable. Failed local captures offer an explicit cancellation command for their remote slot; local drafts are retained.
367
+
368
+ ### Failed local capture recovery
369
+
370
+ If the local recorder reports a terminal failure without a finalized artifact, `rill record stop <id>` attempts to release its unused server reservation and preserves the capture error. The JSON error uses `local_capture_failed` with `details.reservationReleased`; a failed cleanup includes the status command for the selected API origin.
371
+
372
+ When `record start` is blocked by a recording concurrency slot, it checks the reported blocking recording in the local daemon. A confirmed failed capture with no artifact can release its own unused reservation, then retry creation once with the same request ID. Missing/active local state, retained artifacts, other credentials, Stream uploads, encoding jobs and quota-counted reservations are preserved. Older servers or unavailable networks leave the original quota error and manual recovery guidance intact. Daily upload/storage limits still apply. This change does not resume a failed capture, import missing diagnostics, or add Windows recording support.
package/dist/cli.js CHANGED
@@ -116,12 +116,13 @@ function recordingNextStep(apiUrl, recordingId, status, options = {}) {
116
116
  if (["uploading", "queued", "encoding", "processing", "finalizing"].includes(status)) return { nextCommand: cliCommand(apiUrl, "record", "status", recordingId), message: "Finalizing recordings hold a concurrency slot. Wait for ready; cancel only if abandoning the recording." };
117
117
  return {};
118
118
  }
119
- function browserAttachment(cdpUrl) {
119
+ function browserAttachment(cdpUrl, tabId) {
120
120
  return {
121
121
  protocol: "cdp",
122
122
  cdpUrl,
123
+ ...tabId ? { tabId } : {},
123
124
  playwrightMethod: "chromium.connectOverCDP",
124
- instructions: "Attach to this endpoint and use the existing browser context. A browser tool must explicitly support CDP attachment; do not drive a separate browser. Disconnect the automation client without closing the recorded browser, then run record stop.",
125
+ instructions: (tabId ? "Record only the returned tabId. Match it using a page CDP session and Target.getTargetInfo; do not pick the first page. " : "") + "Attach to this endpoint and use the existing browser context. A browser tool must explicitly support CDP attachment; do not drive a separate browser. Disconnect the automation client without closing the recorded browser, then run record stop.",
125
126
  shellFallback: "See the shell-only Playwright example in the Rill CLI README; install playwright-core in a temporary directory outside the project."
126
127
  };
127
128
  }
@@ -452,6 +453,9 @@ var ControlPlaneClient = class {
452
453
  createRecording(input) {
453
454
  return this.request("/api/recordings", { method: "POST", body: JSON.stringify(input) });
454
455
  }
456
+ failLocalCapture(recordingId) {
457
+ return this.request(`/api/recordings/${encodeURIComponent(recordingId)}/local-failed`, { method: "POST", body: "{}" });
458
+ }
455
459
  markRecordingStarted(recordingId, input) {
456
460
  return this.request(`/api/recordings/${recordingId}/local-started`, {
457
461
  method: "POST",
@@ -15147,19 +15151,20 @@ var frameManifestSchema = external_exports.object({
15147
15151
  });
15148
15152
 
15149
15153
  // src/remote-encoding.ts
15150
- async function withFrameUploadRetry(upload, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
15154
+ async function withFrameUploadRetry(upload, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), canRetry = () => true) {
15151
15155
  for (let attempt = 0; ; attempt++) {
15152
15156
  try {
15153
15157
  return await upload();
15154
15158
  } catch (error51) {
15155
15159
  const status = error51 instanceof ControlPlaneError || error51 instanceof ControlPlaneHttpError ? error51.httpStatus : void 0;
15156
15160
  const transient = status !== void 0 ? status === 408 || status === 429 || status >= 500 : error51 instanceof TypeError || error51 instanceof DOMException && ["TimeoutError", "AbortError"].includes(error51.name);
15157
- if (!transient || attempt >= 7) throw error51;
15161
+ if (!transient || attempt >= 7 || !canRetry()) throw error51;
15158
15162
  const delay = Math.min(15e3, 1e3 * 2 ** attempt);
15159
15163
  process.stderr.write(`
15160
15164
  Frame upload interrupted; retrying in ${delay / 1e3}s.
15161
15165
  `);
15162
15166
  await sleep(delay);
15167
+ if (!canRetry()) throw error51;
15163
15168
  }
15164
15169
  }
15165
15170
  }
@@ -15175,8 +15180,12 @@ async function submitFrameCapture(client, id, directory) {
15175
15180
  try {
15176
15181
  const total = manifest.frames.reduce((n, f) => n + f.size, 0);
15177
15182
  if ((await file2.stat()).size !== total) throw new Error("Capture bytes do not match the manifest.");
15178
- const uploaded = new Set(status.uploadedParts);
15179
- let acceptedBytes = [...uploaded].reduce((sum, part) => sum + (Number.isInteger(part) && part >= 0 && part < Math.ceil(total / ENCODING_PART_BYTES) ? Math.min(ENCODING_PART_BYTES, total - part * ENCODING_PART_BYTES) : 0), 0);
15183
+ const parts = Math.ceil(total / ENCODING_PART_BYTES);
15184
+ const uploaded = new Set(status.uploadedParts.filter((part) => Number.isInteger(part) && part >= 0 && part < parts));
15185
+ const missing = Array.from({ length: parts }, (_, part) => part).filter((part) => !uploaded.has(part));
15186
+ let next = 0, failed = false;
15187
+ let firstFailure;
15188
+ let acceptedBytes = [...uploaded].reduce((sum, part) => sum + Math.min(ENCODING_PART_BYTES, total - part * ENCODING_PART_BYTES), 0);
15180
15189
  let transferredBytes = 0;
15181
15190
  const startedAt = Date.now();
15182
15191
  const progress = () => {
@@ -15186,20 +15195,34 @@ async function submitFrameCapture(client, id, directory) {
15186
15195
  process.stderr.write(`\ruploading frames ${Math.floor(acceptedBytes / total * 100)}% \xB7 ${(acceptedBytes / 1048576).toFixed(2)}/${(total / 1048576).toFixed(2)} MiB${rate > 0 ? ` \xB7 ${(rate / 1048576).toFixed(2)} MiB/s` : ""}${remaining}`);
15187
15196
  };
15188
15197
  progress();
15189
- for (let part = 0; part < Math.ceil(total / ENCODING_PART_BYTES); part++) {
15190
- if (uploaded.has(part)) continue;
15191
- const bytes = Buffer.alloc(Math.min(ENCODING_PART_BYTES, total - part * ENCODING_PART_BYTES));
15192
- let offset = 0;
15193
- while (offset < bytes.length) {
15194
- const r = await file2.read(bytes, offset, bytes.length - offset, part * ENCODING_PART_BYTES + offset);
15195
- if (!r.bytesRead) throw new Error("Incomplete capture bytes.");
15196
- offset += r.bytesRead;
15197
- }
15198
- await withFrameUploadRetry(() => client.uploadEncodingPart(id, part, bytes));
15199
- acceptedBytes += bytes.length;
15200
- transferredBytes += bytes.length;
15201
- progress();
15202
- }
15198
+ const transfer = async () => {
15199
+ while (!failed) {
15200
+ const part = missing[next++];
15201
+ if (part === void 0) return;
15202
+ try {
15203
+ const bytes = Buffer.alloc(Math.min(ENCODING_PART_BYTES, total - part * ENCODING_PART_BYTES));
15204
+ let offset = 0;
15205
+ while (offset < bytes.length) {
15206
+ const r = await file2.read(bytes, offset, bytes.length - offset, part * ENCODING_PART_BYTES + offset);
15207
+ if (!r.bytesRead) throw new Error("Incomplete capture bytes.");
15208
+ offset += r.bytesRead;
15209
+ }
15210
+ if (failed) return;
15211
+ await withFrameUploadRetry(() => client.uploadEncodingPart(id, part, bytes), void 0, () => !failed);
15212
+ acceptedBytes += bytes.length;
15213
+ transferredBytes += bytes.length;
15214
+ progress();
15215
+ } catch (error51) {
15216
+ if (!failed) {
15217
+ firstFailure = error51;
15218
+ failed = true;
15219
+ }
15220
+ throw error51;
15221
+ }
15222
+ }
15223
+ };
15224
+ await Promise.allSettled(Array.from({ length: Math.min(3, missing.length) }, transfer));
15225
+ if (failed) throw firstFailure;
15203
15226
  } finally {
15204
15227
  await file2.close();
15205
15228
  }
@@ -15327,11 +15350,118 @@ async function windowsIpcIdentity(directory = join5(rillDirectory(), "windows-ip
15327
15350
  // ../recorder/src/client.ts
15328
15351
  import { request as request2 } from "http";
15329
15352
  import { spawn as spawn4 } from "child_process";
15330
- async function ensureDaemon(cliEntryPath) {
15353
+
15354
+ // ../recorder/src/browser-tabs.ts
15355
+ import { chromium } from "playwright-core";
15356
+
15357
+ // ../recorder/src/redaction.ts
15358
+ var secretKeyPattern = /(?:pass(?:word)?|secret|token|api[_-]?key|authorization|cookie|session|access[_-]?token|refresh[_-]?token|client[_-]?secret)/i;
15359
+ var tokenValuePattern = /\b(?:sk|pk|ghp|github_pat|xox[baprs]|eyJ)[-_a-zA-Z0-9.]{16,}\b/g;
15360
+ function sanitizeUrl(input) {
15331
15361
  try {
15332
- await callDaemon("GET", "/health");
15333
- return;
15362
+ const url2 = new URL(input);
15363
+ url2.username = "";
15364
+ url2.password = "";
15365
+ url2.hash = "";
15366
+ for (const key2 of [...url2.searchParams.keys()]) url2.searchParams.set(key2, "[REDACTED]");
15367
+ return url2.toString();
15334
15368
  } catch {
15369
+ return "[invalid-url]";
15370
+ }
15371
+ }
15372
+ function redactText(value, maxLength = 8e3) {
15373
+ return value.replace(tokenValuePattern, "[REDACTED]").slice(0, maxLength);
15374
+ }
15375
+ function redactBody(value, contentType, maxBytes = 65536) {
15376
+ if (Buffer.byteLength(value) > maxBytes) return { body: void 0, omittedReason: "body_too_large" };
15377
+ if (!isAllowedBodyType(contentType)) return { body: void 0, omittedReason: "unsupported_content_type" };
15378
+ if (contentType?.includes("json")) {
15379
+ try {
15380
+ const parsed = JSON.parse(value);
15381
+ return { body: JSON.stringify(redactJson(parsed)) };
15382
+ } catch {
15383
+ return { body: redactText(value) };
15384
+ }
15385
+ }
15386
+ return { body: redactText(value) };
15387
+ }
15388
+ function isAllowedBodyType(contentType) {
15389
+ if (!contentType) return false;
15390
+ const normalized = contentType.toLowerCase();
15391
+ return normalized.includes("application/json") || normalized.includes("application/problem+json") || normalized.includes("application/graphql-response+json") || normalized.includes("text/plain") || normalized.includes("application/xml") || normalized.includes("text/xml");
15392
+ }
15393
+ function redactJson(value, depth = 0) {
15394
+ if (depth > 12) return "[TRUNCATED]";
15395
+ if (Array.isArray(value)) return value.slice(0, 100).map((item) => redactJson(item, depth + 1));
15396
+ if (value && typeof value === "object") {
15397
+ return Object.fromEntries(Object.entries(value).slice(0, 200).map(([key2, child]) => [key2, secretKeyPattern.test(key2) ? "[REDACTED]" : redactJson(child, depth + 1)]));
15398
+ }
15399
+ if (typeof value === "string") return redactText(value, 8e3);
15400
+ return value;
15401
+ }
15402
+
15403
+ // ../recorder/src/browser-tabs.ts
15404
+ function validateTabSelection(options) {
15405
+ if (options.tabId && !options.cdpUrl) throw new Error("validation_failed: --tab-id requires --cdp-url.");
15406
+ }
15407
+ async function tabs(browser) {
15408
+ const result = [];
15409
+ for (const context of browser.contexts()) for (const page of context.pages()) {
15410
+ if (page.isClosed()) continue;
15411
+ const session = await context.newCDPSession(page).catch(() => null);
15412
+ if (!session) {
15413
+ if (page.isClosed()) continue;
15414
+ throw new Error("browser_unavailable: Could not inspect an open tab.");
15415
+ }
15416
+ try {
15417
+ const { targetInfo } = await session.send("Target.getTargetInfo");
15418
+ result.push({ page, context, tab: { tabId: targetInfo.targetId, url: /^(?:https?:|about:)/i.test(page.url()) ? sanitizeUrl(page.url()) : `[${page.url().split(":", 1)[0]} URL omitted]`, title: redactText(targetInfo.title, 200) } });
15419
+ } catch (error51) {
15420
+ if (!page.isClosed()) throw error51;
15421
+ } finally {
15422
+ await session.detach().catch(() => void 0);
15423
+ }
15424
+ }
15425
+ return result;
15426
+ }
15427
+ async function listBrowserTabs(cdpUrl) {
15428
+ const browser = await chromium.connectOverCDP(cdpUrl, { noDefaults: true });
15429
+ try {
15430
+ return (await tabs(browser)).map((entry) => entry.tab);
15431
+ } finally {
15432
+ await browser.close();
15433
+ }
15434
+ }
15435
+ async function connectRecordingTab(cdpUrl, outputDirectory, tabId) {
15436
+ const browser = await chromium.connectOverCDP(cdpUrl, { artifactsDir: outputDirectory, isLocal: true, noDefaults: true });
15437
+ try {
15438
+ const available = await tabs(browser);
15439
+ const selected = tabId ? available.find((entry) => entry.tab.tabId === tabId) : available.length === 1 ? available[0] : void 0;
15440
+ if (!selected) {
15441
+ if (tabId) throw new Error("browser_unavailable: The selected tab is no longer available. Run record tabs --cdp-url <endpoint> and choose an open tab.");
15442
+ if (!available.length) throw new Error("browser_unavailable: The attached browser has no page target to record.");
15443
+ throw new Error("validation_failed: Several tabs are open. Run record tabs --cdp-url <endpoint>, then record start --cdp-url <endpoint> --tab-id <tab-id>.");
15444
+ }
15445
+ return { cdpUrl, browser, context: selected.context, page: selected.page, tabId: selected.tab.tabId };
15446
+ } catch (error51) {
15447
+ await browser.close().catch(() => void 0);
15448
+ throw error51;
15449
+ }
15450
+ }
15451
+
15452
+ // ../recorder/src/client.ts
15453
+ async function ensureDaemon(cliEntryPath, requireTabSelection = false) {
15454
+ const check2 = (health2) => {
15455
+ if (requireTabSelection && !health2.capabilities?.includes("tab-selection-v1")) throw new Error("browser_unavailable: The running Rill recorder predates tab selection. Stop active recordings, then restart the recorder service before attaching.");
15456
+ };
15457
+ let health;
15458
+ try {
15459
+ health = await callDaemon("GET", "/health");
15460
+ } catch {
15461
+ }
15462
+ if (health) {
15463
+ check2(health);
15464
+ return;
15335
15465
  }
15336
15466
  const child = spawn4(process.execPath, [cliEntryPath, "daemon"], { detached: true, stdio: "ignore", env: process.env, windowsHide: true });
15337
15467
  let startupError;
@@ -15343,11 +15473,13 @@ async function ensureDaemon(cliEntryPath) {
15343
15473
  while (Date.now() < deadline) {
15344
15474
  if (startupError) throw startupError;
15345
15475
  try {
15346
- await callDaemon("GET", "/health");
15347
- return;
15476
+ health = await callDaemon("GET", "/health");
15348
15477
  } catch {
15349
15478
  await new Promise((resolve) => setTimeout(resolve, 100));
15479
+ continue;
15350
15480
  }
15481
+ check2(health);
15482
+ return;
15351
15483
  }
15352
15484
  throw new Error("The local recorder service did not start.");
15353
15485
  }
@@ -15424,6 +15556,55 @@ import { createServer as createServer2 } from "http";
15424
15556
  import { mkdir as mkdir7, readFile as readFile7, unlink, writeFile as writeFile6, rename as rename2, stat as stat4, chmod } from "fs/promises";
15425
15557
  import { dirname, join as join10 } from "path";
15426
15558
 
15559
+ // ../recorder/src/click-capture.ts
15560
+ import { randomUUID as randomUUID2 } from "crypto";
15561
+ async function captureClicks(page, session, onClick) {
15562
+ const binding = `__rillClick_${randomUUID2().replaceAll("-", "")}`;
15563
+ const source = `(() => {
15564
+ const key = ${JSON.stringify(binding)};
15565
+ if (window[key + '_listener']) return;
15566
+ const listener = event => {
15567
+ const target = event.target;
15568
+ const label = target?.innerText?.trim().slice(0, 80) || target?.getAttribute?.('aria-label') || target?.tagName || 'Element';
15569
+ try { window[key](JSON.stringify({ x: event.clientX, y: event.clientY, label })); } catch {}
15570
+ };
15571
+ window[key + '_listener'] = listener;
15572
+ document.addEventListener('click', listener, { capture: true });
15573
+ })();`;
15574
+ const receive = (event) => {
15575
+ if (event.name !== binding) return;
15576
+ try {
15577
+ const value = JSON.parse(event.payload);
15578
+ if (typeof value.x === "number" && Number.isFinite(value.x) && typeof value.y === "number" && Number.isFinite(value.y) && typeof value.label === "string") onClick({ x: value.x, y: value.y, label: value.label.slice(0, 80) });
15579
+ } catch {
15580
+ }
15581
+ };
15582
+ let identifier;
15583
+ session.on("Runtime.bindingCalled", receive);
15584
+ const cleanup = async () => {
15585
+ session.off("Runtime.bindingCalled", receive);
15586
+ if (identifier) await session.send("Page.removeScriptToEvaluateOnNewDocument", { identifier }).catch(() => void 0);
15587
+ await Promise.all(page.frames().map((frame) => frame.evaluate((key2) => {
15588
+ const target = window;
15589
+ const listener = target[key2 + "_listener"];
15590
+ if (typeof listener === "function") document.removeEventListener("click", listener, { capture: true });
15591
+ delete target[key2 + "_listener"];
15592
+ delete target[key2];
15593
+ }, binding).catch(() => void 0)));
15594
+ await session.send("Runtime.removeBinding", { name: binding }).catch(() => void 0);
15595
+ };
15596
+ try {
15597
+ await session.send("Runtime.enable");
15598
+ await session.send("Runtime.addBinding", { name: binding });
15599
+ ({ identifier } = await session.send("Page.addScriptToEvaluateOnNewDocument", { source }));
15600
+ await Promise.all(page.frames().map((frame) => frame.evaluate(source)));
15601
+ return cleanup;
15602
+ } catch (error51) {
15603
+ await cleanup();
15604
+ throw error51;
15605
+ }
15606
+ }
15607
+
15427
15608
  // ../contracts/src/cloud-blocklist.ts
15428
15609
  var blockedCloudDomains = [
15429
15610
  // Torrent indexes and unofficial software-download destinations.
@@ -15764,7 +15945,7 @@ async function attachCursorOverlay(page, cdp, input) {
15764
15945
  }
15765
15946
 
15766
15947
  // ../recorder/src/session.ts
15767
- import { chromium } from "playwright-core";
15948
+ import { chromium as chromium2 } from "playwright-core";
15768
15949
  import { spawn as spawn6 } from "child_process";
15769
15950
  import { createServer } from "net";
15770
15951
  import { mkdir as mkdir6, mkdtemp, rm as rm5, writeFile as writeFile5 } from "fs/promises";
@@ -15806,52 +15987,6 @@ function findChromeExecutable(platform = process.platform, env = process.env, ex
15806
15987
  return candidates.find(exists) ?? "google-chrome";
15807
15988
  }
15808
15989
 
15809
- // ../recorder/src/redaction.ts
15810
- var secretKeyPattern = /(?:pass(?:word)?|secret|token|api[_-]?key|authorization|cookie|session|access[_-]?token|refresh[_-]?token|client[_-]?secret)/i;
15811
- var tokenValuePattern = /\b(?:sk|pk|ghp|github_pat|xox[baprs]|eyJ)[-_a-zA-Z0-9.]{16,}\b/g;
15812
- function sanitizeUrl(input) {
15813
- try {
15814
- const url2 = new URL(input);
15815
- url2.username = "";
15816
- url2.password = "";
15817
- url2.hash = "";
15818
- for (const key2 of [...url2.searchParams.keys()]) url2.searchParams.set(key2, "[REDACTED]");
15819
- return url2.toString();
15820
- } catch {
15821
- return "[invalid-url]";
15822
- }
15823
- }
15824
- function redactText(value, maxLength = 8e3) {
15825
- return value.replace(tokenValuePattern, "[REDACTED]").slice(0, maxLength);
15826
- }
15827
- function redactBody(value, contentType, maxBytes = 65536) {
15828
- if (Buffer.byteLength(value) > maxBytes) return { body: void 0, omittedReason: "body_too_large" };
15829
- if (!isAllowedBodyType(contentType)) return { body: void 0, omittedReason: "unsupported_content_type" };
15830
- if (contentType?.includes("json")) {
15831
- try {
15832
- const parsed = JSON.parse(value);
15833
- return { body: JSON.stringify(redactJson(parsed)) };
15834
- } catch {
15835
- return { body: redactText(value) };
15836
- }
15837
- }
15838
- return { body: redactText(value) };
15839
- }
15840
- function isAllowedBodyType(contentType) {
15841
- if (!contentType) return false;
15842
- const normalized = contentType.toLowerCase();
15843
- return normalized.includes("application/json") || normalized.includes("application/problem+json") || normalized.includes("application/graphql-response+json") || normalized.includes("text/plain") || normalized.includes("application/xml") || normalized.includes("text/xml");
15844
- }
15845
- function redactJson(value, depth = 0) {
15846
- if (depth > 12) return "[TRUNCATED]";
15847
- if (Array.isArray(value)) return value.slice(0, 100).map((item) => redactJson(item, depth + 1));
15848
- if (value && typeof value === "object") {
15849
- return Object.fromEntries(Object.entries(value).slice(0, 200).map(([key2, child]) => [key2, secretKeyPattern.test(key2) ? "[REDACTED]" : redactJson(child, depth + 1)]));
15850
- }
15851
- if (typeof value === "string") return redactText(value, 8e3);
15852
- return value;
15853
- }
15854
-
15855
15990
  // ../recorder/src/encoding.ts
15856
15991
  import { open as open2, writeFile as writeFile3, mkdir as mkdir4 } from "fs/promises";
15857
15992
  import { spawn as spawn5 } from "child_process";
@@ -16009,6 +16144,7 @@ var RecordingSession = class _RecordingSession {
16009
16144
  recordingId;
16010
16145
  startedAt = /* @__PURE__ */ new Date();
16011
16146
  cdpUrl;
16147
+ tabId;
16012
16148
  browser;
16013
16149
  context;
16014
16150
  page;
@@ -16023,6 +16159,7 @@ var RecordingSession = class _RecordingSession {
16023
16159
  stopPromise;
16024
16160
  maxTimer;
16025
16161
  maxDurationSeconds;
16162
+ removeClickCapture;
16026
16163
  removeCursorOverlay;
16027
16164
  diagnosticsPaused = false;
16028
16165
  secretValues = [];
@@ -16039,6 +16176,7 @@ var RecordingSession = class _RecordingSession {
16039
16176
  this.captureSignal = options.signal;
16040
16177
  this.recordingId = options.recordingId;
16041
16178
  this.cdpUrl = runtime.cdpUrl;
16179
+ this.tabId = runtime.tabId;
16042
16180
  this.browser = runtime.browser;
16043
16181
  this.context = runtime.context;
16044
16182
  this.page = runtime.page;
@@ -16048,16 +16186,25 @@ var RecordingSession = class _RecordingSession {
16048
16186
  this.outputDirectory = options.outputDirectory;
16049
16187
  }
16050
16188
  static async start(options) {
16189
+ validateTabSelection(options);
16051
16190
  const width = options.width ?? 1280;
16052
16191
  const height = options.height ?? 720;
16053
16192
  await mkdir6(options.outputDirectory, { recursive: true, mode: 448 });
16054
16193
  if (options.encoding === "remote") await writeFile5(join9(options.outputDirectory, "remote-capture.json"), JSON.stringify({ expiresAt: Date.now() + 864e5 }), { mode: 384 });
16055
- const runtime = options.cdpUrl ? await connectAttached(options.cdpUrl, options.outputDirectory) : await launchManaged({ headed: options.headed ?? false, width, height, outputDirectory: options.outputDirectory, proxyServer: options.proxyServer, blockServiceWorkers: options.blockServiceWorkers });
16056
- const cdpSession = await runtime.context.newCDPSession(runtime.page);
16194
+ const runtime = options.cdpUrl ? await connectRecordingTab(options.cdpUrl, options.outputDirectory, options.tabId) : await launchManaged({ headed: options.headed ?? false, width, height, outputDirectory: options.outputDirectory, proxyServer: options.proxyServer, blockServiceWorkers: options.blockServiceWorkers });
16195
+ let cdpSession;
16196
+ try {
16197
+ cdpSession = await runtime.context.newCDPSession(runtime.page);
16198
+ } catch (error51) {
16199
+ await runtime.browser.close().catch(() => void 0);
16200
+ if ("browserProcess" in runtime && runtime.browserProcess) await terminateBrowser(runtime.browserProcess);
16201
+ if ("profileDirectory" in runtime && runtime.profileDirectory) await rm5(runtime.profileDirectory, { recursive: true, force: true }).catch(() => void 0);
16202
+ throw error51;
16203
+ }
16057
16204
  const screencast = new ScreencastRecorder(cdpSession, join9(options.outputDirectory, "frames"), width, height);
16058
16205
  const session = new _RecordingSession(options, { ...runtime, screencast });
16059
16206
  try {
16060
- await session.attachDiagnostics(options.captureNetworkBodies ?? false);
16207
+ await session.attachDiagnostics(options.captureNetworkBodies ?? false, cdpSession);
16061
16208
  if (!options.hideCursor) session.removeCursorOverlay = await attachCursorOverlay(runtime.page, cdpSession, options.cursorAppearance);
16062
16209
  await options.beforeCapture?.(runtime.page);
16063
16210
  session.startedAt = /* @__PURE__ */ new Date();
@@ -16082,6 +16229,7 @@ var RecordingSession = class _RecordingSession {
16082
16229
  return {
16083
16230
  recordingId: this.recordingId,
16084
16231
  cdpUrl: this.cdpUrl,
16232
+ ...this.tabId ? { tabId: this.tabId } : {},
16085
16233
  startedAt: this.startedAt.toISOString(),
16086
16234
  maxDurationSeconds: this.maxDurationSeconds,
16087
16235
  elapsedSeconds,
@@ -16118,9 +16266,10 @@ var RecordingSession = class _RecordingSession {
16118
16266
  await writeBundle(safeBundle, diagnosticsPath);
16119
16267
  await this.screencast.stop(videoPath, durationSeconds, this.encoding === "remote", this.captureSignal);
16120
16268
  } finally {
16121
- await this.removeCursorOverlay?.();
16269
+ await this.removeClickCapture?.().catch(() => void 0);
16270
+ await this.removeCursorOverlay?.().catch(() => void 0);
16271
+ await this.browser.close().catch(() => void 0);
16122
16272
  if (this.browserProcess) {
16123
- await this.browser.close().catch(() => void 0);
16124
16273
  await terminateBrowser(this.browserProcess);
16125
16274
  if (this.profileDirectory) await rm5(this.profileDirectory, { recursive: true, force: true }).catch(() => void 0);
16126
16275
  }
@@ -16135,7 +16284,7 @@ var RecordingSession = class _RecordingSession {
16135
16284
  videoFormat = "webm";
16136
16285
  encoding = "local";
16137
16286
  outputDirectory = "";
16138
- async attachDiagnostics(captureNetworkBodies) {
16287
+ async attachDiagnostics(captureNetworkBodies, cdpSession) {
16139
16288
  this.captureNetworkBodies = captureNetworkBodies;
16140
16289
  const offset = () => Date.now() - this.startedAt.getTime();
16141
16290
  const pushTimeline = (event) => {
@@ -16146,14 +16295,7 @@ var RecordingSession = class _RecordingSession {
16146
16295
  }
16147
16296
  this.timelineEvents.push({ id: `time_${this.timelineEvents.length + 1}`, ...event });
16148
16297
  };
16149
- await this.page.exposeFunction("__rillClick", (payload) => pushTimeline({ offsetMs: offset(), type: "click", label: `${payload.label || "Element"} at ${Math.round(payload.x)},${Math.round(payload.y)}` })).catch(() => void 0);
16150
- await this.page.addInitScript(() => {
16151
- document.addEventListener("click", (event) => {
16152
- const target = event.target;
16153
- const label = target?.innerText?.trim().slice(0, 80) || target?.getAttribute("aria-label") || target?.tagName || "Element";
16154
- void window.__rillClick?.({ x: event.clientX, y: event.clientY, label });
16155
- }, { capture: true });
16156
- });
16298
+ this.removeClickCapture = await captureClicks(this.page, cdpSession, (payload) => pushTimeline({ offsetMs: offset(), type: "click", label: `${payload.label || "Element"} at ${Math.round(payload.x)},${Math.round(payload.y)}` }));
16157
16299
  this.page.on("framenavigated", (frame) => {
16158
16300
  if (frame === this.page.mainFrame()) pushTimeline({ offsetMs: offset(), type: "navigation", label: sanitizeUrl(frame.url()) });
16159
16301
  });
@@ -16264,7 +16406,7 @@ async function launchManaged(options) {
16264
16406
  const cdpUrl = `http://127.0.0.1:${port}`;
16265
16407
  try {
16266
16408
  await waitForCdp(cdpUrl, browserProcess);
16267
- const browser = await chromium.connectOverCDP(cdpUrl, { artifactsDir: options.outputDirectory, isLocal: true });
16409
+ const browser = await chromium2.connectOverCDP(cdpUrl, { artifactsDir: options.outputDirectory, isLocal: true });
16268
16410
  const context = options.blockServiceWorkers ? await browser.newContext({ serviceWorkers: "block" }) : browser.contexts()[0];
16269
16411
  const page = context.pages()[0] ?? await context.newPage();
16270
16412
  await page.setViewportSize({ width: options.width, height: options.height });
@@ -16275,13 +16417,6 @@ async function launchManaged(options) {
16275
16417
  throw error51;
16276
16418
  }
16277
16419
  }
16278
- async function connectAttached(cdpUrl, outputDirectory) {
16279
- const browser = await chromium.connectOverCDP(cdpUrl, { artifactsDir: outputDirectory, isLocal: true, noDefaults: true });
16280
- const context = browser.contexts()[0];
16281
- const page = context.pages()[0];
16282
- if (!page) throw new Error("The attached browser has no page target to record.");
16283
- return { cdpUrl, browser, context, page };
16284
- }
16285
16420
  async function getAvailablePort() {
16286
16421
  const server = createServer();
16287
16422
  await new Promise((resolve, reject) => server.listen(0, "127.0.0.1", resolve).once("error", reject));
@@ -16366,7 +16501,7 @@ async function runDaemon() {
16366
16501
  async function route(request3, response) {
16367
16502
  lastActivity = Date.now();
16368
16503
  const url2 = new URL(request3.url ?? "/", "http://daemon.local");
16369
- if (request3.method === "GET" && url2.pathname === "/health") return json2(response, 200, { ok: true, pid: process.pid });
16504
+ if (request3.method === "GET" && url2.pathname === "/health") return json2(response, 200, { ok: true, pid: process.pid, capabilities: ["tab-selection-v1"] });
16370
16505
  if (request3.method === "POST" && url2.pathname === "/sessions") {
16371
16506
  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." } });
16372
16507
  try {
@@ -16375,7 +16510,7 @@ async function route(request3, response) {
16375
16510
  const outputDirectory = join10(recordingTempRoot(), input.recordingId);
16376
16511
  const session = await RecordingSession.start({ ...input, outputDirectory });
16377
16512
  const status = session.status();
16378
- const entry = { session, base: { recordingId: status.recordingId, cdpUrl: status.cdpUrl, startedAt: status.startedAt }, state: "recording" };
16513
+ const entry = { session, base: { recordingId: status.recordingId, cdpUrl: status.cdpUrl, tabId: status.tabId, startedAt: status.startedAt }, state: "recording" };
16379
16514
  sessions.set(input.recordingId, entry);
16380
16515
  const maxTimer = setTimeout(() => void finalizeEntry(entry, "max_duration"), Math.max(0, Date.parse(status.stopsAt) - Date.now()) + 250);
16381
16516
  maxTimer.unref();
@@ -16426,7 +16561,7 @@ async function restoreRecoverableSessions() {
16426
16561
  const value = JSON.parse(await readFile7(daemonStatePath(), "utf8"));
16427
16562
  for (const [id, entry] of Object.entries(value)) {
16428
16563
  if (entry.status !== "ready" && entry.status !== "failed") continue;
16429
- sessions.set(id, { base: { recordingId: entry.recordingId, cdpUrl: entry.cdpUrl, startedAt: entry.startedAt }, state: entry.status, result: entry.result, error: entry.error });
16564
+ sessions.set(id, { base: { recordingId: entry.recordingId, cdpUrl: entry.cdpUrl, tabId: entry.tabId, startedAt: entry.startedAt }, state: entry.status, result: entry.result, error: entry.error });
16430
16565
  }
16431
16566
  } catch {
16432
16567
  }
@@ -16502,7 +16637,7 @@ function emit(value, pretty = false) {
16502
16637
  function failureValue(error51) {
16503
16638
  const message = error51 instanceof Error ? error51.message : String(error51);
16504
16639
  const [candidate] = message.split(":", 1);
16505
- const knownCodes = /* @__PURE__ */ new Set(["authentication_required", "authorization_not_found", "expired_token", "access_denied", "unsupported_platform", "scope_denied", "quota_exceeded", "browser_unavailable", "recording_not_found", "upload_interrupted", "processing_pending", "processing_failed", "share_revoked", "validation_failed"]);
16640
+ const knownCodes = /* @__PURE__ */ new Set(["authentication_required", "authorization_not_found", "expired_token", "access_denied", "unsupported_platform", "scope_denied", "quota_exceeded", "browser_unavailable", "local_capture_failed", "recording_not_found", "upload_interrupted", "processing_pending", "processing_failed", "share_revoked", "validation_failed"]);
16506
16641
  const structured = error51 && typeof error51 === "object" ? error51 : {};
16507
16642
  const code = structured.code && knownCodes.has(structured.code) ? structured.code : knownCodes.has(candidate) ? candidate : "internal_error";
16508
16643
  return { error: { code, message, ...structured.recovery ? { recovery: structured.recovery } : {}, ...structured.details ? { details: structured.details } : {} } };
@@ -16778,6 +16913,48 @@ async function remoteAwareRecordingStatus(recordingId, dependencies, apiUrl = "h
16778
16913
  return remoteView;
16779
16914
  }
16780
16915
  }
16916
+ async function releaseFailedLocalCapture(id, dependencies) {
16917
+ const local = await dependencies.localStatus(id).catch(() => null);
16918
+ if (!local || local.recordingId !== id || local.status !== "failed" || local.result) return false;
16919
+ try {
16920
+ await dependencies.failRemote(id);
16921
+ return true;
16922
+ } catch {
16923
+ return false;
16924
+ }
16925
+ }
16926
+ async function createRecordingWithRecovery(dependencies) {
16927
+ try {
16928
+ return await dependencies.createRemote();
16929
+ } catch (error51) {
16930
+ if (!(error51 instanceof ControlPlaneError) || error51.code !== "quota_exceeded") throw error51;
16931
+ const quota = error51.details?.quota;
16932
+ if (!quota || !["credential_concurrency", "workspace_concurrency"].includes(String(quota.type)) || typeof quota.blockingRecordingId !== "string" || !/^rec_[a-zA-Z0-9_-]+$/.test(quota.blockingRecordingId)) throw error51;
16933
+ if (!await releaseFailedLocalCapture(quota.blockingRecordingId, dependencies)) throw error51;
16934
+ dependencies.onRecovered?.(quota.blockingRecordingId);
16935
+ return dependencies.createRemote();
16936
+ }
16937
+ }
16938
+ async function stopRecordingWithCompensation(id, dependencies, apiUrl) {
16939
+ let local;
16940
+ try {
16941
+ local = await dependencies.stopLocal(id);
16942
+ } catch (error51) {
16943
+ const known = await dependencies.localStatus(id).catch(() => null);
16944
+ if (!known || known.recordingId !== id || known.status !== "failed" || known.result) throw error51;
16945
+ local = known;
16946
+ }
16947
+ if (local.recordingId === id && local.status === "failed" && !local.result) {
16948
+ const released = await releaseFailedLocalCapture(id, dependencies);
16949
+ throw new ControlPlaneError(
16950
+ "local_capture_failed",
16951
+ local.error ?? "The local capture could not be finalized.",
16952
+ released ? "Its unused recording slot was released. Start a new recording." : `The recording slot could not be released automatically. Check ${cliCommand(apiUrl, "record", "status", id)} before cancelling it.`,
16953
+ { recordingId: id, reservationReleased: released }
16954
+ );
16955
+ }
16956
+ return local;
16957
+ }
16781
16958
 
16782
16959
  // src/config.ts
16783
16960
  var PRODUCTION_API_URL = "https://userill.dev";
@@ -16881,9 +17058,14 @@ for (const action of ["status", "cancel"]) cloudRun.command(action).argument("<r
16881
17058
  if (options.json || options.pretty) emit(customerRun(run2), options.pretty);
16882
17059
  else process.stdout.write(formatCloudRun(run2));
16883
17060
  });
16884
- 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) => {
17061
+ record2.command("tabs").requiredOption("--cdp-url <url>", "Existing Chromium CDP endpoint").action(async (commandOptions) => {
17062
+ const options = program.opts();
17063
+ emit({ schemaVersion: 1, tabs: await listBrowserTabs(commandOptions.cdpUrl) }, options.pretty);
17064
+ });
17065
+ 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("--tab-id <id>", "Record this existing tab from record tabs").option("--width <pixels>", "", "1280").option("--height <pixels>", "", "720").option("--max-duration <seconds>", "", "600").action(async (commandOptions) => {
16885
17066
  const options = program.opts();
16886
17067
  assertRecorderTransport();
17068
+ validateTabSelection(commandOptions);
16887
17069
  await ensureAnonymousCredential(options.apiUrl);
16888
17070
  const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
16889
17071
  const result = await withRunFeedback(() => client.agentSession(), async (rememberRecording) => {
@@ -16892,12 +17074,21 @@ record2.command("start").option("--encoding <mode>", "remote or local", "remote"
16892
17074
  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.");
16893
17075
  const width = Number(commandOptions.width);
16894
17076
  const height = Number(commandOptions.height);
16895
- const remote = await client.createRecording({ title: commandOptions.title ?? `Browser recording \xB7 ${(/* @__PURE__ */ new Date()).toISOString()}`, description: commandOptions.description, sourceUrl: commandOptions.url ?? null, width, height });
17077
+ const metadata = { requestId: crypto.randomUUID(), title: commandOptions.title ?? `Browser recording \xB7 ${(/* @__PURE__ */ new Date()).toISOString()}`, description: commandOptions.description, sourceUrl: commandOptions.url ?? null, width, height };
17078
+ const remote = await createRecordingWithRecovery({
17079
+ createRemote: () => client.createRecording(metadata),
17080
+ localStatus: recordingStatus,
17081
+ failRemote: (id) => client.failLocalCapture(id),
17082
+ onRecovered: (id) => {
17083
+ process.stderr.write(`Released the unused slot for failed local capture ${id}.
17084
+ `);
17085
+ }
17086
+ });
16896
17087
  rememberRecording(remote.recordingId);
16897
17088
  const local = await startRecordingWithCompensation(remote, {
16898
17089
  startLocal: async () => {
16899
- await ensureDaemon(process.argv[1]);
16900
- 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 });
17090
+ await ensureDaemon(process.argv[1], Boolean(commandOptions.cdpUrl));
17091
+ return startRecording({ encoding: commandOptions.encoding, recordingId: remote.recordingId, url: commandOptions.url, cdpUrl: commandOptions.cdpUrl, tabId: commandOptions.tabId, headed: Boolean(commandOptions.headed), width, height, maxDurationSeconds: Math.min(Number(commandOptions.maxDuration), remote.maxDurationSeconds ?? 600), captureNetworkBodies: remote.bodyCaptureEnabled, cursorAppearance: remote.cursorAppearance });
16901
17092
  },
16902
17093
  cancelRemote: (recordingId, reason) => client.cancelRecording(recordingId, reason)
16903
17094
  }, options.apiUrl);
@@ -16908,16 +17099,20 @@ record2.command("start").option("--encoding <mode>", "remote or local", "remote"
16908
17099
  headed: Boolean(commandOptions.headed),
16909
17100
  attached: Boolean(commandOptions.cdpUrl)
16910
17101
  }).catch(() => void 0);
16911
- return { schemaVersion: 1, recordingId: remote.recordingId, status: local.status, cdpUrl: local.cdpUrl, browserAttachment: browserAttachment(local.cdpUrl), startedAt: local.startedAt, stopsAt: local.stopsAt, elapsedSeconds: local.elapsedSeconds, remainingSeconds: local.remainingSeconds, bodyCaptureEnabled: remote.bodyCaptureEnabled, anonymous: remote.anonymous ?? false, expiresAt: remote.expiresAt ?? null, maxDurationSeconds: Math.min(Number(commandOptions.maxDuration), remote.maxDurationSeconds ?? 600) };
17102
+ return { schemaVersion: 1, recordingId: remote.recordingId, status: local.status, cdpUrl: local.cdpUrl, ...local.tabId ? { tabId: local.tabId } : {}, browserAttachment: browserAttachment(local.cdpUrl, local.tabId), startedAt: local.startedAt, stopsAt: local.stopsAt, elapsedSeconds: local.elapsedSeconds, remainingSeconds: local.remainingSeconds, bodyCaptureEnabled: remote.bodyCaptureEnabled, anonymous: remote.anonymous ?? false, expiresAt: remote.expiresAt ?? null, maxDurationSeconds: Math.min(Number(commandOptions.maxDuration), remote.maxDurationSeconds ?? 600) };
16912
17103
  });
16913
17104
  emit(result, options.pretty);
16914
17105
  });
16915
17106
  record2.command("stop").argument("<recording-id>").option("--no-wait").option("--no-feedback", "Disable feedback only when the user explicitly requests it").action(async (recordingId, commandOptions) => {
16916
17107
  const options = program.opts();
16917
17108
  await ensureDaemon(process.argv[1]);
16918
- const local = await stopRecording(recordingId);
16919
- if (!local.result) throw new Error(local.error ?? "The recorder did not produce an artifact.");
16920
17109
  const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
17110
+ const local = await stopRecordingWithCompensation(recordingId, {
17111
+ stopLocal: stopRecording,
17112
+ localStatus: recordingStatus,
17113
+ failRemote: (id) => client.failLocalCapture(id)
17114
+ }, options.apiUrl);
17115
+ if (!local.result) throw new Error(local.error ?? "The recorder did not produce an artifact.");
16921
17116
  const stopped = await client.markRecordingStopped(recordingId, {
16922
17117
  durationSeconds: local.result.durationSeconds,
16923
17118
  stoppedReason: local.result.stoppedReason,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runalabs/rill-cli",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "Record, inspect, and share agent-controlled browser runs with Rill.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {