vibeview 0.32.0 → 0.34.0

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.
@@ -377,9 +377,19 @@ var ApiClient = class {
377
377
  /** Report presence on a session and read it back in one call. A Roku dev
378
378
  * loop has no tunnel and no browser, so this is what tells the backend
379
379
  * someone is still using the session between device commands. Same
380
- * response shape as `getSession`. */
381
- async touchSession(sessionId) {
382
- return this.requestRaw("POST", `/sessions/${sessionId}/presence`);
380
+ * response shape as `getSession`.
381
+ *
382
+ * `lease: true` also renews the caller's hold on the session: a dev loop
383
+ * (`vibeview dev`, `--detach`, MCP `dev_start`) renews it every
384
+ * `AGENT_LEASE_RENEW_MS`, and once renewals stop — the process was killed,
385
+ * crashed, or the computer went to sleep — the backend ends the session
386
+ * about a minute later instead of after the org idle timeout. */
387
+ async touchSession(sessionId, opts = {}) {
388
+ return this.requestRaw(
389
+ "POST",
390
+ `/sessions/${sessionId}/presence`,
391
+ opts.lease ? { lease: true } : void 0
392
+ );
383
393
  }
384
394
  async stopSession(sessionId) {
385
395
  await this.requestRaw("DELETE", `/sessions/${sessionId}`);
@@ -13,7 +13,7 @@ import {
13
13
  resolveArtifact,
14
14
  resolveAuth,
15
15
  uploadAppBuild
16
- } from "./chunk-ZZK7YQSP.js";
16
+ } from "./chunk-4D2E2PE4.js";
17
17
 
18
18
  // src/lib/dev-config.ts
19
19
  import { readFileSync, renameSync, rmSync, writeFileSync } from "fs";
@@ -360,6 +360,104 @@ function checkDevBuild(app, buildId, platform, opts = {}) {
360
360
  }
361
361
  return build;
362
362
  }
363
+ function debugBuildsNewestFirst(app) {
364
+ const time = (b) => b.uploaded_at ? Date.parse(b.uploaded_at) || 0 : 0;
365
+ return app.builds.filter((b) => canUseMetro(b) === true).sort((a, b) => time(b) - time(a) || b.id - a.id);
366
+ }
367
+ function pickDevBuild(app, localVersion) {
368
+ const builds = debugBuildsNewestFirst(app);
369
+ if (builds.length === 0) return null;
370
+ const newest = builds[0];
371
+ if (!localVersion || newest.version === localVersion) return { build: newest, reason: null };
372
+ const match = builds.find((b) => b.version === localVersion);
373
+ if (!match) return { build: newest, reason: null };
374
+ return {
375
+ build: match,
376
+ reason: `the newest debug build (${newest.id}) is version ${newest.version ?? "unknown"}, this project is version ${localVersion}`
377
+ };
378
+ }
379
+ function buildLabel(b) {
380
+ const parts = [];
381
+ if (b.version) parts.push(`version ${b.version}${b.build_number ? ` (${b.build_number})` : ""}`);
382
+ parts.push(b.kind ?? buildKind(b));
383
+ if (b.uploaded_at) parts.push(`uploaded ${formatUtcTimestamp(b.uploaded_at)}`);
384
+ if (b.note) parts.push(JSON.stringify(b.note.length > 60 ? `${b.note.slice(0, 57)}...` : b.note));
385
+ return `${b.id} (${parts.join(", ")})`;
386
+ }
387
+ function startedBuild(b) {
388
+ return {
389
+ id: b.id,
390
+ version: b.version ?? null,
391
+ build_number: b.build_number ?? null,
392
+ kind: buildKind(b),
393
+ uploaded_at: b.uploaded_at ?? null,
394
+ note: b.note ?? null
395
+ };
396
+ }
397
+ function otherDebugBuilds(app, installedId) {
398
+ const builds = debugBuildsNewestFirst(app);
399
+ const installed = app.builds.find((b) => b.id === installedId);
400
+ const seen = /* @__PURE__ */ new Set();
401
+ if (installed) seen.add(installed.version ?? "");
402
+ const out = [];
403
+ for (const b of builds) {
404
+ if (b.id === installedId) continue;
405
+ const key = b.version ?? "";
406
+ if (seen.has(key)) continue;
407
+ seen.add(key);
408
+ out.push(b);
409
+ }
410
+ return out;
411
+ }
412
+ function preferDevBuild(app, pinned, project) {
413
+ if (pinned !== void 0 || !app) return { buildId: pinned, reason: null };
414
+ const picked = pickDevBuild(app, project?.version ?? null);
415
+ return picked?.reason ? { buildId: picked.build.id, reason: picked.reason } : { buildId: void 0, reason: null };
416
+ }
417
+ function describeDevBuild(app, session, pick, project) {
418
+ const installedId = session?.app_build_id ?? pick.buildId ?? null;
419
+ const installed = app?.builds.find((b) => b.id === installedId);
420
+ const build = installed ? startedBuild(installed) : typeof installedId === "number" ? {
421
+ id: installedId,
422
+ version: session?.app_version ?? null,
423
+ build_number: session?.app_build_number ?? null,
424
+ kind: "unknown",
425
+ uploaded_at: null,
426
+ note: null
427
+ } : null;
428
+ return {
429
+ build,
430
+ reason: pick.reason,
431
+ pinned: pick.pinned,
432
+ other_debug_builds: app ? otherDebugBuilds(app, installedId).map(startedBuild) : [],
433
+ project
434
+ };
435
+ }
436
+ function buildLines(choice, opts) {
437
+ const lines = [];
438
+ if (!choice) return lines;
439
+ if (choice.build) lines.push(`build: ${buildLabel(choice.build)}`);
440
+ if (choice.reason) lines.push(`picked that build because ${choice.reason}`);
441
+ const project = choice.project;
442
+ if (!opts.standalone && !choice.pinned && !choice.reason && project && !project.version) {
443
+ const why = project.versionUnreadable ? ` (${project.versionUnreadable})` : "";
444
+ lines.push(
445
+ `couldn't read this project's app version${why}, so this is the newest debug build, not necessarily one of this version`
446
+ );
447
+ }
448
+ if (choice.other_debug_builds.length > 0) {
449
+ lines.push(
450
+ `other debug builds: ${choice.other_debug_builds.map((b) => buildLabel(b)).join("; ")} \u2014 pass ${opts.flag} to run one`
451
+ );
452
+ }
453
+ const rn = project?.reactNative;
454
+ if (!opts.standalone && rn) {
455
+ lines.push(
456
+ `this project: react-native ${rn}${project?.version ? `, version ${project.version}` : ""}. If the app shows a red error screen right after it starts (for example "React Native version mismatch", or "undefined is not a function" inside a native module), the build may have been made from other native code than this project \u2014 try a build of this project (${opts.flag}, see ${opts.listBuilds}).`
457
+ );
458
+ }
459
+ return lines;
460
+ }
363
461
 
364
462
  // src/commands/list-builds.ts
365
463
  import chalk from "chalk";
@@ -615,6 +713,7 @@ import chalk2 from "chalk";
615
713
  import { input, select } from "@inquirer/prompts";
616
714
 
617
715
  // src/lib/project-detect.ts
716
+ import { execFile } from "child_process";
618
717
  import { existsSync as existsSync2, readFileSync as readFileSync4, readdirSync, statSync } from "fs";
619
718
  import { join as join4 } from "path";
620
719
  import ignore from "ignore";
@@ -791,6 +890,110 @@ function productionDefaultsFor(cwd, platform) {
791
890
  }
792
891
  return {};
793
892
  }
893
+ function uniqueMatch(text, re) {
894
+ const found = /* @__PURE__ */ new Set();
895
+ for (const m of text.matchAll(re)) {
896
+ const v = m[1].trim().replace(/^"|"$/g, "");
897
+ if (v && !v.includes("$")) found.add(v);
898
+ }
899
+ return found.size === 1 ? [...found][0] : null;
900
+ }
901
+ function readText(path) {
902
+ try {
903
+ return readFileSync4(path, "utf-8");
904
+ } catch {
905
+ return null;
906
+ }
907
+ }
908
+ function pbxprojText(cwd) {
909
+ try {
910
+ const proj = readdirSync(join4(cwd, "ios")).find((f) => f.endsWith(".xcodeproj"));
911
+ return proj ? readText(join4(cwd, "ios", proj, "project.pbxproj")) : null;
912
+ } catch {
913
+ return null;
914
+ }
915
+ }
916
+ var readExpoConfig = (cwd) => {
917
+ const cli = join4(cwd, "node_modules", "expo", "bin", "cli");
918
+ if (!existsSync2(cli)) {
919
+ return Promise.reject(new Error("Expo is not installed in this project (node_modules/expo)"));
920
+ }
921
+ return new Promise((resolve, reject) => {
922
+ execFile(
923
+ process.execPath,
924
+ [cli, "config", "--json", "--type", "public"],
925
+ {
926
+ cwd,
927
+ timeout: 3e4,
928
+ maxBuffer: 8 * 1024 * 1024,
929
+ env: { ...process.env, EXPO_NO_TELEMETRY: "1", CI: "1" }
930
+ },
931
+ (err, stdout, stderr) => {
932
+ if (err) {
933
+ const why = String(stderr || err.message).split("\n").map((l) => l.trim()).find((l) => l.length > 0);
934
+ reject(new Error(`expo config failed${why ? `: ${why}` : ""}`));
935
+ return;
936
+ }
937
+ try {
938
+ const parsed = JSON.parse(stdout);
939
+ if (parsed && typeof parsed === "object") resolve(parsed);
940
+ else reject(new Error("expo config printed no config"));
941
+ } catch {
942
+ reject(new Error("expo config printed something that is not JSON"));
943
+ }
944
+ }
945
+ );
946
+ });
947
+ };
948
+ async function localAppIdentity(cwd, platform, readConfig = readExpoConfig) {
949
+ const rn = readJson(cwd, "node_modules/react-native/package.json");
950
+ const reactNative = typeof rn?.version === "string" ? rn.version : null;
951
+ const android = platform === "android" || platform === "androidtv";
952
+ const ios = platform === "ios" || platform === "tvos";
953
+ let version = null;
954
+ let bundleId = null;
955
+ let versionUnreadable = null;
956
+ const dynamicName = ["app.config.ts", "app.config.js", "app.config.mjs", "app.config.cjs"].find(
957
+ (f) => existsSync2(join4(cwd, f))
958
+ );
959
+ const staticRaw = readJson(cwd, "app.config.json") ?? readJson(cwd, "app.json");
960
+ const isExpo = Boolean(dynamicName) || staticRaw !== null && "expo" in staticRaw || existsSync2(join4(cwd, "node_modules", "expo", "package.json"));
961
+ let expo = null;
962
+ if (isExpo && (dynamicName || existsSync2(join4(cwd, "node_modules", "expo", "bin", "cli")))) {
963
+ try {
964
+ expo = await readConfig(cwd);
965
+ } catch (err) {
966
+ if (dynamicName) {
967
+ versionUnreadable = `${dynamicName} is dynamic and could not be evaluated: ${err instanceof Error ? err.message : String(err)}`;
968
+ }
969
+ }
970
+ }
971
+ if (expo === null && !dynamicName && staticRaw) {
972
+ expo = "expo" in staticRaw ? staticRaw.expo : staticRaw;
973
+ }
974
+ if (expo && typeof expo === "object") {
975
+ const cfg = expo;
976
+ if (typeof cfg.version === "string") version = cfg.version;
977
+ const section = android ? cfg.android : ios ? cfg.ios : null;
978
+ const id = android ? section?.package : section?.bundleIdentifier;
979
+ if (typeof id === "string") bundleId = id;
980
+ }
981
+ if (android && (!version || !bundleId)) {
982
+ const gradle = readText(join4(cwd, "android", "app", "build.gradle")) ?? readText(join4(cwd, "android", "app", "build.gradle.kts"));
983
+ if (gradle) {
984
+ version ??= uniqueMatch(gradle, /versionName\s*=?\s*["']([^"']+)["']/g);
985
+ bundleId ??= uniqueMatch(gradle, /applicationId\s*=?\s*["']([^"']+)["']/g);
986
+ }
987
+ }
988
+ if (ios && (!version || !bundleId)) {
989
+ const pbx = pbxprojText(cwd);
990
+ if (pbx) {
991
+ version ??= uniqueMatch(pbx, /MARKETING_VERSION = ([^;]+);/g);
992
+ bundleId ??= uniqueMatch(pbx, /PRODUCT_BUNDLE_IDENTIFIER = ([^;]+);/g);
993
+ }
994
+ }
995
+ return { reactNative, version, bundleId, versionUnreadable: version ? null : versionUnreadable };
996
+ }
794
997
 
795
998
  // src/lib/dev-setup.ts
796
999
  function platformChoices(cwd) {
@@ -890,7 +1093,7 @@ async function promptMetroCommand(cwd, platform) {
890
1093
  }
891
1094
 
892
1095
  // src/lib/source-pack.ts
893
- import { execFile, spawn } from "child_process";
1096
+ import { execFile as execFile2, spawn } from "child_process";
894
1097
  import { lstatSync, mkdtempSync, readFileSync as readFileSync5, rmSync as rmSync4, statSync as statSync2, writeFileSync as writeFileSync4 } from "fs";
895
1098
  import { tmpdir } from "os";
896
1099
  import { join as join5 } from "path";
@@ -1029,7 +1232,7 @@ function probeTarNullSupport() {
1029
1232
  const listPath = join5(tmp, "empty.list");
1030
1233
  writeFileSync4(listPath, "");
1031
1234
  const out = join5(tmp, "probe.tgz");
1032
- execFile("tar", ["-czf", out, "-C", tmp, "--null", "-T", listPath], (err) => {
1235
+ execFile2("tar", ["-czf", out, "-C", tmp, "--null", "-T", listPath], (err) => {
1033
1236
  rmSync4(tmp, { recursive: true, force: true });
1034
1237
  resolve(!err);
1035
1238
  });
@@ -2359,11 +2562,24 @@ function parseWatchSeconds(flag) {
2359
2562
  }
2360
2563
  return seconds;
2361
2564
  }
2565
+ var AGENT_LEASE_RENEW_MS = 15e3;
2566
+ var AGENT_LEASE_TTL_MS = 6e4;
2567
+ function leaseLapsedReason(gapMs) {
2568
+ if (gapMs <= AGENT_LEASE_TTL_MS) return void 0;
2569
+ return `the session ended because this computer did not check in with VibeView for ${Math.round(gapMs / 1e3)}s (asleep, offline, or the process was paused) \u2014 start a new session to continue`;
2570
+ }
2362
2571
  function queuedLine(position, reasonMessage) {
2363
2572
  const where = position ? ` (position ${position})` : "";
2364
2573
  const why = reasonMessage ?? "Every device is busy.";
2365
2574
  return `\u23F3 ${why} Waiting in the queue${where}, Ctrl-C to give up.`;
2366
2575
  }
2576
+ function devBuildLines(choice, standalone) {
2577
+ return buildLines(choice, {
2578
+ standalone,
2579
+ flag: "--build <id>",
2580
+ listBuilds: "vibeview list-builds"
2581
+ });
2582
+ }
2367
2583
  function emit(json, evt) {
2368
2584
  if (json) console.log(JSON.stringify(evt));
2369
2585
  }
@@ -2372,16 +2588,43 @@ async function startDevSession(cfg, onEvent = () => {
2372
2588
  const api = new ApiClient(cfg.baseUrl, cfg.token);
2373
2589
  const usesTunnel = PLATFORM_NEEDS_METRO[cfg.platform] && !cfg.standalone;
2374
2590
  const pool = cfg.model ? resolveDeviceModel(await api.listDeviceModels(), cfg.platform, cfg.model) : null;
2591
+ let app = null;
2592
+ let project = null;
2593
+ let pick = {
2594
+ buildId: cfg.buildId,
2595
+ reason: null
2596
+ };
2597
+ if (usesTunnel && cfg.projectDir) {
2598
+ try {
2599
+ app = cfg.app ?? await api.getApp(cfg.appId);
2600
+ } catch {
2601
+ app = null;
2602
+ }
2603
+ try {
2604
+ project = await localAppIdentity(cfg.projectDir, cfg.platform);
2605
+ } catch {
2606
+ project = null;
2607
+ }
2608
+ pick = preferDevBuild(app, cfg.buildId, project);
2609
+ }
2610
+ const body = { ...cfg, buildId: pick.buildId };
2375
2611
  const created = await api.createSession(
2376
- devSessionCreateBody(pool ? { ...cfg, deviceCategory: pool.category, model: pool.model } : cfg)
2612
+ devSessionCreateBody(
2613
+ pool ? { ...body, deviceCategory: pool.category, model: pool.model } : body
2614
+ )
2377
2615
  );
2378
2616
  const sessionId = created.session_id;
2379
2617
  let stopping = false;
2380
2618
  let tunnel = null;
2381
2619
  let queueEntryId = null;
2620
+ let lastLeaseAt = Date.now();
2621
+ let leaseTimer = null;
2382
2622
  const stopSession = async (code) => {
2383
2623
  if (stopping) return;
2384
2624
  stopping = true;
2625
+ if (leaseTimer) clearInterval(leaseTimer);
2626
+ leaseTimer = null;
2627
+ const reason = code !== 0 ? leaseLapsedReason(Date.now() - lastLeaseAt) : void 0;
2385
2628
  tunnel?.stop();
2386
2629
  if (queueEntryId !== null) {
2387
2630
  await api.cancelQueueEntry(queueEntryId).catch(() => {
@@ -2390,8 +2633,27 @@ async function startDevSession(cfg, onEvent = () => {
2390
2633
  }
2391
2634
  await api.stopSession(sessionId).catch(() => {
2392
2635
  });
2393
- onEvent({ event: "session_ended", session_id: sessionId, code });
2636
+ onEvent({ event: "session_ended", session_id: sessionId, code, ...reason ? { reason } : {} });
2637
+ };
2638
+ const renewLease = async () => {
2639
+ if (stopping) return;
2640
+ let info;
2641
+ try {
2642
+ info = await api.touchSession(sessionId, { lease: true });
2643
+ } catch {
2644
+ return;
2645
+ }
2646
+ if (stopping) return;
2647
+ if (info.status === "stopped" || info.status === "failed") {
2648
+ void stopSession(1);
2649
+ return;
2650
+ }
2651
+ lastLeaseAt = Date.now();
2394
2652
  };
2653
+ void renewLease();
2654
+ leaseTimer = setInterval(() => void renewLease(), AGENT_LEASE_RENEW_MS);
2655
+ leaseTimer.unref?.();
2656
+ cfg.onCreated?.({ sessionId, stop: () => stopSession(1) });
2395
2657
  try {
2396
2658
  const started = await api.startSession(sessionId);
2397
2659
  if (started.status === "queued") {
@@ -2406,6 +2668,7 @@ async function startDevSession(cfg, onEvent = () => {
2406
2668
  });
2407
2669
  await pollWhileQueued(api, sessionId, QUEUE_WAIT_MS);
2408
2670
  queueEntryId = null;
2671
+ cfg.onDequeued?.();
2409
2672
  }
2410
2673
  const pageUrl = `${cfg.webUrl || cfg.baseUrl}/sandbox/${sessionId}`;
2411
2674
  const reportedWarnings = /* @__PURE__ */ new Set();
@@ -2458,7 +2721,15 @@ async function startDevSession(cfg, onEvent = () => {
2458
2721
  reportedWarnings.add(w);
2459
2722
  onEvent({ event: "warning", message: w });
2460
2723
  }
2461
- return { sessionId, pageUrl, stop: () => stopSession(0), reportedWarnings, session: active };
2724
+ return {
2725
+ sessionId,
2726
+ pageUrl,
2727
+ stop: () => stopSession(0),
2728
+ reportedWarnings,
2729
+ session: active,
2730
+ lapsedReason: () => leaseLapsedReason(Date.now() - lastLeaseAt),
2731
+ build: usesTunnel && cfg.projectDir ? describeDevBuild(app, active, { ...pick, pinned: cfg.buildId !== void 0 }, project) : null
2732
+ };
2462
2733
  } catch (err) {
2463
2734
  await stopSession(1);
2464
2735
  throw err;
@@ -3076,6 +3347,7 @@ async function runDevInProcess(opts) {
3076
3347
  deviceCategory: cfg.deviceCategory,
3077
3348
  model: cfg.model,
3078
3349
  buildId: pinnedBuildId,
3350
+ projectDir: process.cwd(),
3079
3351
  ...standalone ? { standalone } : {},
3080
3352
  metroPort: cfg.metroPort ?? 8081,
3081
3353
  // startDevSession's own browser-open happens before we've written the
@@ -3102,7 +3374,7 @@ async function runDevInProcess(opts) {
3102
3374
  chalk4.dim(" Putting the channel on the Roku \u2014 the link appears once it is up.")
3103
3375
  );
3104
3376
  } else if (evt.event === "session_ended" && !stopping && handle) {
3105
- console.error(chalk4.red("tunnel closed by server \u2014 session ended"));
3377
+ console.error(chalk4.red(evt.reason ?? "session ended by the server"));
3106
3378
  stopping = true;
3107
3379
  endedEmitted = true;
3108
3380
  emit(opts.json, evt);
@@ -3133,6 +3405,8 @@ async function runDevInProcess(opts) {
3133
3405
  }
3134
3406
  );
3135
3407
  const keysLine = rokuBuild ? `Press ${chalk4.bold("r")} to reload the channel, ${chalk4.bold("q")} or Ctrl-C to end the session.` : "Ctrl-C to end the session.";
3408
+ for (const line of devBuildLines(handle.build, standalone))
3409
+ console.error(chalk4.dim(` ${line}`));
3136
3410
  console.error(`
3137
3411
  Watch and interact: ${chalk4.cyan(handle.pageUrl)}
3138
3412
  ${keysLine}
@@ -3150,7 +3424,8 @@ async function runDevInProcess(opts) {
3150
3424
  event: "session_ready",
3151
3425
  session_id: handle.sessionId,
3152
3426
  url: handle.pageUrl,
3153
- pid: process.pid
3427
+ pid: process.pid,
3428
+ build: handle.build ?? null
3154
3429
  });
3155
3430
  if (opts.open !== false) await openBrowser(handle.pageUrl).catch(() => {
3156
3431
  });
@@ -3217,8 +3492,14 @@ async function runDevInProcess(opts) {
3217
3492
  }
3218
3493
  const verdict = sessionLivenessVerdict(info);
3219
3494
  if (verdict.end && !stopping) {
3220
- console.error(chalk4.red(verdict.message));
3221
- emit(opts.json, { event: "session_ended", session_id: sessionId, code: 1 });
3495
+ const reason = handle?.lapsedReason?.();
3496
+ console.error(chalk4.red(reason ?? verdict.message));
3497
+ emit(opts.json, {
3498
+ event: "session_ended",
3499
+ session_id: sessionId,
3500
+ code: 1,
3501
+ ...reason ? { reason } : {}
3502
+ });
3222
3503
  endedEmitted = true;
3223
3504
  await stopSession(1);
3224
3505
  return;
@@ -3276,6 +3557,8 @@ async function dev(opts) {
3276
3557
  } else {
3277
3558
  console.error(chalk4.dim(`Using ${resolveAuth(opts).baseUrl}`));
3278
3559
  console.error(chalk4.green(`\u2713 dev session started in the background (pid ${parsed.pid})`));
3560
+ for (const line2 of devBuildLines(parsed.build, false))
3561
+ console.error(chalk4.dim(` ${line2}`));
3279
3562
  console.error(`
3280
3563
  Watch and interact: ${chalk4.cyan(parsed.url)}`);
3281
3564
  console.error(chalk4.dim(` vibeview dev-status \u2014 check on it`));
@@ -3664,7 +3947,7 @@ var AGENT_COMMANDS = [
3664
3947
  // iOS system alerts only.
3665
3948
  verb: "alert",
3666
3949
  tool: "alert",
3667
- summary: "Inspect or respond to a visible iOS system alert (get, accept, or dismiss).",
3950
+ summary: "Inspect or respond to a visible system alert on iOS or Apple TV (get, accept, or dismiss). `get` returns the message and the button labels.",
3668
3951
  mutating: true,
3669
3952
  params: [
3670
3953
  {
@@ -3747,7 +4030,7 @@ var AGENT_COMMANDS = [
3747
4030
  // tools.py open_url: { url: string }.
3748
4031
  verb: "open-url",
3749
4032
  tool: "open_url",
3750
- summary: "Open a deep link or URL in the app under test. The link goes to that app only; one it has no screen for fails rather than opening a browser.",
4033
+ summary: "Open a deep link in the app under test. The link goes to that app only, never a browser: on Android a link the app has no screen for fails; on iOS only the app's own URL scheme (e.g. myapp://settings) is accepted, and a web or system link is refused.",
3751
4034
  mutating: true,
3752
4035
  params: [
3753
4036
  {
@@ -3759,6 +4042,16 @@ var AGENT_COMMANDS = [
3759
4042
  }
3760
4043
  ]
3761
4044
  },
4045
+ {
4046
+ // relaunch_app: {} — terminate the session's app and launch it again the
4047
+ // way the session launched it (same launch params); its data is kept.
4048
+ // Refused on embed sessions (their watchdog owns launching) and on Roku.
4049
+ verb: "relaunch-app",
4050
+ tool: "relaunch_app",
4051
+ summary: "Close the app under test and start it again: a cold start that keeps the app's data, e.g. to check that something persists. Its log keeps streaming across the restart. Not available on Roku or embed sessions.",
4052
+ mutating: true,
4053
+ params: []
4054
+ },
3762
4055
  {
3763
4056
  // set_posture: { posture: 'closed' | 'partial' | 'open' } OR { angle: 0..180 }
3764
4057
  // — exactly one (buildToolCall enforces it CLI-side, agent_control.py's
@@ -3805,6 +4098,33 @@ var AGENT_COMMANDS = [
3805
4098
  }
3806
4099
  ]
3807
4100
  },
4101
+ {
4102
+ // set_location: { latitude: -90..90, longitude: -180..180 } — both
4103
+ // required (agent_control.py validate_tool_args refuses anything else).
4104
+ // The same control as the dashboard's Set location. Flags rather than
4105
+ // positionals: a western/southern coordinate starts with `-`, which a
4106
+ // positional would read as an option.
4107
+ verb: "set-location",
4108
+ tool: "set_location",
4109
+ summary: "Set the device's simulated GPS location, for location-based flows. Phones, tablets and foldables (iOS and Android); not available on TV devices.",
4110
+ mutating: true,
4111
+ params: [
4112
+ {
4113
+ name: "latitude",
4114
+ cli: "--lat",
4115
+ type: "number",
4116
+ required: true,
4117
+ description: "Latitude in decimal degrees, -90 to 90."
4118
+ },
4119
+ {
4120
+ name: "longitude",
4121
+ cli: "--lon",
4122
+ type: "number",
4123
+ required: true,
4124
+ description: "Longitude in decimal degrees, -180 to 180 (west is negative)."
4125
+ }
4126
+ ]
4127
+ },
3808
4128
  {
3809
4129
  // tools.py wait: { seconds?: number } (default: 2). Note the wire arg is
3810
4130
  // "seconds", NOT "ms" — tool_dispatch.py reads args.get("seconds", 2)
@@ -3897,12 +4217,12 @@ var AGENT_COMMANDS = [
3897
4217
 
3898
4218
  // src/agent/render.ts
3899
4219
  import chalk6 from "chalk";
3900
- function renderEnvelope(env) {
4220
+ function renderEnvelope(env, label = env.tool) {
3901
4221
  const lines = [];
3902
4222
  if (env.ok) {
3903
- lines.push(chalk6.green(`ok`) + chalk6.dim(` (${env.tool})`));
4223
+ lines.push(chalk6.green(`ok`) + chalk6.dim(` (${label})`));
3904
4224
  } else {
3905
- lines.push(chalk6.red(`error`) + chalk6.dim(` (${env.tool})`));
4225
+ lines.push(chalk6.red(`error`) + chalk6.dim(` (${label})`));
3906
4226
  }
3907
4227
  lines.push(...resultLines(env.result));
3908
4228
  if (env.observation) {
@@ -3949,6 +4269,14 @@ function resultLines(result) {
3949
4269
  const state = deviceStateLine(r);
3950
4270
  if (state) lines.push(state);
3951
4271
  }
4272
+ if (Array.isArray(r.buttons) && r.buttons.length > 0) {
4273
+ const labels = r.buttons.filter((b) => typeof b === "string");
4274
+ if (labels.length > 0)
4275
+ lines.push(`buttons: ${labels.map((b) => JSON.stringify(b)).join(", ")}`);
4276
+ }
4277
+ if (r.action === "location_set" && typeof r.latitude === "number" && typeof r.longitude === "number") {
4278
+ lines.push(`location: ${r.latitude}, ${r.longitude}`);
4279
+ }
3952
4280
  if (typeof r.note === "string" && r.note) lines.push(chalk6.dim(r.note));
3953
4281
  if (typeof r.message === "string" && r.message) lines.push(r.message);
3954
4282
  if (typeof r.warning === "string" && r.warning) lines.push(r.warning);
@@ -3959,8 +4287,8 @@ function deviceStateLine(r) {
3959
4287
  if (r.action === "rotated") {
3960
4288
  if (typeof r.device_orientation === "string") {
3961
4289
  let o = `device held ${r.device_orientation}`;
3962
- if (typeof r.screen_orientation === "string" && r.screen_orientation !== r.device_orientation) {
3963
- o += ` (picture on the lit screen: ${r.screen_orientation})`;
4290
+ if (typeof r.screen_orientation === "string" && !r.device_orientation.startsWith(r.screen_orientation)) {
4291
+ o += typeof r.posture === "string" ? ` (picture on the lit screen: ${r.screen_orientation})` : ` (screen stayed ${r.screen_orientation})`;
3964
4292
  }
3965
4293
  parts.push(o);
3966
4294
  }
@@ -4196,9 +4524,11 @@ async function runAgentVerb(cmd, positionals, opts) {
4196
4524
  }
4197
4525
  writeFileSync6(outPath, imageBytes);
4198
4526
  if (opts.json) {
4199
- console.log(JSON.stringify(envelope));
4527
+ console.log(
4528
+ JSON.stringify({ ...envelope, result: { written_to: outPath, bytes: imageBytes.length } })
4529
+ );
4200
4530
  } else {
4201
- process.stderr.write(renderEnvelope(envelope) + "\n");
4531
+ process.stderr.write(renderEnvelope(envelope, cmd.verb) + "\n");
4202
4532
  console.log(outPath);
4203
4533
  }
4204
4534
  process.exit(agentExitCode(envelope));
@@ -4207,7 +4537,7 @@ async function runAgentVerb(cmd, positionals, opts) {
4207
4537
  if (opts.json) {
4208
4538
  console.log(JSON.stringify(envelope));
4209
4539
  } else {
4210
- console.log(renderEnvelope(envelope));
4540
+ console.log(renderEnvelope(envelope, cmd.verb));
4211
4541
  if (cmd.verb === "press" && !envelope.ok) process.stderr.write(pressButtonsHelp() + "\n");
4212
4542
  }
4213
4543
  process.exit(agentExitCode(envelope));
@@ -4223,6 +4553,8 @@ export {
4223
4553
  resolveMetro,
4224
4554
  parseBuildId,
4225
4555
  checkDevBuild,
4556
+ describeDevBuild,
4557
+ buildLines,
4226
4558
  buildRows,
4227
4559
  formatBuildRowsHuman,
4228
4560
  listBuilds,
@@ -4243,7 +4575,6 @@ export {
4243
4575
  printOutcomeLine,
4244
4576
  createRokuPush,
4245
4577
  startDevSession,
4246
- ROKU_LIVENESS_INTERVAL_MS,
4247
4578
  PLATFORM_DEVICE,
4248
4579
  dev,
4249
4580
  formatDevicesHuman,