vibeview 0.31.0 → 0.33.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,30 @@ 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
+ }
363
387
 
364
388
  // src/commands/list-builds.ts
365
389
  import chalk from "chalk";
@@ -461,6 +485,10 @@ function writeDevState(cwd, state) {
461
485
  ensureGitignored(cwd);
462
486
  }
463
487
  function readDevState(cwd) {
488
+ const found = readDevStateFile(cwd);
489
+ return found && found.alive ? found.state : null;
490
+ }
491
+ function readDevStateFile(cwd) {
464
492
  let raw;
465
493
  try {
466
494
  raw = readFileSync2(statePath(cwd), "utf-8");
@@ -473,10 +501,10 @@ function readDevState(cwd) {
473
501
  } catch {
474
502
  return null;
475
503
  }
476
- if (typeof parsed.pid !== "number" || !isPidAlive(parsed.pid)) {
504
+ if (!parsed || typeof parsed.pid !== "number" || typeof parsed.session_id !== "string") {
477
505
  return null;
478
506
  }
479
- return parsed;
507
+ return { state: parsed, alive: isPidAlive(parsed.pid) };
480
508
  }
481
509
  function clearDevState(cwd) {
482
510
  const path = statePath(cwd);
@@ -600,16 +628,6 @@ function describeLocalChanges(before, after) {
600
628
  return changes;
601
629
  }
602
630
 
603
- // src/commands/build.ts
604
- import { spawn as spawn2 } from "child_process";
605
- import { createInterface } from "readline";
606
- import { select as select2 } from "@inquirer/prompts";
607
- import chalk3 from "chalk";
608
-
609
- // src/lib/dev-setup.ts
610
- import chalk2 from "chalk";
611
- import { input, select } from "@inquirer/prompts";
612
-
613
631
  // src/lib/project-detect.ts
614
632
  import { existsSync as existsSync2, readFileSync as readFileSync4, readdirSync, statSync } from "fs";
615
633
  import { join as join4 } from "path";
@@ -787,8 +805,72 @@ function productionDefaultsFor(cwd, platform) {
787
805
  }
788
806
  return {};
789
807
  }
808
+ function uniqueMatch(text, re) {
809
+ const found = /* @__PURE__ */ new Set();
810
+ for (const m of text.matchAll(re)) {
811
+ const v = m[1].trim().replace(/^"|"$/g, "");
812
+ if (v && !v.includes("$")) found.add(v);
813
+ }
814
+ return found.size === 1 ? [...found][0] : null;
815
+ }
816
+ function readText(path) {
817
+ try {
818
+ return readFileSync4(path, "utf-8");
819
+ } catch {
820
+ return null;
821
+ }
822
+ }
823
+ function pbxprojText(cwd) {
824
+ try {
825
+ const proj = readdirSync(join4(cwd, "ios")).find((f) => f.endsWith(".xcodeproj"));
826
+ return proj ? readText(join4(cwd, "ios", proj, "project.pbxproj")) : null;
827
+ } catch {
828
+ return null;
829
+ }
830
+ }
831
+ function localAppIdentity(cwd, platform) {
832
+ const rn = readJson(cwd, "node_modules/react-native/package.json");
833
+ const reactNative = typeof rn?.version === "string" ? rn.version : null;
834
+ const android = platform === "android" || platform === "androidtv";
835
+ const ios = platform === "ios" || platform === "tvos";
836
+ let version = null;
837
+ let bundleId = null;
838
+ const dynamic = existsSync2(join4(cwd, "app.config.js")) || existsSync2(join4(cwd, "app.config.ts"));
839
+ const raw = dynamic ? null : readJson(cwd, "app.config.json") ?? readJson(cwd, "app.json");
840
+ const expo = raw ? "expo" in raw ? raw.expo : raw : null;
841
+ if (expo && typeof expo === "object") {
842
+ const cfg = expo;
843
+ if (typeof cfg.version === "string") version = cfg.version;
844
+ const section = android ? cfg.android : ios ? cfg.ios : null;
845
+ const id = android ? section?.package : section?.bundleIdentifier;
846
+ if (typeof id === "string") bundleId = id;
847
+ }
848
+ if (android && (!version || !bundleId)) {
849
+ const gradle = readText(join4(cwd, "android", "app", "build.gradle")) ?? readText(join4(cwd, "android", "app", "build.gradle.kts"));
850
+ if (gradle) {
851
+ version ??= uniqueMatch(gradle, /versionName\s*=?\s*["']([^"']+)["']/g);
852
+ bundleId ??= uniqueMatch(gradle, /applicationId\s*=?\s*["']([^"']+)["']/g);
853
+ }
854
+ }
855
+ if (ios && (!version || !bundleId)) {
856
+ const pbx = pbxprojText(cwd);
857
+ if (pbx) {
858
+ version ??= uniqueMatch(pbx, /MARKETING_VERSION = ([^;]+);/g);
859
+ bundleId ??= uniqueMatch(pbx, /PRODUCT_BUNDLE_IDENTIFIER = ([^;]+);/g);
860
+ }
861
+ }
862
+ return { reactNative, version, bundleId };
863
+ }
864
+
865
+ // src/commands/build.ts
866
+ import { spawn as spawn2 } from "child_process";
867
+ import { createInterface } from "readline";
868
+ import { select as select2 } from "@inquirer/prompts";
869
+ import chalk3 from "chalk";
790
870
 
791
871
  // src/lib/dev-setup.ts
872
+ import chalk2 from "chalk";
873
+ import { input, select } from "@inquirer/prompts";
792
874
  function platformChoices(cwd) {
793
875
  const choices = [
794
876
  { value: "ios", name: "iOS simulator" },
@@ -2355,6 +2437,12 @@ function parseWatchSeconds(flag) {
2355
2437
  }
2356
2438
  return seconds;
2357
2439
  }
2440
+ var AGENT_LEASE_RENEW_MS = 15e3;
2441
+ var AGENT_LEASE_TTL_MS = 6e4;
2442
+ function leaseLapsedReason(gapMs) {
2443
+ if (gapMs <= AGENT_LEASE_TTL_MS) return void 0;
2444
+ 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`;
2445
+ }
2358
2446
  function queuedLine(position, reasonMessage) {
2359
2447
  const where = position ? ` (position ${position})` : "";
2360
2448
  const why = reasonMessage ?? "Every device is busy.";
@@ -2375,9 +2463,14 @@ async function startDevSession(cfg, onEvent = () => {
2375
2463
  let stopping = false;
2376
2464
  let tunnel = null;
2377
2465
  let queueEntryId = null;
2466
+ let lastLeaseAt = Date.now();
2467
+ let leaseTimer = null;
2378
2468
  const stopSession = async (code) => {
2379
2469
  if (stopping) return;
2380
2470
  stopping = true;
2471
+ if (leaseTimer) clearInterval(leaseTimer);
2472
+ leaseTimer = null;
2473
+ const reason = code !== 0 ? leaseLapsedReason(Date.now() - lastLeaseAt) : void 0;
2381
2474
  tunnel?.stop();
2382
2475
  if (queueEntryId !== null) {
2383
2476
  await api.cancelQueueEntry(queueEntryId).catch(() => {
@@ -2386,8 +2479,27 @@ async function startDevSession(cfg, onEvent = () => {
2386
2479
  }
2387
2480
  await api.stopSession(sessionId).catch(() => {
2388
2481
  });
2389
- onEvent({ event: "session_ended", session_id: sessionId, code });
2482
+ onEvent({ event: "session_ended", session_id: sessionId, code, ...reason ? { reason } : {} });
2483
+ };
2484
+ const renewLease = async () => {
2485
+ if (stopping) return;
2486
+ let info;
2487
+ try {
2488
+ info = await api.touchSession(sessionId, { lease: true });
2489
+ } catch {
2490
+ return;
2491
+ }
2492
+ if (stopping) return;
2493
+ if (info.status === "stopped" || info.status === "failed") {
2494
+ void stopSession(1);
2495
+ return;
2496
+ }
2497
+ lastLeaseAt = Date.now();
2390
2498
  };
2499
+ void renewLease();
2500
+ leaseTimer = setInterval(() => void renewLease(), AGENT_LEASE_RENEW_MS);
2501
+ leaseTimer.unref?.();
2502
+ cfg.onCreated?.({ sessionId, stop: () => stopSession(1) });
2391
2503
  try {
2392
2504
  const started = await api.startSession(sessionId);
2393
2505
  if (started.status === "queued") {
@@ -2402,6 +2514,7 @@ async function startDevSession(cfg, onEvent = () => {
2402
2514
  });
2403
2515
  await pollWhileQueued(api, sessionId, QUEUE_WAIT_MS);
2404
2516
  queueEntryId = null;
2517
+ cfg.onDequeued?.();
2405
2518
  }
2406
2519
  const pageUrl = `${cfg.webUrl || cfg.baseUrl}/sandbox/${sessionId}`;
2407
2520
  const reportedWarnings = /* @__PURE__ */ new Set();
@@ -2454,7 +2567,14 @@ async function startDevSession(cfg, onEvent = () => {
2454
2567
  reportedWarnings.add(w);
2455
2568
  onEvent({ event: "warning", message: w });
2456
2569
  }
2457
- return { sessionId, pageUrl, stop: () => stopSession(0), reportedWarnings, session: active };
2570
+ return {
2571
+ sessionId,
2572
+ pageUrl,
2573
+ stop: () => stopSession(0),
2574
+ reportedWarnings,
2575
+ session: active,
2576
+ lapsedReason: () => leaseLapsedReason(Date.now() - lastLeaseAt)
2577
+ };
2458
2578
  } catch (err) {
2459
2579
  await stopSession(1);
2460
2580
  throw err;
@@ -2575,7 +2695,7 @@ async function pollUntilActive(api, sessionId, timeoutMs, intervalMs = 2e3, onIn
2575
2695
  }
2576
2696
  function sessionLivenessVerdict(info) {
2577
2697
  if (info && (info.status === "stopped" || info.status === "failed")) {
2578
- return { end: true, message: `session ${info.status} \u2014 ending the reload loop` };
2698
+ return { end: true, message: `session ${info.status} \u2014 ending the dev session` };
2579
2699
  }
2580
2700
  return { end: false };
2581
2701
  }
@@ -3098,7 +3218,7 @@ async function runDevInProcess(opts) {
3098
3218
  chalk4.dim(" Putting the channel on the Roku \u2014 the link appears once it is up.")
3099
3219
  );
3100
3220
  } else if (evt.event === "session_ended" && !stopping && handle) {
3101
- console.error(chalk4.red("tunnel closed by server \u2014 session ended"));
3221
+ console.error(chalk4.red(evt.reason ?? "session ended by the server"));
3102
3222
  stopping = true;
3103
3223
  endedEmitted = true;
3104
3224
  emit(opts.json, evt);
@@ -3197,6 +3317,8 @@ async function runDevInProcess(opts) {
3197
3317
  process.stdin.pause();
3198
3318
  });
3199
3319
  }
3320
+ }
3321
+ if (rokuBuild || standalone) {
3200
3322
  const sessionId = handle.sessionId;
3201
3323
  const reportedWarnings = handle.reportedWarnings;
3202
3324
  let keepAlive;
@@ -3211,8 +3333,14 @@ async function runDevInProcess(opts) {
3211
3333
  }
3212
3334
  const verdict = sessionLivenessVerdict(info);
3213
3335
  if (verdict.end && !stopping) {
3214
- console.error(chalk4.red(verdict.message));
3215
- emit(opts.json, { event: "session_ended", session_id: sessionId, code: 1 });
3336
+ const reason = handle?.lapsedReason?.();
3337
+ console.error(chalk4.red(reason ?? verdict.message));
3338
+ emit(opts.json, {
3339
+ event: "session_ended",
3340
+ session_id: sessionId,
3341
+ code: 1,
3342
+ ...reason ? { reason } : {}
3343
+ });
3216
3344
  endedEmitted = true;
3217
3345
  await stopSession(1);
3218
3346
  return;
@@ -3658,7 +3786,7 @@ var AGENT_COMMANDS = [
3658
3786
  // iOS system alerts only.
3659
3787
  verb: "alert",
3660
3788
  tool: "alert",
3661
- summary: "Inspect or respond to a visible iOS system alert (get, accept, or dismiss).",
3789
+ 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.",
3662
3790
  mutating: true,
3663
3791
  params: [
3664
3792
  {
@@ -3741,7 +3869,7 @@ var AGENT_COMMANDS = [
3741
3869
  // tools.py open_url: { url: string }.
3742
3870
  verb: "open-url",
3743
3871
  tool: "open_url",
3744
- 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.",
3872
+ 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.",
3745
3873
  mutating: true,
3746
3874
  params: [
3747
3875
  {
@@ -3753,6 +3881,16 @@ var AGENT_COMMANDS = [
3753
3881
  }
3754
3882
  ]
3755
3883
  },
3884
+ {
3885
+ // relaunch_app: {} — terminate the session's app and launch it again the
3886
+ // way the session launched it (same launch params); its data is kept.
3887
+ // Refused on embed sessions (their watchdog owns launching) and on Roku.
3888
+ verb: "relaunch-app",
3889
+ tool: "relaunch_app",
3890
+ 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.",
3891
+ mutating: true,
3892
+ params: []
3893
+ },
3756
3894
  {
3757
3895
  // set_posture: { posture: 'closed' | 'partial' | 'open' } OR { angle: 0..180 }
3758
3896
  // — exactly one (buildToolCall enforces it CLI-side, agent_control.py's
@@ -3799,6 +3937,33 @@ var AGENT_COMMANDS = [
3799
3937
  }
3800
3938
  ]
3801
3939
  },
3940
+ {
3941
+ // set_location: { latitude: -90..90, longitude: -180..180 } — both
3942
+ // required (agent_control.py validate_tool_args refuses anything else).
3943
+ // The same control as the dashboard's Set location. Flags rather than
3944
+ // positionals: a western/southern coordinate starts with `-`, which a
3945
+ // positional would read as an option.
3946
+ verb: "set-location",
3947
+ tool: "set_location",
3948
+ summary: "Set the device's simulated GPS location, for location-based flows. Phones, tablets and foldables (iOS and Android); not available on TV devices.",
3949
+ mutating: true,
3950
+ params: [
3951
+ {
3952
+ name: "latitude",
3953
+ cli: "--lat",
3954
+ type: "number",
3955
+ required: true,
3956
+ description: "Latitude in decimal degrees, -90 to 90."
3957
+ },
3958
+ {
3959
+ name: "longitude",
3960
+ cli: "--lon",
3961
+ type: "number",
3962
+ required: true,
3963
+ description: "Longitude in decimal degrees, -180 to 180 (west is negative)."
3964
+ }
3965
+ ]
3966
+ },
3802
3967
  {
3803
3968
  // tools.py wait: { seconds?: number } (default: 2). Note the wire arg is
3804
3969
  // "seconds", NOT "ms" — tool_dispatch.py reads args.get("seconds", 2)
@@ -3943,6 +4108,14 @@ function resultLines(result) {
3943
4108
  const state = deviceStateLine(r);
3944
4109
  if (state) lines.push(state);
3945
4110
  }
4111
+ if (Array.isArray(r.buttons) && r.buttons.length > 0) {
4112
+ const labels = r.buttons.filter((b) => typeof b === "string");
4113
+ if (labels.length > 0)
4114
+ lines.push(`buttons: ${labels.map((b) => JSON.stringify(b)).join(", ")}`);
4115
+ }
4116
+ if (r.action === "location_set" && typeof r.latitude === "number" && typeof r.longitude === "number") {
4117
+ lines.push(`location: ${r.latitude}, ${r.longitude}`);
4118
+ }
3946
4119
  if (typeof r.note === "string" && r.note) lines.push(chalk6.dim(r.note));
3947
4120
  if (typeof r.message === "string" && r.message) lines.push(r.message);
3948
4121
  if (typeof r.warning === "string" && r.warning) lines.push(r.warning);
@@ -3953,8 +4126,8 @@ function deviceStateLine(r) {
3953
4126
  if (r.action === "rotated") {
3954
4127
  if (typeof r.device_orientation === "string") {
3955
4128
  let o = `device held ${r.device_orientation}`;
3956
- if (typeof r.screen_orientation === "string" && r.screen_orientation !== r.device_orientation) {
3957
- o += ` (picture on the lit screen: ${r.screen_orientation})`;
4129
+ if (typeof r.screen_orientation === "string" && !r.device_orientation.startsWith(r.screen_orientation)) {
4130
+ o += typeof r.posture === "string" ? ` (picture on the lit screen: ${r.screen_orientation})` : ` (screen stayed ${r.screen_orientation})`;
3958
4131
  }
3959
4132
  parts.push(o);
3960
4133
  }
@@ -4190,7 +4363,9 @@ async function runAgentVerb(cmd, positionals, opts) {
4190
4363
  }
4191
4364
  writeFileSync6(outPath, imageBytes);
4192
4365
  if (opts.json) {
4193
- console.log(JSON.stringify(envelope));
4366
+ console.log(
4367
+ JSON.stringify({ ...envelope, result: { written_to: outPath, bytes: imageBytes.length } })
4368
+ );
4194
4369
  } else {
4195
4370
  process.stderr.write(renderEnvelope(envelope) + "\n");
4196
4371
  console.log(outPath);
@@ -4216,17 +4391,23 @@ export {
4216
4391
  loadDevConfig,
4217
4392
  resolveMetro,
4218
4393
  parseBuildId,
4394
+ buildKind,
4219
4395
  checkDevBuild,
4396
+ debugBuildsNewestFirst,
4397
+ pickDevBuild,
4398
+ buildLabel,
4220
4399
  buildRows,
4221
4400
  formatBuildRowsHuman,
4222
4401
  listBuilds,
4223
4402
  readDevState,
4403
+ readDevStateFile,
4224
4404
  clearDevState,
4225
4405
  readMetroState,
4226
4406
  killOwnedMetro,
4227
4407
  checkMetro,
4228
4408
  loadLocalDevConfig,
4229
4409
  effectiveDevConfig,
4410
+ localAppIdentity,
4230
4411
  buildCommand,
4231
4412
  formatDuration,
4232
4413
  BUILD_TERMINAL_STATUSES,
@@ -4236,7 +4417,6 @@ export {
4236
4417
  printOutcomeLine,
4237
4418
  createRokuPush,
4238
4419
  startDevSession,
4239
- ROKU_LIVENESS_INTERVAL_MS,
4240
4420
  PLATFORM_DEVICE,
4241
4421
  dev,
4242
4422
  formatDevicesHuman,
package/dist/cli.js CHANGED
@@ -21,9 +21,10 @@ import {
21
21
  printOutcomeLine,
22
22
  printTransition,
23
23
  readDevState,
24
+ readDevStateFile,
24
25
  readMetroState,
25
26
  runAgentVerb
26
- } from "./chunk-P6UIJALK.js";
27
+ } from "./chunk-D3OZPFJ3.js";
27
28
  import {
28
29
  ApiClient,
29
30
  ApiStatusError,
@@ -44,7 +45,7 @@ import {
44
45
  resolveAuth,
45
46
  saveConfig,
46
47
  uploadApp
47
- } from "./chunk-ZZK7YQSP.js";
48
+ } from "./chunk-4D2E2PE4.js";
48
49
 
49
50
  // src/cli.ts
50
51
  import { createRequire } from "module";
@@ -543,15 +544,20 @@ function formatAction(action) {
543
544
  function formatToolCall(tc) {
544
545
  return formatAction({ tool: tc.name, args: tc.args });
545
546
  }
547
+ function runStartedLine(evt) {
548
+ const steps = `${evt.total_steps} step(s)`;
549
+ const model = chalk2.bold(evt.model || "default");
550
+ if (evt.mode === "replay") return `${chalk2.cyan("\u25B6")} Replay started \u2014 ${steps} (no AI)`;
551
+ if (evt.mode === "hybrid") {
552
+ return `${chalk2.cyan("\u25B6")} Hybrid test started \u2014 ${steps}, replaying the recording, AI fallback model: ${model}`;
553
+ }
554
+ return `${chalk2.cyan("\u25B6")} AI test started \u2014 ${steps}, model: ${model}`;
555
+ }
546
556
  function displayProgressEvent(evt, ctx) {
547
557
  const { spinner } = ctx;
548
558
  switch (evt.event) {
549
559
  case "run_started":
550
- printBelowSpinner(
551
- spinner,
552
- `${chalk2.cyan("\u25B6")} AI test started \u2014 ${evt.total_steps} step(s), model: ${chalk2.bold(evt.model || "default")}`,
553
- " Starting test..."
554
- );
560
+ printBelowSpinner(spinner, runStartedLine(evt), " Starting test...");
555
561
  ctx.totalSteps = evt.total_steps ?? ctx.totalSteps;
556
562
  break;
557
563
  case "step_started": {
@@ -1348,6 +1354,9 @@ async function resolveSteps(options) {
1348
1354
  }
1349
1355
  throw new Error("No steps provided. Use --steps, --steps-file, or pipe JSON to stdin.");
1350
1356
  }
1357
+ function testCaseUrl(baseUrl, publicId) {
1358
+ return `${baseUrl}/tests/cases/${encodeURIComponent(publicId)}`;
1359
+ }
1351
1360
  function assertHasSteps(steps) {
1352
1361
  if (!Array.isArray(steps)) {
1353
1362
  throw new Error(
@@ -1407,7 +1416,7 @@ async function createTest(options) {
1407
1416
  name: result.name,
1408
1417
  app_id: appPublicId || null,
1409
1418
  outcome: result.outcome ?? null,
1410
- url: `${baseUrl}/tests?tab=standalone`
1419
+ url: testCaseUrl(baseUrl, result.public_id)
1411
1420
  },
1412
1421
  null,
1413
1422
  2
@@ -1423,7 +1432,7 @@ async function createTest(options) {
1423
1432
  console.log(` ${chalk10.dim(`Platform: ${result.platform}`)}`);
1424
1433
  }
1425
1434
  console.log(` ${chalk10.dim(`Steps: ${steps.length}`)}`);
1426
- console.log(chalk10.dim(` ${baseUrl}/tests?tab=standalone
1435
+ console.log(chalk10.dim(` ${testCaseUrl(baseUrl, result.public_id)}
1427
1436
  `));
1428
1437
  }
1429
1438
  } catch (err) {
@@ -1519,8 +1528,8 @@ function buildLine(info) {
1519
1528
  return `${info.app_build_id}${version}`;
1520
1529
  }
1521
1530
  async function devStatus(opts) {
1522
- const state = readDevState(process.cwd());
1523
- if (!state) {
1531
+ const found = readDevStateFile(process.cwd());
1532
+ if (!found) {
1524
1533
  if (opts.json) {
1525
1534
  console.log(JSON.stringify({ running: false }));
1526
1535
  } else {
@@ -1531,6 +1540,11 @@ async function devStatus(opts) {
1531
1540
  }
1532
1541
  const { token, baseUrl } = resolveAuth(opts);
1533
1542
  const api = new ApiClient(baseUrl, token);
1543
+ if (!found.alive) {
1544
+ await reportOrphan(api, found.state, opts);
1545
+ return;
1546
+ }
1547
+ const state = found.state;
1534
1548
  let sessionStatus;
1535
1549
  let info;
1536
1550
  try {
@@ -1571,11 +1585,46 @@ async function devStatus(opts) {
1571
1585
  if (metro) console.error(chalk11.dim(`Metro: pid ${metro.pid} on :${metro.port} (CLI-owned)`));
1572
1586
  process.exit(0);
1573
1587
  }
1588
+ var LIVE_STATUSES = /* @__PURE__ */ new Set(["pending", "queued", "starting", "active"]);
1589
+ async function reportOrphan(api, state, opts) {
1590
+ let status;
1591
+ try {
1592
+ status = (await api.getSession(state.session_id)).status;
1593
+ } catch {
1594
+ status = void 0;
1595
+ }
1596
+ const live = status !== void 0 && LIVE_STATUSES.has(status);
1597
+ if (!live && status !== void 0) clearDevState(process.cwd());
1598
+ if (opts.json) {
1599
+ console.log(
1600
+ JSON.stringify({
1601
+ running: false,
1602
+ pid: state.pid,
1603
+ session_id: state.session_id,
1604
+ session_status: status ?? null,
1605
+ orphaned: live
1606
+ })
1607
+ );
1608
+ } else if (live) {
1609
+ console.error(
1610
+ chalk11.yellow(
1611
+ `\u26A0 The dev process (pid ${state.pid}) has exited, but session ${state.session_id} is still ${status}.`
1612
+ )
1613
+ );
1614
+ console.error(` End it: vibeview stop ${state.session_id}`);
1615
+ } else {
1616
+ console.error(
1617
+ `No dev session running \u2014 the dev process (pid ${state.pid}) has exited` + (status ? ` and session ${state.session_id} is ${status}.` : ".")
1618
+ );
1619
+ }
1620
+ process.exit(1);
1621
+ }
1574
1622
 
1575
1623
  // src/commands/dev-stop.ts
1576
1624
  async function devStop(opts) {
1577
- const state = readDevState(process.cwd());
1578
- if (!state) {
1625
+ const found = readDevStateFile(process.cwd());
1626
+ const state = found?.state;
1627
+ if (!found || !state) {
1579
1628
  if (opts.json) {
1580
1629
  console.log(JSON.stringify({ stopped: false, reason: "no_dev_session" }));
1581
1630
  } else {
@@ -1588,10 +1637,12 @@ async function devStop(opts) {
1588
1637
  const api = new ApiClient(baseUrl, token);
1589
1638
  await api.stopSession(state.session_id).catch(() => {
1590
1639
  });
1591
- try {
1592
- process.kill(state.pid, "SIGTERM");
1593
- } catch (err) {
1594
- if (err.code !== "ESRCH") throw err;
1640
+ if (found.alive) {
1641
+ try {
1642
+ process.kill(state.pid, "SIGTERM");
1643
+ } catch (err) {
1644
+ if (err.code !== "ESRCH") throw err;
1645
+ }
1595
1646
  }
1596
1647
  clearDevState(process.cwd());
1597
1648
  killOwnedMetro(process.cwd());
@@ -4028,7 +4079,7 @@ program.command("dev-reload").description(
4028
4079
  program.command("mcp").description(
4029
4080
  "Run an MCP stdio server exposing the agent verbs (tap, swipe, type, ...) plus dev_start/dev_stop/dev_reload as tools"
4030
4081
  ).option("--token <token>", "API token (or set VIBEVIEW_API_TOKEN)").action(async (opts) => {
4031
- const { runMcpServer } = await import("./server-GPFOZOFL.js");
4082
+ const { runMcpServer } = await import("./server-GHCQHKYX.js");
4032
4083
  await runMcpServer({ ...opts, version: pkg.version });
4033
4084
  });
4034
4085
  program.command("agent-setup").description(
@@ -4,12 +4,14 @@ import {
4
4
  DEV_PLATFORMS,
5
5
  PLATFORM_DEVICE,
6
6
  PLATFORM_NEEDS_METRO,
7
- ROKU_LIVENESS_INTERVAL_MS,
7
+ buildKind,
8
+ buildLabel,
8
9
  buildRows,
9
10
  buildToolCall,
10
11
  checkDevBuild,
11
12
  checkMetro,
12
13
  createRokuPush,
14
+ debugBuildsNewestFirst,
13
15
  decodeScreenshotResult,
14
16
  effectiveDevConfig,
15
17
  filterDeviceModels,
@@ -18,18 +20,20 @@ import {
18
20
  isDevPlatform,
19
21
  loadDevConfig,
20
22
  loadLocalDevConfig,
23
+ localAppIdentity,
21
24
  parseBuildId,
25
+ pickDevBuild,
22
26
  platformWithArticle,
23
27
  readDevState,
24
28
  renderEnvelope,
25
29
  resolveMetro,
26
30
  startDevSession
27
- } from "./chunk-P6UIJALK.js";
31
+ } from "./chunk-D3OZPFJ3.js";
28
32
  import {
29
33
  ApiClient,
30
34
  NotFoundError,
31
35
  resolveAuth
32
- } from "./chunk-ZZK7YQSP.js";
36
+ } from "./chunk-4D2E2PE4.js";
33
37
 
34
38
  // src/mcp/server.ts
35
39
  import { writeFileSync } from "fs";
@@ -45,14 +49,25 @@ function isMcpDevPlatform(p) {
45
49
  return isDevPlatform(p) && MCP_DEV_PLATFORMS.includes(p);
46
50
  }
47
51
  var MCP_PARAM_DESCRIPTIONS = {
48
- wait: { seconds: "How long to wait, in seconds. Default: 2." }
52
+ wait: { seconds: "How long to wait, in seconds. Default: 2." },
53
+ screenshot: {
54
+ out: "File path to write the PNG to on the machine running this server (default: a temp file)."
55
+ }
49
56
  };
50
57
  function paramToProperty(cmd, param) {
51
58
  return {
52
59
  type: param.type,
53
- description: MCP_PARAM_DESCRIPTIONS[cmd.verb]?.[param.name] ?? param.description
60
+ description: MCP_PARAM_DESCRIPTIONS[cmd.verb]?.[param.name] ?? mcpText(cmd, param.description)
54
61
  };
55
62
  }
63
+ function mcpText(cmd, text) {
64
+ let out = text.replace(/`?(?:vibeview )?list-devices --models`?/g, "list_device_models").replace(/`vibeview ([a-z-]+)`/g, (_, verb) => toolNameFor(verb));
65
+ for (const param of cmd.params) {
66
+ if (param.cli === "positional") continue;
67
+ out = out.replace(new RegExp(`${param.cli}(?![a-z-])`, "g"), param.name);
68
+ }
69
+ return out;
70
+ }
56
71
  function toolNameFor(verb) {
57
72
  return verb.replace(/-/g, "_");
58
73
  }
@@ -66,12 +81,12 @@ function toolDefFromCommand(cmd) {
66
81
  if (cmd.verb === "screenshot") {
67
82
  properties.inline = {
68
83
  type: "boolean",
69
- description: "Return the image itself so you can see it, not just the file path. Set this to true whenever you need to judge how the screen LOOKS, or whenever you cannot open files on the machine running this server \u2014 otherwise the screenshot is only written to disk and you never see it. It costs a lot of context, so skip it when you only need to confirm an action happened (the response already tells you what changed)."
84
+ description: "Return the image itself so you can see it, not just the file path. Set this to true whenever you need to judge how the screen LOOKS, or whenever you cannot open files on the machine running this server \u2014 otherwise the screenshot is only written to disk and you never see it. It costs context, so skip it when you only need to confirm an action happened (the response already tells you what changed). The image has the same size as the screenshot (a JPEG when the PNG is large), so a point read off it is the coordinate tap and drag take."
70
85
  };
71
86
  }
72
87
  return {
73
88
  name: toolNameFor(cmd.verb),
74
- description: cmd.summary,
89
+ description: mcpText(cmd, cmd.summary),
75
90
  mutating: cmd.mutating,
76
91
  properties,
77
92
  required
@@ -205,8 +220,15 @@ function errorResult(message) {
205
220
  return { content: [{ type: "text", text: forMcp(message) }], isError: true };
206
221
  }
207
222
  function forMcp(message) {
208
- let text = message.replace(/`vibeview list-devices --models`/g, "list_device_models").replace(/`vibeview list-builds <app>`/g, "list_builds").replace(/`vibeview list-builds ([^`\s]+)`/g, 'list_builds (app "$1")').replace(/`vibeview list-apps`/g, "list_apps").replace(/\blist-apps\b/g, "list_apps").replace(/`vibeview upload-app`/g, "upload_app").replace(/`vibeview dev` needs/g, "dev_start needs");
223
+ let text = message.replace(/`vibeview list-devices --models`/g, "list_device_models").replace(/`vibeview list-builds <app>`/g, "list_builds").replace(/`vibeview list-builds ([^`\s]+)`/g, 'list_builds with app "$1"').replace(
224
+ /use `vibeview dev --standalone --build (\d+)`/g,
225
+ "call dev_start with standalone: true and build_id: $1"
226
+ ).replace(
227
+ /Set VIBEVIEW_API_TOKEN \(or --token\) to a full-access token for this command\./g,
228
+ "Start this MCP server with a full-access token (VIBEVIEW_API_TOKEN or --token)."
229
+ ).replace(/`vibeview list-apps`/g, "list_apps").replace(/\blist-apps\b/g, "list_apps").replace(/`vibeview upload-app`/g, "upload_app").replace(/`vibeview dev` needs/g, "dev_start needs");
209
230
  if (/No debug build|is not a debug build/.test(text) && !/standalone: true/.test(text)) {
231
+ text = `${text.replace(/\s+$/, "")}${/[.!?]$/.test(text.trim()) ? "" : "."}`;
210
232
  text += " To run a release build as it is, with no Metro, call dev_start with standalone: true.";
211
233
  }
212
234
  return text;
@@ -244,9 +266,9 @@ async function explainInactive(sessionId, deps) {
244
266
  function findCommand(toolName) {
245
267
  return AGENT_COMMANDS.find((c) => toolNameFor(c.verb) === toolName);
246
268
  }
247
- async function handleToolCall(toolName, args, deps) {
269
+ async function handleToolCall(toolName, args, deps, extra = {}) {
248
270
  if (toolName === "dev_start") {
249
- return handleDevStart(args, deps);
271
+ return handleDevStart(args, deps, extra);
250
272
  }
251
273
  if (toolName === "dev_stop") {
252
274
  return handleDevStop(deps);
@@ -287,6 +309,9 @@ async function handleToolCall(toolName, args, deps) {
287
309
  const positionals = positionalsFor(cmd, rest);
288
310
  const opts = optsFor(cmd, rest);
289
311
  call = buildToolCall(cmd, positionals, opts);
312
+ if (cmd.verb === "screenshot" && args.inline === true) {
313
+ call = { ...call, args: { ...call.args, preview: true } };
314
+ }
290
315
  } catch (err) {
291
316
  return errorResult(`error: ${err instanceof Error ? err.message : String(err)}`);
292
317
  }
@@ -307,9 +332,10 @@ async function handleToolCall(toolName, args, deps) {
307
332
  return textResult(renderEnvelope(envelope), envelope, !envelope.ok);
308
333
  }
309
334
  function screenshotToolResult(envelope, args) {
335
+ const { image, preview } = splitScreenshotResult(envelope.result);
310
336
  let imageBytes;
311
337
  try {
312
- imageBytes = decodeScreenshotResult(envelope.result);
338
+ imageBytes = decodeScreenshotResult(image);
313
339
  } catch (err) {
314
340
  return errorResult(`error: ${err instanceof Error ? err.message : String(err)}`);
315
341
  }
@@ -325,15 +351,54 @@ function screenshotToolResult(envelope, args) {
325
351
  ...envelope,
326
352
  result: { written_to: outPath, bytes: imageBytes.length }
327
353
  };
328
- const content = [
329
- { type: "text", text: `${renderEnvelope(sanitized)}
330
- screenshot written to ${outPath}` }
331
- ];
354
+ const lines = [`${renderEnvelope(sanitized)}
355
+ screenshot written to ${outPath}`];
356
+ let inlineBlock = null;
332
357
  if (args.inline === true) {
333
- content.push({ type: "image", data: imageBytes.toString("base64"), mimeType: "image/png" });
358
+ if (preview) {
359
+ inlineBlock = { type: "image", data: preview.data, mimeType: preview.mimeType };
360
+ lines.push(
361
+ `inline image: ${preview.mimeType === "image/jpeg" ? "JPEG" : "PNG"} ${preview.width}x${preview.height}, ${kb(preview.bytes)} \u2014 the same size as the saved PNG, so a point read off it is the coordinate tap and drag take`
362
+ );
363
+ } else if (imageBytes.length <= INLINE_PNG_MAX_BYTES) {
364
+ inlineBlock = { type: "image", data: imageBytes.toString("base64"), mimeType: "image/png" };
365
+ } else {
366
+ lines.push(
367
+ `inline image left out: the PNG is ${kb(imageBytes.length)}, too large to send inline \u2014 open the file instead`
368
+ );
369
+ }
334
370
  }
371
+ const content = [{ type: "text", text: lines.join("\n") }];
372
+ if (inlineBlock) content.push(inlineBlock);
335
373
  return { content, structuredContent: sanitized };
336
374
  }
375
+ var INLINE_PNG_MAX_BYTES = 15e5;
376
+ function kb(bytes) {
377
+ return bytes >= 1e6 ? `${(bytes / 1e6).toFixed(1)} MB` : `${Math.round(bytes / 1e3)} KB`;
378
+ }
379
+ function splitScreenshotResult(result) {
380
+ if (!result || typeof result !== "object" || !("image" in result)) {
381
+ return { image: result, preview: null };
382
+ }
383
+ const r = result;
384
+ const p = r.preview;
385
+ const url = typeof p?.data_url === "string" ? p.data_url : "";
386
+ const comma = url.indexOf(",");
387
+ if (!p || comma < 0 || typeof p.mime_type !== "string" || typeof p.width !== "number" || typeof p.height !== "number") {
388
+ return { image: r.image, preview: null };
389
+ }
390
+ const data = url.slice(comma + 1);
391
+ return {
392
+ image: r.image,
393
+ preview: {
394
+ data,
395
+ mimeType: p.mime_type,
396
+ width: p.width,
397
+ height: p.height,
398
+ bytes: typeof p.bytes === "number" ? p.bytes : Math.floor(data.length * 3 / 4)
399
+ }
400
+ };
401
+ }
337
402
  function positionalsFor(cmd, args) {
338
403
  return cmd.params.filter((p) => p.cli === "positional").map((p) => args[p.name] === void 0 ? void 0 : String(args[p.name])).filter((v) => v !== void 0);
339
404
  }
@@ -348,7 +413,7 @@ function optsFor(cmd, args) {
348
413
  }
349
414
  return opts;
350
415
  }
351
- async function handleDevStart(args, deps) {
416
+ async function handleDevStart(args, deps, extra = {}) {
352
417
  const platform = args.platform;
353
418
  if (!isMcpDevPlatform(platform)) {
354
419
  return errorResult(
@@ -373,7 +438,8 @@ async function handleDevStart(args, deps) {
373
438
  metroPort: typeof args.metro_port === "number" ? args.metro_port : void 0,
374
439
  model: typeof args.model === "string" && args.model.trim() ? args.model : void 0,
375
440
  ...buildId !== void 0 ? { buildId } : {},
376
- ...standalone ? { standalone } : {}
441
+ ...standalone ? { standalone } : {},
442
+ ...extra.progress ? { onProgress: extra.progress } : {}
377
443
  });
378
444
  const warnings = result.warnings ?? [];
379
445
  const payload = {
@@ -381,17 +447,56 @@ async function handleDevStart(args, deps) {
381
447
  page_url: result.pageUrl,
382
448
  standalone,
383
449
  device: result.device ?? null,
450
+ build: result.build ?? null,
451
+ build_reason: result.buildReason ?? null,
452
+ other_debug_builds: result.otherBuilds ?? [],
453
+ project: result.project ?? null,
454
+ queued_seconds: result.queuedSeconds ?? null,
384
455
  warnings
385
456
  };
386
457
  const lines = [`${standalone ? "session" : "dev session"} started: ${payload.session_id}`];
387
458
  const device = result.device ? deviceLine(result.device) : null;
388
459
  if (device) lines.push(device);
460
+ lines.push(...buildLines(result, standalone));
461
+ if (typeof result.queuedSeconds === "number") {
462
+ lines.push(`waited ${Math.round(result.queuedSeconds)} s in the queue for a device`);
463
+ }
389
464
  lines.push(payload.page_url, ...warnings.map((w) => `warning: ${w}`));
390
465
  return textResult(lines.join("\n"), payload);
391
466
  } catch (err) {
392
467
  return errorResult(`error: ${err instanceof Error ? err.message : String(err)}`);
393
468
  }
394
469
  }
470
+ function buildLines(result, standalone) {
471
+ const lines = [];
472
+ if (result.build) {
473
+ lines.push(`build: ${buildLabel(result.build)}`);
474
+ }
475
+ if (result.buildReason) lines.push(`picked that build because ${result.buildReason}`);
476
+ const others = result.otherBuilds ?? [];
477
+ if (others.length > 0) {
478
+ lines.push(
479
+ `other debug builds: ${others.map((b) => buildLabel(b)).join("; ")} \u2014 pass build_id to run one`
480
+ );
481
+ }
482
+ const rn = result.project?.reactNative;
483
+ if (!standalone && rn) {
484
+ lines.push(
485
+ `this project: react-native ${rn}. If the app shows "React Native version mismatch", the build was made with another React Native: run a build made from this project (build_id, see list_builds).`
486
+ );
487
+ }
488
+ return lines;
489
+ }
490
+ function startedBuild(b) {
491
+ return {
492
+ id: b.id,
493
+ version: b.version ?? null,
494
+ build_number: b.build_number ?? null,
495
+ kind: buildKind(b),
496
+ uploaded_at: b.uploaded_at ?? null,
497
+ note: b.note ?? null
498
+ };
499
+ }
395
500
  function deviceLine(d) {
396
501
  const name = d.model || d.name;
397
502
  if (!name) return null;
@@ -542,7 +647,7 @@ async function handleUploadApp(args, deps) {
542
647
  }
543
648
  const name = typeof args.name === "string" && args.name ? args.name : void 0;
544
649
  const upload = deps.uploadApp ?? (async (f, n) => {
545
- const { uploadAppBuild } = await import("./upload-app-2LVG4LMB.js");
650
+ const { uploadAppBuild } = await import("./upload-app-AKD73742.js");
546
651
  const { result: result2 } = await uploadAppBuild(f, { ...deps.auth, name: n });
547
652
  return result2;
548
653
  });
@@ -575,16 +680,69 @@ async function handleUploadApp(args, deps) {
575
680
  ];
576
681
  return textResult(lines.join("\n"), payload);
577
682
  }
683
+ var START_PROGRESS_INTERVAL_MS = 1e4;
684
+ function startProgress(report) {
685
+ const started = Date.now();
686
+ let queuedAt = null;
687
+ let dequeuedAt = null;
688
+ let phase = "starting the device";
689
+ let timer = null;
690
+ const say = (message) => {
691
+ try {
692
+ report?.(message);
693
+ } catch {
694
+ }
695
+ };
696
+ if (report) {
697
+ timer = setInterval(() => {
698
+ const since = Math.round(
699
+ (Date.now() - (queuedAt !== null && dequeuedAt === null ? queuedAt : started)) / 1e3
700
+ );
701
+ say(`${phase} (${since} s so far)`);
702
+ }, START_PROGRESS_INTERVAL_MS);
703
+ timer.unref?.();
704
+ }
705
+ return {
706
+ event(evt) {
707
+ if (evt.event === "queued") {
708
+ queuedAt = Date.now();
709
+ const where = evt.position ? ` at position ${evt.position}` : "";
710
+ phase = `waiting in the queue for a device${where}`;
711
+ say(
712
+ `${evt.message ?? "Every device of this kind is busy."} Waiting in the queue${where}; dev_start returns once a device is free.`
713
+ );
714
+ } else if (evt.event === "tunnel_connected") {
715
+ phase = "connected to Metro; waiting for the app to be installed";
716
+ say("connected to Metro; the device is starting and the app is being installed");
717
+ } else if (evt.event === "installing") {
718
+ phase = "installing the app";
719
+ say("the device is up; installing the app");
720
+ }
721
+ },
722
+ dequeued() {
723
+ dequeuedAt = Date.now();
724
+ phase = "got a device; starting it";
725
+ if (queuedAt !== null)
726
+ say(
727
+ `got a device after ${Math.round((dequeuedAt - queuedAt) / 1e3)} s in the queue; starting it`
728
+ );
729
+ },
730
+ queuedSeconds() {
731
+ if (queuedAt === null) return null;
732
+ return ((dequeuedAt ?? Date.now()) - queuedAt) / 1e3;
733
+ },
734
+ done() {
735
+ if (timer) clearInterval(timer);
736
+ timer = null;
737
+ }
738
+ };
739
+ }
578
740
  function createDevSessionController(auth, webUrl) {
579
741
  let handle = null;
580
742
  let platform = null;
581
743
  let ended = null;
582
- let presence = null;
583
744
  let reloading = null;
584
- const stopPresence = () => {
585
- if (presence) clearInterval(presence);
586
- presence = null;
587
- };
745
+ let pending = null;
588
746
  const readRokuBuild = () => {
589
747
  try {
590
748
  return loadDevConfig(process.cwd())?.platforms?.roku?.build ?? null;
@@ -597,10 +755,9 @@ function createDevSessionController(auth, webUrl) {
597
755
  const markEnded = (e) => {
598
756
  if (!handle || handle.sessionId !== e.sessionId) return;
599
757
  const dead = handle;
600
- stopPresence();
601
758
  handle = null;
602
759
  platform = null;
603
- ended = e;
760
+ ended = { ...e, reason: e.reason ?? dead.lapsedReason?.() ?? null };
604
761
  void dead.stop().catch(() => {
605
762
  });
606
763
  };
@@ -662,71 +819,116 @@ function createDevSessionController(auth, webUrl) {
662
819
  );
663
820
  }
664
821
  }
665
- if (cfg.buildId !== void 0) {
666
- checkDevBuild(app, cfg.buildId, cfg.platform, { standalone });
822
+ let buildId = cfg.buildId;
823
+ if (buildId !== void 0) {
824
+ checkDevBuild(app, buildId, cfg.platform, { standalone });
825
+ }
826
+ const project = usesMetro ? localAppIdentity(process.cwd(), cfg.platform) : null;
827
+ let buildReason = null;
828
+ if (buildId === void 0 && usesMetro) {
829
+ const picked = pickDevBuild(app, project?.version ?? null);
830
+ if (picked?.reason) {
831
+ buildId = picked.build.id;
832
+ buildReason = picked.reason;
833
+ }
667
834
  }
668
835
  const isTv = PLATFORM_DEVICE[cfg.platform].tv;
669
836
  const warnings = [];
837
+ if (project?.bundleId && app.package_name && project.bundleId !== app.package_name) {
838
+ warnings.push(
839
+ `this project's ${isTv || cfg.platform.startsWith("android") ? "package name" : "bundle id"} is ${project.bundleId}, but app ${app.public_id} is ${app.package_name} \u2014 check that dev_start runs the app this project builds (list_apps)`
840
+ );
841
+ }
842
+ const progress = startProgress(cfg.onProgress);
670
843
  let started = null;
671
- started = await startDevSession(
672
- {
673
- token: auth.token,
674
- baseUrl: auth.baseUrl,
675
- webUrl,
676
- platform: cfg.platform,
677
- appId,
678
- deviceCategory: isTv ? "tv" : entry?.deviceCategory ?? "phone",
679
- // A named model decides the category itself (startDevSession).
680
- model: cfg.model,
681
- buildId: cfg.buildId,
682
- ...standalone ? { standalone } : {},
683
- metroPort,
684
- open: false
685
- },
686
- (evt) => {
687
- if (evt.event === "warning") warnings.push(evt.message);
688
- if (evt.event === "session_ended" && evt.code !== 0 && started) {
689
- const id = evt.session_id;
690
- void api().getSession(id).then(
691
- (info) => ({ status: info.status, reason: info.failure_reason ?? null }),
692
- () => ({ status: "stopped", reason: null })
693
- ).then(({ status, reason }) => markEnded({ sessionId: id, status, reason }));
844
+ try {
845
+ started = await startDevSession(
846
+ {
847
+ token: auth.token,
848
+ baseUrl: auth.baseUrl,
849
+ webUrl,
850
+ platform: cfg.platform,
851
+ appId,
852
+ deviceCategory: isTv ? "tv" : entry?.deviceCategory ?? "phone",
853
+ // A named model decides the category itself (startDevSession).
854
+ model: cfg.model,
855
+ buildId,
856
+ ...standalone ? { standalone } : {},
857
+ metroPort,
858
+ open: false,
859
+ onCreated: (created) => {
860
+ pending = created;
861
+ },
862
+ onDequeued: () => progress.dequeued()
863
+ },
864
+ (evt) => {
865
+ progress.event(evt);
866
+ if (evt.event === "warning") warnings.push(evt.message);
867
+ if (evt.event === "session_ended" && evt.code !== 0 && started) {
868
+ const id = evt.session_id;
869
+ void api().getSession(id).then(
870
+ (info) => ({ status: info.status, reason: info.failure_reason ?? null }),
871
+ () => ({ status: "stopped", reason: null })
872
+ ).then(({ status, reason }) => markEnded({ sessionId: id, status, reason }));
873
+ }
694
874
  }
695
- }
696
- );
875
+ );
876
+ } finally {
877
+ progress.done();
878
+ pending = null;
879
+ }
697
880
  handle = started;
698
881
  platform = cfg.platform;
699
882
  ended = null;
700
- if (!usesMetro) {
701
- const client = api();
702
- const sessionId = handle.sessionId;
703
- presence = setInterval(() => {
704
- client.touchSession(sessionId).then(
705
- (info) => {
706
- if (info && isEndedStatus(info.status)) {
707
- markEnded({ sessionId, status: info.status, reason: info.failure_reason ?? null });
708
- }
709
- },
710
- () => {
711
- }
712
- );
713
- }, ROKU_LIVENESS_INTERVAL_MS);
714
- presence.unref?.();
715
- }
883
+ const installedId = handle.session?.app_build_id ?? buildId ?? null;
884
+ const installed = app.builds.find((b) => b.id === installedId);
885
+ const others = usesMetro ? debugBuildsNewestFirst(app).filter((b) => b.id !== installedId) : [];
716
886
  return {
717
887
  sessionId: handle.sessionId,
718
888
  pageUrl: handle.pageUrl,
719
889
  device: startedDevice(handle.session),
890
+ build: installed ? startedBuild(installed) : typeof installedId === "number" ? {
891
+ id: installedId,
892
+ version: handle.session?.app_version ?? null,
893
+ build_number: handle.session?.app_build_number ?? null,
894
+ kind: "unknown",
895
+ uploaded_at: null,
896
+ note: null
897
+ } : null,
898
+ buildReason,
899
+ otherBuilds: others.slice(0, 3).map(startedBuild),
900
+ project,
901
+ queuedSeconds: progress.queuedSeconds(),
720
902
  warnings
721
903
  };
722
904
  },
723
905
  async stop() {
724
- stopPresence();
725
906
  await handle?.stop();
726
907
  handle = null;
727
908
  platform = null;
728
909
  ended = null;
729
910
  },
911
+ async shutdown() {
912
+ const stopped = [];
913
+ const held = handle;
914
+ const starting = pending;
915
+ handle = null;
916
+ platform = null;
917
+ pending = null;
918
+ await Promise.all([
919
+ held ? held.stop().then(
920
+ () => void stopped.push(held.sessionId),
921
+ () => {
922
+ }
923
+ ) : null,
924
+ starting ? starting.stop().then(
925
+ () => void stopped.push(starting.sessionId),
926
+ () => {
927
+ }
928
+ ) : null
929
+ ]);
930
+ return stopped;
931
+ },
730
932
  current() {
731
933
  return handle ? { sessionId: handle.sessionId, pageUrl: handle.pageUrl } : null;
732
934
  },
@@ -803,6 +1005,7 @@ var MCP_INSTRUCTIONS = [
803
1005
  "VibeView runs your app on a cloud iOS simulator, Android emulator, Apple TV, Android TV or Roku (beta) device you control with these tools.",
804
1006
  "Loop: dev_start (platform, app from list_apps; add standalone: true for a release or CI build that needs no Metro) -> ui_tree -> act with refs (tap @e5, type, swipe, press) and read the change each action reports -> dev_stop when done.",
805
1007
  "Foldables: pick one by model in dev_start (list_device_models marks them foldable, with free/busy counts), then set_posture and rotate.",
1008
+ "set_location sets the simulated GPS position (phones and tablets) for location-based flows.",
806
1009
  "screenshot writes a PNG and returns its path; pass inline: true to see the image. logs reads the app output after a crash or blank screen.",
807
1010
  "A session stopped elsewhere is reported as ended; call dev_start again for a new one."
808
1011
  ].join("\n");
@@ -841,30 +1044,89 @@ async function createMcpServer(opts) {
841
1044
  inputSchema: zodShape(def),
842
1045
  annotations: { readOnlyHint: !def.mutating }
843
1046
  },
844
- async (args) => {
845
- const result = await handleToolCall(def.name, args ?? {}, deps);
1047
+ async (args, extra) => {
1048
+ const result = await handleToolCall(def.name, args ?? {}, deps, {
1049
+ progress: progressReporter(extra)
1050
+ });
846
1051
  return result;
847
1052
  }
848
1053
  );
849
1054
  }
850
- return server;
1055
+ return { server, devSession };
1056
+ }
1057
+ function progressReporter(extra) {
1058
+ const token = extra?._meta?.progressToken;
1059
+ const send = extra?.sendNotification;
1060
+ if (token === void 0 || !send) return void 0;
1061
+ let step = 0;
1062
+ return (message) => {
1063
+ step += 1;
1064
+ void send({
1065
+ method: "notifications/progress",
1066
+ params: { progressToken: token, progress: step, message }
1067
+ }).catch(() => {
1068
+ });
1069
+ };
1070
+ }
1071
+ var SHUTDOWN_TIMEOUT_MS = 5e3;
1072
+ function createShutdown(devSession, opts = {}) {
1073
+ const exit = opts.exit ?? ((code) => process.exit(code));
1074
+ const log = opts.log ?? ((line) => console.error(line));
1075
+ const timeoutMs = opts.timeoutMs ?? SHUTDOWN_TIMEOUT_MS;
1076
+ let running = null;
1077
+ return (reason) => {
1078
+ running ??= (async () => {
1079
+ let timer;
1080
+ const timedOut = new Promise((resolve) => {
1081
+ timer = setTimeout(() => resolve("timeout"), timeoutMs);
1082
+ });
1083
+ const outcome = await Promise.race([
1084
+ devSession.shutdown().catch(() => []),
1085
+ timedOut
1086
+ ]);
1087
+ clearTimeout(timer);
1088
+ if (outcome === "timeout") {
1089
+ log(
1090
+ `vibeview mcp: ${reason}; could not confirm the session stopped within ${timeoutMs / 1e3} s`
1091
+ );
1092
+ } else if (outcome.length > 0) {
1093
+ log(`vibeview mcp: ${reason}; stopped session ${outcome.join(", ")}`);
1094
+ }
1095
+ exit(0);
1096
+ })();
1097
+ return running;
1098
+ };
851
1099
  }
852
1100
  async function runMcpServer(opts) {
853
1101
  chalk.level = 0;
854
- const [server, { StdioServerTransport }] = await Promise.all([
1102
+ const [{ server, devSession }, { StdioServerTransport }] = await Promise.all([
855
1103
  createMcpServer(opts),
856
1104
  import("@modelcontextprotocol/sdk/server/stdio.js")
857
1105
  ]);
858
1106
  const transport = new StdioServerTransport();
1107
+ const shutdown = createShutdown(devSession);
1108
+ process.stdin.once("end", () => void shutdown("the client closed the connection"));
1109
+ process.stdin.once("close", () => void shutdown("the client closed the connection"));
1110
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
1111
+ process.once(signal, () => void shutdown(`received ${signal}`));
1112
+ }
1113
+ server.server.onclose = () => void shutdown("the connection closed");
859
1114
  await server.connect(transport);
860
1115
  }
861
1116
  export {
862
1117
  MCP_INSTRUCTIONS,
1118
+ SHUTDOWN_TIMEOUT_MS,
1119
+ START_PROGRESS_INTERVAL_MS,
1120
+ buildLines,
863
1121
  buildToolDefinitions,
864
1122
  createDevSessionController,
1123
+ createShutdown,
865
1124
  deviceLine,
866
1125
  forMcp,
867
1126
  handleToolCall,
1127
+ mcpText,
1128
+ progressReporter,
868
1129
  runMcpServer,
1130
+ startProgress,
869
1131
  startedDevice
870
1132
  };
@@ -3,7 +3,7 @@ import {
3
3
  describeBuild,
4
4
  uploadApp,
5
5
  uploadAppBuild
6
- } from "./chunk-ZZK7YQSP.js";
6
+ } from "./chunk-4D2E2PE4.js";
7
7
  export {
8
8
  describeBuild,
9
9
  uploadApp,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibeview",
3
- "version": "0.31.0",
3
+ "version": "0.33.0",
4
4
  "description": "VibeView CLI -- run your app on cloud iOS simulators, Android emulators, Apple TV, Android TV and Roku (beta) devices: a React Native dev loop, device control for you or your coding agent (CLI or MCP), and tests from CI",
5
5
  "mcpName": "io.github.vibeview/vibeview",
6
6
  "type": "module",
@@ -115,7 +115,9 @@ appear unexplained in their `git status`.
115
115
  vibeview screenshot --out ./check.png --session <id> # then open it
116
116
  ```
117
117
  Over MCP, `screenshot` only writes the file and returns its path unless
118
- you pass `inline: true`, which returns the image itself. If you cannot
118
+ you pass `inline: true`, which returns the image itself (under 1 MB — a
119
+ JPEG when the PNG is large — at the same size, so its pixels are still tap
120
+ coordinates). If you cannot
119
121
  open files on the machine running the server, `inline: true` is the only
120
122
  way you will ever see the screen. It costs a lot of context, so use it
121
123
  when appearance matters — not to confirm a tap landed, which the
@@ -161,6 +163,9 @@ appear unexplained in their `git status`.
161
163
  ```
162
164
  On Apple TV this also covers the "Open in …?" prompt a deep link raises;
163
165
  the button is answered with the remote.
166
+ - Use `set-location --lat 51.5074 --lon -0.1278` to place the device before
167
+ a location-based flow (maps, store finders, region-gated screens); the app
168
+ reads it like a GPS fix. Not on TV.
164
169
  - Use `open-url` to jump straight to a deep link instead of navigating by
165
170
  hand:
166
171
  ```bash
@@ -190,10 +195,11 @@ appear unexplained in their `git status`.
190
195
  angle can keep the inner screen lit on the way down), or the new
191
196
  orientation. The posture names come from each device's own angle ranges,
192
197
  so trust the reported `posture` over the angle you asked for: the Duo
193
- reads 120-169 as `partial`, 170 up as `open`, and 1-119 by direction of
194
- travel; an Android foldable uses its own ranges (the Pixel 9 Pro Fold: below
195
- 30 `closed`, 30-149 `partial`, 150 up `open`) and its inner screen whenever
196
- it is not closed. An Android foldable rotates like a phone. Folding
198
+ reads 0 as `closed` and 170 up as `open`; in between it depends on how the
199
+ hinge got there (moved in one go it switches at about 85 degrees, moved in
200
+ small steps it keeps the screen it had); an Android foldable uses its own
201
+ ranges (the Pixel 9 Pro Fold: 0-30 `closed`, 31-149 `partial`, 150 up
202
+ `open`) and its inner screen whenever it is not closed. An Android foldable rotates like a phone. Folding
197
203
  switches screens of different sizes and rotating turns the picture, so run
198
204
  `ui-tree` afterwards — old refs are stale. On other phones and tablets
199
205
  `rotate` toggles portrait/landscape (only 90 is accepted); TV devices don't
@@ -266,9 +272,11 @@ human-readable text.
266
272
  | `type` | `<text>` | Type text into the currently focused input field. On a phone or tablet a line break presses Enter and submits the field (`$'query\n'`), like `press enter`. |
267
273
  | `clear-text` | — | Clear the text in the currently focused input field. |
268
274
  | `press` | `<button>` | Press a device button or perform a system gesture (home, back, d-pad, etc). |
269
- | `open-url` | `<url>` | Open a deep link or URL in the app under test. The link goes to that app only; a link it has no screen for fails (no browser). |
275
+ | `open-url` | `<url>` | Open a deep link in the app under test, never a browser. Android: a link the app has no screen for fails. iOS/Apple TV: only the app's own URL scheme (`myapp://...`); a web or system link is refused. |
276
+ | `relaunch-app` | — | Close the app and start it again (cold start, data kept) — for "does it persist after a restart" checks. The app log keeps streaming. Not on Roku or embed sessions. |
270
277
  | `set-posture` | `<closed\|partial\|open>` or `--angle <0-180>` | Fold or unfold a foldable device (form factor `foldable`: the iPhone Duo, Android foldables) to a preset or an exact hinge angle — exactly one. Reports posture, angle and lit screen. Errors on a device without a hinge. |
271
278
  | `rotate` | `[--degrees <90\|180\|270>]` | Rotate the device and report the new orientation. Phones/tablets and Android foldables toggle portrait/landscape (90 only); the iPhone Duo turns a quarter clockwise by default. Not on TV. |
279
+ | `set-location` | `--lat <n> --lon <n>` | Set the device's simulated GPS position (decimal degrees; west and south are negative) for location-based flows. Phones, tablets and foldables; not on TV. |
272
280
  | `wait` | `[--ms <n>]` | Wait for a specified number of seconds before continuing (default: 2s). |
273
281
  | `find` | `<text>` `[--below <text>] [--above <text>] [--near <text>]` | Find an element by text with optional spatial constraints. |
274
282
  | `tap-focused` | `<ref>` | TV: move focus to an element and press SELECT in one step. |
@@ -279,7 +287,7 @@ Dev-loop lifecycle commands (not registry verbs, but needed for every run):
279
287
  | Command | Purpose |
280
288
  |---------|---------|
281
289
  | `dev --detach --json` | Start a device + Metro-tunnel session in the background, emitting `session_ready`/`warning`/`error` JSON events on stdout. Add `--model "<name>"` to pick an exact device model. |
282
- | `dev --build <id>` | Run that uploaded build instead of the app's newest debug build (combine with `--app`, `--platform`, `--model`, `--detach --json`). Only a debug build (kind `debug` in `list-builds`) is accepted; a release build or one with an embedded JS bundle is refused. Not on Roku. |
290
+ | `dev --build <id>` | Run that uploaded build instead of the app's newest debug build (combine with `--app`, `--platform`, `--model`, `--detach --json`). Only a debug build (kind `debug` in `list-builds`) is accepted, including a debug build with its JS bundle embedded; a release build is refused (run it with `--standalone`). Not on Roku. |
283
291
  | `list-builds <app>` | List an app's uploaded builds, newest first: id, version (build number), platform, kind (`debug`/`release`/`unknown`), upload time, note. `--json` prints an array. |
284
292
  | `list-devices --models` | List the device models you can start (the names `--model` takes); foldables are marked `foldable`. |
285
293
  | `dev-status` | Check on the current project's detached dev session. |
@@ -304,11 +312,12 @@ steps the CLI does with plain shell commands:
304
312
  |----------|------|---------|
305
313
  | `upload_app` | `file` (required path), `name` | Upload a build (zipped iOS/tvOS simulator `.app`, Android/Android TV `.apk`, Roku channel `.zip`). The MCP equivalent of `vibeview upload-app`, so step 1 of the loop needs no shell. Returns the `app_id` to pass to `dev_start`. |
306
314
  | `list_apps` | `platform` | List the org's apps (app id, name, platform, bundle id). The MCP equivalent of `vibeview list-apps` — use it to find an app someone else uploaded. |
307
- | `dev_start` | `platform` (required: `ios`/`android`/`tvos`/`androidtv`/`roku`), `app`, `metro_port`, `model`, `build_id`, `standalone` | Start a session held open by the MCP server. The MCP equivalent of `vibeview dev --detach`; `model` picks an exact device model (e.g. `"iPhone Duo"`); `build_id` runs that build instead of the newest debug one (see `list_builds`). `standalone: true` runs any build as it is with no Metro — a release, CI or cloud build (without `build_id`: the newest release build). An app for another platform is refused before a device is taken. Returns `page_url` — relay it to the developer immediately, same as step 5 of the loop — and the device it got (model, OS, category; posture and lit screen on a foldable). |
315
+ | `dev_start` | `platform` (required: `ios`/`android`/`tvos`/`androidtv`/`roku`), `app`, `metro_port`, `model`, `build_id`, `standalone` | Start a session held open by the MCP server. The MCP equivalent of `vibeview dev --detach`; `model` picks an exact device model (e.g. `"iPhone Duo"`); `build_id` runs that build instead of the newest debug one (see `list_builds`). `standalone: true` runs any build as it is with no Metro — a release, CI or cloud build (without `build_id`: the newest release build). An app for another platform is refused before a device is taken. Returns `page_url` — relay it to the developer immediately, same as step 5 of the loop — the device it got (model, OS, category; posture and lit screen on a foldable), and the build it installed (id, version, upload time) with the app's other debug builds. A red "React Native version mismatch" screen means that build was made with another React Native than the project: pick one made from this project with `build_id`. While every device is busy it waits in the queue, sending progress. |
308
316
  | `list_device_models` | `device_type` | List the device models you can start (model, OS, platform, category, `foldable` marker, and how many are free/busy). The MCP equivalent of `vibeview list-devices --models`. |
309
317
  | `list_builds` | `app` (required) | List an app's uploaded builds with their kind (`debug`/`release`/`unknown`). The MCP equivalent of `vibeview list-builds`. |
310
318
  | `dev_stop` | — | Stop the session `dev_start` started. The MCP equivalent of `vibeview dev-stop`. Says "no dev session was running" when there was none. |
311
319
  | `stop_session` | `session_id` (required) | Stop any session by id. The MCP equivalent of `vibeview stop <session-id>`. Says so when the session had already ended. |
320
+ | `dev_reload` | — | Roku only: re-package, upload and restart the channel in the held session (or this project's `vibeview dev --detach` session). The MCP equivalent of `vibeview dev-reload`. |
312
321
 
313
322
  If the held session ends elsewhere (dashboard, idle timeout, failure), the
314
323
  next tool call answers "session … has ended (…) — call dev_start"; the
@@ -319,5 +328,6 @@ call.** Once it succeeds, any tool called without an explicit `session_id`
319
328
  targets that session instead of whatever `vibeview dev --detach` state exists
320
329
  in the project directory. Only one such session can be held at a time
321
330
  (`dev_start` fails if one is already running), and it stays live — billing
322
- streaming minutes — until `dev_stop` or the MCP connection ends. Always call
323
- `dev_stop` when you finish verifying.
331
+ streaming minutes — until `dev_stop` or the MCP connection ends (the server
332
+ stops it when the client quits or closes the connection; a killed server
333
+ can't). Always call `dev_stop` when you finish verifying.