@runalabs/rill-cli 0.1.14 → 0.1.16

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 +49 -5
  2. package/dist/cli.js +421 -129
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -25,6 +25,10 @@ The following local-recording instructions remain available through `record star
25
25
 
26
26
  ## Install and verify
27
27
 
28
+ Check `node --version`, `node -p "process.platform"`, and `rill --version` before installing. Reuse an installed compatible CLI. Commands execute in the agent's environment, which may be a separate sandbox from the user's desktop. Chrome must be accessible there, and the agent must be able to attach over CDP; doctor does not verify browser-control capability.
29
+
30
+ If the CLI is missing, check npm with `npm ping --fetch-timeout=5000 --fetch-retries=0` before installation. See the [canonical skill](https://userill.dev/skill.md) for the bounded Rill connectivity check and Windows Terminal fallback. Report blocked network access instead of repeating installation attempts.
31
+
28
32
  Browser recording runs on macOS, Linux, and Windows. Install Node.js 22+ and Google Chrome or Chromium. Rill encodes recordings remotely by default; local FFmpeg is optional. Chrome stays on your machine and can access localhost. Raw frames upload after recording stops.
29
33
 
30
34
  Then install the scoped package:
@@ -42,7 +46,7 @@ Do not use `npx rill`: the unscoped package belongs to a different product. Doct
42
46
 
43
47
  ### Connect with pollable approval
44
48
 
45
- `rill whoami` verifies the current account without starting sign-in. `rill connect --email you@example.com` waits for human approval by default. For agents that cannot hold a foreground command open:
49
+ `rill whoami` shows a short account summary without starting sign-in. Agents should use `rill whoami --json` for verified identity fields. `rill connect --email you@example.com` waits for human approval by default. For agents that cannot hold a foreground command open:
46
50
 
47
51
  ```sh
48
52
  rill connect --email you@example.com --no-wait
@@ -124,7 +128,7 @@ rill record status <recording-id>
124
128
 
125
129
  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
130
 
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.
131
+ 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
132
 
129
133
  ### Recording options
130
134
 
@@ -135,12 +139,32 @@ Capture follows the first page selected at recording start. Keep the flow in tha
135
139
  | `--title <title>` | Name the run; defaults to a timestamped title. |
136
140
  | `--description <text>` | Describe what the run demonstrates. |
137
141
  | `--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. |
142
+ | `--cdp-url <url>` | Attach to an existing Chromium CDP endpoint. Select a tab when several are open. |
143
+ | `--tab-id <id>` | Record the stable target ID returned by `record tabs`; requires `--cdp-url`. |
139
144
  | `--width <pixels>` / `--height <pixels>` | Managed viewport; defaults to 1280 × 720. |
140
145
  | `--max-duration <seconds>` | Automatically finalize local capture; defaults to 600. Still call `record stop` to upload. |
141
146
 
142
147
  Rill closes browsers it launches during finalization. Attached browsers remain open.
143
148
 
149
+ ### Record a tab you already use
150
+
151
+ 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.
152
+
153
+ ```bash
154
+ rill record tabs --cdp-url http://127.0.0.1:9222
155
+ rill record start --cdp-url http://127.0.0.1:9222 --tab-id <tab-id> --title "Checkout review"
156
+ # Continue your flow in that exact tab, then finalize/upload:
157
+ rill record stop <recording-id>
158
+ ```
159
+
160
+ 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.
161
+
162
+ 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.
163
+
164
+ 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.
165
+
166
+ The CDP connection and disconnect behavior follow the [Playwright browser API](https://playwright.dev/docs/api/class-browser#browser-close).
167
+
144
168
  ## Set up a coding agent
145
169
 
146
170
  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 +211,7 @@ Use `rill --help` or `<command> --help` for supported flags.
187
211
  | `rill connect --email <email> [--no-wait]` | Start or resume account approval. |
188
212
  | `rill connect status <authorization-id>` | Poll a saved approval once; obey `retryAfterSeconds`. |
189
213
  | `rill doctor` | Check prerequisites, API compatibility, and authentication. |
214
+ | `rill record tabs --cdp-url <url>` | List existing Chromium tabs without starting a recording. |
190
215
  | `rill record start [options]` | Start a managed or attached browser recording. |
191
216
  | `rill record stop <recording-id>` | Finalize and upload; supports `--no-wait` and `--no-feedback`. |
192
217
  | `rill record status <recording-id>` | Read remote state first, then local capture for unsubmitted recordings; local completion is `captured`. |
@@ -200,7 +225,20 @@ Use `rill --help` or `<command> --help` for supported flags.
200
225
 
201
226
  ## Output and errors
202
227
 
203
- Data commands emit JSON on stdout and human progress on stderr. Use global `--pretty` for indented JSON. Help and version commands print plain text.
228
+ `whoami` shows a concise human summary by default. Use `rill whoami --json` for the existing schema-versioned identity response, including email, workspace, scopes and expiry. Its exit codes are unchanged: missing credentials exit 1; connected and anonymous access exit 0.
229
+
230
+ Doctor and local recording commands retain JSON on stdout by default. Use `--human` for readable doctor, login, recording start/stop/status/retry summaries and errors. Doctor shows the command execution OS, named prerequisite checks, recovery guidance, and a pass/fail/warning count. Recording summaries show the ID, status, duration when available, share URL when returned, and the exact next command. Pending processing is distinct from a ready recording; a private recording may have no share URL.
231
+
232
+ Explicit `--json` or `--pretty` takes precedence over `--human`. Use `--pretty` for indented JSON. Human output uses restrained terminal color only on a TTY, respects `NO_COLOR` and `TERM=dumb`, and retains status symbols and text without color. Human errors go to stderr; JSON errors remain on stdout. Help and version commands print plain text. Feedback and attachment metadata remain available in JSON; selecting a human view does not disable workspace feedback.
233
+
234
+ ```sh
235
+ rill whoami
236
+ rill whoami --json
237
+ rill --human doctor
238
+ rill --human record start --url https://example.com --title "Smoke test" --headed
239
+ rill --human record stop <recording-id>
240
+ rill --human record status <recording-id>
241
+ ```
204
242
 
205
243
  ```sh
206
244
  rill --pretty record status <recording-id>
@@ -336,10 +374,16 @@ Use `rill record list` to list accessible recordings and `rill inspect <recordin
336
374
 
337
375
  ### Check the connected account
338
376
 
339
- Run `rill whoami` (or `rill --pretty whoami`) to verify your current identity. JSON output includes status, account email when available, workspace, scopes, and access expiry. It uses RILL_TOKEN when set, otherwise the saved OS credential. Existing access can renew automatically; it never creates an identity or opens an approval flow. Missing credentials return `not_connected` and exit 1. Invalid or revoked credentials return an error.
377
+ Run `rill whoami` for a short human account summary, or `rill whoami --json` (also `rill --pretty whoami`) for structured identity fields. JSON includes status, account email when available, workspace, scopes, and access expiry. It uses RILL_TOKEN when set, otherwise the saved OS credential. Existing access can renew automatically; it never creates an identity or opens an approval flow. Missing credentials return `not_connected` in JSON and exit 1. Invalid or revoked credentials return an error.
340
378
 
341
379
  ### Capture time and upload progress (0.1.14)
342
380
 
343
381
  `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
382
 
345
383
  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.
384
+
385
+ ### Failed local capture recovery
386
+
387
+ 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.
388
+
389
+ 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
  }
@@ -441,7 +442,7 @@ var ControlPlaneClient = class {
441
442
  return this.request(`/api/cloud/runs/${encodeURIComponent(id)}/cancel`, { method: "POST" });
442
443
  }
443
444
  health() {
444
- return this.request("/api/health", {}, false);
445
+ return this.request("/api/health", { signal: AbortSignal.timeout(5e3) }, false);
445
446
  }
446
447
  agentSession() {
447
448
  return this.request("/api/agent/session", { signal: AbortSignal.timeout(5e3) });
@@ -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
  }
@@ -15221,7 +15244,7 @@ async function waitForEncoding(client, id, timeoutMs = 30 * 6e4) {
15221
15244
  // src/cli.ts
15222
15245
  import { spawn as spawn8 } from "child_process";
15223
15246
  import { hostname as hostname4, tmpdir as tmpdir3 } from "os";
15224
- import { Command } from "commander";
15247
+ import { Command, CommanderError } from "commander";
15225
15248
  import { basename, dirname as dirname2, join as join11 } from "path";
15226
15249
  import { readdir as readdir2, rm as rm7, stat as stat5 } from "fs/promises";
15227
15250
 
@@ -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 } : {} } };
@@ -16512,6 +16647,72 @@ function fail(error51) {
16512
16647
  process.exitCode = 1;
16513
16648
  }
16514
16649
 
16650
+ // src/human-output.ts
16651
+ function terminalColor(enabled = Boolean(process.stdout.isTTY) && !("NO_COLOR" in process.env) && process.env.TERM !== "dumb") {
16652
+ return (text, code) => enabled ? `\x1B[${code}m${text}\x1B[0m` : text;
16653
+ }
16654
+ function formatIdentity(identity, color = terminalColor()) {
16655
+ if (identity.status === "not_connected") return `${color("! Not connected to Rill", 33)}
16656
+
16657
+ Sign in: ${cliCommand(identity.apiUrl, "login")}
16658
+ `;
16659
+ if (identity.status === "anonymous") return `${color("\u2713 Using Rill anonymously", 32)}
16660
+
16661
+ Connect your account: ${cliCommand(identity.apiUrl, "connect", "--email", "<your-email>")}
16662
+ `;
16663
+ return `${color("\u2713 Connected to Rill", 32)}
16664
+ Account: ${identity.email ?? "Email unavailable"}
16665
+ `;
16666
+ }
16667
+ function formatDoctor(result, color = terminalColor()) {
16668
+ const labels = { node: "Node.js", api: "API connectivity", cli_version: "CLI version", authentication: "Authentication", chrome: "Browser", ffmpeg: "FFmpeg", encoding: "Encoding", recorder_transport: "Recorder transport" };
16669
+ const symbols = { pass: "\u2713", fail: "\xD7", warn: "!", skip: "\u2013" };
16670
+ const colors = { pass: 32, fail: 31, warn: 33, skip: 90 };
16671
+ const lines = [color("Rill CLI Doctor", 34), `Execution environment: ${result.environment.platform}`, ""];
16672
+ for (const check2 of result.checks) {
16673
+ lines.push(color(`${symbols[check2.status]} ${labels[check2.name]}: ${check2.message}`, colors[check2.status]));
16674
+ if (check2.recovery) lines.push(` \u2192 ${check2.recovery}`);
16675
+ }
16676
+ const warnings = result.checks.filter((c) => c.status === "warn").length;
16677
+ lines.push(
16678
+ "",
16679
+ "Browser control: not verified. Your agent must attach to the returned CDP endpoint.",
16680
+ "",
16681
+ `${result.checks.filter((c) => c.status === "pass").length} passed \xB7 ${result.checks.filter((c) => c.status === "fail").length} failed \xB7 ${warnings} ${warnings === 1 ? "warning" : "warnings"}`
16682
+ );
16683
+ return lines.join("\n") + "\n";
16684
+ }
16685
+ function formatRecording(value, apiUrl, color = terminalColor()) {
16686
+ const artifact = value.result && typeof value.result === "object" ? value.result : {};
16687
+ const id = String(value.recordingId ?? value.id ?? "");
16688
+ const status = String(value.status ?? "unknown");
16689
+ const titles = { recording: "Recording started", ready: "Recording ready", captured: "Captured locally \xB7 upload required", uploading: "Uploading recording", queued: "Recording queued for encoding", encoding: "Encoding recording", processing: "Processing recording", finalizing: "Finalizing recording", failed: "Recording failed", cancelled: "Recording cancelled", expired: "Recording expired" };
16690
+ const lines = [color(`${["recording", "ready"].includes(status) ? "\u2713" : ["failed", "expired"].includes(status) ? "\xD7" : "\u2013"} ${titles[status] ?? `Recording status: ${status}`}`, ["failed", "expired"].includes(status) ? 31 : status === "ready" || status === "recording" ? 32 : 33)];
16691
+ if (id) lines.push(`ID: ${id}`);
16692
+ const duration3 = value.durationSeconds ?? value.duration_seconds ?? artifact.durationSeconds;
16693
+ if (typeof duration3 === "number" && Number.isFinite(duration3)) lines.push(`Duration: ${Math.floor(duration3 / 60)}m ${Math.floor(duration3 % 60)}s`);
16694
+ if (typeof value.remainingSeconds === "number") lines.push(`Time remaining: ${Math.max(0, Math.ceil(value.remainingSeconds))}s`);
16695
+ if (typeof value.cdpUrl === "string") lines.push(`Browser connection: ${value.cdpUrl}`);
16696
+ if (typeof value.tabId === "string") lines.push(`Recorded tab: ${value.tabId}`);
16697
+ if (typeof value.shareUrl === "string" && value.shareUrl) lines.push("", `Share: ${value.shareUrl}`);
16698
+ else if (status === "ready") lines.push("", "Ready. No share link returned; open this recording in your Rill account.");
16699
+ const message = value.message ?? value.error;
16700
+ if (typeof message === "string" && message) lines.push("", message);
16701
+ const next = typeof value.nextCommand === "string" ? value.nextCommand : status === "recording" && id ? cliCommand(apiUrl, "record", "stop", id) : void 0;
16702
+ if (next) lines.push("", status === "recording" ? "Perform your test, then stop and upload:" : "Next:", ` ${next}`);
16703
+ return lines.join("\n") + "\n";
16704
+ }
16705
+ function formatFailure(error51, color = terminalColor()) {
16706
+ const { error: failure } = failureValue(error51);
16707
+ let message = failure.message.startsWith(`${failure.code}: `) ? failure.message.slice(failure.code.length + 2) : failure.message;
16708
+ if (failure.recovery && message.endsWith(`: ${failure.recovery}`)) message = message.slice(0, -failure.recovery.length - 2);
16709
+ const lines = [color(`\xD7 ${message}`, 31)];
16710
+ if (typeof failure.details?.recordingId === "string") lines.push(`ID: ${failure.details.recordingId}`);
16711
+ if (failure.recovery) lines.push("", `Next: ${failure.recovery}`);
16712
+ else if (typeof failure.details?.nextCommand === "string") lines.push("", `Next: ${failure.details.nextCommand}`);
16713
+ return lines.join("\n") + "\n";
16714
+ }
16715
+
16515
16716
  // src/feedback-protocol.ts
16516
16717
  function suggestedFeedbackAction(recordingId) {
16517
16718
  return {
@@ -16606,9 +16807,11 @@ async function runDoctor(apiUrl, overrides = {}) {
16606
16807
  if (!health.ok || health.service !== "rill") throw new Error("The endpoint did not identify itself as Rill.");
16607
16808
  checks.push({ name: "api", status: "pass", message: `Rill API is reachable at ${apiUrl}.` });
16608
16809
  } catch (error51) {
16609
- checks.push({ name: "api", status: "fail", message: `Rill API is unreachable: ${errorMessage(error51)}`, recovery: `Check the network and API URL, then rerun \`rill --api-url ${apiUrl} doctor\`.` });
16810
+ checks.push({ name: "api", status: "fail", message: `Rill API is unreachable: ${errorMessage(error51)}`, recovery: `Check network access in this ${dependencies.platform} execution environment and the API URL, then rerun \`rill --api-url ${apiUrl} doctor\`. An agent sandbox may have different network access from your desktop.` });
16610
16811
  }
16611
- if (!health?.minimumCliVersion || !health.recommendedCliVersion) {
16812
+ if (!health) {
16813
+ checks.push({ name: "cli_version", status: "skip", message: `CLI ${dependencies.cliVersion} compatibility was not checked because the API is unreachable.`, version: dependencies.cliVersion });
16814
+ } else if (!health.minimumCliVersion || !health.recommendedCliVersion) {
16612
16815
  checks.push({ name: "cli_version", status: "fail", message: `CLI ${dependencies.cliVersion} could not be checked against the API compatibility policy.`, version: dependencies.cliVersion, recovery: "Deploy a Rill API that advertises minimumCliVersion and recommendedCliVersion." });
16613
16816
  } else if (compareVersions(dependencies.cliVersion, health.minimumCliVersion) < 0) {
16614
16817
  checks.push({ name: "cli_version", status: "fail", message: `CLI ${dependencies.cliVersion} is older than the minimum supported version ${health.minimumCliVersion}.`, version: dependencies.cliVersion, recovery: `Run \`npm install --global @runalabs/rill-cli@${health.recommendedCliVersion}\`.` });
@@ -16643,20 +16846,23 @@ async function runDoctor(apiUrl, overrides = {}) {
16643
16846
  }
16644
16847
  }
16645
16848
  }
16646
- checks.push(await executableCheck("chrome", dependencies.chromePath, ["--version"], dependencies.commandVersion, "Install Google Chrome or Chromium, or set RILL_CHROME_PATH to its executable."));
16849
+ checks.push(await executableCheck("chrome", dependencies.chromePath, ["--version"], dependencies.commandVersion, `Chrome must be available in this ${dependencies.platform} execution environment. Install Chrome/Chromium here or set RILL_CHROME_PATH. A browser on a separate desktop is not automatically accessible; use that desktop's terminal to record locally.`));
16647
16850
  if (dependencies.encoding === "local") {
16648
16851
  checks.push(await executableCheck("ffmpeg", dependencies.ffmpegPath, ["-version"], dependencies.commandVersion, dependencies.platform === "darwin" ? "Install ffmpeg with `brew install ffmpeg`, or set RILL_FFMPEG." : "Install ffmpeg using the system package manager, or set RILL_FFMPEG."));
16649
16852
  } else {
16650
16853
  checks.push({ name: "ffmpeg", status: "skip", message: "Remote encoding does not require local FFmpeg." });
16651
- try {
16652
- const token = await dependencies.loadCredential(apiUrl);
16653
- if (!(token ? (await dependencies.encodingCapabilities(apiUrl, token)).enabled : health?.anonymousAccess && health.anonymousRemoteEncoding)) throw new Error("Remote encoding is not enabled for this workspace.");
16654
- checks.push({ name: "encoding", status: "pass", message: token ? "Remote encoding is enabled." : "Remote encoding is available for the first anonymous recording." });
16655
- } catch (error51) {
16656
- checks.push({ name: "encoding", status: "fail", message: errorMessage(error51), recovery: "Run doctor --encoding local to check local FFmpeg, then record start --encoding local, or ask the workspace owner to enable remote encoding." });
16854
+ if (!health) checks.push({ name: "encoding", status: "skip", message: "Remote encoding was not checked because the API is unreachable." });
16855
+ else {
16856
+ try {
16857
+ const token = await dependencies.loadCredential(apiUrl);
16858
+ if (!(token ? (await dependencies.encodingCapabilities(apiUrl, token)).enabled : health?.anonymousAccess && health.anonymousRemoteEncoding)) throw new Error("Remote encoding is not enabled for this workspace.");
16859
+ checks.push({ name: "encoding", status: "pass", message: token ? "Remote encoding is enabled." : "Remote encoding is available for the first anonymous recording." });
16860
+ } catch (error51) {
16861
+ checks.push({ name: "encoding", status: "fail", message: errorMessage(error51), recovery: "Run doctor --encoding local to check local FFmpeg, then record start --encoding local, or ask the workspace owner to enable remote encoding." });
16862
+ }
16657
16863
  }
16658
16864
  }
16659
- return { schemaVersion: 1, status: checks.some((check2) => check2.status === "fail") ? "error" : "ok", apiUrl, checks, ...runFeedbackFields(feedbackEnabled) };
16865
+ return { schemaVersion: 1, status: checks.some((check2) => check2.status === "fail") ? "error" : "ok", apiUrl, environment: { platform: dependencies.platform }, checks, ...runFeedbackFields(feedbackEnabled) };
16660
16866
  }
16661
16867
  async function executableCheck(name, path, args, run2, recovery) {
16662
16868
  try {
@@ -16778,6 +16984,48 @@ async function remoteAwareRecordingStatus(recordingId, dependencies, apiUrl = "h
16778
16984
  return remoteView;
16779
16985
  }
16780
16986
  }
16987
+ async function releaseFailedLocalCapture(id, dependencies) {
16988
+ const local = await dependencies.localStatus(id).catch(() => null);
16989
+ if (!local || local.recordingId !== id || local.status !== "failed" || local.result) return false;
16990
+ try {
16991
+ await dependencies.failRemote(id);
16992
+ return true;
16993
+ } catch {
16994
+ return false;
16995
+ }
16996
+ }
16997
+ async function createRecordingWithRecovery(dependencies) {
16998
+ try {
16999
+ return await dependencies.createRemote();
17000
+ } catch (error51) {
17001
+ if (!(error51 instanceof ControlPlaneError) || error51.code !== "quota_exceeded") throw error51;
17002
+ const quota = error51.details?.quota;
17003
+ if (!quota || !["credential_concurrency", "workspace_concurrency"].includes(String(quota.type)) || typeof quota.blockingRecordingId !== "string" || !/^rec_[a-zA-Z0-9_-]+$/.test(quota.blockingRecordingId)) throw error51;
17004
+ if (!await releaseFailedLocalCapture(quota.blockingRecordingId, dependencies)) throw error51;
17005
+ dependencies.onRecovered?.(quota.blockingRecordingId);
17006
+ return dependencies.createRemote();
17007
+ }
17008
+ }
17009
+ async function stopRecordingWithCompensation(id, dependencies, apiUrl) {
17010
+ let local;
17011
+ try {
17012
+ local = await dependencies.stopLocal(id);
17013
+ } catch (error51) {
17014
+ const known = await dependencies.localStatus(id).catch(() => null);
17015
+ if (!known || known.recordingId !== id || known.status !== "failed" || known.result) throw error51;
17016
+ local = known;
17017
+ }
17018
+ if (local.recordingId === id && local.status === "failed" && !local.result) {
17019
+ const released = await releaseFailedLocalCapture(id, dependencies);
17020
+ throw new ControlPlaneError(
17021
+ "local_capture_failed",
17022
+ local.error ?? "The local capture could not be finalized.",
17023
+ 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.`,
17024
+ { recordingId: id, reservationReleased: released }
17025
+ );
17026
+ }
17027
+ return local;
17028
+ }
16781
17029
 
16782
17030
  // src/config.ts
16783
17031
  var PRODUCTION_API_URL = "https://userill.dev";
@@ -16797,13 +17045,23 @@ function watchEncoding(apiUrl, id) {
16797
17045
  child.unref();
16798
17046
  }
16799
17047
  var program = new Command();
16800
- 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);
17048
+ 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 machine-readable JSON (including whoami)", false).option("--human", "Readable doctor, login, whoami and recording start/stop/status/retry output", false);
17049
+ program.configureOutput({ outputError: () => void 0 }).exitOverride();
17050
+ function humanOutput(identity = false) {
17051
+ const options = program.opts();
17052
+ return !options.json && !options.pretty && (identity || options.human);
17053
+ }
17054
+ function emitRecording(value, pretty) {
17055
+ if (humanOutput()) process.stdout.write(formatRecording(value, program.opts().apiUrl));
17056
+ else emit(value, pretty);
17057
+ }
16801
17058
  var activeTelemetry = null;
16802
17059
  program.command("daemon", { hidden: true }).action(() => runDaemon());
16803
17060
  program.command("doctor").description("Validate Rill prerequisites before recording.").option("--encoding <mode>", "remote or local", "remote").action(async (commandOptions) => {
16804
17061
  const options = program.opts();
16805
17062
  const result = await runDoctor(options.apiUrl, { encoding: commandOptions.encoding });
16806
- emit(result, options.pretty);
17063
+ if (humanOutput()) process.stdout.write(formatDoctor(result));
17064
+ else emit(result, options.pretty);
16807
17065
  if (result.status === "error") process.exitCode = 1;
16808
17066
  });
16809
17067
  async function authorize(quiet = false) {
@@ -16824,15 +17082,19 @@ async function authorize(quiet = false) {
16824
17082
  if (result.status === "pending") continue;
16825
17083
  if (result.status !== "approved" || !result.token) throw new Error(`Device authorization ${result.status}.`);
16826
17084
  await saveCredential(options.apiUrl, result.token);
16827
- if (!quiet) emit({ schemaVersion: 1, status: "authenticated", expiresAt: result.expiresAt, apiUrl: options.apiUrl }, options.pretty);
17085
+ if (!quiet) {
17086
+ if (humanOutput()) process.stdout.write("\u2713 Signed in to Rill\nCheck your account: rill whoami\n");
17087
+ else emit({ schemaVersion: 1, status: "authenticated", expiresAt: result.expiresAt, apiUrl: options.apiUrl }, options.pretty);
17088
+ }
16828
17089
  return result.token;
16829
17090
  }
16830
17091
  throw new Error("Device authorization expired.");
16831
17092
  }
16832
- program.command("whoami").description("Show the current verified agent identity without starting sign-in.").action(async () => {
17093
+ program.command("whoami").description("Show the current verified identity without starting sign-in.").addHelpText("after", "\nShows a short account summary by default. Use rill whoami --json for agents, or rill --pretty whoami for indented JSON.").action(async () => {
16833
17094
  const options = program.opts();
16834
17095
  const identity = await whoami(options.apiUrl);
16835
- emit(identity, options.pretty);
17096
+ if (humanOutput(true)) process.stdout.write(formatIdentity(identity));
17097
+ else emit(identity, options.pretty);
16836
17098
  if (!identity.authenticated) process.exitCode = 1;
16837
17099
  });
16838
17100
  var connect = program.command("connect").description("Connect this agent once, with renewable account access.").option("--email <email>", "Email of the Rill account approving access").option("--no-wait", "Return approval instructions immediately; poll with connect status").action(async (input) => {
@@ -16881,9 +17143,14 @@ for (const action of ["status", "cancel"]) cloudRun.command(action).argument("<r
16881
17143
  if (options.json || options.pretty) emit(customerRun(run2), options.pretty);
16882
17144
  else process.stdout.write(formatCloudRun(run2));
16883
17145
  });
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) => {
17146
+ record2.command("tabs").requiredOption("--cdp-url <url>", "Existing Chromium CDP endpoint").action(async (commandOptions) => {
17147
+ const options = program.opts();
17148
+ emit({ schemaVersion: 1, tabs: await listBrowserTabs(commandOptions.cdpUrl) }, options.pretty);
17149
+ });
17150
+ 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
17151
  const options = program.opts();
16886
17152
  assertRecorderTransport();
17153
+ validateTabSelection(commandOptions);
16887
17154
  await ensureAnonymousCredential(options.apiUrl);
16888
17155
  const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
16889
17156
  const result = await withRunFeedback(() => client.agentSession(), async (rememberRecording) => {
@@ -16892,12 +17159,21 @@ record2.command("start").option("--encoding <mode>", "remote or local", "remote"
16892
17159
  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
17160
  const width = Number(commandOptions.width);
16894
17161
  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 });
17162
+ const metadata = { requestId: crypto.randomUUID(), title: commandOptions.title ?? `Browser recording \xB7 ${(/* @__PURE__ */ new Date()).toISOString()}`, description: commandOptions.description, sourceUrl: commandOptions.url ?? null, width, height };
17163
+ const remote = await createRecordingWithRecovery({
17164
+ createRemote: () => client.createRecording(metadata),
17165
+ localStatus: recordingStatus,
17166
+ failRemote: (id) => client.failLocalCapture(id),
17167
+ onRecovered: (id) => {
17168
+ process.stderr.write(`Released the unused slot for failed local capture ${id}.
17169
+ `);
17170
+ }
17171
+ });
16896
17172
  rememberRecording(remote.recordingId);
16897
17173
  const local = await startRecordingWithCompensation(remote, {
16898
17174
  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 });
17175
+ await ensureDaemon(process.argv[1], Boolean(commandOptions.cdpUrl));
17176
+ 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
17177
  },
16902
17178
  cancelRemote: (recordingId, reason) => client.cancelRecording(recordingId, reason)
16903
17179
  }, options.apiUrl);
@@ -16908,16 +17184,20 @@ record2.command("start").option("--encoding <mode>", "remote or local", "remote"
16908
17184
  headed: Boolean(commandOptions.headed),
16909
17185
  attached: Boolean(commandOptions.cdpUrl)
16910
17186
  }).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) };
17187
+ 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
17188
  });
16913
- emit(result, options.pretty);
17189
+ emitRecording(result, options.pretty);
16914
17190
  });
16915
17191
  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
17192
  const options = program.opts();
16917
17193
  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
17194
  const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
17195
+ const local = await stopRecordingWithCompensation(recordingId, {
17196
+ stopLocal: stopRecording,
17197
+ localStatus: recordingStatus,
17198
+ failRemote: (id) => client.failLocalCapture(id)
17199
+ }, options.apiUrl);
17200
+ if (!local.result) throw new Error(local.error ?? "The recorder did not produce an artifact.");
16921
17201
  const stopped = await client.markRecordingStopped(recordingId, {
16922
17202
  durationSeconds: local.result.durationSeconds,
16923
17203
  stoppedReason: local.result.stoppedReason,
@@ -16928,7 +17208,7 @@ record2.command("stop").argument("<recording-id>").option("--no-wait").option("-
16928
17208
  await client.uploadDiagnostics(recordingId, local.result.diagnosticsPath, local.result.diagnostics);
16929
17209
  const accepted = await submitFrameCapture(client, recordingId, local.result.frameDirectory);
16930
17210
  const result2 = commandOptions.wait && record2.opts().wait !== false ? await waitForEncoding(client, recordingId) : accepted;
16931
- emit({ schemaVersion: 1, ...result2, ...recordingNextStep(options.apiUrl, recordingId, result2.status, { resumableFrames: true }), ...recordingFeedbackFields(recordingId, stopped?.feedbackRequested ?? false) }, options.pretty);
17211
+ emitRecording({ schemaVersion: 1, ...result2, recordingId, ...recordingNextStep(options.apiUrl, recordingId, result2.status, { resumableFrames: true }), ...recordingFeedbackFields(recordingId, stopped?.feedbackRequested ?? false) }, options.pretty);
16932
17212
  if (result2.status === "ready") await rm7(dirname2(local.result.videoPath), { recursive: true, force: true });
16933
17213
  else watchEncoding(options.apiUrl, recordingId);
16934
17214
  return;
@@ -16950,7 +17230,7 @@ record2.command("stop").argument("<recording-id>").option("--no-wait").option("-
16950
17230
  ...recordingNextStep(options.apiUrl, recordingId, remote ? "ready" : "processing"),
16951
17231
  ...recordingFeedbackFields(recordingId, stopped?.feedbackRequested ?? false)
16952
17232
  };
16953
- emit(result, options.pretty);
17233
+ emitRecording(result, options.pretty);
16954
17234
  if (remote) await rm7(dirname2(local.result.videoPath), { recursive: true, force: true });
16955
17235
  });
16956
17236
  record2.command("retry").argument("<recording-id>").option("--no-wait").action(async (recordingId, commandOptions) => {
@@ -16971,7 +17251,7 @@ record2.command("retry").argument("<recording-id>").option("--no-wait").action(a
16971
17251
  if (!accepted) throw new Error("Encoding job is unavailable.");
16972
17252
  if (["expired", "cancelled"].includes(accepted.status)) throw new Error(`Recording ${accepted.status}.`);
16973
17253
  const result = commandOptions.wait && record2.opts().wait !== false ? await waitForEncoding(client, recordingId) : accepted;
16974
- emit({ schemaVersion: 1, ...result, ...recordingNextStep(options.apiUrl, recordingId, result.status, { resumableFrames: true }) }, options.pretty);
17254
+ emitRecording({ schemaVersion: 1, ...result, recordingId, ...recordingNextStep(options.apiUrl, recordingId, result.status, { resumableFrames: true }) }, options.pretty);
16975
17255
  if (result.status === "ready") await removeLocalCapture(recordingId);
16976
17256
  else watchEncoding(options.apiUrl, recordingId);
16977
17257
  });
@@ -17013,7 +17293,7 @@ record2.command("status").argument("<recording-id>").action(async (recordingId)
17013
17293
  try {
17014
17294
  const encoding = await client.encodingStatus(recordingId);
17015
17295
  const remote = encoding.status === "ready" ? await client.recordingStatus(recordingId) : {};
17016
- emit({ schemaVersion: 1, ...encoding, ...remote, recordingId, source: "remote", ...recordingNextStep(options.apiUrl, recordingId, encoding.status, { resumableFrames: true }) }, options.pretty);
17296
+ emitRecording({ schemaVersion: 1, ...encoding, ...remote, recordingId, source: "remote", ...recordingNextStep(options.apiUrl, recordingId, encoding.status, { resumableFrames: true }) }, options.pretty);
17017
17297
  if (encoding.status === "ready") await removeLocalCapture(recordingId);
17018
17298
  return;
17019
17299
  } catch (error51) {
@@ -17026,7 +17306,7 @@ record2.command("status").argument("<recording-id>").action(async (recordingId)
17026
17306
  },
17027
17307
  remoteStatus: (id) => client.recordingStatus(id)
17028
17308
  }, options.apiUrl);
17029
- emit({ schemaVersion: 1, ...status }, options.pretty);
17309
+ emitRecording({ schemaVersion: 1, ...status, recordingId }, options.pretty);
17030
17310
  });
17031
17311
  record2.command("cancel").argument("<recording-id>").description("Cancel an active recording and release its remote quota slot.").action(async (recordingId) => {
17032
17312
  const options = program.opts();
@@ -17106,8 +17386,20 @@ program.hook("postAction", async () => {
17106
17386
  await finishCliTelemetry(process.exitCode ? new Error("command_reported_failure: Command returned a failure status.") : null);
17107
17387
  });
17108
17388
  program.parseAsync().catch(async (error51) => {
17389
+ if (error51 instanceof CommanderError && error51.exitCode === 0) return;
17390
+ if (error51 instanceof CommanderError) {
17391
+ const action = program.args[0] === "record" ? program.args[1] : void 0;
17392
+ const missingRecording = error51.code === "commander.missingArgument" && ["stop", "status", "retry", "cancel"].includes(action ?? "");
17393
+ error51 = Object.assign(new Error(`validation_failed: ${missingRecording ? "A recording ID is required." : error51.message.replace(/^error: /, "")}`), {
17394
+ code: "validation_failed",
17395
+ recovery: missingRecording ? `Run rill record ${action} <recording-id>. Use rill record list to find the ID.` : "Run rill --help for available commands and options."
17396
+ });
17397
+ }
17109
17398
  await finishCliTelemetry(error51);
17110
- fail(error51);
17399
+ if (humanOutput(program.args[0] === "whoami")) {
17400
+ process.stderr.write(formatFailure(error51));
17401
+ process.exitCode = 1;
17402
+ } else fail(error51);
17111
17403
  });
17112
17404
  function commandPath(actionCommand) {
17113
17405
  const path = [];
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.16",
4
4
  "description": "Record, inspect, and share agent-controlled browser runs with Rill.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {