devicerent 0.1.0 → 0.2.1

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.
@@ -2,14 +2,15 @@
2
2
  import { createRequire } from "node:module";
3
3
  import { parseArgs } from "node:util";
4
4
  import fs from "node:fs";
5
+ import os from "node:os";
5
6
  import path from "node:path";
6
7
  import { createDecipheriv, createHash, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes, randomUUID } from "node:crypto";
7
8
  import http from "node:http";
8
- import { spawn } from "node:child_process";
9
- import os from "node:os";
9
+ import { execFile, spawn } from "node:child_process";
10
10
  import readline from "node:readline/promises";
11
- import sharp from "sharp";
11
+ import { pipeline } from "node:stream/promises";
12
12
  import net from "node:net";
13
+ import readline$1 from "node:readline";
13
14
  //#region \0rolldown/runtime.js
14
15
  var __create = Object.create;
15
16
  var __defProp = Object.defineProperty;
@@ -76,6 +77,61 @@ function int$1(opts, key, fallback) {
76
77
  return n;
77
78
  }
78
79
  //#endregion
80
+ //#region src/interactive/gate.ts
81
+ /** CI=true / CI=1 / CI=anything — but not CI=false or CI=0. */
82
+ function isCi(env) {
83
+ const v = env.CI;
84
+ return v !== void 0 && v !== "" && v !== "false" && v !== "0";
85
+ }
86
+ function shouldStartInteractive(i) {
87
+ return i.argv.length === 0 && !i.globals.includes("--json") && i.stdinTTY && i.stdoutTTY && i.stderrTTY && i.env.TERM !== "dumb" && !isCi(i.env) && !i.env.DEVICERENT_NO_INTERACTIVE;
88
+ }
89
+ //#endregion
90
+ //#region src/output.ts
91
+ /** An error whose message is already written for the person at the keyboard. */
92
+ var CliError = class extends Error {
93
+ exitCode;
94
+ constructor(message, exitCode = 1) {
95
+ super(message);
96
+ this.exitCode = exitCode;
97
+ }
98
+ };
99
+ const useColor = () => !process.env.NO_COLOR && process.stderr.isTTY && process.stdout.isTTY;
100
+ const paint = (code) => (s) => useColor() ? `\u001b[${code}m${s}\u001b[0m` : s;
101
+ const bold = paint(1);
102
+ const dim = paint(2);
103
+ const red = paint(31);
104
+ const green = paint(32);
105
+ const yellow = paint(33);
106
+ function note(message) {
107
+ process.stderr.write(`${message}\n`);
108
+ }
109
+ function warn(message) {
110
+ process.stderr.write(`${yellow("!")} ${message}\n`);
111
+ }
112
+ function success(message) {
113
+ process.stderr.write(`${green("✓")} ${message}\n`);
114
+ }
115
+ function printJson(value) {
116
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
117
+ }
118
+ function printLine(line = "") {
119
+ process.stdout.write(`${line}\n`);
120
+ }
121
+ /** A plain left-aligned table; widths fit the widest cell per column. */
122
+ function formatTable(headers, rows) {
123
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
124
+ const line = (cells) => cells.map((c, i) => i === cells.length - 1 ? c : c.padEnd(widths[i])).join(" ").trimEnd();
125
+ return [line(headers), ...rows.map(line)].join("\n");
126
+ }
127
+ function printTable(headers, rows) {
128
+ if (rows.length === 0) return;
129
+ printLine(formatTable(headers, rows));
130
+ }
131
+ function eur(n) {
132
+ return `€${n.toFixed(2)}`;
133
+ }
134
+ //#endregion
79
135
  //#region ../../../OneDrive/Masaüstü/DeviceRent Software part 5/node_modules/zod/v4/core/core.js
80
136
  var _a$1;
81
137
  function $constructor(name, initializer, params) {
@@ -26909,51 +26965,6 @@ function openSealed(privateKey, sealed, aad) {
26909
26965
  return Buffer.concat([decipher.update(body), decipher.final()]).toString("utf8");
26910
26966
  }
26911
26967
  //#endregion
26912
- //#region src/output.ts
26913
- /** An error whose message is already written for the person at the keyboard. */
26914
- var CliError = class extends Error {
26915
- exitCode;
26916
- constructor(message, exitCode = 1) {
26917
- super(message);
26918
- this.exitCode = exitCode;
26919
- }
26920
- };
26921
- const useColor = () => !process.env.NO_COLOR && process.stderr.isTTY && process.stdout.isTTY;
26922
- const paint = (code) => (s) => useColor() ? `\u001b[${code}m${s}\u001b[0m` : s;
26923
- const bold = paint(1);
26924
- const dim = paint(2);
26925
- const red = paint(31);
26926
- const green = paint(32);
26927
- const yellow = paint(33);
26928
- function note(message) {
26929
- process.stderr.write(`${message}\n`);
26930
- }
26931
- function warn(message) {
26932
- process.stderr.write(`${yellow("!")} ${message}\n`);
26933
- }
26934
- function success(message) {
26935
- process.stderr.write(`${green("✓")} ${message}\n`);
26936
- }
26937
- function printJson(value) {
26938
- process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
26939
- }
26940
- function printLine(line = "") {
26941
- process.stdout.write(`${line}\n`);
26942
- }
26943
- /** A plain left-aligned table; widths fit the widest cell per column. */
26944
- function formatTable(headers, rows) {
26945
- const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
26946
- const line = (cells) => cells.map((c, i) => i === cells.length - 1 ? c : c.padEnd(widths[i])).join(" ").trimEnd();
26947
- return [line(headers), ...rows.map(line)].join("\n");
26948
- }
26949
- function printTable(headers, rows) {
26950
- if (rows.length === 0) return;
26951
- printLine(formatTable(headers, rows));
26952
- }
26953
- function eur(n) {
26954
- return `€${n.toFixed(2)}`;
26955
- }
26956
- //#endregion
26957
26968
  //#region src/version.ts
26958
26969
  /**
26959
26970
  * This CLI's version. Sent to the server in every request and in the login
@@ -26961,7 +26972,7 @@ function eur(n) {
26961
26972
  * version.test.ts pins it to package.json so the two cannot drift (the
26962
26973
  * connector's hard-coded `--version` once did).
26963
26974
  */
26964
- const CLI_VERSION = "0.1.0";
26975
+ const CLI_VERSION = "0.2.1";
26965
26976
  //#endregion
26966
26977
  //#region src/auth/loopback.ts
26967
26978
  function page(message) {
@@ -27007,9 +27018,31 @@ function listenOnLoopback(state) {
27007
27018
  }
27008
27019
  //#endregion
27009
27020
  //#region src/auth/terminal.ts
27010
- /** Open a URL in the default browser. False when there is no way to. */
27021
+ /**
27022
+ * A Windows system program by its full path under %SystemRoot%\System32.
27023
+ * Never a bare name: Windows looks in the CURRENT folder before PATH, so a
27024
+ * `rundll32.exe` or `powershell.exe` sitting in whatever folder the person
27025
+ * ran `npx devicerent` from (a downloaded project, Downloads itself) would
27026
+ * run instead of the real one.
27027
+ */
27028
+ function systemExe(relative) {
27029
+ const root = process.env.SystemRoot ?? process.env.windir ?? "C:\\Windows";
27030
+ return path.win32.join(root, "System32", relative);
27031
+ }
27032
+ /**
27033
+ * What the CLI ever hands to the system's "open" handler: a web page (the
27034
+ * approval page, a Stripe hand-off), or a screenshot it just saved. A URL the
27035
+ * server sent is otherwise opened as-is, and `file:`, `ms-*:` or a program's
27036
+ * path would launch whatever it names.
27037
+ */
27038
+ function isOpenable(target) {
27039
+ if (/^https?:\/\/[^\s]+$/i.test(target)) return true;
27040
+ return path.isAbsolute(target) && /\.png$/i.test(target);
27041
+ }
27042
+ /** Open a web page, or a saved screenshot, in its default app. False when there is no way to. */
27011
27043
  function openBrowser(url) {
27012
- const [cmd, args] = process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
27044
+ if (!isOpenable(url)) return false;
27045
+ const [cmd, args] = process.platform === "win32" ? [systemExe("rundll32.exe"), ["url.dll,FileProtocolHandler", url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
27013
27046
  try {
27014
27047
  const child = spawn(cmd, args, {
27015
27048
  stdio: "ignore",
@@ -27022,15 +27055,31 @@ function openBrowser(url) {
27022
27055
  return false;
27023
27056
  }
27024
27057
  }
27025
- /** One line from the person at the terminal. Refuses when there is none. */
27058
+ /** Ctrl-C at a prompt: a deliberate stop, exit code 130 as for SIGINT. */
27059
+ const CANCELLED = () => new CliError("Cancelled.", 130);
27060
+ /**
27061
+ * One line from the person at the terminal. Refuses when there is none.
27062
+ *
27063
+ * readline puts the terminal in raw mode while it asks, so Ctrl-C arrives as a
27064
+ * key, never as SIGINT: a process-level SIGINT handler never sees it, and
27065
+ * Node 24 just rejects the question with a bare AbortError. Ctrl-C (and an
27066
+ * aborted `signal`) therefore end here, as a plain "Cancelled.".
27067
+ */
27026
27068
  async function promptLine(question, signal) {
27027
27069
  if (!process.stdin.isTTY) throw new CliError("This step needs an interactive terminal.");
27028
27070
  const rl = readline.createInterface({
27029
27071
  input: process.stdin,
27030
27072
  output: process.stderr
27031
27073
  });
27074
+ const interrupted = new Promise((_, reject) => {
27075
+ rl.once("SIGINT", () => reject(CANCELLED()));
27076
+ });
27032
27077
  try {
27033
- return (await rl.question(question, signal ? { signal } : {})).trim();
27078
+ return (await Promise.race([rl.question(question, signal ? { signal } : {}), interrupted])).trim();
27079
+ } catch (err) {
27080
+ if (err instanceof CliError) throw err;
27081
+ if (err.name === "AbortError" || signal?.aborted) throw CANCELLED();
27082
+ throw err;
27034
27083
  } finally {
27035
27084
  rl.close();
27036
27085
  }
@@ -27062,7 +27111,7 @@ async function login(ctx, opts) {
27062
27111
  const url = `${ctx.config.app_url}/account/cli/${segment}`;
27063
27112
  note("Approve this sign-in in your browser:");
27064
27113
  note(` ${url}`);
27065
- sealed = opts.noBrowser ? await promptLine("\nPaste the code the page shows after you approve: ") : await waitForBrowser(url, listener.payload);
27114
+ sealed = opts.noBrowser ? await promptLine("\nPaste the code the page shows after you approve: ", opts.signal) : await waitForBrowser(url, listener.payload, opts.signal);
27066
27115
  } finally {
27067
27116
  listener.close();
27068
27117
  }
@@ -27084,9 +27133,9 @@ async function login(ctx, opts) {
27084
27133
  const abandon = () => {
27085
27134
  staging.client.auth.signOut({ scope: "local" }).finally(() => process.exit(130));
27086
27135
  };
27087
- process.once("SIGINT", abandon);
27136
+ if (!opts.signal) process.once("SIGINT", abandon);
27088
27137
  try {
27089
- await stepUpIfEnrolled(staging.client);
27138
+ await stepUpIfEnrolled(staging.client, opts.signal);
27090
27139
  } finally {
27091
27140
  process.off("SIGINT", abandon);
27092
27141
  }
@@ -27094,17 +27143,22 @@ async function login(ctx, opts) {
27094
27143
  staging.commit(ctx.store);
27095
27144
  return data.user;
27096
27145
  }
27097
- async function waitForBrowser(url, payload) {
27146
+ async function waitForBrowser(url, payload, signal) {
27098
27147
  if (!openBrowser(url)) warn("Could not open a browser; open the link above yourself.");
27099
27148
  note("Waiting for approval… If the page asked you to sign in first and then showed your dashboard, open the link again. (Ctrl-C to cancel. Browser on another machine? Run: devicerent login --no-browser)");
27100
27149
  let timer;
27150
+ let onAbort;
27101
27151
  const timeout = new Promise((_, reject) => {
27102
27152
  timer = setTimeout(() => reject(new CliError("Timed out waiting for the browser.")), APPROVAL_TIMEOUT_MS);
27153
+ onAbort = () => reject(CANCELLED());
27154
+ if (signal?.aborted) onAbort();
27155
+ else signal?.addEventListener("abort", onAbort, { once: true });
27103
27156
  });
27104
27157
  try {
27105
27158
  return await Promise.race([payload, timeout]);
27106
27159
  } finally {
27107
27160
  clearTimeout(timer);
27161
+ if (onAbort) signal?.removeEventListener("abort", onAbort);
27108
27162
  }
27109
27163
  }
27110
27164
  /**
@@ -27114,7 +27168,7 @@ async function waitForBrowser(url, payload) {
27114
27168
  * (getUser), never from a cached copy. Every failure signs the staged
27115
27169
  * session out, so it ends server-side too.
27116
27170
  */
27117
- async function stepUpIfEnrolled(client) {
27171
+ async function stepUpIfEnrolled(client, signal) {
27118
27172
  const refuse = async (message) => {
27119
27173
  await client.auth.signOut({ scope: "local" });
27120
27174
  throw new CliError(message);
@@ -27128,7 +27182,7 @@ async function stepUpIfEnrolled(client) {
27128
27182
  const mfa = client.auth.mfa;
27129
27183
  try {
27130
27184
  for (let attempt = 1; attempt <= MFA_ATTEMPTS; attempt++) {
27131
- const code = await promptLine("Authenticator code: ");
27185
+ const code = await promptLine("Authenticator code: ", signal);
27132
27186
  const { error } = await mfa.challengeAndVerify({
27133
27187
  factorId: totp.id,
27134
27188
  code
@@ -27354,8 +27408,11 @@ const loginCommand = {
27354
27408
  summary: "Sign this terminal in through your browser",
27355
27409
  usage: "devicerent login [--no-browser]",
27356
27410
  options: { "no-browser": { type: "boolean" } },
27357
- async run({ ctx, opts, json }) {
27358
- const user = await login(ctx, { noBrowser: opts["no-browser"] === true });
27411
+ async run({ ctx, opts, json, signal }) {
27412
+ const user = await login(ctx, {
27413
+ noBrowser: opts["no-browser"] === true,
27414
+ signal
27415
+ });
27359
27416
  if (json) return printJson({
27360
27417
  email: user.email,
27361
27418
  id: user.id,
@@ -27414,7 +27471,7 @@ const walletCommand = {
27414
27471
  printLine(eur(p.wallet));
27415
27472
  return;
27416
27473
  }
27417
- const page = Math.max(0, Number(str$1(opts, "page") ?? "1") - 1);
27474
+ const page = int$1(opts, "page", 1) - 1;
27418
27475
  const { rows, hasMore } = await fetchWalletTransactions(page);
27419
27476
  if (json) return printJson({
27420
27477
  balance: p.wallet,
@@ -27445,7 +27502,7 @@ const historyCommand = {
27445
27502
  options: { limit: { type: "string" } },
27446
27503
  async run({ ctx, opts, json }) {
27447
27504
  const user = await requireUser(ctx);
27448
- const limit = Math.min(200, Math.max(1, Number(str$1(opts, "limit") ?? "20")));
27505
+ const limit = Math.min(200, int$1(opts, "limit", 20));
27449
27506
  const { data, error } = await ctx.supabase.from("rentals").select("id,device_id,status,start_time,end_time,total_cost,devices!inner(name)").eq("user_id", user.id).eq("status", "completed").order("end_time", {
27450
27507
  ascending: false,
27451
27508
  nullsFirst: false
@@ -29130,7 +29187,7 @@ function write4HexDigits(buffer, index, value) {
29130
29187
  }
29131
29188
  //#endregion
29132
29189
  //#region ../../../OneDrive/Masaüstü/DeviceRent Software part 5/node_modules/@yume-chan/adb/esm/utils/no-op.js
29133
- const NOOP = /* @__NO_SIDE_EFFECTS__ */ () => {};
29190
+ const NOOP$1 = /* @__NO_SIDE_EFFECTS__ */ () => {};
29134
29191
  /**
29135
29192
  * When used in `Promise#catch`, means the promise should never throw errors.
29136
29193
  * An explicit way to suppress ESLint floating promise warnings.
@@ -29968,7 +30025,7 @@ async function pipeFileData(locked, file, packetSize, mtime) {
29968
30025
  } }), { signal: abortController.signal }).then(async () => {
29969
30026
  await adbSyncWriteRequest(locked, AdbSyncRequestId.Done, mtime);
29970
30027
  await locked.flush();
29971
- }, NOOP);
30028
+ }, NOOP$1);
29972
30029
  await adbSyncReadResponse(locked, AdbSyncResponseId.Ok, AdbSyncOkResponse).catch((e) => {
29973
30030
  abortController.abort();
29974
30031
  throw e;
@@ -32022,6 +32079,70 @@ function obj(props, required) {
32022
32079
  };
32023
32080
  }
32024
32081
  const NO_ARGS = obj({}, []);
32082
+ /** Tools that only read. Available as soon as a session exists. */
32083
+ const READ_TOOLS = [
32084
+ {
32085
+ name: "observe_screen",
32086
+ description: "Read what is currently on the phone screen as a numbered list of interactable elements with exact pixel bounds and a ready-made tap point. This is your primary way of seeing the device: it is far cheaper and far more precise than a screenshot. Use it to look at a screen you have not just changed — at the start of a task, or after a wait. You do NOT need it after an action: tap, swipe, type_text, press_key and launch_app already return the resulting screen with them.",
32087
+ parameters: NO_ARGS
32088
+ },
32089
+ {
32090
+ name: "screenshot",
32091
+ description: "Capture the screen as an image. Costs roughly three times as much as observe_screen and gives you no coordinates, so use it only when observe_screen returns no elements (games, video, canvas, some Jetpack Compose surfaces) or when the question is genuinely visual — colours, layout, whether something rendered correctly.",
32092
+ parameters: NO_ARGS
32093
+ },
32094
+ {
32095
+ name: "list_packages",
32096
+ description: "List the third-party app packages installed on the phone. Use this to find the package name of an app before launching it.",
32097
+ parameters: NO_ARGS
32098
+ },
32099
+ {
32100
+ name: "device_info",
32101
+ description: "Report the device model, Android version, screen resolution and current screen orientation.",
32102
+ parameters: NO_ARGS
32103
+ },
32104
+ {
32105
+ name: "wait",
32106
+ description: "Pause for an animation, a page load or a splash screen, then get the screen as it stands after the pause — the same listing observe_screen returns, so you do NOT need observe_screen after a wait. Prefer waiting once for long enough over polling observe_screen repeatedly, which costs tokens each time.",
32107
+ parameters: obj({ ms: {
32108
+ type: "integer",
32109
+ description: "Milliseconds to wait. Clamped to 100-5000."
32110
+ } }, ["ms"])
32111
+ },
32112
+ {
32113
+ name: "list_instrumentations",
32114
+ description: "List the instrumentation test runners installed on the phone, with the exact target string to pass to run_instrumentation. Use this instead of guessing the runner name — a wrong target is the most common reason a test run never starts.",
32115
+ parameters: NO_ARGS
32116
+ },
32117
+ {
32118
+ name: "logcat_tail",
32119
+ description: "Read the tail of the device log. This is the triage tool: when a test fails or an app crashes, read the log before you change anything. Guessing at a cause you have not read is how a fix loop turns into ten wasted steps.",
32120
+ parameters: obj({
32121
+ lines: {
32122
+ type: "integer",
32123
+ description: "How many recent lines to return. Clamped to 1-1000."
32124
+ },
32125
+ filter: {
32126
+ type: "string",
32127
+ description: "Case-insensitive substring to keep only matching lines - a tag, a package, or an exception class. Empty string returns everything."
32128
+ }
32129
+ }, ["lines", "filter"])
32130
+ },
32131
+ {
32132
+ name: "read_device_file",
32133
+ description: "Read a text file off the phone - a JUnit XML report, a coverage file, or anything a test wrote out. Only /data/local/tmp and /sdcard are readable, and binary files are refused.",
32134
+ parameters: obj({
32135
+ path: {
32136
+ type: "string",
32137
+ description: "Absolute path, under /data/local/tmp or /sdcard."
32138
+ },
32139
+ max_bytes: {
32140
+ type: "integer",
32141
+ description: "Maximum bytes to return. Clamped to 1000-100000."
32142
+ }
32143
+ }, ["path", "max_bytes"])
32144
+ }
32145
+ ];
32025
32146
  /** Tools that touch the device. Gated on the session's allow_actions toggle. */
32026
32147
  const ACTION_TOOLS = [
32027
32148
  {
@@ -32805,7 +32926,7 @@ const browserImageCodec = { async pngToScaledJpeg(png) {
32805
32926
  //#region ../../src/lib/ai/clientToolArgs.ts
32806
32927
  /** Cap on any single tool result. One runaway dump must not blow up a turn. */
32807
32928
  const MAX_RESULT_CHARS = 2e4;
32808
- function truncate(s) {
32929
+ function truncate$1(s) {
32809
32930
  if (s.length <= MAX_RESULT_CHARS) return s;
32810
32931
  return s.slice(0, MAX_RESULT_CHARS) + `\n… truncated (${s.length - MAX_RESULT_CHARS} more characters)`;
32811
32932
  }
@@ -33656,65 +33777,77 @@ var WebSocketServerConnector = class {
33656
33777
  });
33657
33778
  let readableController = null;
33658
33779
  let readableClosed = false;
33659
- return {
33660
- readable: new ReadableStream$1({
33661
- start(controller) {
33662
- readableController = controller;
33663
- ws.addEventListener("message", (ev) => {
33664
- if (readableClosed) return;
33665
- if (ev.data instanceof ArrayBuffer) controller.enqueue(new Uint8Array(ev.data));
33666
- else if (ev.data instanceof Uint8Array) controller.enqueue(ev.data);
33667
- });
33668
- ws.addEventListener("close", () => {
33669
- if (readableClosed) return;
33670
- readableClosed = true;
33671
- try {
33672
- controller.close();
33673
- } catch {}
33674
- resolveClosed(void 0);
33675
- });
33676
- ws.addEventListener("error", () => {
33677
- if (readableClosed) return;
33678
- readableClosed = true;
33679
- try {
33680
- controller.error(/* @__PURE__ */ new Error("WebSocket error"));
33681
- } catch {}
33682
- resolveClosed(void 0);
33683
- });
33684
- },
33685
- cancel() {
33686
- try {
33687
- ws.close();
33688
- } catch {}
33689
- }
33690
- }),
33691
- writable: new MaybeConsumableWritableStream({
33692
- write(chunk) {
33693
- if (ws.readyState !== WebSocket.OPEN) throw new Error("bridge socket not open");
33694
- ws.send(chunk.slice());
33695
- },
33696
- close() {
33697
- try {
33698
- ws.close(1e3, "writable closed");
33699
- } catch {}
33700
- },
33701
- abort() {
33780
+ const readable = new ReadableStream$1({
33781
+ start(controller) {
33782
+ readableController = controller;
33783
+ ws.addEventListener("message", (ev) => {
33784
+ if (readableClosed) return;
33785
+ if (ev.data instanceof ArrayBuffer) controller.enqueue(new Uint8Array(ev.data));
33786
+ else if (ev.data instanceof Uint8Array) controller.enqueue(ev.data);
33787
+ });
33788
+ ws.addEventListener("close", () => {
33789
+ if (readableClosed) return;
33790
+ readableClosed = true;
33702
33791
  try {
33703
- ws.close(1011, "writable aborted");
33792
+ controller.close();
33704
33793
  } catch {}
33705
- }
33706
- }),
33707
- close: () => {
33708
- if (!readableClosed && readableController) {
33794
+ resolveClosed(void 0);
33795
+ });
33796
+ ws.addEventListener("error", () => {
33797
+ if (readableClosed) return;
33709
33798
  readableClosed = true;
33710
33799
  try {
33711
- readableController.close();
33800
+ controller.error(/* @__PURE__ */ new Error("WebSocket error"));
33712
33801
  } catch {}
33713
- }
33802
+ resolveClosed(void 0);
33803
+ });
33804
+ },
33805
+ cancel() {
33714
33806
  try {
33715
- ws.close(1e3, "client closed");
33807
+ ws.close();
33716
33808
  } catch {}
33809
+ }
33810
+ });
33811
+ const writable = new MaybeConsumableWritableStream({
33812
+ write(chunk) {
33813
+ if (ws.readyState !== WebSocket.OPEN) throw new Error("bridge socket not open");
33814
+ ws.send(chunk.slice());
33717
33815
  },
33816
+ close() {
33817
+ try {
33818
+ ws.close(1e3, "writable closed");
33819
+ } catch {}
33820
+ },
33821
+ abort() {
33822
+ try {
33823
+ ws.close(1011, "writable aborted");
33824
+ } catch {}
33825
+ }
33826
+ });
33827
+ const close = () => {
33828
+ if (!readableClosed && readableController) {
33829
+ readableClosed = true;
33830
+ try {
33831
+ readableController.close();
33832
+ } catch {}
33833
+ }
33834
+ try {
33835
+ ws.close(1e3, "client closed");
33836
+ } catch {}
33837
+ };
33838
+ const signal = options?.signal;
33839
+ if (signal) {
33840
+ const onAbort = () => close();
33841
+ if (signal.aborted) onAbort();
33842
+ else {
33843
+ signal.addEventListener("abort", onAbort);
33844
+ ws.addEventListener("close", () => signal.removeEventListener("abort", onAbort));
33845
+ }
33846
+ }
33847
+ return {
33848
+ readable,
33849
+ writable,
33850
+ close,
33718
33851
  get closed() {
33719
33852
  return closed;
33720
33853
  }
@@ -34100,7 +34233,7 @@ async function executeToolCall(ctx, call) {
34100
34233
  });
34101
34234
  const ok = (msg) => ({
34102
34235
  toolCallId: call.id,
34103
- content: truncate(msg),
34236
+ content: truncate$1(msg),
34104
34237
  isError: false
34105
34238
  });
34106
34239
  const { adb, control, mirrorSize, status } = ctx.getHandles();
@@ -34466,6 +34599,7 @@ async function executeToolCall(ctx, call) {
34466
34599
  * which is the one conversion here and the one place they could have drifted.
34467
34600
  */
34468
34601
  const nodeImageCodec = { async pngToScaledJpeg(png) {
34602
+ const { default: sharp } = await import("sharp");
34469
34603
  const image = sharp(Buffer.from(png));
34470
34604
  const meta = await image.metadata();
34471
34605
  if (!meta.width || !meta.height) throw new Error("screencap bytes were not a decodable image");
@@ -34807,25 +34941,49 @@ function parseWmSize(out) {
34807
34941
  }
34808
34942
  //#endregion
34809
34943
  //#region src/ai/loop.ts
34944
+ /**
34945
+ * DeviceyAI was given look-only access and the task needs to tap or type. Its
34946
+ * message is the one-shot instruction; the interactive session catches the
34947
+ * type and asks for the permission instead.
34948
+ */
34949
+ var AiNeedsActionsError = class extends CliError {};
34810
34950
  /** What a refused turn means, as an instruction a person can act on. */
34811
34951
  function describeTurnFailure(status, failure) {
34812
34952
  if (status === 409 && failure.needs_actions) return "This task needs to tap and type on the phone. Run it again with --allow-actions.";
34813
34953
  if (status === 402) return "You are out of DeviceyAI credits. Buy a pack: devicerent credits buy s";
34814
34954
  return failure.error ?? `DeviceyAI refused the turn (${status}).`;
34815
34955
  }
34956
+ /**
34957
+ * A turn that did not come back. Null when the person stopped it (Ctrl-C);
34958
+ * anything else — a dropped connection, a reply cut off mid-stream — says
34959
+ * so, instead of reading as if Ctrl-C had been pressed.
34960
+ */
34961
+ function lostTurn(err, signal) {
34962
+ if (signal.aborted) return null;
34963
+ if (err instanceof CliError) throw err;
34964
+ throw new CliError(`Lost the connection to DeviceyAI (${String(err?.message ?? err).split("\n")[0].slice(0, 160)}). Your phone is still rented; try again.`);
34965
+ }
34816
34966
  async function readTurn(payload, signal, hooks) {
34817
34967
  let reply;
34818
34968
  try {
34819
34969
  reply = await postAiTurn(payload, signal);
34820
- } catch {
34821
- return null;
34970
+ } catch (err) {
34971
+ return lostTurn(err, signal);
34972
+ }
34973
+ if (reply.kind === "failed") {
34974
+ const message = describeTurnFailure(reply.status, reply.failure);
34975
+ throw reply.status === 409 && reply.failure.needs_actions ? new AiNeedsActionsError(message) : new CliError(message);
34822
34976
  }
34823
- if (reply.kind === "failed") throw new CliError(describeTurnFailure(reply.status, reply.failure));
34824
34977
  if (reply.kind === "json") return reply.turn;
34825
34978
  let done = null;
34826
- for await (const ev of reply.events) if (ev.name === "text") hooks.onText(ev.delta);
34827
- else if (ev.name === "done") done = ev.turn;
34828
- else if (ev.name === "error") throw new CliError(ev.error);
34979
+ try {
34980
+ for await (const ev of reply.events) if (ev.name === "text") hooks.onText(ev.delta);
34981
+ else if (ev.name === "done") done = ev.turn;
34982
+ else if (ev.name === "error") throw new CliError(ev.error);
34983
+ } catch (err) {
34984
+ return lostTurn(err, signal);
34985
+ }
34986
+ if (!done) return lostTurn(/* @__PURE__ */ new Error("the reply ended early"), signal);
34829
34987
  return done;
34830
34988
  }
34831
34989
  async function runAiTask(device, opts, signal, hooks) {
@@ -35014,8 +35172,10 @@ async function loadFleetDevices(ctx) {
35014
35172
  */
35015
35173
  function pickDevice(devices, query, taken = /* @__PURE__ */ new Set()) {
35016
35174
  const q = query.trim().toLowerCase();
35175
+ if (!q) throw new CliError("Name a device (id, serial or model). See: devicerent devices");
35017
35176
  const exact = devices.find((d) => d.id.toLowerCase() === q || q.length >= 8 && d.id.toLowerCase().startsWith(q) || d.serial.toLowerCase() === q || d.serial.toLowerCase().endsWith(`:${q}`));
35018
35177
  if (exact) {
35178
+ if (taken.has(exact.id)) throw new CliError(`${exact.name} is named twice in one request.`);
35019
35179
  if (!exact.rentable) throw new CliError(`${exact.name} is ${exact.status} right now.`);
35020
35180
  return exact;
35021
35181
  }
@@ -35028,20 +35188,29 @@ function pickDevice(devices, query, taken = /* @__PURE__ */ new Set()) {
35028
35188
  async function loadActiveRentals() {
35029
35189
  return loadActiveRentalRows();
35030
35190
  }
35191
+ const listRentals = (rentals) => rentals.map((r) => `${r.id.slice(0, 8)} ${r.deviceName}`).join(", ");
35031
35192
  /**
35032
35193
  * Which rental a device command acts on: the one named by --rental (an id, an
35033
35194
  * id prefix, or a device name), else the only one running.
35195
+ *
35196
+ * A prefix or name that fits SEVERAL rentals is refused, never resolved to
35197
+ * the first: `stop pixel` with two Pixels rented must not stop — and wipe —
35198
+ * whichever one happened to come back first.
35034
35199
  */
35035
35200
  function chooseRental(rentals, selector) {
35036
35201
  if (rentals.length === 0) throw new CliError("No active rental. Start one: devicerent rent <device>");
35037
35202
  if (!selector) {
35038
35203
  if (rentals.length === 1) return rentals[0];
35039
- throw new CliError(`You have ${rentals.length} rentals running; pick one with --rental (${rentals.map((r) => `${r.id.slice(0, 8)} ${r.deviceName}`).join(", ")}).`);
35204
+ throw new CliError(`You have ${rentals.length} rentals running; pick one with --rental (${listRentals(rentals)}).`);
35040
35205
  }
35041
- const s = selector.toLowerCase();
35042
- const hit = rentals.find((r) => r.id.toLowerCase() === s) ?? rentals.find((r) => r.id.toLowerCase().startsWith(s)) ?? rentals.find((r) => r.deviceName.toLowerCase().includes(s));
35043
- if (!hit) throw new CliError(`No active rental matches "${selector}".`);
35044
- return hit;
35206
+ const s = selector.trim().toLowerCase();
35207
+ const exact = rentals.find((r) => r.id.toLowerCase() === s);
35208
+ if (exact) return exact;
35209
+ for (const matches of [rentals.filter((r) => r.id.toLowerCase().startsWith(s)), rentals.filter((r) => r.deviceName.toLowerCase().includes(s))]) {
35210
+ if (matches.length === 1) return matches[0];
35211
+ if (matches.length > 1) throw new CliError(`"${selector}" fits ${matches.length} of your rentals (${listRentals(matches)}); name one by its id.`);
35212
+ }
35213
+ throw new CliError(`No active rental matches "${selector}".`);
35045
35214
  }
35046
35215
  /** Minutes since a timestamp, never negative. */
35047
35216
  function minutesSince(iso, now) {
@@ -35076,6 +35245,8 @@ function renewDelayMs(expiresAt, now) {
35076
35245
  return Math.max(5e3, Date.parse(expiresAt) - now - RENEW_BEFORE_EXPIRY_MS);
35077
35246
  }
35078
35247
  async function openDevice(ctx, rental) {
35248
+ const touch = () => ctx.supabase.rpc("touch_rental_activity", { p_rental_id: rental.id }).then(() => void 0, () => void 0);
35249
+ const opened = touch();
35079
35250
  let ticket;
35080
35251
  try {
35081
35252
  ticket = await mintBridgeTicket(rental.id);
@@ -35105,17 +35276,18 @@ async function openDevice(ctx, rental) {
35105
35276
  if (closed) return;
35106
35277
  try {
35107
35278
  const next = await mintBridgeTicket(rental.id);
35279
+ if (closed) return;
35108
35280
  connector.setToken(next.token);
35109
35281
  await client.getVersion();
35110
35282
  schedule(renewDelayMs(next.expires_at, Date.now()));
35111
35283
  } catch (err) {
35284
+ if (closed) return;
35112
35285
  warn(`Could not renew access to ${rental.deviceName}: ${err.message}`);
35113
35286
  schedule(RENEW_RETRY_MS);
35114
35287
  }
35115
35288
  };
35116
35289
  schedule(renewDelayMs(ticket.expires_at, Date.now()));
35117
- const touch = () => ctx.supabase.rpc("touch_rental_activity", { p_rental_id: rental.id }).then(() => void 0, () => void 0);
35118
- await touch();
35290
+ await opened;
35119
35291
  let dirty = false;
35120
35292
  const flush = setInterval(() => {
35121
35293
  if (!dirty) return;
@@ -35159,17 +35331,141 @@ const RENTAL_OPTION = { rental: {
35159
35331
  type: "string",
35160
35332
  short: "r"
35161
35333
  } };
35162
- /** Resolve the rental, open the phone, run, and always close it again. */
35334
+ const STOPPED = () => new CliError("Stopped.", 130);
35335
+ /**
35336
+ * Resolve the rental, open the phone, run, and always close it again.
35337
+ *
35338
+ * In the interactive session Ctrl-C aborts `input.signal`. That closes the
35339
+ * connection to the phone at once — a `finally` alone would never run while a
35340
+ * `logcat -f` read loop is blocked — and the stream error it causes is
35341
+ * reported as the deliberate stop it was. A one-shot run has no signal.
35342
+ */
35163
35343
  async function withDevice(input, fn) {
35344
+ const rentals = loadActiveRentals();
35345
+ rentals.catch(() => {});
35164
35346
  const user = await requireUser(input.ctx);
35165
- const rental = chooseRental(await loadActiveRentals(), str$1(input.opts, "rental") ?? process.env.DEVICERENT_RENTAL);
35347
+ const rental = chooseRental(await rentals, str$1(input.opts, "rental") ?? process.env.DEVICERENT_RENTAL);
35348
+ if (input.signal?.aborted) throw STOPPED();
35166
35349
  const device = await openDevice(input.ctx, rental);
35350
+ const onAbort = () => void device.close();
35351
+ input.signal?.addEventListener("abort", onAbort, { once: true });
35167
35352
  try {
35168
35353
  return await fn(device, user);
35354
+ } catch (err) {
35355
+ if (input.signal?.aborted) throw STOPPED();
35356
+ throw err;
35169
35357
  } finally {
35358
+ input.signal?.removeEventListener("abort", onAbort);
35170
35359
  await device.close();
35171
35360
  }
35172
35361
  }
35362
+ const CSI = `[`;
35363
+ function colorLevel(env, isTTY) {
35364
+ if (env.NO_COLOR) return 0;
35365
+ const force = env.FORCE_COLOR;
35366
+ if (force === "0" || force === "false") return 0;
35367
+ const truecolor = env.COLORTERM === "truecolor" || env.COLORTERM === "24bit" || Boolean(env.WT_SESSION) || env.TERM_PROGRAM === "vscode" || env.TERM_PROGRAM === "iTerm.app" || env.TERM_PROGRAM === "WezTerm";
35368
+ if (force !== void 0 && force !== "") return force === "3" || truecolor ? 3 : 1;
35369
+ if (!isTTY || env.TERM === "dumb") return 0;
35370
+ return truecolor ? 3 : 1;
35371
+ }
35372
+ const ANSI_RE = /\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g;
35373
+ function stripAnsi(s) {
35374
+ return s.replace(ANSI_RE, "");
35375
+ }
35376
+ const CONTROL_RE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
35377
+ /**
35378
+ * Text from outside — DeviceyAI's answer, a tool's error, a file name — made
35379
+ * safe to print. Escape sequences are removed whole, then every remaining
35380
+ * control character, so text read off a phone's screen cannot retitle the
35381
+ * window, rewrite the clipboard (OSC 52) or hide what is printed after it. A
35382
+ * sequence split across two chunks loses its ESC and prints as harmless text.
35383
+ */
35384
+ function sanitize(text) {
35385
+ return text.replace(ANSI_RE, "").replace(CONTROL_RE, "");
35386
+ }
35387
+ /** Zero-width: combining marks, joiners, variation selectors. */
35388
+ function isZeroWidth(cp) {
35389
+ return cp >= 768 && cp <= 879 || cp >= 8203 && cp <= 8207 || cp >= 65024 && cp <= 65039 || cp >= 917760 && cp <= 917999 || cp === 8419;
35390
+ }
35391
+ /** Drawn two cells wide: East Asian wide/fullwidth and emoji pictographs. */
35392
+ function isWide(cp) {
35393
+ return cp >= 4352 && cp <= 4447 || cp >= 11904 && cp <= 12350 || cp >= 12353 && cp <= 13311 || cp >= 13312 && cp <= 19903 || cp >= 19968 && cp <= 40959 || cp >= 40960 && cp <= 42191 || cp >= 44032 && cp <= 55203 || cp >= 63744 && cp <= 64255 || cp >= 65072 && cp <= 65103 || cp >= 65280 && cp <= 65376 || cp >= 65504 && cp <= 65510 || cp >= 127744 && cp <= 128591 || cp >= 128640 && cp <= 128767 || cp >= 129280 && cp <= 129535 || cp >= 129648 && cp <= 129791 || cp >= 131072 && cp <= 262141;
35394
+ }
35395
+ function charWidth(ch) {
35396
+ const cp = ch.codePointAt(0) ?? 0;
35397
+ if (cp < 32 || cp >= 127 && cp < 160) return 0;
35398
+ if (isZeroWidth(cp)) return 0;
35399
+ return isWide(cp) ? 2 : 1;
35400
+ }
35401
+ /** How many terminal cells `s` occupies once its escape codes are drawn. */
35402
+ function visibleWidth(s) {
35403
+ let w = 0;
35404
+ for (const ch of stripAnsi(s)) w += charWidth(ch);
35405
+ return w;
35406
+ }
35407
+ /**
35408
+ * Cut `s` to at most `max` visible cells, keeping escape codes intact and
35409
+ * ending with "…" when anything was cut. A reset is appended after a cut so a
35410
+ * colour cannot bleed into the next line.
35411
+ */
35412
+ function truncate(s, max) {
35413
+ if (max <= 0) return "";
35414
+ if (visibleWidth(s) <= max) return s;
35415
+ let out = "";
35416
+ let w = 0;
35417
+ let i = 0;
35418
+ while (i < s.length) {
35419
+ ANSI_RE.lastIndex = i;
35420
+ const m = ANSI_RE.exec(s);
35421
+ if (m && m.index === i) {
35422
+ out += m[0];
35423
+ i += m[0].length;
35424
+ continue;
35425
+ }
35426
+ const ch = String.fromCodePoint(s.codePointAt(i) ?? 0);
35427
+ const cw = charWidth(ch);
35428
+ if (w + cw > max - 1) break;
35429
+ out += ch;
35430
+ w += cw;
35431
+ i += ch.length;
35432
+ }
35433
+ ANSI_RE.lastIndex = 0;
35434
+ return `${out}…${CSI}0m`;
35435
+ }
35436
+ /** Pad with spaces to exactly `width` visible cells (truncating if longer). */
35437
+ function padEnd(s, width) {
35438
+ const t = truncate(s, width);
35439
+ return t + " ".repeat(Math.max(0, width - visibleWidth(t)));
35440
+ }
35441
+ /** Word-wrap plain text to `width` cells. Words longer than a line are split. */
35442
+ function wrap(text, width) {
35443
+ const out = [];
35444
+ for (const paragraph of text.split("\n")) {
35445
+ let line = "";
35446
+ for (const word of paragraph.split(/ +/)) {
35447
+ if (word === "") continue;
35448
+ let w = word;
35449
+ while (visibleWidth(w) > width) {
35450
+ if (line) {
35451
+ out.push(line);
35452
+ line = "";
35453
+ }
35454
+ const head = [...w].slice(0, width).join("");
35455
+ out.push(head);
35456
+ w = w.slice(head.length);
35457
+ }
35458
+ if (!line) line = w;
35459
+ else if (visibleWidth(line) + 1 + visibleWidth(w) <= width) line += ` ${w}`;
35460
+ else {
35461
+ out.push(line);
35462
+ line = w;
35463
+ }
35464
+ }
35465
+ out.push(line);
35466
+ }
35467
+ return out;
35468
+ }
35173
35469
  //#endregion
35174
35470
  //#region src/commands/ai.ts
35175
35471
  const ConfigSchema = object({
@@ -35181,6 +35477,20 @@ const ConfigSchema = object({
35181
35477
  })),
35182
35478
  credits: number$1()
35183
35479
  });
35480
+ /** The models and credit balance DeviceyAI offers on this rental. */
35481
+ async function loadAiConfig(rentalId) {
35482
+ const res = await fetch(`/api/ai/config?rental_id=${encodeURIComponent(rentalId)}`);
35483
+ const cfg = ConfigSchema.safeParse(await res.json().catch(() => ({})));
35484
+ if (!res.ok || !cfg.success) throw new CliError(res.status === 404 ? "DeviceyAI is not available on this deployment." : `DeviceyAI config failed (${res.status}).`);
35485
+ return cfg.data;
35486
+ }
35487
+ /** The model --model names, else the default, else the first offered. */
35488
+ function pickModel(models, wanted) {
35489
+ const model = models.find((m) => m.id === (wanted ?? "claude-sonnet-5")) ?? (wanted ? void 0 : models[0]);
35490
+ if (!model) throw new CliError(wanted ? `Model ${wanted} is not offered here. See: devicerent ai --models` : "DeviceyAI has no model available right now. Try again in a few minutes.");
35491
+ return model;
35492
+ }
35493
+ const OUT_OF_CREDITS = "You are out of DeviceyAI credits. Buy a pack: devicerent credits buy s";
35184
35494
  const aiCommands = [{
35185
35495
  name: "ai",
35186
35496
  summary: "Give DeviceyAI a task on your rented phone",
@@ -35196,25 +35506,21 @@ const aiCommands = [{
35196
35506
  const task = input.args.join(" ").trim();
35197
35507
  if (!task && input.opts.models !== true) throw new CliError("Say what to do: devicerent ai \"open Settings and tell me the Android version\"");
35198
35508
  return withDevice(input, async (device) => {
35199
- const res = await fetch(`/api/ai/config?rental_id=${encodeURIComponent(device.rental.id)}`);
35200
- const cfg = ConfigSchema.safeParse(await res.json().catch(() => ({})));
35201
- if (!res.ok || !cfg.success) throw new CliError(res.status === 404 ? "DeviceyAI is not available on this deployment." : `DeviceyAI config failed (${res.status}).`);
35202
- const { models, credits } = cfg.data;
35509
+ const { models, credits } = await loadAiConfig(device.rental.id);
35203
35510
  if (input.opts.models === true) {
35204
35511
  if (input.json) return void printJson(models);
35205
35512
  for (const m of models) printLine(`${m.id} ${dim(m.label)}${m.id === "claude-sonnet-5" ? " (default)" : ""}`);
35206
35513
  return 0;
35207
35514
  }
35208
- const wanted = str$1(input.opts, "model") ?? "claude-sonnet-5";
35209
- const model = models.find((m) => m.id === wanted) ?? (str$1(input.opts, "model") ? void 0 : models[0]);
35210
- if (!model) throw new CliError(`Model ${wanted} is not offered here. See: devicerent ai --models`);
35211
- if (credits <= 0) throw new CliError("You are out of DeviceyAI credits. Buy a pack: devicerent credits buy s");
35515
+ const model = pickModel(models, str$1(input.opts, "model"));
35516
+ if (credits <= 0) throw new CliError(OUT_OF_CREDITS);
35212
35517
  const allowActions = input.opts["allow-actions"] === true;
35213
35518
  if (!allowActions) note("Read-only: DeviceyAI can look but not touch. Add --allow-actions to let it tap and type.");
35214
35519
  note(`DeviceyAI (${model.label}) on ${device.rental.deviceName} — Ctrl-C stops it.`);
35215
35520
  const controller = new AbortController();
35216
35521
  const onSigint = () => controller.abort();
35217
35522
  process.once("SIGINT", onSigint);
35523
+ input.signal?.addEventListener("abort", onSigint, { once: true });
35218
35524
  let streamed = false;
35219
35525
  try {
35220
35526
  const result = await runAiTask(device, {
@@ -35227,7 +35533,7 @@ const aiCommands = [{
35227
35533
  onText: (delta) => {
35228
35534
  if (input.json) return;
35229
35535
  streamed = true;
35230
- process.stdout.write(delta);
35536
+ process.stdout.write(sanitize(delta));
35231
35537
  },
35232
35538
  onTool: (name) => {
35233
35539
  if (input.json) return;
@@ -35236,7 +35542,7 @@ const aiCommands = [{
35236
35542
  process.stderr.write(dim(`→ ${name}\n`));
35237
35543
  },
35238
35544
  onToolDone: (name, isError, content) => {
35239
- if (!input.json && isError) warn(`${name}: ${content.split("\n")[0]}`);
35545
+ if (!input.json && isError) warn(`${name}: ${sanitize(content.split("\n")[0])}`);
35240
35546
  }
35241
35547
  });
35242
35548
  if (input.json) return void printJson(result);
@@ -35248,6 +35554,7 @@ const aiCommands = [{
35248
35554
  return result.stopReason === "done" ? 0 : 1;
35249
35555
  } finally {
35250
35556
  process.off("SIGINT", onSigint);
35557
+ input.signal?.removeEventListener("abort", onSigint);
35251
35558
  }
35252
35559
  });
35253
35560
  }
@@ -35455,6 +35762,19 @@ function parseAmStart(out) {
35455
35762
  function isOwnProcess(name, pkg) {
35456
35763
  return name === pkg || name.startsWith(`${pkg}:`);
35457
35764
  }
35765
+ /**
35766
+ * Taps, from monkey's own event total, when it printed none of them.
35767
+ *
35768
+ * Monkey on Android 16 and 17 no longer prints `:Sending Touch` at `-v` (nor
35769
+ * at `-v -v`, checked on a Pixel 7a on 17, 2026-10-07), so counting those
35770
+ * lines reported "survived 0 taps" for every run. `Events injected` still
35771
+ * comes: with MONKEY_WEIGHTS every event but the opening `:Switch:` is a
35772
+ * touch, and a tap is exactly two of them — ACTION_DOWN and ACTION_UP (its
35773
+ * throttle sleep is not counted). A trailing half-tap rounds down.
35774
+ */
35775
+ function tapsFromInjected(injected, switches) {
35776
+ return Math.max(0, Math.floor((injected - switches) / 2));
35777
+ }
35458
35778
  function parseMonkey(out, pkg) {
35459
35779
  const lines = out.split("\n").map((l) => l.replace(/\r$/, ""));
35460
35780
  const pid = /^QTPID=(\d+)/m.exec(out);
@@ -35465,10 +35785,12 @@ function parseMonkey(out, pkg) {
35465
35785
  let inCrash = false;
35466
35786
  let error = null;
35467
35787
  let taps = 0;
35788
+ let switches = 0;
35468
35789
  let rejected = 0;
35469
35790
  for (const raw of lines) {
35470
35791
  const line = raw.trimStart();
35471
35792
  if (line.includes(":Sending Touch (ACTION_DOWN)")) taps++;
35793
+ if (line.startsWith(":Switch:")) switches++;
35472
35794
  if (/^\/\/ Rejecting (start|resume)/.test(line)) rejected++;
35473
35795
  const c = /^\/\/ CRASH: (\S+)/.exec(line);
35474
35796
  if (c) {
@@ -35490,10 +35812,11 @@ function parseMonkey(out, pkg) {
35490
35812
  const e = /^\*\* (.+)$/.exec(line);
35491
35813
  if (e && !error && !/^Monkey aborted due to error/.test(e[1])) error = e[1].replace(/^Error:\s*/, "").trim();
35492
35814
  }
35815
+ const injectedCount = injected ? Number(injected[1]) : 0;
35493
35816
  return {
35494
35817
  pid: pid ? Number(pid[1]) : null,
35495
- injected: injected ? Number(injected[1]) : 0,
35496
- taps,
35818
+ injected: injectedCount,
35819
+ taps: taps > 0 ? taps : tapsFromInjected(injectedCount, switches),
35497
35820
  crash,
35498
35821
  anr,
35499
35822
  crashLines,
@@ -35993,6 +36316,74 @@ async function archive(ctx, file, storagePath) {
35993
36316
  }
35994
36317
  }
35995
36318
  //#endregion
36319
+ //#region src/device/transfer.ts
36320
+ /** `push`'s remote path: a folder ("/sdcard/Download/") gets the file's own name, as adb push does. */
36321
+ function remoteTarget(local, remote) {
36322
+ return remote.endsWith("/") ? `${remote}${path.basename(local)}` : remote;
36323
+ }
36324
+ /** `pull`'s local path: none, or an existing folder, gets the phone file's own name. */
36325
+ function localTarget(remote, local) {
36326
+ const name = path.posix.basename(remote);
36327
+ if (!local) return name;
36328
+ try {
36329
+ if (fs.statSync(local).isDirectory()) return path.join(local, name);
36330
+ } catch {}
36331
+ return local;
36332
+ }
36333
+ /**
36334
+ * Stream a file read off the phone into `local`, honouring backpressure.
36335
+ *
36336
+ * Either side can fail — the phone has no such file, the folder cannot be
36337
+ * written — and either way what was written is removed and the error says
36338
+ * which side it was. An unhandled write-stream error used to crash the
36339
+ * process (the whole interactive session, with a phone still rented), and a
36340
+ * missing remote file used to leave an empty local one behind, which looks
36341
+ * like a copy that worked.
36342
+ */
36343
+ async function pullToFile(source, local, remote) {
36344
+ const reader = source.getReader();
36345
+ let bytes = 0;
36346
+ let readFailed = false;
36347
+ let finished = false;
36348
+ async function* chunks() {
36349
+ try {
36350
+ for (;;) {
36351
+ let r;
36352
+ try {
36353
+ r = await reader.read();
36354
+ } catch (err) {
36355
+ readFailed = true;
36356
+ throw err;
36357
+ }
36358
+ if (r.done) {
36359
+ finished = true;
36360
+ return;
36361
+ }
36362
+ bytes += r.value.length;
36363
+ yield r.value;
36364
+ }
36365
+ } finally {
36366
+ if (!finished && !readFailed) reader.cancel().catch(() => {});
36367
+ }
36368
+ }
36369
+ let file;
36370
+ try {
36371
+ file = await fs.promises.open(local, "w");
36372
+ } catch (err) {
36373
+ reader.cancel().catch(() => {});
36374
+ throw new CliError(`Could not write ${local}: ${err.message}`);
36375
+ }
36376
+ try {
36377
+ await pipeline(chunks(), file.createWriteStream());
36378
+ return bytes;
36379
+ } catch (err) {
36380
+ await file.close().catch(() => {});
36381
+ fs.rmSync(local, { force: true });
36382
+ const message = err.message;
36383
+ throw new CliError(readFailed ? `Could not read ${remote} from the phone: ${message}` : `Could not write ${local}: ${message}`);
36384
+ }
36385
+ }
36386
+ //#endregion
35996
36387
  //#region src/commands/device.ts
35997
36388
  function requirePackage(pkg) {
35998
36389
  if (!pkg || !QUICK_TEST_PACKAGE.test(pkg)) throw new CliError("Give an Android package name, e.g. com.example.app");
@@ -36003,7 +36394,7 @@ function progressPrinter(label) {
36003
36394
  let last = -1;
36004
36395
  return (p) => {
36005
36396
  if (p.stage === "installing") {
36006
- process.stderr.write(`${tty ? "\r" : ""}${label}: installing…${tty ? " " : ""}\n`);
36397
+ process.stderr.write(`${tty ? "\r\x1B[K" : ""}${label}: installing…\n`);
36007
36398
  return;
36008
36399
  }
36009
36400
  const pct = p.totalBytes ? Math.floor(p.pushedBytes / p.totalBytes * 100) : 0;
@@ -36058,6 +36449,13 @@ const installCommand = {
36058
36449
  }
36059
36450
  return out;
36060
36451
  });
36452
+ input.onResult?.({
36453
+ kind: "installed",
36454
+ apps: results.map((r) => ({
36455
+ filename: r.filename,
36456
+ packageName: r.packageName
36457
+ }))
36458
+ });
36061
36459
  if (input.json) printJson(results);
36062
36460
  }
36063
36461
  };
@@ -36221,6 +36619,10 @@ const screenshotCommand = {
36221
36619
  });
36222
36620
  if (!bytes) throw new CliError("The phone did not return a PNG.");
36223
36621
  fs.writeFileSync(file, bytes);
36622
+ input.onResult?.({
36623
+ kind: "screenshot",
36624
+ file
36625
+ });
36224
36626
  if (input.json) return printJson({
36225
36627
  file,
36226
36628
  bytes: bytes.length
@@ -36255,9 +36657,10 @@ const deviceCommands = [
36255
36657
  usage: "devicerent push <local> <remote-path> [--rental <id>]",
36256
36658
  options: { ...RENTAL_OPTION },
36257
36659
  async run(input) {
36258
- const [local, remote] = input.args;
36259
- if (!local || !remote) throw new CliError("devicerent push <local> <remote-path>, e.g. /sdcard/Download/data.json");
36260
- if (!fs.existsSync(local)) throw new CliError(`No such file: ${local}`);
36660
+ const [local, remoteArg] = input.args;
36661
+ if (!local || !remoteArg) throw new CliError("devicerent push <local> <remote-path>, e.g. /sdcard/Download/data.json");
36662
+ if (!fs.statSync(local, { throwIfNoEntry: false })?.isFile()) throw new CliError(`No such file: ${local}`);
36663
+ const remote = remoteTarget(local, remoteArg);
36261
36664
  const blob = await fs.openAsBlob(local);
36262
36665
  await withDevice(input, async (device) => {
36263
36666
  const sync = await device.adb.sync();
@@ -36281,25 +36684,15 @@ const deviceCommands = [
36281
36684
  async run(input) {
36282
36685
  const [remote, localArg] = input.args;
36283
36686
  if (!remote) throw new CliError("devicerent pull <remote-path> [local]");
36284
- const local = localArg ?? path.basename(remote);
36285
- let written = 0;
36286
- await withDevice(input, async (device) => {
36687
+ const local = localTarget(remote, localArg);
36688
+ success(`Pulled ${formatBytes(await withDevice(input, async (device) => {
36287
36689
  const sync = await device.adb.sync();
36288
- const out = fs.createWriteStream(local);
36289
36690
  try {
36290
- const reader = sync.read(remote).getReader();
36291
- for (;;) {
36292
- const { value, done } = await reader.read();
36293
- if (done) break;
36294
- written += value.length;
36295
- out.write(value);
36296
- }
36691
+ return await device.whileBusy(pullToFile(sync.read(remote), local, remote));
36297
36692
  } finally {
36298
- await new Promise((r) => out.end(() => r()));
36299
36693
  await sync.dispose();
36300
36694
  }
36301
- });
36302
- success(`Pulled ${formatBytes(written)} to ${local}`);
36695
+ }))} to ${local}`);
36303
36696
  }
36304
36697
  },
36305
36698
  {
@@ -36358,7 +36751,7 @@ const rentalCommands = [
36358
36751
  name: "rent",
36359
36752
  summary: "Rent one to three phones (billing starts now)",
36360
36753
  usage: "devicerent rent <device…> e.g. devicerent rent \"Pixel 7a\" \"Galaxy A54\", or an Id from devicerent devices",
36361
- async run({ ctx, args, json }) {
36754
+ async run({ ctx, args, json, onResult }) {
36362
36755
  await requireUser(ctx);
36363
36756
  if (args.length === 0) throw new CliError("Name a device (id, serial or model). See: devicerent devices");
36364
36757
  if (args.length > MAX_PER_REQUEST) throw new CliError(`At most ${MAX_PER_REQUEST} devices at once.`);
@@ -36372,6 +36765,10 @@ const rentalCommands = [
36372
36765
  const { data, error } = picks.length === 1 ? await ctx.supabase.rpc("rent_device", { p_device_id: picks[0].id }) : await ctx.supabase.rpc("rent_devices", { p_device_ids: picks.map((d) => d.id) });
36373
36766
  if (error) throw new CliError(`Rental refused: ${error.message}`);
36374
36767
  const rentals = Array.isArray(data) ? data : [data];
36768
+ onResult?.({
36769
+ kind: "rented",
36770
+ rentalIds: rentals.map((r) => r.id)
36771
+ });
36375
36772
  if (json) return printJson(rentals);
36376
36773
  for (const r of rentals) success(`Rented ${picks.find((p) => p.id === r.device_id)?.name ?? r.device_id} — rental ${r.id}`);
36377
36774
  warn("Billing runs per minute until you `devicerent stop` (a rental with no activity for 30 minutes is stopped for you).");
@@ -36422,12 +36819,16 @@ const rentalCommands = [
36422
36819
  all: { type: "boolean" },
36423
36820
  rental: { type: "string" }
36424
36821
  },
36425
- async run({ ctx, args, opts, json }) {
36822
+ async run({ ctx, args, opts, json, onResult }) {
36426
36823
  await requireUser(ctx);
36427
36824
  if (opts.all === true) {
36428
36825
  const { data, error } = await ctx.supabase.rpc("stop_all_rentals");
36429
36826
  if (error) throw new CliError(`Could not stop: ${error.message}`);
36430
36827
  const stopped = data ?? [];
36828
+ onResult?.({
36829
+ kind: "stopped",
36830
+ rentalIds: stopped.map((r) => r.id)
36831
+ });
36431
36832
  if (json) return printJson(stopped);
36432
36833
  if (stopped.length === 0) return note("Nothing was running.");
36433
36834
  for (const r of stopped) success(`Stopped ${r.id} — ${eur(Number(r.total_cost ?? 0))}`);
@@ -36437,6 +36838,10 @@ const rentalCommands = [
36437
36838
  const { data, error } = await ctx.supabase.rpc("stop_rental", { p_rental_id: rental.id });
36438
36839
  if (error) throw new CliError(`Could not stop: ${error.message}`);
36439
36840
  const row = data;
36841
+ onResult?.({
36842
+ kind: "stopped",
36843
+ rentalIds: [rental.id]
36844
+ });
36440
36845
  if (json) return printJson(data);
36441
36846
  success(`Stopped ${rental.deviceName} — ${eur(Number(row?.total_cost ?? 0))}`);
36442
36847
  }
@@ -40720,7 +41125,7 @@ function createAdbServer({ getCreds, log = () => {} }) {
40720
41125
  const server = net.createServer((sock) => {
40721
41126
  sock.on("error", () => {});
40722
41127
  readFirstRequest(sock, (payload, tail) => {
40723
- dispatch(sock, payload, tail, {
41128
+ dispatch$1(sock, payload, tail, {
40724
41129
  getCreds,
40725
41130
  log,
40726
41131
  forwards
@@ -40758,7 +41163,7 @@ function readFirstRequest(sock, cb) {
40758
41163
  };
40759
41164
  sock.on("data", onData);
40760
41165
  }
40761
- async function dispatch(sock, payload, tail, ctx) {
41166
+ async function dispatch$1(sock, payload, tail, ctx) {
40762
41167
  let creds = null;
40763
41168
  try {
40764
41169
  creds = ctx.getCreds();
@@ -41069,6 +41474,7 @@ const testingCommands = [
41069
41474
  input.ctx.supabase.rpc("touch_rental_activity", { p_rental_id: rental.id });
41070
41475
  }, 6e4);
41071
41476
  let timer;
41477
+ let disconnected = false;
41072
41478
  const renew = async () => {
41073
41479
  try {
41074
41480
  ticket = await mintBridgeTicket(rental.id);
@@ -41077,8 +41483,10 @@ const testingCommands = [
41077
41483
  token: ticket.token,
41078
41484
  tokenTransport: "subprotocol"
41079
41485
  })).getVersion();
41486
+ if (disconnected) return;
41080
41487
  timer = setTimeout(renew, renewDelayMs(ticket.expires_at, Date.now()));
41081
41488
  } catch (err) {
41489
+ if (disconnected) return;
41082
41490
  warn(`Could not renew device access: ${err.message}`);
41083
41491
  timer = setTimeout(renew, 3e4);
41084
41492
  }
@@ -41095,29 +41503,46 @@ const testingCommands = [
41095
41503
  },
41096
41504
  log: (...parts) => note(parts.join(" "))
41097
41505
  });
41098
- await new Promise((resolve, reject) => {
41099
- server.once("error", reject);
41100
- server.listen(port, "127.0.0.1", () => resolve());
41506
+ const sockets = /* @__PURE__ */ new Set();
41507
+ server.on("connection", (s) => {
41508
+ sockets.add(s);
41509
+ s.once("close", () => sockets.delete(s));
41101
41510
  });
41102
- success(`${rental.deviceName} (${ticket.serial}) is now a local adb device.`);
41103
- printLine("");
41104
- printLine(process.platform === "win32" ? ` $env:ANDROID_ADB_SERVER_PORT=${port} # PowerShell` : ` export ANDROID_ADB_SERVER_PORT=${port}`);
41105
- printLine("");
41106
- note("Then use adb, gradle or Appium as usual in that shell. Ctrl-C disconnects (the rental keeps running).");
41107
- await new Promise((resolve) => {
41108
- const stop = () => resolve();
41109
- process.once("SIGINT", stop);
41110
- process.once("SIGTERM", stop);
41111
- });
41112
- clearTimeout(timer);
41113
- clearInterval(touch);
41114
- server.close();
41511
+ try {
41512
+ await new Promise((resolve, reject) => {
41513
+ server.once("error", (err) => reject(err.code === "EADDRINUSE" ? new CliError(`Port ${port} is already in use — is another devicerent connect (or an adb server) running there? Pick another with --port.`) : err));
41514
+ server.listen(port, "127.0.0.1", () => resolve());
41515
+ });
41516
+ success(`${rental.deviceName} (${ticket.serial}) is now a local adb device.`);
41517
+ printLine("");
41518
+ printLine(process.platform === "win32" ? ` $env:ANDROID_ADB_SERVER_PORT=${port} # PowerShell` : ` export ANDROID_ADB_SERVER_PORT=${port}`);
41519
+ printLine("");
41520
+ note("Then use adb, gradle or Appium as usual in that shell. Ctrl-C disconnects (the rental keeps running).");
41521
+ await new Promise((resolve) => {
41522
+ const stop = () => {
41523
+ process.off("SIGINT", stop);
41524
+ process.off("SIGTERM", stop);
41525
+ input.signal?.removeEventListener("abort", stop);
41526
+ resolve();
41527
+ };
41528
+ process.on("SIGINT", stop);
41529
+ process.on("SIGTERM", stop);
41530
+ if (input.signal?.aborted) stop();
41531
+ else input.signal?.addEventListener("abort", stop);
41532
+ });
41533
+ } finally {
41534
+ disconnected = true;
41535
+ clearTimeout(timer);
41536
+ clearInterval(touch);
41537
+ server.close();
41538
+ for (const s of sockets) s.destroy();
41539
+ }
41115
41540
  note("Disconnected. Stop the rental with: devicerent stop");
41116
41541
  }
41117
41542
  }
41118
41543
  ];
41119
41544
  //#endregion
41120
- //#region src/main.ts
41545
+ //#region src/registry.ts
41121
41546
  const GROUPS = [
41122
41547
  {
41123
41548
  title: "Account",
@@ -41153,6 +41578,2818 @@ function findCommand(argv, commands = COMMANDS) {
41153
41578
  }
41154
41579
  return null;
41155
41580
  }
41581
+ /** --api-base, else DEVICERENT_API_BASE, else production. */
41582
+ function resolveApiBase(values) {
41583
+ return normalizeApiBase((typeof values["api-base"] === "string" ? values["api-base"] : void 0) ?? process.env.DEVICERENT_API_BASE ?? "https://devicerent.net");
41584
+ }
41585
+ /**
41586
+ * Find, parse and run one command. Arguments are parsed BEFORE the context is
41587
+ * asked for, so a usage error never touches the network.
41588
+ */
41589
+ async function runCommandLine(getCtx, argv, opts = {}) {
41590
+ const found = findCommand(argv);
41591
+ if (!found) return { kind: "unknown" };
41592
+ const { command, rest } = found;
41593
+ let values;
41594
+ let positionals;
41595
+ try {
41596
+ ({values, positionals} = parseArgs({
41597
+ args: [...opts.globals ?? [], ...rest],
41598
+ options: {
41599
+ ...GLOBAL_OPTIONS,
41600
+ ...command.options
41601
+ },
41602
+ allowPositionals: true,
41603
+ strict: true
41604
+ }));
41605
+ } catch (err) {
41606
+ process.stderr.write(`${red(err.message)}\nUsage: ${command.usage}\n`);
41607
+ return {
41608
+ kind: "usage",
41609
+ code: 2
41610
+ };
41611
+ }
41612
+ if (values.help === true) {
41613
+ printLine(`${command.summary}\n\nUsage: ${command.usage}`);
41614
+ return {
41615
+ kind: "ran",
41616
+ code: 0
41617
+ };
41618
+ }
41619
+ if (opts.interactive && values["api-base"] !== void 0) throw new CliError("--api-base cannot change inside this session. Leave, then run: devicerent --api-base <url>", 2);
41620
+ const ctx = await getCtx(resolveApiBase(values));
41621
+ const code = await command.run({
41622
+ ctx,
41623
+ args: positionals,
41624
+ opts: values,
41625
+ json: values.json === true,
41626
+ signal: opts.signal,
41627
+ onResult: opts.onResult
41628
+ });
41629
+ return {
41630
+ kind: "ran",
41631
+ code: typeof code === "number" ? code : 0
41632
+ };
41633
+ }
41634
+ //#endregion
41635
+ //#region src/tui/box.ts
41636
+ /** The width a frame is drawn at: the terminal's, kept between 40 and 100. */
41637
+ function frameWidth(columns) {
41638
+ const cols = columns && columns > 0 ? columns : 80;
41639
+ return Math.max(Math.min(cols - 1, 40), Math.min(100, cols - 1));
41640
+ }
41641
+ /**
41642
+ * A rounded box around `lines`. Each line is padded (or cut) to the inside
41643
+ * width, so the right edge stays straight whatever colours a line carries.
41644
+ */
41645
+ function renderBox(lines, opts) {
41646
+ const { g } = opts.theme;
41647
+ const paint = opts.border ?? opts.theme.dim;
41648
+ const inner = Math.max(1, opts.width - 4);
41649
+ let top = g.h.repeat(opts.width - 2);
41650
+ if (opts.title) {
41651
+ const t = ` ${truncate(opts.title, opts.width - 6)} `;
41652
+ top = g.h + t + g.h.repeat(Math.max(0, opts.width - 3 - visibleWidth(t)));
41653
+ }
41654
+ return [
41655
+ paint(`${g.tl}${top}${g.tr}`),
41656
+ ...lines.map((l) => `${paint(g.v)} ${padEnd(l, inner)} ${paint(g.v)}`),
41657
+ paint(`${g.bl}${g.h.repeat(opts.width - 2)}${g.br}`)
41658
+ ];
41659
+ }
41660
+ //#endregion
41661
+ //#region src/tui/keys.ts
41662
+ let decoderReady = false;
41663
+ let owned = false;
41664
+ let restoreInstalled = false;
41665
+ function toKey(str, key) {
41666
+ const k = {
41667
+ name: key?.name,
41668
+ ctrl: key?.ctrl === true,
41669
+ meta: key?.meta === true,
41670
+ shift: key?.shift === true
41671
+ };
41672
+ if (str !== void 0 && !k.ctrl && !k.meta && str.length > 0 && !/[\u0000-\u001f\u007f]/.test(str)) k.text = str;
41673
+ return k;
41674
+ }
41675
+ /** Undo every mode the session may have switched on. Safe to call twice. */
41676
+ function restoreTerminal() {
41677
+ process.stdout.write(`${CSI}?25h${CSI}?1l${CSI}?2004l${CSI}0m`);
41678
+ }
41679
+ /**
41680
+ * Read keys until the returned function is called. Throws if the keyboard is
41681
+ * already being read, so two readers can never race for the same key.
41682
+ */
41683
+ function listenKeys(onKey) {
41684
+ const stdin = process.stdin;
41685
+ if (owned) throw new Error("the keyboard is already being read");
41686
+ if (!decoderReady) {
41687
+ readline$1.emitKeypressEvents(stdin, { escapeCodeTimeout: 50 });
41688
+ decoderReady = true;
41689
+ }
41690
+ if (!restoreInstalled) {
41691
+ process.on("exit", restoreTerminal);
41692
+ restoreInstalled = true;
41693
+ }
41694
+ const handler = (str, key) => onKey(toKey(str, key));
41695
+ stdin.on("keypress", handler);
41696
+ if (!stdin.isRaw) stdin.setRawMode(true);
41697
+ stdin.resume();
41698
+ process.stdout.write(`${CSI}?2004h`);
41699
+ owned = true;
41700
+ let released = false;
41701
+ return () => {
41702
+ if (released) return;
41703
+ released = true;
41704
+ stdin.off("keypress", handler);
41705
+ process.stdout.write(`${CSI}?2004l`);
41706
+ stdin.pause();
41707
+ owned = false;
41708
+ };
41709
+ }
41710
+ /**
41711
+ * Ordinary line mode, for work that needs it: Ctrl-C as SIGINT, readline's
41712
+ * own prompts, a child's input. A no-op when raw mode is already off.
41713
+ */
41714
+ function cookedMode() {
41715
+ if (process.stdin.isTTY && process.stdin.isRaw) process.stdin.setRawMode(false);
41716
+ }
41717
+ //#endregion
41718
+ //#region src/tui/lineEditor.ts
41719
+ function initialEditor(history = []) {
41720
+ return {
41721
+ value: "",
41722
+ cursor: 0,
41723
+ history,
41724
+ historyIndex: null,
41725
+ draft: "",
41726
+ suggestion: 0,
41727
+ dismissed: false,
41728
+ pasting: false
41729
+ };
41730
+ }
41731
+ /** The commands a `/…` line could mean, best match first. */
41732
+ function suggestionsFor(value, all, limit = 8) {
41733
+ if (!value.startsWith("/")) return [];
41734
+ const q = value.slice(1).toLowerCase();
41735
+ if (/\s/.test(q)) return all.filter((s) => s.name.startsWith(q) && s.name !== q).slice(0, limit);
41736
+ const prefix = all.filter((s) => s.name.startsWith(q));
41737
+ const word = all.filter((s) => !prefix.includes(s) && s.name.split(" ").some((w) => w.startsWith(q)));
41738
+ const described = q.length >= 3 ? all.filter((s) => !prefix.includes(s) && !word.includes(s) && s.summary.toLowerCase().includes(q)) : [];
41739
+ return [
41740
+ ...prefix,
41741
+ ...word,
41742
+ ...described
41743
+ ].slice(0, limit);
41744
+ }
41745
+ /** The suggestions that are on screen right now. */
41746
+ function visibleSuggestions(state, all) {
41747
+ return state.dismissed ? [] : suggestionsFor(state.value, all);
41748
+ }
41749
+ function prevBoundary(s, i) {
41750
+ if (i <= 0) return 0;
41751
+ const c = s.charCodeAt(i - 1);
41752
+ return c >= 56320 && c <= 57343 && i >= 2 ? i - 2 : i - 1;
41753
+ }
41754
+ function nextBoundary(s, i) {
41755
+ if (i >= s.length) return s.length;
41756
+ const c = s.charCodeAt(i);
41757
+ return c >= 55296 && c <= 56319 && i + 1 < s.length ? i + 2 : i + 1;
41758
+ }
41759
+ /** Start of the word before the cursor (Ctrl-W, Alt-Backspace). */
41760
+ function wordStart(s, i) {
41761
+ let j = i;
41762
+ while (j > 0 && /\s/.test(s[j - 1])) j--;
41763
+ while (j > 0 && !/\s/.test(s[j - 1])) j--;
41764
+ return j;
41765
+ }
41766
+ function edit(state, value, cursor) {
41767
+ return {
41768
+ ...state,
41769
+ value,
41770
+ cursor,
41771
+ historyIndex: null,
41772
+ draft: value,
41773
+ suggestion: 0,
41774
+ dismissed: false
41775
+ };
41776
+ }
41777
+ function recall(state, index) {
41778
+ const value = index === null ? state.draft : state.history[index] ?? state.draft;
41779
+ return {
41780
+ ...state,
41781
+ historyIndex: index,
41782
+ value,
41783
+ cursor: value.length,
41784
+ suggestion: 0,
41785
+ dismissed: true
41786
+ };
41787
+ }
41788
+ function editorReducer(state, key, all) {
41789
+ const s = state;
41790
+ const ok = (next, event = null) => ({
41791
+ state: next,
41792
+ event
41793
+ });
41794
+ const shown = visibleSuggestions(s, all);
41795
+ if (key.name === "paste-start") return ok({
41796
+ ...s,
41797
+ pasting: true
41798
+ });
41799
+ if (key.name === "paste-end") return ok({
41800
+ ...s,
41801
+ pasting: false
41802
+ });
41803
+ if (key.name === "return" || key.name === "enter") {
41804
+ if (s.pasting) return ok(edit(s, `${s.value.slice(0, s.cursor)} ${s.value.slice(s.cursor)}`, s.cursor + 1));
41805
+ const pick = shown[s.suggestion];
41806
+ if (pick) {
41807
+ const value = `/${pick.name}`;
41808
+ return ok({
41809
+ ...s,
41810
+ value,
41811
+ cursor: value.length
41812
+ }, "submit");
41813
+ }
41814
+ return ok(s, "submit");
41815
+ }
41816
+ if (key.ctrl && key.name === "c") return s.value ? ok(edit(s, "", 0)) : ok(s, "interrupt");
41817
+ if (key.ctrl && key.name === "d") {
41818
+ if (!s.value) return ok(s, "eof");
41819
+ return ok(edit(s, s.value.slice(0, s.cursor) + s.value.slice(nextBoundary(s.value, s.cursor)), s.cursor));
41820
+ }
41821
+ if (key.name === "escape") {
41822
+ if (shown.length > 0) return ok({
41823
+ ...s,
41824
+ dismissed: true
41825
+ });
41826
+ return ok(edit(s, "", 0));
41827
+ }
41828
+ if (key.name === "tab") {
41829
+ const pick = shown[s.suggestion];
41830
+ if (!pick) return ok(s);
41831
+ const value = `/${pick.name} `;
41832
+ return ok({
41833
+ ...edit(s, value, value.length),
41834
+ dismissed: true
41835
+ });
41836
+ }
41837
+ if (key.name === "up" || key.ctrl && key.name === "p") {
41838
+ if (shown.length > 0) return ok({
41839
+ ...s,
41840
+ suggestion: (s.suggestion - 1 + shown.length) % shown.length
41841
+ });
41842
+ if (s.history.length === 0) return ok(s);
41843
+ return ok(recall(s, s.historyIndex === null ? s.history.length - 1 : Math.max(0, s.historyIndex - 1)));
41844
+ }
41845
+ if (key.name === "down" || key.ctrl && key.name === "n") {
41846
+ if (shown.length > 0) return ok({
41847
+ ...s,
41848
+ suggestion: (s.suggestion + 1) % shown.length
41849
+ });
41850
+ if (s.historyIndex === null) return ok(s);
41851
+ const i = s.historyIndex + 1;
41852
+ return ok(recall(s, i >= s.history.length ? null : i));
41853
+ }
41854
+ if (key.name === "left" || key.ctrl && key.name === "b") return ok({
41855
+ ...s,
41856
+ cursor: prevBoundary(s.value, s.cursor)
41857
+ });
41858
+ if (key.name === "right" || key.ctrl && key.name === "f") return ok({
41859
+ ...s,
41860
+ cursor: nextBoundary(s.value, s.cursor)
41861
+ });
41862
+ if (key.name === "home" || key.ctrl && key.name === "a") return ok({
41863
+ ...s,
41864
+ cursor: 0
41865
+ });
41866
+ if (key.name === "end" || key.ctrl && key.name === "e") return ok({
41867
+ ...s,
41868
+ cursor: s.value.length
41869
+ });
41870
+ if (key.name === "backspace" || key.ctrl && key.name === "h") {
41871
+ if (key.meta) {
41872
+ const from = wordStart(s.value, s.cursor);
41873
+ return ok(edit(s, s.value.slice(0, from) + s.value.slice(s.cursor), from));
41874
+ }
41875
+ const from = prevBoundary(s.value, s.cursor);
41876
+ return ok(edit(s, s.value.slice(0, from) + s.value.slice(s.cursor), from));
41877
+ }
41878
+ if (key.name === "delete") return ok(edit(s, s.value.slice(0, s.cursor) + s.value.slice(nextBoundary(s.value, s.cursor)), s.cursor));
41879
+ if (key.ctrl && key.name === "u") return ok(edit(s, s.value.slice(s.cursor), 0));
41880
+ if (key.ctrl && key.name === "k") return ok(edit(s, s.value.slice(0, s.cursor), s.cursor));
41881
+ if (key.ctrl && key.name === "w") {
41882
+ const from = wordStart(s.value, s.cursor);
41883
+ return ok(edit(s, s.value.slice(0, from) + s.value.slice(s.cursor), from));
41884
+ }
41885
+ if (key.text) {
41886
+ const text = key.text.replace(/[\r\n\t]+/g, " ");
41887
+ return ok(edit(s, s.value.slice(0, s.cursor) + text + s.value.slice(s.cursor), s.cursor + text.length));
41888
+ }
41889
+ return ok(s);
41890
+ }
41891
+ //#endregion
41892
+ //#region src/tui/region.ts
41893
+ /**
41894
+ * A block of lines at the bottom of the terminal that can be redrawn in
41895
+ * place — the prompt box, a menu. It remembers how many lines it drew and
41896
+ * where it left the cursor, so the next render can climb back to its top
41897
+ * line and draw again. Every line is cut to the terminal's width first: a
41898
+ * line the terminal wrapped would throw that count off.
41899
+ */
41900
+ var LiveRegion = class {
41901
+ out;
41902
+ lines = 0;
41903
+ cursorRow = 0;
41904
+ constructor(out = process.stdout) {
41905
+ this.out = out;
41906
+ }
41907
+ render(lines, cursor) {
41908
+ const cols = Math.max(10, (this.out.columns || 80) - 1);
41909
+ let s = this.lines > 0 && this.cursorRow > 0 ? `${CSI}${this.cursorRow}A` : "";
41910
+ s += `\r${CSI}0J`;
41911
+ s += lines.map((l) => truncate(l, cols)).join("\n");
41912
+ const last = Math.max(0, lines.length - 1);
41913
+ if (cursor) {
41914
+ const up = last - cursor.row;
41915
+ if (up > 0) s += `${CSI}${up}A`;
41916
+ s += "\r";
41917
+ if (cursor.col > 0) s += `${CSI}${cursor.col}C`;
41918
+ this.cursorRow = cursor.row;
41919
+ } else this.cursorRow = last;
41920
+ this.lines = lines.length;
41921
+ this.out.write(s);
41922
+ }
41923
+ /** Erase the region; the cursor ends where the region began. */
41924
+ clear() {
41925
+ if (this.lines === 0) return;
41926
+ this.out.write(`${this.cursorRow > 0 ? `${CSI}${this.cursorRow}A` : ""}\r${CSI}0J`);
41927
+ this.lines = 0;
41928
+ this.cursorRow = 0;
41929
+ }
41930
+ /** Keep what is drawn and move to a fresh line below it. */
41931
+ done() {
41932
+ if (this.lines === 0) return;
41933
+ const down = this.lines - 1 - this.cursorRow;
41934
+ this.out.write(`${down > 0 ? `${CSI}${down}B` : ""}\n`);
41935
+ this.lines = 0;
41936
+ this.cursorRow = 0;
41937
+ }
41938
+ };
41939
+ //#endregion
41940
+ //#region src/tui/theme.ts
41941
+ const FANCY = {
41942
+ spark: "✻",
41943
+ dot: "⏺",
41944
+ elbow: "⎿",
41945
+ pointer: "❯",
41946
+ check: "✓",
41947
+ cross: "✗",
41948
+ bullet: "●",
41949
+ barFull: "█",
41950
+ barEmpty: "░",
41951
+ tl: "╭",
41952
+ tr: "╮",
41953
+ bl: "╰",
41954
+ br: "╯",
41955
+ h: "─",
41956
+ v: "│",
41957
+ spinner: [
41958
+ "·",
41959
+ "✢",
41960
+ "✳",
41961
+ "✶",
41962
+ "✻",
41963
+ "✽",
41964
+ "✻",
41965
+ "✶",
41966
+ "✳",
41967
+ "✢"
41968
+ ]
41969
+ };
41970
+ const PLAIN = {
41971
+ spark: "*",
41972
+ dot: "*",
41973
+ elbow: "└",
41974
+ pointer: ">",
41975
+ check: "v",
41976
+ cross: "x",
41977
+ bullet: "*",
41978
+ barFull: "#",
41979
+ barEmpty: "-",
41980
+ tl: "┌",
41981
+ tr: "┐",
41982
+ bl: "└",
41983
+ br: "┘",
41984
+ h: "─",
41985
+ v: "│",
41986
+ spinner: [
41987
+ "|",
41988
+ "/",
41989
+ "-",
41990
+ "\\"
41991
+ ]
41992
+ };
41993
+ function glyphsFor(env, platform) {
41994
+ return platform === "win32" && !env.WT_SESSION && !env.TERM_PROGRAM && !env.ConEmuANSI && !env.TERM ? PLAIN : FANCY;
41995
+ }
41996
+ function createTheme(level, g) {
41997
+ const sgr = (open, close) => (s) => level === 0 ? s : `${CSI}${open}m${s}${CSI}${close}m`;
41998
+ return {
41999
+ level,
42000
+ g,
42001
+ accent: level === 3 ? sgr("38;2;57;255;20", "39") : sgr("92", "39"),
42002
+ bold: sgr("1", "22"),
42003
+ dim: sgr("2", "22"),
42004
+ red: sgr("31", "39"),
42005
+ yellow: sgr("33", "39"),
42006
+ green: sgr("32", "39"),
42007
+ inverse: sgr("7", "27")
42008
+ };
42009
+ }
42010
+ let current;
42011
+ /** The theme for this process, decided on first use (never at import). */
42012
+ function theme() {
42013
+ current ??= createTheme(colorLevel(process.env, process.stdout.isTTY === true), glyphsFor(process.env, process.platform));
42014
+ return current;
42015
+ }
42016
+ //#endregion
42017
+ //#region src/tui/spinner.ts
42018
+ const NOOP = {
42019
+ update() {},
42020
+ stop() {}
42021
+ };
42022
+ let active = null;
42023
+ function startSpinner(label, opts = {}) {
42024
+ const out = process.stdout;
42025
+ const err = process.stderr;
42026
+ if (!out.isTTY) return NOOP;
42027
+ active?.stop();
42028
+ const t = opts.theme ?? theme();
42029
+ const now = opts.now ?? Date.now;
42030
+ const started = now();
42031
+ const outWrite = out.write;
42032
+ const errWrite = err.write;
42033
+ const raw = (s) => outWrite.call(out, s);
42034
+ let text = label;
42035
+ let frame = 0;
42036
+ let stopped = false;
42037
+ let timer;
42038
+ const draw = () => {
42039
+ const secs = Math.floor((now() - started) / 1e3);
42040
+ const hint = opts.cancellable ? `(${secs}s · ctrl+c to cancel)` : `(${secs}s)`;
42041
+ const glyph = t.g.spinner[frame % t.g.spinner.length];
42042
+ frame += 1;
42043
+ raw(`\r${CSI}2K${truncate(`${t.accent(glyph)} ${text}… ${t.dim(hint)}`, (out.columns || 80) - 1)}`);
42044
+ };
42045
+ const self = {
42046
+ update(next) {
42047
+ text = next;
42048
+ if (!stopped) draw();
42049
+ },
42050
+ stop() {
42051
+ if (stopped) return;
42052
+ stopped = true;
42053
+ clearInterval(timer);
42054
+ out.write = outWrite;
42055
+ err.write = errWrite;
42056
+ raw(`\r${CSI}2K`);
42057
+ if (active === self) active = null;
42058
+ }
42059
+ };
42060
+ const intercepted = (orig) => function(...args) {
42061
+ self.stop();
42062
+ return orig.apply(this, args);
42063
+ };
42064
+ out.write = intercepted(outWrite);
42065
+ err.write = intercepted(errWrite);
42066
+ timer = setInterval(draw, 100);
42067
+ timer.unref();
42068
+ draw();
42069
+ active = self;
42070
+ return self;
42071
+ }
42072
+ /** Stop whatever spinner is running (before drawing a menu over it). */
42073
+ function stopSpinner() {
42074
+ active?.stop();
42075
+ }
42076
+ //#endregion
42077
+ //#region src/tui/prompt.ts
42078
+ const PROMPT = "> ";
42079
+ const ARM_MS = 2e3;
42080
+ /** Wrap the typed text into box rows and find the cursor's cell. */
42081
+ function layoutInput(value, cursor, inner) {
42082
+ const rows = [];
42083
+ let row = PROMPT;
42084
+ let w = 2;
42085
+ let at = {
42086
+ row: 0,
42087
+ col: w
42088
+ };
42089
+ let i = 0;
42090
+ for (const ch of value) {
42091
+ const cw = charWidth(ch);
42092
+ if (w + cw > inner) {
42093
+ rows.push(row);
42094
+ row = " ".repeat(2);
42095
+ w = 2;
42096
+ }
42097
+ if (i === cursor) at = {
42098
+ row: rows.length,
42099
+ col: w
42100
+ };
42101
+ row += ch;
42102
+ w += cw;
42103
+ i += ch.length;
42104
+ }
42105
+ if (cursor >= value.length) {
42106
+ if (w >= inner) {
42107
+ rows.push(row);
42108
+ row = " ".repeat(2);
42109
+ w = 2;
42110
+ }
42111
+ at = {
42112
+ row: rows.length,
42113
+ col: w
42114
+ };
42115
+ }
42116
+ rows.push(row);
42117
+ return {
42118
+ rows,
42119
+ ...at
42120
+ };
42121
+ }
42122
+ /** The whole prompt as lines, and where the cursor goes. */
42123
+ function renderPrompt(view, t, width) {
42124
+ const inner = width - 4;
42125
+ const { state } = view;
42126
+ const laid = layoutInput(state.value, state.cursor, inner);
42127
+ const rows = laid.rows.map((r, i) => i === 0 ? `${t.bold(">")}${r.slice(1)}` : r);
42128
+ if (!state.value) rows[0] = `${t.bold(">")} ${t.dim(view.placeholder)}`;
42129
+ const lines = renderBox(rows, {
42130
+ width,
42131
+ theme: t
42132
+ });
42133
+ const shown = view.suggestions;
42134
+ if (shown.length > 0) {
42135
+ const nameWidth = Math.max(...shown.map((s) => s.name.length)) + 3;
42136
+ shown.forEach((s, i) => {
42137
+ const name = padEnd(`/${s.name}`, nameWidth);
42138
+ lines.push(i === state.suggestion ? ` ${t.accent(name)}${s.summary}` : t.dim(` ${name}${s.summary}`));
42139
+ });
42140
+ } else {
42141
+ const left = view.armed ? t.yellow("Press Ctrl-C again to exit") : t.dim(view.hint);
42142
+ const gap = width - 2 - visibleWidth(left) - visibleWidth(view.status);
42143
+ lines.push(gap >= 2 ? ` ${left}${" ".repeat(gap)}${view.status}` : ` ${left}`);
42144
+ }
42145
+ return {
42146
+ lines,
42147
+ cursor: {
42148
+ row: 1 + laid.row,
42149
+ col: 2 + laid.col
42150
+ }
42151
+ };
42152
+ }
42153
+ /**
42154
+ * Read one line. Resolves with it on Enter, or with `exit` on Ctrl-D or a
42155
+ * second Ctrl-C within two seconds on an empty line. The box is replaced by
42156
+ * a dim echo of what was typed, as Claude Code does.
42157
+ */
42158
+ function readPrompt(opts) {
42159
+ stopSpinner();
42160
+ const t = opts.theme ?? theme();
42161
+ const out = process.stdout;
42162
+ return new Promise((resolve) => {
42163
+ const region = new LiveRegion(out);
42164
+ let state = initialEditor(opts.history);
42165
+ let armedUntil = 0;
42166
+ let armTimer;
42167
+ const draw = () => {
42168
+ const { lines, cursor } = renderPrompt({
42169
+ state,
42170
+ suggestions: visibleSuggestions(state, opts.commands),
42171
+ placeholder: opts.placeholder,
42172
+ hint: opts.hint,
42173
+ status: opts.status(),
42174
+ armed: Date.now() < armedUntil
42175
+ }, t, frameWidth(out.columns));
42176
+ region.render(lines, cursor);
42177
+ };
42178
+ const tick = setInterval(draw, 3e4);
42179
+ tick.unref();
42180
+ out.on("resize", draw);
42181
+ const finish = (result) => {
42182
+ release();
42183
+ clearInterval(tick);
42184
+ clearTimeout(armTimer);
42185
+ out.off("resize", draw);
42186
+ if (result.kind === "line" && result.value.trim()) {
42187
+ region.render([t.dim(`> ${result.value}`)]);
42188
+ region.done();
42189
+ } else region.clear();
42190
+ resolve(result);
42191
+ };
42192
+ const release = listenKeys((key) => {
42193
+ const r = editorReducer(state, key, opts.commands);
42194
+ state = r.state;
42195
+ if (r.event === "submit") return finish({
42196
+ kind: "line",
42197
+ value: state.value
42198
+ });
42199
+ if (r.event === "eof") return finish({ kind: "exit" });
42200
+ if (r.event === "interrupt") {
42201
+ if (Date.now() < armedUntil) return finish({ kind: "exit" });
42202
+ armedUntil = Date.now() + ARM_MS;
42203
+ clearTimeout(armTimer);
42204
+ armTimer = setTimeout(draw, 2050);
42205
+ armTimer.unref();
42206
+ }
42207
+ draw();
42208
+ });
42209
+ draw();
42210
+ });
42211
+ }
42212
+ //#endregion
42213
+ //#region src/tui/select.ts
42214
+ function initialSelect(choices, start = 0) {
42215
+ const ok = (i) => i >= 0 && i < choices.length && !choices[i].disabled;
42216
+ const index = ok(start) ? start : choices.findIndex((c) => !c.disabled);
42217
+ return {
42218
+ index: Math.max(0, index),
42219
+ done: null
42220
+ };
42221
+ }
42222
+ function step(index, dir, choices) {
42223
+ const n = choices.length;
42224
+ for (let k = 1; k <= n; k++) {
42225
+ const i = (index + dir * k + n * k) % n;
42226
+ if (!choices[i].disabled) return i;
42227
+ }
42228
+ return index;
42229
+ }
42230
+ function selectReducer(state, key, choices) {
42231
+ if (state.done) return state;
42232
+ if (key.name === "escape" || key.ctrl && key.name === "c") return {
42233
+ ...state,
42234
+ done: "cancelled"
42235
+ };
42236
+ if (key.name === "return" || key.name === "enter") return choices[state.index]?.disabled ? state : {
42237
+ ...state,
42238
+ done: "chosen"
42239
+ };
42240
+ if (key.name === "up" || key.text === "k" || key.ctrl && key.name === "p") return {
42241
+ ...state,
42242
+ index: step(state.index, -1, choices)
42243
+ };
42244
+ if (key.name === "down" || key.text === "j" || key.name === "tab" || key.ctrl && key.name === "n") return {
42245
+ ...state,
42246
+ index: step(state.index, 1, choices)
42247
+ };
42248
+ if (key.text && /^[1-9]$/.test(key.text)) {
42249
+ const i = Number(key.text) - 1;
42250
+ if (i < choices.length && !choices[i].disabled) return {
42251
+ index: i,
42252
+ done: "chosen"
42253
+ };
42254
+ }
42255
+ return state;
42256
+ }
42257
+ /** The menu as lines, inside its box, plus the key hint under it. */
42258
+ function renderSelect(state, view, t, width, maxVisible = 12) {
42259
+ const inner = width - 4;
42260
+ const lines = [t.bold(view.title)];
42261
+ if (view.body && view.body.length > 0) {
42262
+ lines.push("");
42263
+ for (const b of view.body) lines.push(...wrap(b, inner));
42264
+ }
42265
+ lines.push("");
42266
+ const labelWidth = Math.min(Math.max(...view.choices.map((c) => visibleWidth(c.label))), Math.floor(inner * .6));
42267
+ const n = view.choices.length;
42268
+ const first = Math.max(0, Math.min(state.index - Math.floor(maxVisible / 2), n - maxVisible));
42269
+ const last = Math.min(n, first + maxVisible);
42270
+ if (first > 0) lines.push(t.dim(` ↑ ${first} more`));
42271
+ for (let i = first; i < last; i++) {
42272
+ const c = view.choices[i];
42273
+ const on = i === state.index;
42274
+ const num = `${i + 1}.`;
42275
+ const plain = sanitize(c.label);
42276
+ const hint = c.hint ? sanitize(c.hint) : "";
42277
+ const label = hint ? padEnd(plain, labelWidth) : plain;
42278
+ const text = `${num} ${label}${hint ? ` ${t.dim(hint)}` : ""}`;
42279
+ if (c.disabled) lines.push(t.dim(` ${num} ${label}${hint ? ` ${hint}` : ""}`));
42280
+ else if (on) lines.push(`${t.accent(t.g.pointer)} ${t.bold(text)}`);
42281
+ else lines.push(` ${text}`);
42282
+ }
42283
+ if (last < n) lines.push(t.dim(` ↓ ${n - last} more`));
42284
+ return [...renderBox(lines, {
42285
+ width,
42286
+ theme: t,
42287
+ border: t.accent
42288
+ }), t.dim(" ↑↓ to move · enter to choose · esc to go back")];
42289
+ }
42290
+ /**
42291
+ * Ask, and resolve with the chosen value, or null if the person backed out.
42292
+ * The menu is replaced by a one-line record of the answer when it closes.
42293
+ */
42294
+ function select(view, opts = {}) {
42295
+ stopSpinner();
42296
+ const t = opts.theme ?? theme();
42297
+ const out = process.stdout;
42298
+ return new Promise((resolve) => {
42299
+ const region = new LiveRegion(out);
42300
+ let state = initialSelect(view.choices, opts.start);
42301
+ const draw = () => region.render(renderSelect(state, view, t, frameWidth(out.columns), Math.max(4, (out.rows || 24) - 14)));
42302
+ out.write(`${CSI}?25l`);
42303
+ draw();
42304
+ out.on("resize", draw);
42305
+ const release = listenKeys((key) => {
42306
+ state = selectReducer(state, key, view.choices);
42307
+ if (!state.done) return draw();
42308
+ release();
42309
+ out.off("resize", draw);
42310
+ out.write(`${CSI}?25h`);
42311
+ const chosen = state.done === "chosen" ? view.choices[state.index] : null;
42312
+ region.render([`${t.dim(view.title)} ${t.accent(t.g.pointer)} ${chosen ? sanitize(chosen.label) : t.dim("back")}`]);
42313
+ region.done();
42314
+ resolve(chosen ? chosen.value : null);
42315
+ });
42316
+ });
42317
+ }
42318
+ /** Yes/No in the same style; resolves false on Esc. */
42319
+ async function confirm(title, body, yes = "Yes", no = "No") {
42320
+ return await select({
42321
+ title,
42322
+ body,
42323
+ choices: [{
42324
+ label: yes,
42325
+ value: true
42326
+ }, {
42327
+ label: no,
42328
+ value: false
42329
+ }]
42330
+ }) === true;
42331
+ }
42332
+ //#endregion
42333
+ //#region ../../src/lib/session/liveCost.ts
42334
+ function computeLiveCost(i) {
42335
+ const start = new Date(i.startTime).getTime();
42336
+ const end = i.endTime ? new Date(i.endTime).getTime() : i.now;
42337
+ const elapsedMs = Math.max(end - start, 0);
42338
+ const minutes = elapsedMs / 6e4;
42339
+ const retained = 1 - Math.min(Math.max(i.discountPct ?? 0, 0), 100) / 100;
42340
+ const effectiveRate = i.ratePerMinute * retained;
42341
+ return {
42342
+ elapsedMs,
42343
+ effectiveRate,
42344
+ cost: i.free ? 0 : minutes * effectiveRate,
42345
+ ended: Boolean(i.endTime)
42346
+ };
42347
+ }
42348
+ //#endregion
42349
+ //#region ../../src/features/ActiveSessionsFeature/ActiveSessionsLogic.ts
42350
+ /**
42351
+ * A dedicated-slot holder on their reserved device pays nothing. `slotDeviceId`
42352
+ * is null unless the subscription is active on that tier, so the comparison
42353
+ * alone is the whole rule.
42354
+ */
42355
+ function isFreeSession(session, slotDeviceId) {
42356
+ return slotDeviceId !== null && session.deviceId === slotDeviceId;
42357
+ }
42358
+ //#endregion
42359
+ //#region ../../src/lib/ai/credits.ts
42360
+ /**
42361
+ * Monthly AI credit grant per subscription tier, applied on `invoice.paid`.
42362
+ *
42363
+ * Lives HERE rather than in pricing.ts, which is `import "server-only"`, because
42364
+ * /pricing and /deviceyai both render these numbers — /deviceyai has printed
42365
+ * them since it shipped. They are not confidential the way MARKUP is; they are
42366
+ * the offer.
42367
+ *
42368
+ * Moved 2026-09-19 so `tiers.ts` could derive its feature bullets from this
42369
+ * object instead of retyping the figures. It had one for about an hour, and the
42370
+ * retyped Pro bullet was already wrong — it claimed "the largest monthly
42371
+ * allowance" for 1,000 while a slot and enterprise get 2,000. A derived bullet
42372
+ * cannot be wrong about a number; a typed one is only ever right until someone
42373
+ * edits the other copy.
42374
+ */
42375
+ const AI_MONTHLY_GRANT = {
42376
+ free: 0,
42377
+ plus: 300,
42378
+ pro: 1e3,
42379
+ dedicated_slot: 2e3,
42380
+ enterprise: 2e3
42381
+ };
42382
+ //#endregion
42383
+ //#region ../../src/lib/tiers.ts
42384
+ /**
42385
+ * The DeviceyAI bullet for a tier, derived from the grant the Stripe webhook
42386
+ * actually makes rather than retyped beside it.
42387
+ *
42388
+ * Retyping lasted about an hour on 2026-09-19 before it was wrong: the Pro
42389
+ * bullet read "the largest monthly DeviceyAI allowance" for 1,000 credits while
42390
+ * a dedicated slot and enterprise each get 2,000. Superlatives in a feature list
42391
+ * are a comparison against the other rows, and nothing was checking it.
42392
+ */
42393
+ function aiGrantFeature(tier) {
42394
+ return `${(AI_MONTHLY_GRANT[tier] ?? 0).toLocaleString("en-GB")} AI credits a month for DeviceyAI`;
42395
+ }
42396
+ /** "€549.89" / "€1,099.89" — euros with a thousands separator. */
42397
+ function formatEur(euros) {
42398
+ const [whole, frac] = Math.abs(euros).toFixed(2).split(".");
42399
+ const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
42400
+ return `${euros < 0 ? "−" : ""}€${grouped}.${frac}`;
42401
+ }
42402
+ const TIERS = [
42403
+ {
42404
+ id: "free",
42405
+ name: "Free",
42406
+ tagline: "Pay-as-you-go for hobby projects",
42407
+ monthlyPrice: 0,
42408
+ priceLabel: "€0",
42409
+ monthlyCredits: 0,
42410
+ discountPct: 0,
42411
+ features: [
42412
+ "€0.10 per device-minute — the standard rate",
42413
+ "€10 starter credit on signup",
42414
+ "Automation API keys — Appium, Espresso & CI",
42415
+ "Hosted Appium endpoint",
42416
+ `DeviceyAI copilot — 25 credits to try it, once`,
42417
+ "WebUSB local-device pairing"
42418
+ ]
42419
+ },
42420
+ {
42421
+ id: "plus",
42422
+ name: "Plus",
42423
+ tagline: "For solo devs shipping a side project",
42424
+ monthlyPrice: 29.99,
42425
+ priceLabel: "€29.99",
42426
+ monthlyCredits: 35,
42427
+ discountPct: 40,
42428
+ features: [
42429
+ "€0.06 per device-minute (40% off)",
42430
+ "€35 in monthly credits",
42431
+ aiGrantFeature("plus"),
42432
+ "12 months for the price of 11 on yearly billing",
42433
+ "Email support"
42434
+ ],
42435
+ priceIdEnv: "STRIPE_PRICE_PLUS",
42436
+ yearlyPriceIdEnv: "STRIPE_PRICE_PLUS_YEARLY"
42437
+ },
42438
+ {
42439
+ id: "pro",
42440
+ name: "Pro",
42441
+ tagline: "For teams running CI on real hardware",
42442
+ monthlyPrice: 49.99,
42443
+ priceLabel: "€49.99",
42444
+ monthlyCredits: 80,
42445
+ discountPct: 90,
42446
+ features: [
42447
+ "€0.01 per device-minute (90% off) — about €15/day of nonstop use",
42448
+ "€80 in monthly credits",
42449
+ aiGrantFeature("pro"),
42450
+ "12 months for the price of 11 on yearly billing",
42451
+ "Slack support · 1 business day"
42452
+ ],
42453
+ badge: "Recommended",
42454
+ highlight: true,
42455
+ priceIdEnv: "STRIPE_PRICE_PRO",
42456
+ yearlyPriceIdEnv: "STRIPE_PRICE_PRO_YEARLY"
42457
+ },
42458
+ {
42459
+ id: "dedicated_slot",
42460
+ name: "Rent a Slot",
42461
+ tagline: "Your own device, 24/7, never billed by the minute",
42462
+ monthlyPrice: 99.99,
42463
+ priceLabel: "€99.99",
42464
+ monthlyCredits: 0,
42465
+ discountPct: 100,
42466
+ features: [
42467
+ "No per-minute charge at all — the meter is off",
42468
+ "A dedicated device, auto-assigned & reserved to you",
42469
+ "A permanently-paired device — automation with no rental to start",
42470
+ aiGrantFeature("dedicated_slot"),
42471
+ "24/7 availability — nobody else can rent it",
42472
+ "Priority hardware swap on failure"
42473
+ ],
42474
+ priceIdEnv: "STRIPE_PRICE_SLOT",
42475
+ yearlyPriceIdEnv: "STRIPE_PRICE_SLOT_YEARLY"
42476
+ },
42477
+ {
42478
+ id: "enterprise",
42479
+ name: "Enterprise",
42480
+ tagline: "Whitelabel the fleet for your org",
42481
+ monthlyPrice: null,
42482
+ priceLabel: "Custom",
42483
+ monthlyCredits: 0,
42484
+ discountPct: 0,
42485
+ features: [
42486
+ "Custom SLA + uptime guarantees",
42487
+ aiGrantFeature("enterprise"),
42488
+ "Whitelabeled device fleet",
42489
+ "Dedicated account manager",
42490
+ "On-prem racks available"
42491
+ ],
42492
+ contactSales: true
42493
+ }
42494
+ ];
42495
+ function tierById(id) {
42496
+ return TIERS.find((t) => t.id === id) ?? TIERS[0];
42497
+ }
42498
+ /**
42499
+ * The discount the meter will actually apply: the tier's advertised
42500
+ * percentage, but only while the subscription is `active`. That is the
42501
+ * condition consume_credits checks at charge time, so a past_due Plus
42502
+ * customer is billed list price and must be quoted list price — every
42503
+ * surface that shows a rate (session ticker, multi-rent bar, active-sessions
42504
+ * panel) goes through this one rule rather than restating it.
42505
+ */
42506
+ function activeDiscountPct(subscription) {
42507
+ return subscription.status === "active" ? tierById(subscription.tier).discountPct : 0;
42508
+ }
42509
+ function safeTierById(id) {
42510
+ if (!id) return null;
42511
+ const found = TIERS.find((t) => t.id === id);
42512
+ return found ? found.id : null;
42513
+ }
42514
+ //#endregion
42515
+ //#region src/interactive/account.ts
42516
+ async function loadAccount(ctx, user) {
42517
+ const [profile, sub] = await Promise.all([loadProfile(ctx, user.id), ctx.supabase.rpc("my_subscription").maybeSingle()]);
42518
+ const row = sub.data ?? null;
42519
+ const tier = safeTierById(row?.tier ?? profile.tier) ?? "free";
42520
+ const status = row?.status ?? profile.subscriptionStatus;
42521
+ return {
42522
+ userId: user.id,
42523
+ email: user.email ?? "",
42524
+ wallet: profile.wallet,
42525
+ aiCredits: profile.aiCredits,
42526
+ planName: tierById(tier).name,
42527
+ standing: profile.standing,
42528
+ discountPct: activeDiscountPct({
42529
+ status,
42530
+ tier
42531
+ }),
42532
+ slotDeviceId: status === "active" && tier === "dedicated_slot" ? row?.dedicated_device_id ?? null : null
42533
+ };
42534
+ }
42535
+ /** € a minute this account actually pays for a phone at `ratePerMinute`. */
42536
+ function yourRate(account, device) {
42537
+ if (account.slotDeviceId === device.id) return 0;
42538
+ return computeLiveCost({
42539
+ startTime: (/* @__PURE__ */ new Date(0)).toISOString(),
42540
+ ratePerMinute: device.ratePerMinute,
42541
+ discountPct: account.discountPct,
42542
+ now: 0
42543
+ }).effectiveRate;
42544
+ }
42545
+ /** "€0.01/min" — or "included" on a dedicated slot. */
42546
+ function formatRate(rate) {
42547
+ if (rate === 0) return "included in your plan";
42548
+ return Math.round(rate * 1e3) < 10 ? `€${rate.toFixed(3)}/min` : `${eur(rate)}/min`;
42549
+ }
42550
+ /** The catalogue stores "Android 17" (or, on older rows, just "17"). */
42551
+ function androidLabel(version) {
42552
+ const v = version.trim();
42553
+ return /^android\b/i.test(v) ? v : `Android ${v}`;
42554
+ }
42555
+ /**
42556
+ * "● Pixel 7a #2 · 12 min · €0.12" for each running rental — computed from
42557
+ * the start time on every draw, with no network call.
42558
+ */
42559
+ function statusLine(rentals, account, now, t) {
42560
+ if (!account) return t.dim("not signed in");
42561
+ if (rentals.length === 0) return t.dim("no phone rented");
42562
+ return rentals.map((r) => {
42563
+ const free = isFreeSession(r, account.slotDeviceId);
42564
+ const live = computeLiveCost({
42565
+ startTime: r.startTime,
42566
+ ratePerMinute: r.ratePerMinute,
42567
+ discountPct: account.discountPct,
42568
+ free,
42569
+ now
42570
+ });
42571
+ const minutes = Math.floor(live.elapsedMs / 6e4);
42572
+ return `${t.accent(t.g.bullet)} ${r.deviceName} ${t.dim(`· ${minutes} min · ${free ? "included" : eur(live.cost)}`)}`;
42573
+ }).join(" ");
42574
+ }
42575
+ //#endregion
42576
+ //#region ../../src/lib/ai/pricing.ts
42577
+ const AI_CREDIT_PACKS = [
42578
+ {
42579
+ id: "s",
42580
+ label: "Starter",
42581
+ priceEur: 5,
42582
+ credits: 500,
42583
+ priceIdEnv: "STRIPE_PRICE_AI_PACK_S"
42584
+ },
42585
+ {
42586
+ id: "m",
42587
+ label: "Standard",
42588
+ priceEur: 12,
42589
+ credits: 1500,
42590
+ priceIdEnv: "STRIPE_PRICE_AI_PACK_M"
42591
+ },
42592
+ {
42593
+ id: "l",
42594
+ label: "Bulk",
42595
+ priceEur: 30,
42596
+ credits: 4e3,
42597
+ priceIdEnv: "STRIPE_PRICE_AI_PACK_L"
42598
+ }
42599
+ ];
42600
+ //#endregion
42601
+ //#region ../../src/lib/ai/toolBlurbs.ts
42602
+ const TOOL_BLURBS = {
42603
+ observe_screen: "Read the screen as a list of elements with exact positions",
42604
+ screenshot: "Take a picture of the screen and look at it",
42605
+ list_packages: "List the apps installed on the phone",
42606
+ device_info: "Read the model, Android version, screen size and orientation",
42607
+ wait: "Pause while something loads or animates",
42608
+ list_instrumentations: "Find the exact test-runner target installed on the phone",
42609
+ logcat_tail: "Read the tail of the device log, filtered to what matters",
42610
+ read_device_file: "Read a text file a test wrote — a JUnit XML or coverage report",
42611
+ tap: "Tap a point on the screen",
42612
+ swipe: "Swipe or scroll",
42613
+ type_text: "Type into the focused field",
42614
+ press_key: "Press Home, Back, Recents, Power, Volume, Enter or Delete",
42615
+ launch_app: "Open an installed app",
42616
+ install_apk: "Install an APK you uploaded to this rental",
42617
+ list_uploads: "See which of your files are available to install",
42618
+ run_instrumentation: "Run an instrumentation suite already on the phone and report pass/fail",
42619
+ save_test_file: "Keep a test file it wrote, for you to download",
42620
+ shell: "Run an ADB shell command",
42621
+ appium_start_session: "Open a real Appium (UiAutomator2) session on the phone",
42622
+ appium_page_source: "Read the UI hierarchy through that session",
42623
+ appium_find: "Prove a locator resolves on the live app before it goes into a test",
42624
+ appium_click: "Click an element it found, rather than a raw coordinate",
42625
+ appium_send_keys: "Type into an element it found",
42626
+ appium_end_session: "Close the session and release the phone"
42627
+ };
42628
+ [
42629
+ ...READ_TOOLS,
42630
+ ...ACTION_TOOLS,
42631
+ ...SHELL_TOOLS,
42632
+ ...APPIUM_TOOLS
42633
+ ].map((t) => t.name);
42634
+ //#endregion
42635
+ //#region src/interactive/aiChat.ts
42636
+ const KEYS = {
42637
+ home: "Home",
42638
+ back: "Back",
42639
+ app_switch: "Recent apps",
42640
+ power: "Power",
42641
+ volume_up: "Volume up",
42642
+ volume_down: "Volume down",
42643
+ enter: "Enter",
42644
+ tab: "Tab",
42645
+ delete: "Delete"
42646
+ };
42647
+ /**
42648
+ * One line for one tool call. Typed text is NEVER shown — it may be a
42649
+ * password (mask the data, not a view of it) — only how long it is. The
42650
+ * model writes these inputs, so whatever is echoed is sanitized.
42651
+ */
42652
+ function describeToolCall(name, input) {
42653
+ return sanitize(describe(name, input));
42654
+ }
42655
+ function describe(name, input) {
42656
+ const i = input ?? {};
42657
+ const n = (v) => typeof v === "number" ? Math.round(v) : "?";
42658
+ switch (name) {
42659
+ case "tap": return `Tap at ${n(i.x)}, ${n(i.y)}`;
42660
+ case "swipe": return `Swipe from ${n(i.x1)}, ${n(i.y1)} to ${n(i.x2)}, ${n(i.y2)}`;
42661
+ case "type_text": {
42662
+ const len = typeof i.text === "string" ? [...i.text].length : 0;
42663
+ return `Type ${len} character${len === 1 ? "" : "s"}`;
42664
+ }
42665
+ case "press_key": return `Press ${KEYS[String(i.key)] ?? String(i.key ?? "a key")}`;
42666
+ case "launch_app": return `Open ${typeof i.package === "string" ? i.package : "an app"}`;
42667
+ case "observe_screen": return "Read the screen";
42668
+ case "screenshot": return "Look at the screen";
42669
+ case "wait": return "Wait for the screen to settle";
42670
+ case "shell": return typeof i.command === "string" ? `Run: ${i.command.slice(0, 80)}` : "Run a shell command";
42671
+ default: return TOOL_BLURBS[name] ?? name;
42672
+ }
42673
+ }
42674
+ /**
42675
+ * Run one task. Throws AiNeedsActionsError (src/ai/loop.ts) when look-only
42676
+ * access was not enough, so the caller can ask for more and try again.
42677
+ */
42678
+ async function runChatTask(input, task, permission, t) {
42679
+ return withDevice(input, async (device) => {
42680
+ const { models, credits } = await loadAiConfig(device.rental.id);
42681
+ if (credits <= 0) throw new CliError(OUT_OF_CREDITS);
42682
+ const model = pickModel(models, void 0);
42683
+ const write = (s) => process.stdout.write(s);
42684
+ let inText = false;
42685
+ const endText = () => {
42686
+ if (inText) write("\n");
42687
+ inText = false;
42688
+ };
42689
+ const controller = new AbortController();
42690
+ const abort = () => controller.abort();
42691
+ input.signal?.addEventListener("abort", abort, { once: true });
42692
+ try {
42693
+ const result = await runAiTask(device, {
42694
+ task,
42695
+ provider: model.provider,
42696
+ model: model.id,
42697
+ allowActions: permission === "act",
42698
+ allowShell: false
42699
+ }, controller.signal, {
42700
+ onText: (delta) => {
42701
+ const text = sanitize(delta);
42702
+ if (!text) return;
42703
+ if (!inText) write(`${t.accent(t.g.dot)} `);
42704
+ inText = true;
42705
+ write(text.replace(/\n/g, "\n "));
42706
+ },
42707
+ onTool: (name, toolInput) => {
42708
+ endText();
42709
+ write(`${t.accent(t.g.dot)} ${describeToolCall(name, toolInput)}\n`);
42710
+ },
42711
+ onToolDone: (_name, isError, content) => {
42712
+ if (isError) write(` ${t.dim(t.g.elbow)} ${t.red(sanitize(content.split("\n")[0]).slice(0, 200))}\n`);
42713
+ }
42714
+ });
42715
+ endText();
42716
+ return result;
42717
+ } catch (err) {
42718
+ endText();
42719
+ throw err;
42720
+ } finally {
42721
+ input.signal?.removeEventListener("abort", abort);
42722
+ }
42723
+ });
42724
+ }
42725
+ //#endregion
42726
+ //#region src/interactive/files.ts
42727
+ let folders;
42728
+ function existing(p) {
42729
+ if (!p) return null;
42730
+ try {
42731
+ return fs.statSync(p).isDirectory() ? p : null;
42732
+ } catch {
42733
+ return null;
42734
+ }
42735
+ }
42736
+ function askWindows() {
42737
+ const script = "[Console]::OutputEncoding=[Text.Encoding]::UTF8;[Environment]::GetFolderPath('Desktop');(New-Object -ComObject Shell.Application).NameSpace('shell:Downloads').Self.Path";
42738
+ return new Promise((resolve) => {
42739
+ execFile(systemExe("WindowsPowerShell\\v1.0\\powershell.exe"), [
42740
+ "-NoProfile",
42741
+ "-NonInteractive",
42742
+ "-Command",
42743
+ script
42744
+ ], {
42745
+ timeout: 5e3,
42746
+ windowsHide: true,
42747
+ encoding: "utf8"
42748
+ }, (err, stdout) => {
42749
+ const [desktop, downloads] = err ? [] : stdout.split(/\r?\n/).map((l) => l.trim());
42750
+ resolve({
42751
+ desktop: existing(desktop) ?? existing(path.join(os.homedir(), "Desktop")),
42752
+ downloads: existing(downloads) ?? existing(path.join(os.homedir(), "Downloads"))
42753
+ });
42754
+ });
42755
+ });
42756
+ }
42757
+ /** The person's Downloads and Desktop folders (looked up once, lazily). */
42758
+ function knownFolders() {
42759
+ folders ??= process.platform === "win32" ? askWindows() : Promise.resolve({
42760
+ downloads: existing(path.join(os.homedir(), "Downloads")),
42761
+ desktop: existing(path.join(os.homedir(), "Desktop"))
42762
+ });
42763
+ return folders;
42764
+ }
42765
+ /** The newest `.apk` files directly inside `dirs` (not their subfolders). */
42766
+ function findApks(dirs, limit = 8) {
42767
+ const seen = /* @__PURE__ */ new Set();
42768
+ const found = [];
42769
+ for (const dir of dirs) {
42770
+ if (!dir || seen.has(dir)) continue;
42771
+ seen.add(dir);
42772
+ let names;
42773
+ try {
42774
+ names = fs.readdirSync(dir);
42775
+ } catch {
42776
+ continue;
42777
+ }
42778
+ for (const name of names) {
42779
+ if (!name.toLowerCase().endsWith(".apk")) continue;
42780
+ const full = path.join(dir, name);
42781
+ try {
42782
+ const st = fs.statSync(full);
42783
+ if (st.isFile()) found.push({
42784
+ path: full,
42785
+ name,
42786
+ folder: dir,
42787
+ bytes: st.size,
42788
+ modified: st.mtimeMs
42789
+ });
42790
+ } catch {}
42791
+ }
42792
+ }
42793
+ return found.sort((a, b) => b.modified - a.modified).slice(0, limit);
42794
+ }
42795
+ /** "2 h ago", "3 d ago" — for the file picker. */
42796
+ function ago(ms, now) {
42797
+ const s = Math.max(0, Math.round((now - ms) / 1e3));
42798
+ if (s < 90) return "just now";
42799
+ const m = Math.round(s / 60);
42800
+ if (m < 90) return `${m} min ago`;
42801
+ const h = Math.round(m / 60);
42802
+ if (h < 36) return `${h} h ago`;
42803
+ return `${Math.round(h / 24)} d ago`;
42804
+ }
42805
+ /** A folder name a person recognises: "Downloads", not the whole path. */
42806
+ function folderLabel(dir, f, cwd) {
42807
+ if (dir === f.downloads) return "Downloads";
42808
+ if (dir === f.desktop) return "Desktop";
42809
+ if (dir === cwd) return "this folder";
42810
+ return dir;
42811
+ }
42812
+ //#endregion
42813
+ //#region src/interactive/tokenize.ts
42814
+ function tokenize(line, p) {
42815
+ const words = [];
42816
+ let word = "";
42817
+ let inWord = false;
42818
+ let quote = null;
42819
+ const escapes = p.platform !== "win32";
42820
+ for (let i = 0; i < line.length; i++) {
42821
+ const ch = line[i];
42822
+ if (quote) {
42823
+ if (ch === quote) quote = null;
42824
+ else if (escapes && quote === "\"" && ch === "\\" && i + 1 < line.length) word += line[++i];
42825
+ else word += ch;
42826
+ continue;
42827
+ }
42828
+ if (ch === "\"" || ch === "'") {
42829
+ quote = ch;
42830
+ inWord = true;
42831
+ continue;
42832
+ }
42833
+ if (escapes && ch === "\\" && i + 1 < line.length) {
42834
+ word += line[++i];
42835
+ inWord = true;
42836
+ continue;
42837
+ }
42838
+ if (/\s/.test(ch)) {
42839
+ if (inWord) words.push(word);
42840
+ word = "";
42841
+ inWord = false;
42842
+ continue;
42843
+ }
42844
+ word += ch;
42845
+ inWord = true;
42846
+ }
42847
+ if (inWord) words.push(word);
42848
+ return words.map((w) => expandHome(w, p));
42849
+ }
42850
+ function expandHome(word, p) {
42851
+ if (word === "~") return p.home;
42852
+ if (word.startsWith("~/") || p.platform === "win32" && word.startsWith("~\\")) return p.home + word.slice(1);
42853
+ return word;
42854
+ }
42855
+ function looksLikePath(w, p) {
42856
+ return (p.platform === "win32" ? /^[a-zA-Z]:[\\/]/.test(w) || w.startsWith("\\\\") || /[\\/]/.test(w) : w.startsWith("/") || w.startsWith("./") || w.startsWith("../") || w.includes("/")) || /\.apk$/i.test(w);
42857
+ }
42858
+ /**
42859
+ * The paths a line consists of, if it is nothing but (dragged or pasted)
42860
+ * paths: `"C:\My Apps\app.apk"`, `& 'C:\x\app.apk'`, `/Users/me/My\ App.apk`
42861
+ * — or several at once: dragging an app and its androidTest APK together
42862
+ * drops both. Null when the line is anything else.
42863
+ */
42864
+ function droppedPaths(line, p) {
42865
+ let s = line.trim();
42866
+ if (s.startsWith("& ")) s = s.slice(2).trim();
42867
+ const words = tokenize(s, p);
42868
+ if (words.length === 0 || !words.every((w) => looksLikePath(w, p))) return null;
42869
+ return words;
42870
+ }
42871
+ /** The one path a line consists of (see droppedPaths), or null. */
42872
+ function droppedPath(line, p) {
42873
+ const paths = droppedPaths(line, p);
42874
+ return paths?.length === 1 ? paths[0] : null;
42875
+ }
42876
+ //#endregion
42877
+ //#region src/interactive/intent.ts
42878
+ /** Session-only commands: typed with or without the slash. */
42879
+ const SESSION_ACTIONS = {
42880
+ help: "help",
42881
+ exit: "exit",
42882
+ quit: "exit",
42883
+ clear: "clear",
42884
+ permissions: "permissions",
42885
+ phones: "phones",
42886
+ balance: "balance",
42887
+ plans: "plans"
42888
+ };
42889
+ /** Words that carry no meaning after a verb: "install THE APP ON MY PHONE". */
42890
+ const FILLER = new Set("a an the my me this that it its one please now phone device android app apk application rental renting session for to some another new current of screen on in up".split(" "));
42891
+ const PACKAGE_RE = /^[a-zA-Z][\w]*(\.[a-zA-Z][\w]*)+$/;
42892
+ /** "top up 20 euros": the currency, said as a word, after an amount. */
42893
+ const CURRENCY = /* @__PURE__ */ new Set([
42894
+ "€",
42895
+ "eur",
42896
+ "euro",
42897
+ "euros"
42898
+ ]);
42899
+ /** `devicerent rent`, `npx devicerent@latest status` → `rent`, `status`. */
42900
+ const PROGRAM_NAME = /^(?:npx\s+)?devicerent(?:@\S*)?(?=\s|$)\s*/i;
42901
+ /** "20", "€20", "20€", "20eur", "20,50" → the number, or NaN. */
42902
+ function parseEuros(word) {
42903
+ return Number(word.replace(/^€|€$/g, "").replace(/(eur|euros?)$/i, "").replace(",", "."));
42904
+ }
42905
+ const VERBS = [
42906
+ [
42907
+ "help",
42908
+ "none",
42909
+ [
42910
+ "help",
42911
+ "?",
42912
+ "what can you do",
42913
+ "what can i do",
42914
+ "how does this work",
42915
+ "commands",
42916
+ "menu",
42917
+ "options"
42918
+ ]
42919
+ ],
42920
+ [
42921
+ "exit",
42922
+ "none",
42923
+ [
42924
+ "exit",
42925
+ "quit",
42926
+ "bye",
42927
+ "goodbye"
42928
+ ]
42929
+ ],
42930
+ [
42931
+ "clear",
42932
+ "none",
42933
+ ["clear", "cls"]
42934
+ ],
42935
+ [
42936
+ "rent",
42937
+ "query",
42938
+ [
42939
+ "rent",
42940
+ "hire",
42941
+ "borrow"
42942
+ ]
42943
+ ],
42944
+ [
42945
+ "stop",
42946
+ "all",
42947
+ [
42948
+ "stop",
42949
+ "end",
42950
+ "finish",
42951
+ "return",
42952
+ "release",
42953
+ "i'm done",
42954
+ "im done",
42955
+ "i am done",
42956
+ "done",
42957
+ "stop renting"
42958
+ ]
42959
+ ],
42960
+ [
42961
+ "install",
42962
+ "path",
42963
+ [
42964
+ "install",
42965
+ "upload",
42966
+ "add"
42967
+ ]
42968
+ ],
42969
+ [
42970
+ "screenshot",
42971
+ "none",
42972
+ [
42973
+ "screenshot",
42974
+ "screen shot",
42975
+ "take a screenshot",
42976
+ "take screenshot",
42977
+ "capture the screen",
42978
+ "capture screen",
42979
+ "take a picture",
42980
+ "picture"
42981
+ ]
42982
+ ],
42983
+ [
42984
+ "test",
42985
+ "target",
42986
+ [
42987
+ "test",
42988
+ "quick test",
42989
+ "smoke test",
42990
+ "check",
42991
+ "try"
42992
+ ]
42993
+ ],
42994
+ [
42995
+ "buy-credits",
42996
+ "none",
42997
+ [
42998
+ "buy credits",
42999
+ "buy ai credits",
43000
+ "buy deviceyai credits",
43001
+ "more credits",
43002
+ "credits buy"
43003
+ ]
43004
+ ],
43005
+ [
43006
+ "balance",
43007
+ "none",
43008
+ [
43009
+ "balance",
43010
+ "wallet",
43011
+ "money",
43012
+ "account",
43013
+ "whoami",
43014
+ "who am i",
43015
+ "my account",
43016
+ "my balance",
43017
+ "how much money do i have",
43018
+ "how much money",
43019
+ "credits",
43020
+ "ai credits"
43021
+ ]
43022
+ ],
43023
+ [
43024
+ "topup",
43025
+ "amount",
43026
+ [
43027
+ "top up",
43028
+ "topup",
43029
+ "add money",
43030
+ "add funds",
43031
+ "add credit",
43032
+ "recharge",
43033
+ "deposit"
43034
+ ]
43035
+ ],
43036
+ [
43037
+ "plans",
43038
+ "plan",
43039
+ [
43040
+ "plans",
43041
+ "plan",
43042
+ "pricing",
43043
+ "prices",
43044
+ "subscribe",
43045
+ "subscription",
43046
+ "upgrade"
43047
+ ]
43048
+ ],
43049
+ [
43050
+ "apps",
43051
+ "none",
43052
+ [
43053
+ "apps",
43054
+ "list apps",
43055
+ "installed apps",
43056
+ "show apps",
43057
+ "what's installed",
43058
+ "what is installed"
43059
+ ]
43060
+ ],
43061
+ [
43062
+ "phones",
43063
+ "none",
43064
+ [
43065
+ "phones",
43066
+ "devices",
43067
+ "list phones",
43068
+ "list devices",
43069
+ "show phones",
43070
+ "show devices",
43071
+ "show me the phones",
43072
+ "which phones",
43073
+ "what phones",
43074
+ "available phones",
43075
+ "available"
43076
+ ]
43077
+ ],
43078
+ [
43079
+ "status",
43080
+ "none",
43081
+ [
43082
+ "status",
43083
+ "what's running",
43084
+ "what is running",
43085
+ "rentals",
43086
+ "my rentals"
43087
+ ]
43088
+ ],
43089
+ [
43090
+ "login",
43091
+ "none",
43092
+ [
43093
+ "login",
43094
+ "log in",
43095
+ "sign in",
43096
+ "signin"
43097
+ ]
43098
+ ],
43099
+ [
43100
+ "logout",
43101
+ "none",
43102
+ [
43103
+ "logout",
43104
+ "log out",
43105
+ "sign out",
43106
+ "signout"
43107
+ ]
43108
+ ],
43109
+ [
43110
+ "launch",
43111
+ "package",
43112
+ [
43113
+ "launch",
43114
+ "open",
43115
+ "start",
43116
+ "run"
43117
+ ]
43118
+ ],
43119
+ [
43120
+ "uninstall",
43121
+ "package",
43122
+ [
43123
+ "uninstall",
43124
+ "remove",
43125
+ "delete"
43126
+ ]
43127
+ ],
43128
+ [
43129
+ "logs",
43130
+ "none",
43131
+ [
43132
+ "logs",
43133
+ "log",
43134
+ "logcat",
43135
+ "show logs",
43136
+ "show me the logs",
43137
+ "show the logs"
43138
+ ]
43139
+ ],
43140
+ [
43141
+ "permissions",
43142
+ "none",
43143
+ ["permissions"]
43144
+ ]
43145
+ ];
43146
+ const RENT_PHRASE = /^(?:can i |could i |please )?(?:get|give me|i want|i need|i'd like|id like|book|start|grab)(?: me)?(?: (?:a|an|another|one|new))? (phone|device|android|pixel|galaxy|samsung)\b(.*)$/;
43147
+ function normalize(line) {
43148
+ return line.toLowerCase().replace(/[.!?,]+$/g, "").replace(/\s+/g, " ").trim();
43149
+ }
43150
+ /** Classify the words after a verb; null when they do not fit the action. */
43151
+ function fitArgs(mode, rest, env) {
43152
+ const meaningful = rest.filter((w) => !FILLER.has(w.toLowerCase()) && !(mode === "amount" && CURRENCY.has(w.toLowerCase())));
43153
+ if (mode === "query") return meaningful.length ? { arg: meaningful.join(" ") } : {};
43154
+ if (mode === "all" && rest.some((w) => /^(app|apk|application)s?$/i.test(w))) return null;
43155
+ if (meaningful.length === 0) return {};
43156
+ if (meaningful.length !== 1) return null;
43157
+ const w = meaningful[0];
43158
+ const lower = w.toLowerCase();
43159
+ switch (mode) {
43160
+ case "all": return lower === "all" || lower === "everything" ? { arg: "all" } : null;
43161
+ case "path": return env.fileExists(w) ? { arg: w } : null;
43162
+ case "package": return PACKAGE_RE.test(w) ? { arg: w } : null;
43163
+ case "target": return env.fileExists(w) || PACKAGE_RE.test(w) ? { arg: w } : null;
43164
+ case "amount": {
43165
+ const n = parseEuros(w);
43166
+ return Number.isFinite(n) && n > 0 ? { arg: String(n) } : null;
43167
+ }
43168
+ case "plan": return [
43169
+ "plus",
43170
+ "pro",
43171
+ "slot"
43172
+ ].includes(lower) ? { arg: lower } : null;
43173
+ default: return null;
43174
+ }
43175
+ }
43176
+ /** What may follow a command's name for the line to count as that command. */
43177
+ function positionalsFit(name, rest, env) {
43178
+ const positionals = rest.filter((w, i) => !w.startsWith("-") && !(i > 0 && rest[i - 1].startsWith("--") && !rest[i - 1].includes("=")));
43179
+ if (positionals.length === 0) return true;
43180
+ switch (name) {
43181
+ case "shell":
43182
+ case "ai":
43183
+ case "keys create":
43184
+ case "keys revoke": return true;
43185
+ case "push": return env.fileExists(positionals[0]);
43186
+ case "pull": return positionals[0].startsWith("/");
43187
+ case "screenshot":
43188
+ case "install":
43189
+ case "test quick": return positionals.every((p) => env.fileExists(p) || PACKAGE_RE.test(p) || p.endsWith(".png"));
43190
+ case "launch":
43191
+ case "uninstall": return PACKAGE_RE.test(positionals[0]);
43192
+ case "topup": return Number.isFinite(Number(positionals[0]));
43193
+ case "subscribe": return [
43194
+ "plus",
43195
+ "pro",
43196
+ "slot"
43197
+ ].includes(positionals[0]);
43198
+ case "credits": return positionals[0] === "buy";
43199
+ case "stop": return /^[0-9a-f][0-9a-f-]{7,}$/i.test(positionals[0]);
43200
+ default: return false;
43201
+ }
43202
+ }
43203
+ function matchCommand(words, names) {
43204
+ const byLength = [...names].sort((a, b) => b.split(" ").length - a.split(" ").length);
43205
+ for (const name of byLength) {
43206
+ const parts = name.split(" ");
43207
+ if (parts.every((p, i) => words[i] === p)) return {
43208
+ name,
43209
+ rest: words.slice(parts.length)
43210
+ };
43211
+ }
43212
+ return null;
43213
+ }
43214
+ /** Edit distance with adjacent swaps ("stauts" → "status" is 1). */
43215
+ function editDistance(a, b) {
43216
+ const d = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (_, j) => i === 0 ? j : j === 0 ? i : 0));
43217
+ for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) {
43218
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
43219
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
43220
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
43221
+ }
43222
+ return d[a.length][b.length];
43223
+ }
43224
+ /** The commands and actions a mistyped single word was probably meant to be. */
43225
+ function nearMisses(word, env) {
43226
+ const candidates = /* @__PURE__ */ new Set([
43227
+ ...env.commandNames.filter((n) => !n.includes(" ")),
43228
+ ...Object.keys(SESSION_ACTIONS),
43229
+ "rent",
43230
+ "stop",
43231
+ "install",
43232
+ "screenshot",
43233
+ "topup",
43234
+ "logs"
43235
+ ]);
43236
+ const limit = word.length <= 4 ? 1 : 2;
43237
+ return [...candidates].map((c) => ({
43238
+ c,
43239
+ d: editDistance(word, c)
43240
+ })).filter((x) => x.d > 0 && x.d <= limit).sort((x, y) => x.d - y.d || x.c.localeCompare(y.c)).slice(0, 3).map((x) => x.c);
43241
+ }
43242
+ function interpret(line, env) {
43243
+ const typed = line.trim();
43244
+ if (!typed) return { kind: "empty" };
43245
+ const trimmed = typed.replace(PROGRAM_NAME, "");
43246
+ if (!trimmed || trimmed === "?") return {
43247
+ kind: "action",
43248
+ action: "help"
43249
+ };
43250
+ const dropped = droppedPaths(trimmed, env);
43251
+ if (dropped && dropped.every((p) => env.fileExists(p))) return {
43252
+ kind: "install-file",
43253
+ paths: dropped
43254
+ };
43255
+ if (trimmed.startsWith("/")) {
43256
+ const words = tokenize(trimmed.slice(1), env);
43257
+ if (words.length === 0) return {
43258
+ kind: "action",
43259
+ action: "help"
43260
+ };
43261
+ const session = SESSION_ACTIONS[words[0].toLowerCase()];
43262
+ if (session && words.length === 1) return {
43263
+ kind: "action",
43264
+ action: session
43265
+ };
43266
+ return {
43267
+ kind: "command",
43268
+ argv: words
43269
+ };
43270
+ }
43271
+ const norm = normalize(trimmed);
43272
+ const words = tokenize(trimmed, env);
43273
+ const normWords = norm.split(" ");
43274
+ const rent = RENT_PHRASE.exec(norm);
43275
+ if (rent) {
43276
+ const query = [rent[1], ...rent[2].trim().split(" ")].filter((w) => w && !FILLER.has(w)).join(" ");
43277
+ return {
43278
+ kind: "action",
43279
+ action: "rent",
43280
+ ...query ? { arg: query } : {}
43281
+ };
43282
+ }
43283
+ for (const [action, mode, verbs] of VERBS) {
43284
+ const verb = [...verbs].sort((a, b) => b.length - a.length).find((v) => norm === v || norm.startsWith(`${v} `));
43285
+ if (!verb) continue;
43286
+ const fit = fitArgs(mode, words.slice(verb.split(" ").length), env);
43287
+ if (fit) return {
43288
+ kind: "action",
43289
+ action,
43290
+ ...fit
43291
+ };
43292
+ }
43293
+ const found = matchCommand(words, env.commandNames);
43294
+ if (found && positionalsFit(found.name, found.rest, env)) return {
43295
+ kind: "command",
43296
+ argv: words
43297
+ };
43298
+ if (normWords.length === 1) {
43299
+ const near = nearMisses(normWords[0], env);
43300
+ return near.length ? {
43301
+ kind: "did-you-mean",
43302
+ options: near
43303
+ } : { kind: "unknown" };
43304
+ }
43305
+ return {
43306
+ kind: "ai",
43307
+ task: trimmed
43308
+ };
43309
+ }
43310
+ //#endregion
43311
+ //#region src/interactive/flows.ts
43312
+ const STATUS_WORDS = {
43313
+ available: "free",
43314
+ busy: "in use",
43315
+ sanitizing: "being wiped for the next person",
43316
+ offline: "offline",
43317
+ maintenance: "in maintenance"
43318
+ };
43319
+ const OTHER = "\0other";
43320
+ function slug(s) {
43321
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
43322
+ }
43323
+ /** A line read at a plain prompt, or null if the person pressed Ctrl-C. */
43324
+ async function ask(question) {
43325
+ try {
43326
+ return await promptLine(question);
43327
+ } catch {
43328
+ return null;
43329
+ }
43330
+ }
43331
+ async function loginFlow(s) {
43332
+ if (s.account) {
43333
+ s.result(`You're signed in as ${s.account.email}.`);
43334
+ return;
43335
+ }
43336
+ if (!s.ctx.config.login_enabled) {
43337
+ s.error(`Signing in from a terminal is switched off on ${s.ctx.apiBase}.`);
43338
+ return;
43339
+ }
43340
+ const how = await select({
43341
+ title: "Sign in to DeviceRent",
43342
+ body: ["Your browser opens devicerent.net; press Approve there and come back. Nothing secret is typed here."],
43343
+ choices: [
43344
+ {
43345
+ label: "Sign in with my browser",
43346
+ value: "browser",
43347
+ hint: "recommended"
43348
+ },
43349
+ {
43350
+ label: "Sign in with a code",
43351
+ value: "code",
43352
+ hint: "when the browser is on another device"
43353
+ },
43354
+ {
43355
+ label: "Not now",
43356
+ value: null
43357
+ }
43358
+ ]
43359
+ });
43360
+ if (!how) return;
43361
+ await s.run(how === "code" ? ["login", "--no-browser"] : ["login"], {
43362
+ title: "Signing in",
43363
+ raw: true
43364
+ });
43365
+ const now = signedIn(s);
43366
+ if (now) s.say(`Signed in as ${s.t.bold(now.email)}.`);
43367
+ }
43368
+ function signedIn(s) {
43369
+ return s.account;
43370
+ }
43371
+ async function ensureSignedIn(s) {
43372
+ if (s.account) return true;
43373
+ s.result("You need to sign in first.");
43374
+ await loginFlow(s);
43375
+ return s.account !== null;
43376
+ }
43377
+ async function logoutFlow(s) {
43378
+ if (!s.account) return s.result("You're not signed in.");
43379
+ if (await confirm("Sign this terminal out?", ["Your browser stays signed in. Rented phones keep running."], "Yes, sign out", "No")) await s.run(["logout"], { title: "Signing out" });
43380
+ }
43381
+ async function loadFleet(s) {
43382
+ return s.runTask(() => loadFleetDevices(s.ctx), {
43383
+ spinner: "Checking which phones are free",
43384
+ refresh: false
43385
+ });
43386
+ }
43387
+ async function phonesFlow(s) {
43388
+ if (!await ensureSignedIn(s)) return;
43389
+ const fleet = await loadFleet(s);
43390
+ if (!fleet) return;
43391
+ const account = s.account;
43392
+ const groups = groupFleet(fleet, new Set(s.rentals.map((r) => r.deviceId)));
43393
+ s.say(s.t.bold("Phones in the rack"));
43394
+ const width = Math.max(...groups.map((g) => g.label.length));
43395
+ groups.forEach((g, i) => {
43396
+ const state = g.free.length > 0 ? s.t.green(`${g.free.length} of ${g.total} free`) : s.t.dim(g.why);
43397
+ const rate = g.free.length > 0 ? ` ${s.t.dim(formatRate(yourRate(account, g.free[0])))}` : "";
43398
+ const line = `${g.label.padEnd(width)} ${state}${rate}`;
43399
+ if (i === 0) s.result(line);
43400
+ else s.write(` ${line}`);
43401
+ });
43402
+ s.hint("Type rent to rent one.");
43403
+ }
43404
+ async function rentFlow(s, query) {
43405
+ if (!await ensureSignedIn(s)) return;
43406
+ const fleet = await loadFleet(s);
43407
+ if (!fleet) return;
43408
+ const account = s.account;
43409
+ const mine = new Set(s.rentals.map((r) => r.deviceId));
43410
+ if (!fleet.some((d) => d.rentable && !mine.has(d.id))) {
43411
+ s.result("Every phone is busy right now. Try again in a few minutes.");
43412
+ return;
43413
+ }
43414
+ let chosen = null;
43415
+ if (query) try {
43416
+ chosen = pickDevice(fleet, query, mine);
43417
+ } catch (err) {
43418
+ s.hint(err.message);
43419
+ }
43420
+ if (!chosen) {
43421
+ chosen = await select({
43422
+ title: "Which phone would you like?",
43423
+ body: ["You pay by the minute from when you rent it until you stop it."],
43424
+ choices: groupFleet(fleet, mine).map((g) => ({
43425
+ label: g.label,
43426
+ value: g.free[0],
43427
+ disabled: g.free.length === 0,
43428
+ hint: g.free.length > 0 ? `${formatRate(yourRate(account, g.free[0]))} · ${g.free.length} free` : g.why
43429
+ }))
43430
+ });
43431
+ if (!chosen) return;
43432
+ }
43433
+ const rate = yourRate(account, chosen);
43434
+ if (!await confirm(`Rent ${chosen.name}?`, [
43435
+ rate === 0 ? "It's included in your plan." : `${formatRate(rate)} on your ${account.planName} plan, from now until you stop it.`,
43436
+ `Your wallet: ${eur(account.wallet)}.`,
43437
+ "A phone left idle for 30 minutes is stopped for you."
43438
+ ], "Yes, rent it", "No")) return;
43439
+ let rented = false;
43440
+ await s.run(["rent", chosen.id], {
43441
+ title: `Renting ${chosen.name}`,
43442
+ spinner: `Renting ${chosen.name}`,
43443
+ onResult: (r) => {
43444
+ if (r.kind === "rented") rented = true;
43445
+ }
43446
+ });
43447
+ if (rented) s.hint("Next: drag your .apk onto this window to install it — or just say what to do on the phone.");
43448
+ }
43449
+ /**
43450
+ * Identical phones as ONE line ("Google Pixel 7a · Android 17 — 5 free"):
43451
+ * several share a name, and a menu of indistinguishable lines only confuses.
43452
+ * One model on two Android versions stays two lines. Pure.
43453
+ */
43454
+ function groupFleet(fleet, mine) {
43455
+ const groups = /* @__PURE__ */ new Map();
43456
+ for (const d of fleet) {
43457
+ const key = `${d.name}\u0000${d.androidVersion}`;
43458
+ groups.set(key, [...groups.get(key) ?? [], d]);
43459
+ }
43460
+ return [...groups.values()].map((all) => {
43461
+ const d = all[0];
43462
+ return {
43463
+ label: `${d.name} · ${androidLabel(d.androidVersion)}`,
43464
+ androidVersion: d.androidVersion,
43465
+ total: all.length,
43466
+ free: all.filter((x) => x.rentable && !mine.has(x.id)),
43467
+ why: all.some((x) => mine.has(x.id)) ? "already yours" : STATUS_WORDS[d.status] ?? d.status
43468
+ };
43469
+ });
43470
+ }
43471
+ /** The rental to act on: the only one, a menu of several, or offer to rent. */
43472
+ async function pickRental(s, title) {
43473
+ if (!await ensureSignedIn(s)) return null;
43474
+ if (s.rentals.length === 0) {
43475
+ if (!await confirm("You don't have a phone yet", ["Rent one now? You only pay while it's rented."], "Yes, pick a phone", "Not now")) return null;
43476
+ await rentFlow(s);
43477
+ }
43478
+ if (s.rentals.length <= 1) return s.rentals[0] ?? null;
43479
+ const now = Date.now();
43480
+ return select({
43481
+ title,
43482
+ choices: s.rentals.map((r) => ({
43483
+ label: r.deviceName,
43484
+ value: r,
43485
+ hint: `rented ${Math.floor(minutesSince(r.startTime, now))} min ago`
43486
+ }))
43487
+ });
43488
+ }
43489
+ async function stopFlow(s, arg) {
43490
+ if (!await ensureSignedIn(s)) return;
43491
+ if (s.rentals.length === 0) {
43492
+ s.result("Nothing is rented, so nothing is being charged.");
43493
+ return;
43494
+ }
43495
+ let target = arg === "all" ? "all" : s.rentals.length === 1 ? s.rentals[0] : null;
43496
+ if (!target) {
43497
+ target = await select({
43498
+ title: "Which phone are you done with?",
43499
+ choices: [...s.rentals.map((r) => ({
43500
+ label: r.deviceName,
43501
+ value: r
43502
+ })), {
43503
+ label: "All of them",
43504
+ value: "all"
43505
+ }]
43506
+ });
43507
+ if (!target) return;
43508
+ }
43509
+ const now = Date.now();
43510
+ const names = target === "all" ? s.rentals.map((r) => r.deviceName).join(", ") : target.deviceName;
43511
+ if (!await confirm(`Stop ${names}?`, [target === "all" ? `${s.rentals.length} phones, billing ends now.` : `It has run ${Math.floor(minutesSince(target.startTime, now))} min; billing ends now.`, "Each phone is wiped after you stop: apps and files you put on it are deleted."], "Yes, stop", "No, keep it")) return;
43512
+ await s.run(target === "all" ? ["stop", "--all"] : ["stop", target.id], {
43513
+ title: `Stopping ${names}`,
43514
+ spinner: "Stopping and settling the bill"
43515
+ });
43516
+ }
43517
+ /** An APK from the usual places, or one dragged in / typed. */
43518
+ async function pickApk(s, title) {
43519
+ const f = await knownFolders();
43520
+ const cwd = process.cwd();
43521
+ const now = Date.now();
43522
+ const apks = findApks([
43523
+ cwd,
43524
+ f.downloads,
43525
+ f.desktop
43526
+ ]);
43527
+ const choices = apks.map((a) => ({
43528
+ label: a.name,
43529
+ value: a.path,
43530
+ hint: `${folderLabel(a.folder, f, cwd)} · ${formatBytes(a.bytes)} · ${ago(a.modified, now)}`
43531
+ }));
43532
+ choices.push({
43533
+ label: "Another file",
43534
+ value: OTHER,
43535
+ hint: "drag it onto this window, or type where it is"
43536
+ });
43537
+ const pick = await select({
43538
+ title,
43539
+ body: apks.length ? [] : ["There are no .apk files in Downloads, on the Desktop or in this folder."],
43540
+ choices
43541
+ });
43542
+ if (pick !== OTHER) return pick;
43543
+ const line = await ask(" Drag the .apk onto this window (or type its path), then press Enter:\n > ");
43544
+ if (!line) return null;
43545
+ const file = droppedPath(line, s.platform) ?? line.trim();
43546
+ if (!fs.existsSync(file)) {
43547
+ s.error(`There is no file at ${file}.`);
43548
+ return null;
43549
+ }
43550
+ return file;
43551
+ }
43552
+ /**
43553
+ * The app to offer to open after an install: an app and its androidTest APK
43554
+ * dragged in together install both, and the test package (`….test`) has no
43555
+ * screen to open.
43556
+ */
43557
+ function appToOpen(apps) {
43558
+ return apps.flatMap((a) => a.packageName ? [a.packageName] : []).find((n) => !n.endsWith(".test")) ?? null;
43559
+ }
43560
+ async function installFlow(s, files) {
43561
+ const rental = await pickRental(s, "Install on which phone?");
43562
+ if (!rental) return;
43563
+ const picked = files?.length ? null : await pickApk(s, "Which app do you want to install?");
43564
+ const apks = files?.length ? [...files] : picked ? [picked] : [];
43565
+ if (apks.length === 0) return;
43566
+ let pkg = null;
43567
+ if (await s.run([
43568
+ "install",
43569
+ ...apks,
43570
+ "--rental",
43571
+ rental.id
43572
+ ], {
43573
+ title: `Installing ${apks.map((a) => path.basename(a)).join(" and ")} on ${rental.deviceName}`,
43574
+ onResult: (r) => {
43575
+ if (r.kind === "installed") pkg = appToOpen(r.apps);
43576
+ }
43577
+ }) !== 0 || !pkg) return;
43578
+ const app = pkg;
43579
+ if (await confirm(`Open ${app} now?`, [], "Yes, open it", "Not now")) await s.run([
43580
+ "launch",
43581
+ app,
43582
+ "--rental",
43583
+ rental.id
43584
+ ], { title: `Opening ${app}` });
43585
+ s.hint("Next: \"test my app\" runs a quick crash check — or say what to do on the phone in plain words.");
43586
+ }
43587
+ /** The apps you installed on the phone (not the system's). */
43588
+ async function listApps(s, rental) {
43589
+ return s.runTask((signal) => withDevice(s.input(rental.id, signal), async (device) => parsePackageList(await sh(device.adb, [
43590
+ "pm",
43591
+ "list",
43592
+ "packages",
43593
+ "-3"
43594
+ ]))), {
43595
+ spinner: `Reading the apps on ${rental.deviceName}`,
43596
+ refresh: false
43597
+ });
43598
+ }
43599
+ async function pickApp(s, rental, title, extra = []) {
43600
+ const apps = await listApps(s, rental);
43601
+ if (!apps) return null;
43602
+ if (apps.length === 0 && extra.length === 0) {
43603
+ s.result(`None of your apps are on ${rental.deviceName} yet. Drag an .apk onto this window to install one.`);
43604
+ return null;
43605
+ }
43606
+ return select({
43607
+ title,
43608
+ body: apps.length ? [] : [`None of your apps are on ${rental.deviceName} yet.`],
43609
+ choices: [...apps.sort().map((a) => ({
43610
+ label: a,
43611
+ value: a
43612
+ })), ...extra]
43613
+ });
43614
+ }
43615
+ async function appsFlow(s) {
43616
+ const rental = await pickRental(s, "Which phone?");
43617
+ if (!rental) return;
43618
+ const apps = await listApps(s, rental);
43619
+ if (!apps) return;
43620
+ s.say(s.t.bold(`Your apps on ${rental.deviceName}`));
43621
+ if (apps.length === 0) s.result("None yet. Drag an .apk onto this window to install one.");
43622
+ apps.sort().forEach((a, i) => i === 0 ? s.result(a) : s.write(` ${a}`));
43623
+ }
43624
+ async function launchFlow(s, pkg) {
43625
+ const rental = await pickRental(s, "On which phone?");
43626
+ if (!rental) return;
43627
+ const app = pkg ?? await pickApp(s, rental, "Which app should I open?");
43628
+ if (!app) return;
43629
+ await s.run([
43630
+ "launch",
43631
+ app,
43632
+ "--rental",
43633
+ rental.id
43634
+ ], { title: `Opening ${app}` });
43635
+ }
43636
+ async function uninstallFlow(s, pkg) {
43637
+ const rental = await pickRental(s, "On which phone?");
43638
+ if (!rental) return;
43639
+ const app = pkg ?? await pickApp(s, rental, "Which app should I remove?");
43640
+ if (!app) return;
43641
+ if (!await confirm(`Remove ${app} from ${rental.deviceName}?`, [], "Yes, remove it", "No")) return;
43642
+ await s.run([
43643
+ "uninstall",
43644
+ app,
43645
+ "--rental",
43646
+ rental.id
43647
+ ], { title: `Removing ${app}` });
43648
+ }
43649
+ async function testFlow(s, target) {
43650
+ const rental = await pickRental(s, "Test on which phone?");
43651
+ if (!rental) return;
43652
+ let what = target ?? null;
43653
+ if (!what) {
43654
+ what = await pickApp(s, rental, "Which app should the quick test check?", [{
43655
+ label: "An .apk file on this computer",
43656
+ value: OTHER,
43657
+ hint: "installs it first"
43658
+ }]);
43659
+ if (what === OTHER) what = await pickApk(s, "Which app do you want to test?");
43660
+ }
43661
+ if (!what) return;
43662
+ s.hint("The quick test opens the app, taps around for a minute and reports any crash. No test code needed.");
43663
+ await s.run([
43664
+ "test",
43665
+ "quick",
43666
+ what,
43667
+ "--rental",
43668
+ rental.id
43669
+ ], { title: `Quick test of ${path.basename(what)} on ${rental.deviceName}` });
43670
+ }
43671
+ async function screenshotFlow(s) {
43672
+ const rental = await pickRental(s, "Screenshot of which phone?");
43673
+ if (!rental) return;
43674
+ const f = await knownFolders();
43675
+ const dir = f.downloads ?? process.cwd();
43676
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replace(/[:T]/g, "-");
43677
+ const file = path.join(dir, `devicerent-${slug(rental.deviceName)}-${stamp}.png`);
43678
+ let saved = null;
43679
+ await s.run([
43680
+ "screenshot",
43681
+ file,
43682
+ "--rental",
43683
+ rental.id
43684
+ ], {
43685
+ title: `Screenshot of ${rental.deviceName}`,
43686
+ spinner: "Taking the screenshot",
43687
+ onResult: (r) => {
43688
+ if (r.kind === "screenshot") saved = r.file;
43689
+ }
43690
+ });
43691
+ if (saved) s.hint(openBrowser(saved) ? `Opened it. It's in your ${folderLabel(dir, f, process.cwd())} folder.` : `It's in your ${folderLabel(dir, f, process.cwd())} folder.`);
43692
+ }
43693
+ async function logsFlow(s) {
43694
+ const rental = await pickRental(s, "Logs from which phone?");
43695
+ if (!rental) return;
43696
+ await s.run([
43697
+ "logcat",
43698
+ "--lines",
43699
+ "60",
43700
+ "--rental",
43701
+ rental.id
43702
+ ], { title: `The last lines of ${rental.deviceName}'s log` });
43703
+ s.hint("Follow it live with: /logcat -f (Ctrl-C stops)");
43704
+ }
43705
+ async function balanceFlow(s) {
43706
+ if (!await ensureSignedIn(s)) return;
43707
+ const a = s.account;
43708
+ s.say(s.t.bold("Your account"));
43709
+ s.result(`${a.email} · ${a.planName} plan`);
43710
+ s.write(` Wallet ${s.t.bold(eur(a.wallet))} · AI credits ${s.t.bold(a.aiCredits.toLocaleString("en"))}`);
43711
+ if (a.standing !== "active") s.write(` ${s.t.yellow(`Account ${a.standing}`)}`);
43712
+ s.hint("Add money: topup · Change plan: plans · More AI credits: buy credits");
43713
+ }
43714
+ async function topupFlow(s, amount) {
43715
+ if (!await ensureSignedIn(s)) return;
43716
+ let value = amount ?? null;
43717
+ if (!value) {
43718
+ value = await select({
43719
+ title: "How much would you like to add?",
43720
+ body: [`Your wallet: ${eur(s.account.wallet)}. Payment happens on Stripe, in your browser.`],
43721
+ choices: [
43722
+ {
43723
+ label: "€10",
43724
+ value: "10"
43725
+ },
43726
+ {
43727
+ label: "€20",
43728
+ value: "20"
43729
+ },
43730
+ {
43731
+ label: "€50",
43732
+ value: "50"
43733
+ },
43734
+ {
43735
+ label: "Another amount",
43736
+ value: OTHER
43737
+ }
43738
+ ]
43739
+ });
43740
+ if (value === OTHER) {
43741
+ const typed = await ask(" How many euros? ");
43742
+ if (!typed) return;
43743
+ const n = parseEuros(typed.replace(/\s*(?:eur|euros?)$/i, "").trim());
43744
+ value = Number.isFinite(n) ? String(n) : typed.trim();
43745
+ }
43746
+ }
43747
+ if (!value) return;
43748
+ if (await s.run(["topup", value], { title: `Adding €${value} to your wallet` }) === 0) s.hint("Finish the payment in your browser; your wallet updates as soon as it goes through.");
43749
+ }
43750
+ async function plansFlow(s, plan) {
43751
+ if (!await ensureSignedIn(s)) return;
43752
+ const sellable = TIERS.filter((t) => t.monthlyPrice !== null && t.monthlyPrice > 0);
43753
+ const ids = {
43754
+ plus: "plus",
43755
+ pro: "pro",
43756
+ dedicated_slot: "slot"
43757
+ };
43758
+ let choice = plan ?? null;
43759
+ if (!choice) choice = await select({
43760
+ title: "Plans",
43761
+ body: [`You're on ${s.account.planName}.`],
43762
+ choices: [...sellable.map((t) => ({
43763
+ label: t.name,
43764
+ value: ids[t.id] ?? t.id,
43765
+ hint: t.name === s.account.planName ? "your plan now" : t.discountPct >= 100 ? `${formatEur(t.monthlyPrice)} a month · a phone of your own` : `${formatEur(t.monthlyPrice)} a month · ${t.discountPct}% off every minute`,
43766
+ disabled: t.name === s.account.planName
43767
+ })), {
43768
+ label: "Manage my plan and invoices",
43769
+ value: "billing",
43770
+ hint: "opens the Stripe portal"
43771
+ }]
43772
+ });
43773
+ if (!choice) return;
43774
+ if (choice === "billing") {
43775
+ await s.run(["billing"], { title: "Opening the billing portal" });
43776
+ return;
43777
+ }
43778
+ const yearly = await select({
43779
+ title: "Pay monthly or yearly?",
43780
+ choices: [{
43781
+ label: "Monthly",
43782
+ value: false
43783
+ }, {
43784
+ label: "Yearly",
43785
+ value: true,
43786
+ hint: `1 month free`
43787
+ }]
43788
+ });
43789
+ if (yearly === null) return;
43790
+ await s.run([
43791
+ "subscribe",
43792
+ choice,
43793
+ ...yearly ? ["--yearly"] : []
43794
+ ], { title: "Opening Stripe" });
43795
+ }
43796
+ async function buyCreditsFlow(s) {
43797
+ if (!await ensureSignedIn(s)) return;
43798
+ const pack = await select({
43799
+ title: "Buy DeviceyAI credits",
43800
+ body: [`You have ${s.account.aiCredits.toLocaleString("en")} credits.`],
43801
+ choices: AI_CREDIT_PACKS.map((p) => ({
43802
+ label: p.label,
43803
+ value: p.id,
43804
+ hint: `${p.credits.toLocaleString("en")} credits · ${formatEur(p.priceEur)}`
43805
+ }))
43806
+ });
43807
+ if (!pack) return;
43808
+ await s.run([
43809
+ "credits",
43810
+ "buy",
43811
+ pack
43812
+ ], { title: "Opening Stripe" });
43813
+ }
43814
+ async function askAiPermission(s) {
43815
+ const answer = await select({
43816
+ title: "Let DeviceyAI use the phone?",
43817
+ body: [`DeviceyAI does what you ask on your rented phone and is paid for in AI credits (you have ${(s.account?.aiCredits ?? 0).toLocaleString("en")}).`, "Each message is a fresh task."],
43818
+ choices: [
43819
+ {
43820
+ label: "Yes — let it tap and type",
43821
+ value: "act"
43822
+ },
43823
+ {
43824
+ label: "Yes, but only let it look",
43825
+ value: "look",
43826
+ hint: "it can't touch anything"
43827
+ },
43828
+ {
43829
+ label: "No",
43830
+ value: "no"
43831
+ }
43832
+ ]
43833
+ });
43834
+ return answer === "act" || answer === "look" ? answer : null;
43835
+ }
43836
+ async function aiFlow(s, task) {
43837
+ if (!await ensureSignedIn(s)) return;
43838
+ if (s.rentals.length === 0) s.result(`DeviceyAI works on a rented phone, and you don't have one yet.`);
43839
+ const rental = await pickRental(s, "Which phone should DeviceyAI use?");
43840
+ if (!rental) return;
43841
+ if (!s.aiPermission) {
43842
+ s.aiPermission = await askAiPermission(s);
43843
+ if (!s.aiPermission) return;
43844
+ }
43845
+ for (let attempt = 0; attempt < 2; attempt++) {
43846
+ let needsActions = false;
43847
+ const permission = s.aiPermission;
43848
+ const result = await s.runTask((signal) => runChatTask(s.input(rental.id, signal), task, permission, s.t).catch((err) => {
43849
+ if (!(err instanceof AiNeedsActionsError)) throw err;
43850
+ needsActions = true;
43851
+ return null;
43852
+ }), {
43853
+ raw: true,
43854
+ spinner: "DeviceyAI is thinking"
43855
+ });
43856
+ if (needsActions) {
43857
+ if (!await confirm("DeviceyAI needs to tap and type for this", ["It only had permission to look. Let it act on the phone for the rest of this session?"], "Yes, let it act", "No")) return;
43858
+ s.aiPermission = "act";
43859
+ continue;
43860
+ }
43861
+ if (!result) return;
43862
+ if (result.stopReason === "refusal") s.result("DeviceyAI declined this request.");
43863
+ if (result.stopReason === "stopped") s.result(s.t.dim("Stopped."));
43864
+ if (result.stopReason === "step_limit") s.result("It ran out of steps for one message. Try a smaller, more specific request.");
43865
+ const left = result.creditsRemaining ?? s.account?.aiCredits ?? null;
43866
+ s.hint(`${result.creditsCharged} credit${result.creditsCharged === 1 ? "" : "s"} used${left !== null ? ` · ${left.toLocaleString("en")} left` : ""}`);
43867
+ return;
43868
+ }
43869
+ }
43870
+ function helpFlow(s) {
43871
+ const t = s.t;
43872
+ const rows = [
43873
+ ["rent", "rent a phone — you pay by the minute until you stop"],
43874
+ ["install", "put your app (.apk) on it — or just drag the file onto this window"],
43875
+ ["test my app", "a quick crash check: opens your app and taps around"],
43876
+ ["screenshot", "save a picture of the phone's screen (and open it)"],
43877
+ ["apps · logs", "what you installed · the phone's log"],
43878
+ ["stop", "give the phone back and stop paying"],
43879
+ ["balance · topup", "your wallet · add money"],
43880
+ ["plans · buy credits", "change plan · more DeviceyAI credits"],
43881
+ ["phones", "every phone and whether it's free"]
43882
+ ];
43883
+ s.say(t.bold("What you can do"));
43884
+ rows.forEach(([cmd, what], i) => {
43885
+ const line = `${t.accent(cmd.padEnd(22))}${what}`;
43886
+ if (i === 0) s.result(line);
43887
+ else s.write(` ${line}`);
43888
+ });
43889
+ s.write();
43890
+ s.hint("Anything else, in plain words — \"open Settings and turn on dark mode\" — goes to DeviceyAI.");
43891
+ s.hint("Type / for every command · permissions changes what DeviceyAI may do · exit to leave");
43892
+ }
43893
+ //#endregion
43894
+ //#region src/interactive/session.ts
43895
+ /** What can change the wallet, credits, plan, rentals or who is signed in. */
43896
+ const CHANGES_ACCOUNT = /* @__PURE__ */ new Set([
43897
+ "rent",
43898
+ "stop",
43899
+ "login",
43900
+ "logout",
43901
+ "ai"
43902
+ ]);
43903
+ /** True when, after the command `argv` names, the session's account view may be stale. */
43904
+ function refreshesAfter(argv) {
43905
+ const found = findCommand(argv);
43906
+ return found !== null && CHANGES_ACCOUNT.has(found.command.name);
43907
+ }
43908
+ /** Some next steps for refusals a person can fix themselves. */
43909
+ function nextStepFor(message) {
43910
+ const m = message.toLowerCase();
43911
+ if (m.includes("insufficient credit") || m.includes("top up")) return "Add money with: topup";
43912
+ if (m.includes("out of deviceyai credits")) return "Buy more with: buy credits";
43913
+ if (m.includes("not signed in")) return "Sign in with: login";
43914
+ if (m.includes("no active rental")) return "Rent a phone with: rent";
43915
+ if (m.includes("concurrent rental limit")) return "Stop one first with: stop";
43916
+ return null;
43917
+ }
43918
+ const LEADING_ESCAPES = /^(?:\u001b\[[0-9;?]*[ -/]*[@-~])+/;
43919
+ /**
43920
+ * Prefix every line written to stdout/stderr while `fn` runs: " ⎿ " on the
43921
+ * first, five spaces after. A carriage return (a progress bar or a spinner
43922
+ * redrawing its line) gets that line's prefix again, before its next visible
43923
+ * character. Returns the undo.
43924
+ */
43925
+ function indentOutput(t) {
43926
+ const out = process.stdout;
43927
+ const err = process.stderr;
43928
+ const outWrite = out.write;
43929
+ const errWrite = err.write;
43930
+ const first = ` ${t.dim(t.g.elbow)} `;
43931
+ const rest = " ";
43932
+ let used = false;
43933
+ /** A new line has started and has no prefix yet. */
43934
+ let newLine = true;
43935
+ /** The current line was rewound with \r and needs its prefix again. */
43936
+ let rewound = false;
43937
+ let atLineStart = true;
43938
+ let current = first;
43939
+ const decoders = /* @__PURE__ */ new Map();
43940
+ const transform = (text) => {
43941
+ let res = "";
43942
+ for (const part of text.split(/(\r\n|\n|\r)/)) {
43943
+ if (part === "") continue;
43944
+ if (part === "\n" || part === "\r\n") {
43945
+ res += part;
43946
+ newLine = true;
43947
+ rewound = false;
43948
+ atLineStart = true;
43949
+ continue;
43950
+ }
43951
+ if (part === "\r") {
43952
+ res += "\r";
43953
+ if (!newLine) rewound = true;
43954
+ continue;
43955
+ }
43956
+ const escapes = LEADING_ESCAPES.exec(part)?.[0] ?? "";
43957
+ const visible = part.slice(escapes.length);
43958
+ res += escapes;
43959
+ if (!visible) continue;
43960
+ if (newLine) {
43961
+ current = used ? rest : first;
43962
+ used = true;
43963
+ res += current;
43964
+ } else if (rewound) res += current;
43965
+ newLine = false;
43966
+ rewound = false;
43967
+ atLineStart = false;
43968
+ res += visible;
43969
+ }
43970
+ return res;
43971
+ };
43972
+ const wrap = (stream, orig) => function(chunk, ...args) {
43973
+ let text;
43974
+ if (typeof chunk === "string") text = chunk;
43975
+ else if (chunk instanceof Uint8Array) {
43976
+ let d = decoders.get(stream);
43977
+ if (!d) decoders.set(stream, d = new TextDecoder());
43978
+ text = d.decode(chunk, { stream: true });
43979
+ } else text = String(chunk);
43980
+ return orig.call(this, transform(text), ...args);
43981
+ };
43982
+ out.write = wrap(out, outWrite);
43983
+ err.write = wrap(err, errWrite);
43984
+ let undone = false;
43985
+ return () => {
43986
+ if (undone) return;
43987
+ undone = true;
43988
+ out.write = outWrite;
43989
+ err.write = errWrite;
43990
+ if (!atLineStart && !rewound) outWrite.call(out, "\n");
43991
+ };
43992
+ }
43993
+ var Session = class {
43994
+ ctx;
43995
+ t;
43996
+ platform;
43997
+ account = null;
43998
+ rentals = [];
43999
+ aiPermission = null;
44000
+ history = [];
44001
+ /** A message to show before the next prompt (never over a drawn prompt). */
44002
+ pending = [];
44003
+ stoppedHere = /* @__PURE__ */ new Set();
44004
+ constructor(ctx, t, platform) {
44005
+ this.ctx = ctx;
44006
+ this.t = t;
44007
+ this.platform = platform;
44008
+ }
44009
+ write(line = "") {
44010
+ process.stdout.write(`${line}\n`);
44011
+ }
44012
+ /** "⏺ text" — something happening, or an answer. */
44013
+ say(text) {
44014
+ this.write(`${this.t.accent(this.t.g.dot)} ${text}`);
44015
+ }
44016
+ /** " ⎿ text" — the result of what was just said. */
44017
+ result(text) {
44018
+ this.write(` ${this.t.dim(this.t.g.elbow)} ${text}`);
44019
+ }
44020
+ hint(text) {
44021
+ this.write(this.t.dim(` ${text}`));
44022
+ }
44023
+ error(text) {
44024
+ this.result(this.t.red(text));
44025
+ const next = nextStepFor(text);
44026
+ if (next) this.hint(next);
44027
+ }
44028
+ /** Who is signed in (null if nobody), and their running rentals. */
44029
+ async refresh() {
44030
+ try {
44031
+ const user = await requireUser(this.ctx);
44032
+ const [account, rentals] = await Promise.all([loadAccount(this.ctx, user), loadActiveRentals()]);
44033
+ this.account = account;
44034
+ this.noteVanished(rentals);
44035
+ this.rentals = rentals;
44036
+ } catch (err) {
44037
+ if (err instanceof CliError && err.exitCode === 2) {
44038
+ this.account = null;
44039
+ this.rentals = [];
44040
+ return;
44041
+ }
44042
+ throw err;
44043
+ }
44044
+ }
44045
+ async refreshRentals() {
44046
+ if (!this.account) return;
44047
+ const rentals = await loadActiveRentals();
44048
+ this.noteVanished(rentals);
44049
+ this.rentals = rentals;
44050
+ }
44051
+ /** A rental that ended without our stop: the idle sweep, or the website. */
44052
+ noteVanished(next) {
44053
+ for (const r of this.rentals) {
44054
+ if (next.some((n) => n.id === r.id) || this.stoppedHere.has(r.id)) continue;
44055
+ this.pending.push(`${r.deviceName} is no longer rented — it was stopped elsewhere, or after 30 idle minutes.`);
44056
+ }
44057
+ }
44058
+ /** A CommandInput for work the session runs itself (pickers, DeviceyAI). */
44059
+ input(rentalId, signal) {
44060
+ return {
44061
+ ctx: this.ctx,
44062
+ args: [],
44063
+ opts: rentalId ? { rental: rentalId } : {},
44064
+ json: false,
44065
+ signal
44066
+ };
44067
+ }
44068
+ /**
44069
+ * Run `work` with Ctrl-C wired to its signal. The first Ctrl-C aborts it
44070
+ * and is then handed back to Node, so a second one ends the process the
44071
+ * ordinary way. The work is always awaited: a rent or stop already on its
44072
+ * way to the database is never abandoned behind the prompt.
44073
+ */
44074
+ async runTask(work, opts = {}) {
44075
+ if (opts.title) this.say(this.t.bold(opts.title));
44076
+ cookedMode();
44077
+ const controller = new AbortController();
44078
+ const onSigint = () => {
44079
+ process.off("SIGINT", onSigint);
44080
+ controller.abort();
44081
+ process.stderr.write(this.t.dim("\n Stopping…\n"));
44082
+ };
44083
+ process.on("SIGINT", onSigint);
44084
+ const undoIndent = opts.raw ? () => {} : indentOutput(this.t);
44085
+ const spin = opts.spinner ? startSpinner(opts.spinner, {
44086
+ cancellable: true,
44087
+ theme: this.t
44088
+ }) : null;
44089
+ try {
44090
+ return await work(controller.signal);
44091
+ } catch (err) {
44092
+ spin?.stop();
44093
+ undoIndent();
44094
+ const message = err instanceof Error ? err.message : String(err);
44095
+ if (err instanceof CliError && err.exitCode === 130) this.result(this.t.dim(message));
44096
+ else this.error(message);
44097
+ return null;
44098
+ } finally {
44099
+ spin?.stop();
44100
+ undoIndent();
44101
+ process.off("SIGINT", onSigint);
44102
+ if (opts.raw) restoreTerminal();
44103
+ if (opts.refresh !== false) await this.refresh().catch(() => {});
44104
+ }
44105
+ }
44106
+ /**
44107
+ * Run a command line exactly as `devicerent <argv>` would. Returns its exit
44108
+ * code, or null when it failed (the error has been printed).
44109
+ */
44110
+ async run(argv, opts = {}) {
44111
+ const raw = opts.raw ?? (argv[0] === "shell" || argv[0] === "connect" || argv[0] === "logcat" && (argv.includes("-f") || argv.includes("--follow")));
44112
+ return this.runTask(async (signal) => {
44113
+ const outcome = await runCommandLine(async () => this.ctx, argv, {
44114
+ signal,
44115
+ interactive: true,
44116
+ onResult: (r) => {
44117
+ if (r.kind === "stopped") for (const id of r.rentalIds) this.stoppedHere.add(id);
44118
+ opts.onResult?.(r);
44119
+ }
44120
+ });
44121
+ if (outcome.kind === "unknown") {
44122
+ const near = nearMisses(argv[0] ?? "", {
44123
+ ...this.platform,
44124
+ fileExists: () => false,
44125
+ commandNames: COMMANDS.map((c) => c.name)
44126
+ });
44127
+ throw new CliError(`There is no command "${argv.join(" ")}".${near.length ? ` Did you mean ${near.map((n) => `/${n}`).join(" or ")}?` : " Type / to see them all."}`);
44128
+ }
44129
+ return outcome.code;
44130
+ }, {
44131
+ ...opts,
44132
+ raw,
44133
+ refresh: opts.refresh ?? refreshesAfter(argv)
44134
+ });
44135
+ }
44136
+ /** True when the command named by argv acts on one rented phone. */
44137
+ static isDeviceCommand(argv) {
44138
+ const found = findCommand(argv);
44139
+ return Boolean(found?.command.options && "rental" in found.command.options);
44140
+ }
44141
+ };
44142
+ //#endregion
44143
+ //#region src/interactive/repl.ts
44144
+ const SESSION_SUMMARIES = {
44145
+ help: "What you can do here, in plain words",
44146
+ exit: "Leave (asks first if a phone is still rented)",
44147
+ quit: "Leave",
44148
+ clear: "Clear the screen",
44149
+ permissions: "Change what DeviceyAI may do on the phone",
44150
+ phones: "Every phone and whether it's free",
44151
+ balance: "Your wallet, AI credits and plan",
44152
+ plans: "Change your plan"
44153
+ };
44154
+ /** Everything the `/` menu offers: session actions first, then commands. */
44155
+ function slashMenu() {
44156
+ return [...Object.keys(SESSION_ACTIONS).filter((k) => k !== "quit").map((name) => ({
44157
+ name,
44158
+ summary: SESSION_SUMMARIES[name] ?? ""
44159
+ })), ...COMMANDS.map((c) => ({
44160
+ name: c.name,
44161
+ summary: c.summary
44162
+ }))];
44163
+ }
44164
+ /** The welcome card's lines (pure). */
44165
+ function welcomeLines(account, rentals, apiBase, t, width) {
44166
+ const card = [
44167
+ `${t.accent(t.g.spark)} ${t.bold("Welcome to DeviceRent")} ${t.dim(CLI_VERSION)}`,
44168
+ "",
44169
+ " Real Android phones, rented by the minute.",
44170
+ account ? ` ${account.email} ${t.dim("·")} ${account.planName} ${t.dim("·")} Wallet ${t.bold(`€${account.wallet.toFixed(2)}`)} ${t.dim("·")} AI credits ${account.aiCredits.toLocaleString("en")}` : t.dim(" You're not signed in yet.")
44171
+ ];
44172
+ if (apiBase !== "https://devicerent.net") card.push(t.dim(` ${apiBase}`));
44173
+ const lines = renderBox(card, {
44174
+ width,
44175
+ theme: t,
44176
+ border: t.accent
44177
+ });
44178
+ lines.push("");
44179
+ if (rentals.length > 0) lines.push(` ${t.accent(t.g.bullet)} You have ${rentals.map((r) => t.bold(r.deviceName)).join(", ")} rented right now.`, "");
44180
+ lines.push(t.dim(" Tips for getting started:"), "", ` 1. Type ${t.bold("rent")} to pick a phone — you pay by the minute until you stop`, ` 2. Drag your ${t.bold(".apk")} file onto this window to install it`, ` 3. Then say what to do: ${t.bold("\"open my app and sign up with a test email\"")}`, ` 4. ${t.bold("help")} explains the rest · ${t.bold("/")} lists every command`, "");
44181
+ return lines;
44182
+ }
44183
+ function placeholderFor(s) {
44184
+ if (!s.account) return "Type \"login\" to sign in";
44185
+ if (s.rentals.length === 0) return "Try \"rent a phone\"";
44186
+ return "Try \"install\", or \"open Settings and turn on dark mode\"";
44187
+ }
44188
+ /** Guided commands typed with their own name but without their arguments. */
44189
+ async function runCommandLineGuided(s, argv) {
44190
+ const found = findCommand(argv);
44191
+ const name = found?.command.name;
44192
+ const rest = found?.rest ?? [];
44193
+ const positional = rest.filter((w) => !w.startsWith("-"));
44194
+ if (name === "rent") return rentFlow(s, rest.join(" ") || void 0);
44195
+ if (name === "stop" && rest.length === 0) return stopFlow(s);
44196
+ if (name === "stop" && rest.length === 1 && rest[0] === "--all") return stopFlow(s, "all");
44197
+ if (name === "install" && positional.length === 0) return installFlow(s);
44198
+ if (name === "screenshot" && positional.length === 0) return screenshotFlow(s);
44199
+ if (name === "test quick" && positional.length === 0) return testFlow(s);
44200
+ if (name === "launch" && positional.length === 0) return launchFlow(s);
44201
+ if (name === "uninstall" && positional.length === 0) return uninstallFlow(s);
44202
+ if (name === "topup" && positional.length === 0) return topupFlow(s);
44203
+ if (name === "subscribe" && positional.length === 0) return plansFlow(s);
44204
+ if (name === "credits" && positional[0] === "buy" && positional.length === 1) return buyCreditsFlow(s);
44205
+ if (name === "login" && rest.length === 0) return loginFlow(s);
44206
+ if (name === "ai" && positional.length > 0 && rest.length === positional.length) return aiFlow(s, positional.join(" "));
44207
+ let line = argv;
44208
+ if (Session.isDeviceCommand(argv) && !argv.some((a) => a === "--rental" || a === "-r" || a.startsWith("--rental="))) {
44209
+ if (s.rentals.length !== 1) {
44210
+ const rental = await pickRental(s, "On which phone?");
44211
+ if (!rental) return;
44212
+ line = [
44213
+ ...argv,
44214
+ "--rental",
44215
+ rental.id
44216
+ ];
44217
+ }
44218
+ }
44219
+ await s.run(line);
44220
+ }
44221
+ async function runAction(s, action, arg) {
44222
+ switch (action) {
44223
+ case "help": return helpFlow(s);
44224
+ case "exit": return "exit";
44225
+ case "clear":
44226
+ process.stdout.write("\x1B[2J\x1B[3J\x1B[H");
44227
+ return;
44228
+ case "rent": return rentFlow(s, arg);
44229
+ case "stop": return stopFlow(s, arg);
44230
+ case "install": return installFlow(s, arg ? [arg] : void 0);
44231
+ case "screenshot": return screenshotFlow(s);
44232
+ case "test": return testFlow(s, arg);
44233
+ case "balance": return balanceFlow(s);
44234
+ case "topup": return topupFlow(s, arg);
44235
+ case "plans": return plansFlow(s, arg);
44236
+ case "buy-credits": return buyCreditsFlow(s);
44237
+ case "apps": return appsFlow(s);
44238
+ case "phones": return phonesFlow(s);
44239
+ case "status":
44240
+ await s.run(["status"], { title: "What you have rented" });
44241
+ return;
44242
+ case "login": return loginFlow(s);
44243
+ case "logout": return logoutFlow(s);
44244
+ case "launch": return launchFlow(s, arg);
44245
+ case "uninstall": return uninstallFlow(s, arg);
44246
+ case "logs": return logsFlow(s);
44247
+ case "permissions": {
44248
+ const p = await askAiPermission(s);
44249
+ s.aiPermission = p;
44250
+ s.result(p === "act" ? "DeviceyAI may tap and type on the phone." : p === "look" ? "DeviceyAI may only look at the phone." : "DeviceyAI will ask again next time.");
44251
+ return;
44252
+ }
44253
+ }
44254
+ }
44255
+ async function dispatch(s, intent) {
44256
+ switch (intent.kind) {
44257
+ case "empty": return;
44258
+ case "install-file": return installFlow(s, intent.paths);
44259
+ case "action": return runAction(s, intent.action, intent.arg);
44260
+ case "command": return runCommandLineGuided(s, intent.argv);
44261
+ case "did-you-mean": {
44262
+ const pick = await select({
44263
+ title: "Did you mean…",
44264
+ choices: [...intent.options.map((o) => ({
44265
+ label: o,
44266
+ value: o
44267
+ })), {
44268
+ label: "None of these",
44269
+ value: ""
44270
+ }]
44271
+ });
44272
+ if (!pick) return;
44273
+ return dispatch(s, interpret(pick, envFor(s)));
44274
+ }
44275
+ case "ai": return aiFlow(s, intent.task);
44276
+ case "unknown":
44277
+ s.result("I didn't catch that. Type help to see what you can do, or / for every command.");
44278
+ return;
44279
+ }
44280
+ }
44281
+ function envFor(s) {
44282
+ return {
44283
+ ...s.platform,
44284
+ commandNames: COMMANDS.map((c) => c.name),
44285
+ fileExists: (p) => {
44286
+ try {
44287
+ return fs.statSync(p).isFile();
44288
+ } catch {
44289
+ return false;
44290
+ }
44291
+ }
44292
+ };
44293
+ }
44294
+ /** Leaving with a phone still rented: ask, never assume. */
44295
+ async function confirmExit(s) {
44296
+ await s.refreshRentals().catch(() => {});
44297
+ if (s.rentals.length === 0) return true;
44298
+ const names = s.rentals.map((r) => r.deviceName).join(", ");
44299
+ const isAre = s.rentals.length === 1 ? "is" : "are";
44300
+ const choice = await select({
44301
+ title: `${names} ${isAre} still rented`,
44302
+ body: ["Billing continues until you stop it, or until it has sat idle for 30 minutes."],
44303
+ choices: [
44304
+ {
44305
+ label: "Stop it and leave",
44306
+ value: "stop"
44307
+ },
44308
+ {
44309
+ label: "Leave it running",
44310
+ value: "leave"
44311
+ },
44312
+ {
44313
+ label: "Stay",
44314
+ value: "stay"
44315
+ }
44316
+ ]
44317
+ });
44318
+ if (choice === "stop") {
44319
+ if (await s.run(["stop", "--all"], {
44320
+ title: `Stopping ${names}`,
44321
+ spinner: "Stopping and settling the bill"
44322
+ }) === 0) return true;
44323
+ s.hint(`${names} ${isAre} still rented and billing. Try exit again, or choose "Leave it running".`);
44324
+ return false;
44325
+ }
44326
+ if (choice === "leave") {
44327
+ s.hint("It keeps running. Stop it later with: npx devicerent stop");
44328
+ return true;
44329
+ }
44330
+ return false;
44331
+ }
44332
+ async function startInteractive(apiBase, helpText) {
44333
+ const t = theme();
44334
+ const platform = {
44335
+ platform: process.platform,
44336
+ home: os.homedir()
44337
+ };
44338
+ const spin = startSpinner("Connecting to DeviceRent", { theme: t });
44339
+ let ctx;
44340
+ try {
44341
+ ctx = await startContext(apiBase);
44342
+ } catch (err) {
44343
+ spin.stop();
44344
+ process.stderr.write(`${t.red(err.message)}\n\n${helpText()}\n`);
44345
+ return 1;
44346
+ }
44347
+ const s = new Session(ctx, t, platform);
44348
+ try {
44349
+ await s.refresh();
44350
+ } catch (err) {
44351
+ spin.stop();
44352
+ process.stderr.write(`${t.red(err.message)}\n`);
44353
+ return 1;
44354
+ }
44355
+ spin.stop();
44356
+ for (const line of welcomeLines(s.account, s.rentals, apiBase, t, frameWidth(process.stdout.columns))) s.write(line);
44357
+ if (!s.account) await loginFlow(s);
44358
+ const poll = setInterval(() => void s.refreshRentals().catch(() => {}), 6e4);
44359
+ poll.unref();
44360
+ const commands = slashMenu();
44361
+ try {
44362
+ for (;;) {
44363
+ for (const p of s.pending.splice(0)) s.hint(p);
44364
+ const r = await readPrompt({
44365
+ history: s.history,
44366
+ commands,
44367
+ placeholder: placeholderFor(s),
44368
+ hint: "/ for commands · ? for help",
44369
+ status: () => statusLine(s.rentals, s.account, Date.now(), t),
44370
+ theme: t
44371
+ });
44372
+ if (r.kind === "exit") {
44373
+ if (await confirmExit(s)) break;
44374
+ continue;
44375
+ }
44376
+ if (r.value.trim()) s.history.push(r.value);
44377
+ try {
44378
+ if (await dispatch(s, interpret(r.value, envFor(s))) === "exit" && await confirmExit(s)) break;
44379
+ } catch (err) {
44380
+ s.error(err instanceof CliError || err instanceof Error ? err.message : String(err));
44381
+ }
44382
+ s.write();
44383
+ }
44384
+ } finally {
44385
+ clearInterval(poll);
44386
+ restoreTerminal();
44387
+ }
44388
+ s.write(t.dim(" Bye."));
44389
+ return 0;
44390
+ }
44391
+ //#endregion
44392
+ //#region src/main.ts
41156
44393
  /**
41157
44394
  * Global flags may come before the command, as with git and gh:
41158
44395
  * `devicerent --api-base http://localhost:3000 keys list`. They are lifted out
@@ -41195,6 +44432,21 @@ function helpText() {
41195
44432
  }
41196
44433
  async function main(rawArgv) {
41197
44434
  const { globals, rest: argv } = splitLeadingGlobals(rawArgv);
44435
+ if (shouldStartInteractive({
44436
+ argv,
44437
+ globals,
44438
+ stdinTTY: process.stdin.isTTY === true,
44439
+ stdoutTTY: process.stdout.isTTY === true,
44440
+ stderrTTY: process.stderr.isTTY === true,
44441
+ env: process.env
44442
+ })) {
44443
+ const { values } = parseArgs({
44444
+ args: globals,
44445
+ options: GLOBAL_OPTIONS,
44446
+ strict: true
44447
+ });
44448
+ return startInteractive(resolveApiBase(values), helpText);
44449
+ }
41198
44450
  if (argv.length === 0 || argv[0] === "help" || argv[0] === "--help" || argv[0] === "-h") {
41199
44451
  printLine(helpText());
41200
44452
  return 0;
@@ -41203,40 +44455,12 @@ async function main(rawArgv) {
41203
44455
  printLine(CLI_VERSION);
41204
44456
  return 0;
41205
44457
  }
41206
- const found = findCommand(argv);
41207
- if (!found) {
44458
+ const outcome = await runCommandLine(startContext, argv, { globals });
44459
+ if (outcome.kind === "unknown") {
41208
44460
  process.stderr.write(`${red("Unknown command:")} ${argv.join(" ")}\n\n${helpText()}\n`);
41209
44461
  return 2;
41210
44462
  }
41211
- const { command, rest } = found;
41212
- let values;
41213
- let positionals;
41214
- try {
41215
- ({values, positionals} = parseArgs({
41216
- args: [...globals, ...rest],
41217
- options: {
41218
- ...GLOBAL_OPTIONS,
41219
- ...command.options
41220
- },
41221
- allowPositionals: true,
41222
- strict: true
41223
- }));
41224
- } catch (err) {
41225
- process.stderr.write(`${red(err.message)}\nUsage: ${command.usage}\n`);
41226
- return 2;
41227
- }
41228
- if (values.help === true) {
41229
- printLine(`${command.summary}\n\nUsage: ${command.usage}`);
41230
- return 0;
41231
- }
41232
- const ctx = await startContext(normalizeApiBase((typeof values["api-base"] === "string" ? values["api-base"] : void 0) ?? process.env.DEVICERENT_API_BASE ?? "https://devicerent.net"));
41233
- const code = await command.run({
41234
- ctx,
41235
- args: positionals,
41236
- opts: values,
41237
- json: values.json === true
41238
- });
41239
- return typeof code === "number" ? code : 0;
44463
+ return outcome.code;
41240
44464
  }
41241
44465
  /** Grace for stray handles before a finished command is ended by force. */
41242
44466
  const EXIT_GRACE_MS = 3e3;
@@ -41261,4 +44485,4 @@ if (process.env.VITEST === void 0) main(process.argv.slice(2)).then(finish, (err
41261
44485
  finish(err instanceof CliError ? err.exitCode : 1);
41262
44486
  });
41263
44487
  //#endregion
41264
- export { findCommand, main, splitLeadingGlobals };
44488
+ export { findCommand, helpText, main, splitLeadingGlobals };