vibeview 0.32.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,13 +4,14 @@ import {
4
4
  DEV_PLATFORMS,
5
5
  PLATFORM_DEVICE,
6
6
  PLATFORM_NEEDS_METRO,
7
- ROKU_LIVENESS_INTERVAL_MS,
7
+ buildLines,
8
8
  buildRows,
9
9
  buildToolCall,
10
10
  checkDevBuild,
11
11
  checkMetro,
12
12
  createRokuPush,
13
13
  decodeScreenshotResult,
14
+ describeDevBuild,
14
15
  effectiveDevConfig,
15
16
  filterDeviceModels,
16
17
  formatBuildRowsHuman,
@@ -24,12 +25,12 @@ import {
24
25
  renderEnvelope,
25
26
  resolveMetro,
26
27
  startDevSession
27
- } from "./chunk-T6H5XTBX.js";
28
+ } from "./chunk-TPZG7NCU.js";
28
29
  import {
29
30
  ApiClient,
30
31
  NotFoundError,
31
32
  resolveAuth
32
- } from "./chunk-ZZK7YQSP.js";
33
+ } from "./chunk-4D2E2PE4.js";
33
34
 
34
35
  // src/mcp/server.ts
35
36
  import { writeFileSync } from "fs";
@@ -45,14 +46,25 @@ function isMcpDevPlatform(p) {
45
46
  return isDevPlatform(p) && MCP_DEV_PLATFORMS.includes(p);
46
47
  }
47
48
  var MCP_PARAM_DESCRIPTIONS = {
48
- wait: { seconds: "How long to wait, in seconds. Default: 2." }
49
+ wait: { seconds: "How long to wait, in seconds. Default: 2." },
50
+ screenshot: {
51
+ out: "File path to write the PNG to on the machine running this server (default: a temp file)."
52
+ }
49
53
  };
50
54
  function paramToProperty(cmd, param) {
51
55
  return {
52
56
  type: param.type,
53
- description: MCP_PARAM_DESCRIPTIONS[cmd.verb]?.[param.name] ?? param.description
57
+ description: MCP_PARAM_DESCRIPTIONS[cmd.verb]?.[param.name] ?? mcpText(cmd, param.description)
54
58
  };
55
59
  }
60
+ function mcpText(cmd, text) {
61
+ let out = text.replace(/`?(?:vibeview )?list-devices --models`?/g, "list_device_models").replace(/`vibeview ([a-z-]+)`/g, (_, verb) => toolNameFor(verb));
62
+ for (const param of cmd.params) {
63
+ if (param.cli === "positional") continue;
64
+ out = out.replace(new RegExp(`${param.cli}(?![a-z-])`, "g"), param.name);
65
+ }
66
+ return out;
67
+ }
56
68
  function toolNameFor(verb) {
57
69
  return verb.replace(/-/g, "_");
58
70
  }
@@ -66,12 +78,12 @@ function toolDefFromCommand(cmd) {
66
78
  if (cmd.verb === "screenshot") {
67
79
  properties.inline = {
68
80
  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)."
81
+ 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
82
  };
71
83
  }
72
84
  return {
73
85
  name: toolNameFor(cmd.verb),
74
- description: cmd.summary,
86
+ description: mcpText(cmd, cmd.summary),
75
87
  mutating: cmd.mutating,
76
88
  properties,
77
89
  required
@@ -102,7 +114,7 @@ var DEV_START_DEF = {
102
114
  },
103
115
  build_id: {
104
116
  type: "number",
105
- description: 'Run this uploaded build of the app (an id from list_builds). Without standalone it must be a debug build (kind "debug"); with standalone any build of the app works. Omitted: the newest debug build, or with standalone the newest release build. Not for roku.'
117
+ description: `Run this uploaded build of the app (an id from list_builds). Without standalone it must be a debug build (kind "debug"); with standalone any build of the app works. Omitted: the newest debug build (preferring one of the local project's own app version over a newer one of another version), or with standalone the newest release build. Not for roku.`
106
118
  },
107
119
  standalone: {
108
120
  type: "boolean",
@@ -205,8 +217,15 @@ function errorResult(message) {
205
217
  return { content: [{ type: "text", text: forMcp(message) }], isError: true };
206
218
  }
207
219
  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");
220
+ 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(
221
+ /use `vibeview dev --standalone --build (\d+)`/g,
222
+ "call dev_start with standalone: true and build_id: $1"
223
+ ).replace(
224
+ /Set VIBEVIEW_API_TOKEN \(or --token\) to a full-access token for this command\./g,
225
+ "Start this MCP server with a full-access token (VIBEVIEW_API_TOKEN or --token)."
226
+ ).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
227
  if (/No debug build|is not a debug build/.test(text) && !/standalone: true/.test(text)) {
228
+ text = `${text.replace(/\s+$/, "")}${/[.!?]$/.test(text.trim()) ? "" : "."}`;
210
229
  text += " To run a release build as it is, with no Metro, call dev_start with standalone: true.";
211
230
  }
212
231
  return text;
@@ -218,6 +237,9 @@ async function readSession(deps, sessionId) {
218
237
  function isEndedStatus(status) {
219
238
  return status === "stopped" || status === "failed";
220
239
  }
240
+ function capitalize(text) {
241
+ return text.charAt(0).toUpperCase() + text.slice(1);
242
+ }
221
243
  function endedLine(ended) {
222
244
  const why = ended.reason ? `: ${ended.reason}` : "";
223
245
  return `session ${ended.sessionId} has ended (${ended.status}${why})`;
@@ -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
  }
@@ -304,12 +329,13 @@ async function handleToolCall(toolName, args, deps) {
304
329
  if (cmd.verb === "screenshot" && envelope.ok) {
305
330
  return screenshotToolResult(envelope, args);
306
331
  }
307
- return textResult(renderEnvelope(envelope), envelope, !envelope.ok);
332
+ return textResult(renderEnvelope(envelope, toolNameFor(cmd.verb)), 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, "screenshot")}
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,38 @@ 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(...buildLines2(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 buildLines2(result, standalone) {
471
+ return buildLines(
472
+ {
473
+ build: result.build ?? null,
474
+ reason: result.buildReason ?? null,
475
+ pinned: result.pinned === true,
476
+ other_debug_builds: result.otherBuilds ?? [],
477
+ project: result.project ?? null
478
+ },
479
+ { standalone, flag: "build_id", listBuilds: "list_builds" }
480
+ );
481
+ }
395
482
  function deviceLine(d) {
396
483
  const name = d.model || d.name;
397
484
  if (!name) return null;
@@ -526,7 +613,7 @@ async function handleDevStop(deps) {
526
613
  }
527
614
  if (!current) {
528
615
  return textResult(
529
- ended ? `no dev session was running \u2014 ${endedLine(ended)} already` : "no dev session was running",
616
+ ended ? `no dev session was running \u2014 it had already ended. ${capitalize(endedLine(ended))}` : "no dev session was running",
530
617
  { session_id: null, stopped: false }
531
618
  );
532
619
  }
@@ -542,7 +629,7 @@ async function handleUploadApp(args, deps) {
542
629
  }
543
630
  const name = typeof args.name === "string" && args.name ? args.name : void 0;
544
631
  const upload = deps.uploadApp ?? (async (f, n) => {
545
- const { uploadAppBuild } = await import("./upload-app-2LVG4LMB.js");
632
+ const { uploadAppBuild } = await import("./upload-app-AKD73742.js");
546
633
  const { result: result2 } = await uploadAppBuild(f, { ...deps.auth, name: n });
547
634
  return result2;
548
635
  });
@@ -575,16 +662,69 @@ async function handleUploadApp(args, deps) {
575
662
  ];
576
663
  return textResult(lines.join("\n"), payload);
577
664
  }
665
+ var START_PROGRESS_INTERVAL_MS = 1e4;
666
+ function startProgress(report) {
667
+ const started = Date.now();
668
+ let queuedAt = null;
669
+ let dequeuedAt = null;
670
+ let phase = "starting the device";
671
+ let timer = null;
672
+ const say = (message) => {
673
+ try {
674
+ report?.(message);
675
+ } catch {
676
+ }
677
+ };
678
+ if (report) {
679
+ timer = setInterval(() => {
680
+ const since = Math.round(
681
+ (Date.now() - (queuedAt !== null && dequeuedAt === null ? queuedAt : started)) / 1e3
682
+ );
683
+ say(`${phase} (${since} s so far)`);
684
+ }, START_PROGRESS_INTERVAL_MS);
685
+ timer.unref?.();
686
+ }
687
+ return {
688
+ event(evt) {
689
+ if (evt.event === "queued") {
690
+ queuedAt = Date.now();
691
+ const where = evt.position ? ` at position ${evt.position}` : "";
692
+ phase = `waiting in the queue for a device${where}`;
693
+ say(
694
+ `${evt.message ?? "Every device of this kind is busy."} Waiting in the queue${where}; dev_start returns once a device is free.`
695
+ );
696
+ } else if (evt.event === "tunnel_connected") {
697
+ phase = "connected to Metro; waiting for the app to be installed";
698
+ say("connected to Metro; the device is starting and the app is being installed");
699
+ } else if (evt.event === "installing") {
700
+ phase = "installing the app";
701
+ say("the device is up; installing the app");
702
+ }
703
+ },
704
+ dequeued() {
705
+ dequeuedAt = Date.now();
706
+ phase = "got a device; starting it";
707
+ if (queuedAt !== null)
708
+ say(
709
+ `got a device after ${Math.round((dequeuedAt - queuedAt) / 1e3)} s in the queue; starting it`
710
+ );
711
+ },
712
+ queuedSeconds() {
713
+ if (queuedAt === null) return null;
714
+ return ((dequeuedAt ?? Date.now()) - queuedAt) / 1e3;
715
+ },
716
+ done() {
717
+ if (timer) clearInterval(timer);
718
+ timer = null;
719
+ }
720
+ };
721
+ }
578
722
  function createDevSessionController(auth, webUrl) {
579
723
  let handle = null;
580
724
  let platform = null;
581
725
  let ended = null;
582
- let presence = null;
583
726
  let reloading = null;
584
- const stopPresence = () => {
585
- if (presence) clearInterval(presence);
586
- presence = null;
587
- };
727
+ let pending = null;
588
728
  const readRokuBuild = () => {
589
729
  try {
590
730
  return loadDevConfig(process.cwd())?.platforms?.roku?.build ?? null;
@@ -597,10 +737,9 @@ function createDevSessionController(auth, webUrl) {
597
737
  const markEnded = (e) => {
598
738
  if (!handle || handle.sessionId !== e.sessionId) return;
599
739
  const dead = handle;
600
- stopPresence();
601
740
  handle = null;
602
741
  platform = null;
603
- ended = e;
742
+ ended = { ...e, reason: e.reason ?? dead.lapsedReason?.() ?? null };
604
743
  void dead.stop().catch(() => {
605
744
  });
606
745
  };
@@ -662,71 +801,110 @@ function createDevSessionController(auth, webUrl) {
662
801
  );
663
802
  }
664
803
  }
665
- if (cfg.buildId !== void 0) {
666
- checkDevBuild(app, cfg.buildId, cfg.platform, { standalone });
804
+ let buildId = cfg.buildId;
805
+ if (buildId !== void 0) {
806
+ checkDevBuild(app, buildId, cfg.platform, { standalone });
667
807
  }
668
808
  const isTv = PLATFORM_DEVICE[cfg.platform].tv;
669
809
  const warnings = [];
810
+ const progress = startProgress(cfg.onProgress);
670
811
  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 }));
812
+ try {
813
+ started = await startDevSession(
814
+ {
815
+ token: auth.token,
816
+ baseUrl: auth.baseUrl,
817
+ webUrl,
818
+ platform: cfg.platform,
819
+ appId,
820
+ deviceCategory: isTv ? "tv" : entry?.deviceCategory ?? "phone",
821
+ // A named model decides the category itself (startDevSession).
822
+ model: cfg.model,
823
+ buildId,
824
+ // The build choice (a debug build of this project's version over
825
+ // a newer one of another) and its report live in startDevSession,
826
+ // shared with `vibeview dev` and `--detach`.
827
+ app,
828
+ ...usesMetro ? { projectDir: process.cwd() } : {},
829
+ ...standalone ? { standalone } : {},
830
+ metroPort,
831
+ open: false,
832
+ onCreated: (created) => {
833
+ pending = created;
834
+ },
835
+ onDequeued: () => progress.dequeued()
836
+ },
837
+ (evt) => {
838
+ progress.event(evt);
839
+ if (evt.event === "warning") warnings.push(evt.message);
840
+ if (evt.event === "session_ended" && evt.code !== 0 && started) {
841
+ const id = evt.session_id;
842
+ void api().getSession(id).then(
843
+ (info) => ({ status: info.status, reason: info.failure_reason ?? null }),
844
+ () => ({ status: "stopped", reason: null })
845
+ ).then(({ status, reason }) => markEnded({ sessionId: id, status, reason }));
846
+ }
694
847
  }
695
- }
696
- );
848
+ );
849
+ } finally {
850
+ progress.done();
851
+ pending = null;
852
+ }
697
853
  handle = started;
698
854
  platform = cfg.platform;
699
855
  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?.();
856
+ const choice = handle.build ?? describeDevBuild(
857
+ app,
858
+ handle.session,
859
+ { buildId, reason: null, pinned: buildId !== void 0 },
860
+ null
861
+ );
862
+ const project = choice.project;
863
+ if (project?.bundleId && app.package_name && project.bundleId !== app.package_name) {
864
+ warnings.push(
865
+ `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)`
866
+ );
715
867
  }
716
868
  return {
717
869
  sessionId: handle.sessionId,
718
870
  pageUrl: handle.pageUrl,
719
871
  device: startedDevice(handle.session),
872
+ build: choice.build,
873
+ buildReason: choice.reason,
874
+ pinned: choice.pinned,
875
+ otherBuilds: usesMetro ? choice.other_debug_builds : [],
876
+ project,
877
+ queuedSeconds: progress.queuedSeconds(),
720
878
  warnings
721
879
  };
722
880
  },
723
881
  async stop() {
724
- stopPresence();
725
882
  await handle?.stop();
726
883
  handle = null;
727
884
  platform = null;
728
885
  ended = null;
729
886
  },
887
+ async shutdown() {
888
+ const stopped = [];
889
+ const held = handle;
890
+ const starting = pending;
891
+ handle = null;
892
+ platform = null;
893
+ pending = null;
894
+ await Promise.all([
895
+ held ? held.stop().then(
896
+ () => void stopped.push(held.sessionId),
897
+ () => {
898
+ }
899
+ ) : null,
900
+ starting ? starting.stop().then(
901
+ () => void stopped.push(starting.sessionId),
902
+ () => {
903
+ }
904
+ ) : null
905
+ ]);
906
+ return stopped;
907
+ },
730
908
  current() {
731
909
  return handle ? { sessionId: handle.sessionId, pageUrl: handle.pageUrl } : null;
732
910
  },
@@ -803,6 +981,7 @@ var MCP_INSTRUCTIONS = [
803
981
  "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
982
  "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
983
  "Foldables: pick one by model in dev_start (list_device_models marks them foldable, with free/busy counts), then set_posture and rotate.",
984
+ "set_location sets the simulated GPS position (phones and tablets) for location-based flows.",
806
985
  "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
986
  "A session stopped elsewhere is reported as ended; call dev_start again for a new one."
808
987
  ].join("\n");
@@ -841,30 +1020,89 @@ async function createMcpServer(opts) {
841
1020
  inputSchema: zodShape(def),
842
1021
  annotations: { readOnlyHint: !def.mutating }
843
1022
  },
844
- async (args) => {
845
- const result = await handleToolCall(def.name, args ?? {}, deps);
1023
+ async (args, extra) => {
1024
+ const result = await handleToolCall(def.name, args ?? {}, deps, {
1025
+ progress: progressReporter(extra)
1026
+ });
846
1027
  return result;
847
1028
  }
848
1029
  );
849
1030
  }
850
- return server;
1031
+ return { server, devSession };
1032
+ }
1033
+ function progressReporter(extra) {
1034
+ const token = extra?._meta?.progressToken;
1035
+ const send = extra?.sendNotification;
1036
+ if (token === void 0 || !send) return void 0;
1037
+ let step = 0;
1038
+ return (message) => {
1039
+ step += 1;
1040
+ void send({
1041
+ method: "notifications/progress",
1042
+ params: { progressToken: token, progress: step, message }
1043
+ }).catch(() => {
1044
+ });
1045
+ };
1046
+ }
1047
+ var SHUTDOWN_TIMEOUT_MS = 5e3;
1048
+ function createShutdown(devSession, opts = {}) {
1049
+ const exit = opts.exit ?? ((code) => process.exit(code));
1050
+ const log = opts.log ?? ((line) => console.error(line));
1051
+ const timeoutMs = opts.timeoutMs ?? SHUTDOWN_TIMEOUT_MS;
1052
+ let running = null;
1053
+ return (reason) => {
1054
+ running ??= (async () => {
1055
+ let timer;
1056
+ const timedOut = new Promise((resolve) => {
1057
+ timer = setTimeout(() => resolve("timeout"), timeoutMs);
1058
+ });
1059
+ const outcome = await Promise.race([
1060
+ devSession.shutdown().catch(() => []),
1061
+ timedOut
1062
+ ]);
1063
+ clearTimeout(timer);
1064
+ if (outcome === "timeout") {
1065
+ log(
1066
+ `vibeview mcp: ${reason}; could not confirm the session stopped within ${timeoutMs / 1e3} s`
1067
+ );
1068
+ } else if (outcome.length > 0) {
1069
+ log(`vibeview mcp: ${reason}; stopped session ${outcome.join(", ")}`);
1070
+ }
1071
+ exit(0);
1072
+ })();
1073
+ return running;
1074
+ };
851
1075
  }
852
1076
  async function runMcpServer(opts) {
853
1077
  chalk.level = 0;
854
- const [server, { StdioServerTransport }] = await Promise.all([
1078
+ const [{ server, devSession }, { StdioServerTransport }] = await Promise.all([
855
1079
  createMcpServer(opts),
856
1080
  import("@modelcontextprotocol/sdk/server/stdio.js")
857
1081
  ]);
858
1082
  const transport = new StdioServerTransport();
1083
+ const shutdown = createShutdown(devSession);
1084
+ process.stdin.once("end", () => void shutdown("the client closed the connection"));
1085
+ process.stdin.once("close", () => void shutdown("the client closed the connection"));
1086
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
1087
+ process.once(signal, () => void shutdown(`received ${signal}`));
1088
+ }
1089
+ server.server.onclose = () => void shutdown("the connection closed");
859
1090
  await server.connect(transport);
860
1091
  }
861
1092
  export {
862
1093
  MCP_INSTRUCTIONS,
1094
+ SHUTDOWN_TIMEOUT_MS,
1095
+ START_PROGRESS_INTERVAL_MS,
1096
+ buildLines2 as buildLines,
863
1097
  buildToolDefinitions,
864
1098
  createDevSessionController,
1099
+ createShutdown,
865
1100
  deviceLine,
866
1101
  forMcp,
867
1102
  handleToolCall,
1103
+ mcpText,
1104
+ progressReporter,
868
1105
  runMcpServer,
1106
+ startProgress,
869
1107
  startedDevice
870
1108
  };
@@ -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.34.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",