vibeview 0.32.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";
@@ -604,16 +628,6 @@ function describeLocalChanges(before, after) {
604
628
  return changes;
605
629
  }
606
630
 
607
- // src/commands/build.ts
608
- import { spawn as spawn2 } from "child_process";
609
- import { createInterface } from "readline";
610
- import { select as select2 } from "@inquirer/prompts";
611
- import chalk3 from "chalk";
612
-
613
- // src/lib/dev-setup.ts
614
- import chalk2 from "chalk";
615
- import { input, select } from "@inquirer/prompts";
616
-
617
631
  // src/lib/project-detect.ts
618
632
  import { existsSync as existsSync2, readFileSync as readFileSync4, readdirSync, statSync } from "fs";
619
633
  import { join as join4 } from "path";
@@ -791,8 +805,72 @@ function productionDefaultsFor(cwd, platform) {
791
805
  }
792
806
  return {};
793
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";
794
870
 
795
871
  // src/lib/dev-setup.ts
872
+ import chalk2 from "chalk";
873
+ import { input, select } from "@inquirer/prompts";
796
874
  function platformChoices(cwd) {
797
875
  const choices = [
798
876
  { value: "ios", name: "iOS simulator" },
@@ -2359,6 +2437,12 @@ function parseWatchSeconds(flag) {
2359
2437
  }
2360
2438
  return seconds;
2361
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
+ }
2362
2446
  function queuedLine(position, reasonMessage) {
2363
2447
  const where = position ? ` (position ${position})` : "";
2364
2448
  const why = reasonMessage ?? "Every device is busy.";
@@ -2379,9 +2463,14 @@ async function startDevSession(cfg, onEvent = () => {
2379
2463
  let stopping = false;
2380
2464
  let tunnel = null;
2381
2465
  let queueEntryId = null;
2466
+ let lastLeaseAt = Date.now();
2467
+ let leaseTimer = null;
2382
2468
  const stopSession = async (code) => {
2383
2469
  if (stopping) return;
2384
2470
  stopping = true;
2471
+ if (leaseTimer) clearInterval(leaseTimer);
2472
+ leaseTimer = null;
2473
+ const reason = code !== 0 ? leaseLapsedReason(Date.now() - lastLeaseAt) : void 0;
2385
2474
  tunnel?.stop();
2386
2475
  if (queueEntryId !== null) {
2387
2476
  await api.cancelQueueEntry(queueEntryId).catch(() => {
@@ -2390,8 +2479,27 @@ async function startDevSession(cfg, onEvent = () => {
2390
2479
  }
2391
2480
  await api.stopSession(sessionId).catch(() => {
2392
2481
  });
2393
- onEvent({ event: "session_ended", session_id: sessionId, code });
2482
+ onEvent({ event: "session_ended", session_id: sessionId, code, ...reason ? { reason } : {} });
2394
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();
2498
+ };
2499
+ void renewLease();
2500
+ leaseTimer = setInterval(() => void renewLease(), AGENT_LEASE_RENEW_MS);
2501
+ leaseTimer.unref?.();
2502
+ cfg.onCreated?.({ sessionId, stop: () => stopSession(1) });
2395
2503
  try {
2396
2504
  const started = await api.startSession(sessionId);
2397
2505
  if (started.status === "queued") {
@@ -2406,6 +2514,7 @@ async function startDevSession(cfg, onEvent = () => {
2406
2514
  });
2407
2515
  await pollWhileQueued(api, sessionId, QUEUE_WAIT_MS);
2408
2516
  queueEntryId = null;
2517
+ cfg.onDequeued?.();
2409
2518
  }
2410
2519
  const pageUrl = `${cfg.webUrl || cfg.baseUrl}/sandbox/${sessionId}`;
2411
2520
  const reportedWarnings = /* @__PURE__ */ new Set();
@@ -2458,7 +2567,14 @@ async function startDevSession(cfg, onEvent = () => {
2458
2567
  reportedWarnings.add(w);
2459
2568
  onEvent({ event: "warning", message: w });
2460
2569
  }
2461
- 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
+ };
2462
2578
  } catch (err) {
2463
2579
  await stopSession(1);
2464
2580
  throw err;
@@ -3102,7 +3218,7 @@ async function runDevInProcess(opts) {
3102
3218
  chalk4.dim(" Putting the channel on the Roku \u2014 the link appears once it is up.")
3103
3219
  );
3104
3220
  } else if (evt.event === "session_ended" && !stopping && handle) {
3105
- console.error(chalk4.red("tunnel closed by server \u2014 session ended"));
3221
+ console.error(chalk4.red(evt.reason ?? "session ended by the server"));
3106
3222
  stopping = true;
3107
3223
  endedEmitted = true;
3108
3224
  emit(opts.json, evt);
@@ -3217,8 +3333,14 @@ async function runDevInProcess(opts) {
3217
3333
  }
3218
3334
  const verdict = sessionLivenessVerdict(info);
3219
3335
  if (verdict.end && !stopping) {
3220
- console.error(chalk4.red(verdict.message));
3221
- 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
+ });
3222
3344
  endedEmitted = true;
3223
3345
  await stopSession(1);
3224
3346
  return;
@@ -3664,7 +3786,7 @@ var AGENT_COMMANDS = [
3664
3786
  // iOS system alerts only.
3665
3787
  verb: "alert",
3666
3788
  tool: "alert",
3667
- 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.",
3668
3790
  mutating: true,
3669
3791
  params: [
3670
3792
  {
@@ -3747,7 +3869,7 @@ var AGENT_COMMANDS = [
3747
3869
  // tools.py open_url: { url: string }.
3748
3870
  verb: "open-url",
3749
3871
  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.",
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.",
3751
3873
  mutating: true,
3752
3874
  params: [
3753
3875
  {
@@ -3759,6 +3881,16 @@ var AGENT_COMMANDS = [
3759
3881
  }
3760
3882
  ]
3761
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
+ },
3762
3894
  {
3763
3895
  // set_posture: { posture: 'closed' | 'partial' | 'open' } OR { angle: 0..180 }
3764
3896
  // — exactly one (buildToolCall enforces it CLI-side, agent_control.py's
@@ -3805,6 +3937,33 @@ var AGENT_COMMANDS = [
3805
3937
  }
3806
3938
  ]
3807
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
+ },
3808
3967
  {
3809
3968
  // tools.py wait: { seconds?: number } (default: 2). Note the wire arg is
3810
3969
  // "seconds", NOT "ms" — tool_dispatch.py reads args.get("seconds", 2)
@@ -3949,6 +4108,14 @@ function resultLines(result) {
3949
4108
  const state = deviceStateLine(r);
3950
4109
  if (state) lines.push(state);
3951
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
+ }
3952
4119
  if (typeof r.note === "string" && r.note) lines.push(chalk6.dim(r.note));
3953
4120
  if (typeof r.message === "string" && r.message) lines.push(r.message);
3954
4121
  if (typeof r.warning === "string" && r.warning) lines.push(r.warning);
@@ -3959,8 +4126,8 @@ function deviceStateLine(r) {
3959
4126
  if (r.action === "rotated") {
3960
4127
  if (typeof r.device_orientation === "string") {
3961
4128
  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})`;
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})`;
3964
4131
  }
3965
4132
  parts.push(o);
3966
4133
  }
@@ -4196,7 +4363,9 @@ async function runAgentVerb(cmd, positionals, opts) {
4196
4363
  }
4197
4364
  writeFileSync6(outPath, imageBytes);
4198
4365
  if (opts.json) {
4199
- console.log(JSON.stringify(envelope));
4366
+ console.log(
4367
+ JSON.stringify({ ...envelope, result: { written_to: outPath, bytes: imageBytes.length } })
4368
+ );
4200
4369
  } else {
4201
4370
  process.stderr.write(renderEnvelope(envelope) + "\n");
4202
4371
  console.log(outPath);
@@ -4222,7 +4391,11 @@ export {
4222
4391
  loadDevConfig,
4223
4392
  resolveMetro,
4224
4393
  parseBuildId,
4394
+ buildKind,
4225
4395
  checkDevBuild,
4396
+ debugBuildsNewestFirst,
4397
+ pickDevBuild,
4398
+ buildLabel,
4226
4399
  buildRows,
4227
4400
  formatBuildRowsHuman,
4228
4401
  listBuilds,
@@ -4234,6 +4407,7 @@ export {
4234
4407
  checkMetro,
4235
4408
  loadLocalDevConfig,
4236
4409
  effectiveDevConfig,
4410
+ localAppIdentity,
4237
4411
  buildCommand,
4238
4412
  formatDuration,
4239
4413
  BUILD_TERMINAL_STATUSES,
@@ -4243,7 +4417,6 @@ export {
4243
4417
  printOutcomeLine,
4244
4418
  createRokuPush,
4245
4419
  startDevSession,
4246
- ROKU_LIVENESS_INTERVAL_MS,
4247
4420
  PLATFORM_DEVICE,
4248
4421
  dev,
4249
4422
  formatDevicesHuman,
package/dist/cli.js CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  readDevStateFile,
25
25
  readMetroState,
26
26
  runAgentVerb
27
- } from "./chunk-T6H5XTBX.js";
27
+ } from "./chunk-D3OZPFJ3.js";
28
28
  import {
29
29
  ApiClient,
30
30
  ApiStatusError,
@@ -45,7 +45,7 @@ import {
45
45
  resolveAuth,
46
46
  saveConfig,
47
47
  uploadApp
48
- } from "./chunk-ZZK7YQSP.js";
48
+ } from "./chunk-4D2E2PE4.js";
49
49
 
50
50
  // src/cli.ts
51
51
  import { createRequire } from "module";
@@ -4079,7 +4079,7 @@ program.command("dev-reload").description(
4079
4079
  program.command("mcp").description(
4080
4080
  "Run an MCP stdio server exposing the agent verbs (tap, swipe, type, ...) plus dev_start/dev_stop/dev_reload as tools"
4081
4081
  ).option("--token <token>", "API token (or set VIBEVIEW_API_TOKEN)").action(async (opts) => {
4082
- const { runMcpServer } = await import("./server-FJSBWEHZ.js");
4082
+ const { runMcpServer } = await import("./server-GHCQHKYX.js");
4083
4083
  await runMcpServer({ ...opts, version: pkg.version });
4084
4084
  });
4085
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-T6H5XTBX.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.32.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.