vibeview 0.27.0 → 0.28.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.
@@ -21,11 +21,12 @@ import {
21
21
  readDevState,
22
22
  renderEnvelope,
23
23
  startDevSession
24
- } from "./chunk-46X7UN6Y.js";
24
+ } from "./chunk-OKOAWGWV.js";
25
25
  import {
26
26
  ApiClient,
27
+ NotFoundError,
27
28
  resolveAuth
28
- } from "./chunk-U7FCGMWG.js";
29
+ } from "./chunk-2JLTC67A.js";
29
30
 
30
31
  // src/mcp/server.ts
31
32
  import { writeFileSync } from "fs";
@@ -40,8 +41,14 @@ var MCP_DEV_PLATFORMS = DEV_PLATFORMS;
40
41
  function isMcpDevPlatform(p) {
41
42
  return isDevPlatform(p) && MCP_DEV_PLATFORMS.includes(p);
42
43
  }
43
- function paramToProperty(param) {
44
- return { type: param.type, description: param.description };
44
+ var MCP_PARAM_DESCRIPTIONS = {
45
+ wait: { seconds: "How long to wait, in seconds. Default: 2." }
46
+ };
47
+ function paramToProperty(cmd, param) {
48
+ return {
49
+ type: param.type,
50
+ description: MCP_PARAM_DESCRIPTIONS[cmd.verb]?.[param.name] ?? param.description
51
+ };
45
52
  }
46
53
  function toolNameFor(verb) {
47
54
  return verb.replace(/-/g, "_");
@@ -50,7 +57,7 @@ function toolDefFromCommand(cmd) {
50
57
  const properties = { session_id: SESSION_ID_PROPERTY };
51
58
  const required = [];
52
59
  for (const param of cmd.params) {
53
- properties[param.name] = paramToProperty(param);
60
+ properties[param.name] = paramToProperty(cmd, param);
54
61
  if (param.required) required.push(param.name);
55
62
  }
56
63
  if (cmd.verb === "screenshot") {
@@ -69,7 +76,7 @@ function toolDefFromCommand(cmd) {
69
76
  }
70
77
  var DEV_START_DEF = {
71
78
  name: "dev_start",
72
- description: "Start a VibeView dev-loop session (device + tunnel) held in-process by this MCP server, and use it as the default session for every other tool call until dev_stop. Returns once the session accepts commands (device up, app installed) \u2014 call ui_tree right away, no readiness polling needed.",
79
+ description: "Start a VibeView session held by this MCP server, and use it as the default session for every other tool call until dev_stop. By default it is a React Native dev loop: a debug build that loads its JS from your local Metro. Pass standalone: true to run any build as it is (a release, CI or cloud build) with no Metro. Returns once the session accepts commands (device up, app installed), with the device it got (model, OS, form factor, posture) \u2014 call ui_tree next.",
73
80
  mutating: true,
74
81
  properties: {
75
82
  session_id: SESSION_ID_PROPERTY,
@@ -80,11 +87,11 @@ var DEV_START_DEF = {
80
87
  },
81
88
  app: {
82
89
  type: "string",
83
- description: "Uploaded debug build (app_id) to run. Defaults to vibeview.json config."
90
+ description: "App id to run (from list_apps or upload_app). Must be an app for the chosen platform. Defaults to vibeview.json config."
84
91
  },
85
92
  metro_port: {
86
93
  type: "number",
87
- description: "Local Metro port (default: 8081, or vibeview.json)."
94
+ description: "Local Metro port (default: 8081, or vibeview.json). Ignored with standalone."
88
95
  },
89
96
  model: {
90
97
  type: "string",
@@ -92,7 +99,11 @@ var DEV_START_DEF = {
92
99
  },
93
100
  build_id: {
94
101
  type: "number",
95
- description: 'Run this uploaded build of the app instead of its newest debug build. Must be a debug build (kind "debug" in list_builds) for the platform \u2014 a release build or one with an embedded JS bundle is refused. Not for roku.'
102
+ 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.'
103
+ },
104
+ standalone: {
105
+ type: "boolean",
106
+ description: "Run the build as it is, with no Metro and no tunnel: for release builds, builds with an embedded JS bundle, CI and cloud builds. Code changes do not hot-reload. Default false."
96
107
  }
97
108
  },
98
109
  required: ["platform"]
@@ -102,7 +113,7 @@ var LIST_BUILDS_DEF = {
102
113
  description: "List an app's uploaded builds, newest first: id, version, build number, platform, kind (debug/release/unknown), upload time and note. Pass a debug build's id to dev_start as build_id.",
103
114
  mutating: false,
104
115
  properties: {
105
- app: { type: "string", description: "App id (public_id, from upload_app or list-apps)." }
116
+ app: { type: "string", description: "App id (from list_apps or upload_app)." }
106
117
  },
107
118
  required: ["app"]
108
119
  };
@@ -117,7 +128,7 @@ var STOP_SESSION_DEF = {
117
128
  };
118
129
  var LIST_DEVICE_MODELS_DEF = {
119
130
  name: "list_device_models",
120
- description: 'List the device models you can start right now (model, OS version, platform, category, and form_factor "foldable" for any device with a hinge \u2014 the iPhone Duo, Android foldables such as the Pixel 9 Pro Fold). Pass a model name to dev_start to run on it.',
131
+ description: 'List the device models you can start right now (model, OS version, platform, category, form_factor "foldable" for any device with a hinge \u2014 the iPhone Duo, Android foldables such as the Pixel 9 Pro Fold \u2014 and how many of each are free and busy). Pass a model name to dev_start to run on it; a model with none free queues until one is.',
121
132
  mutating: false,
122
133
  properties: {
123
134
  device_type: {
@@ -128,6 +139,19 @@ var LIST_DEVICE_MODELS_DEF = {
128
139
  },
129
140
  required: []
130
141
  };
142
+ var LIST_APPS_DEF = {
143
+ name: "list_apps",
144
+ description: "List your organization's uploaded apps: app id, name, platform and bundle id / package name. Pass an app id to dev_start (app) or list_builds.",
145
+ mutating: false,
146
+ properties: {
147
+ platform: {
148
+ type: "string",
149
+ description: "Only apps for this platform.",
150
+ enum: [...DEV_PLATFORMS]
151
+ }
152
+ },
153
+ required: []
154
+ };
131
155
  var DEV_STOP_DEF = {
132
156
  name: "dev_stop",
133
157
  description: "Stop the dev-loop session started by dev_start.",
@@ -149,7 +173,7 @@ var UPLOAD_APP_DEF = {
149
173
  properties: {
150
174
  file: {
151
175
  type: "string",
152
- description: "Path to the build file on this machine (.apk, or a zipped iOS simulator .app)."
176
+ description: "Path to the build file on this machine: a zipped iOS/tvOS simulator .app, an Android/Android TV .apk, or a Roku channel .zip."
153
177
  },
154
178
  name: {
155
179
  type: "string",
@@ -166,6 +190,7 @@ function buildToolDefinitions() {
166
190
  DEV_RELOAD_DEF,
167
191
  UPLOAD_APP_DEF,
168
192
  LIST_DEVICE_MODELS_DEF,
193
+ LIST_APPS_DEF,
169
194
  LIST_BUILDS_DEF,
170
195
  STOP_SESSION_DEF
171
196
  ];
@@ -174,7 +199,44 @@ function textResult(text, structuredContent, isError = false) {
174
199
  return { content: [{ type: "text", text }], structuredContent, isError };
175
200
  }
176
201
  function errorResult(message) {
177
- return { content: [{ type: "text", text: message }], isError: true };
202
+ return { content: [{ type: "text", text: forMcp(message) }], isError: true };
203
+ }
204
+ function forMcp(message) {
205
+ 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");
206
+ if (/No debug build|is not a debug build/.test(text) && !/standalone: true/.test(text)) {
207
+ text += " To run a release build as it is, with no Metro, call dev_start with standalone: true.";
208
+ }
209
+ return text;
210
+ }
211
+ async function readSession(deps, sessionId) {
212
+ const get = deps.getSession ?? ((id) => new ApiClient(deps.auth.baseUrl, deps.auth.token).getSession(id));
213
+ return get(sessionId);
214
+ }
215
+ function isEndedStatus(status) {
216
+ return status === "stopped" || status === "failed";
217
+ }
218
+ function endedLine(ended) {
219
+ const why = ended.reason ? `: ${ended.reason}` : "";
220
+ return `session ${ended.sessionId} has ended (${ended.status}${why})`;
221
+ }
222
+ async function explainInactive(sessionId, deps) {
223
+ let info;
224
+ try {
225
+ info = await readSession(deps, sessionId);
226
+ } catch {
227
+ return null;
228
+ }
229
+ if (!isEndedStatus(info.status)) return null;
230
+ const ended = {
231
+ sessionId,
232
+ status: info.status,
233
+ reason: info.failure_reason ?? null
234
+ };
235
+ const held = deps.devSession.current()?.sessionId === sessionId;
236
+ if (held) deps.devSession.markEnded(ended);
237
+ return errorResult(
238
+ `${endedLine(ended)} \u2014 ${held ? "call dev_start to start a new one" : "start a new session"}`
239
+ );
178
240
  }
179
241
  function findCommand(toolName) {
180
242
  return AGENT_COMMANDS.find((c) => toolNameFor(c.verb) === toolName);
@@ -198,6 +260,9 @@ async function handleToolCall(toolName, args, deps) {
198
260
  if (toolName === "list_builds") {
199
261
  return handleListBuilds(args, deps);
200
262
  }
263
+ if (toolName === "list_apps") {
264
+ return handleListApps(args, deps);
265
+ }
201
266
  if (toolName === "stop_session") {
202
267
  return handleStopSession(args, deps);
203
268
  }
@@ -207,6 +272,8 @@ async function handleToolCall(toolName, args, deps) {
207
272
  }
208
273
  const sessionId = typeof args.session_id === "string" ? args.session_id : deps.resolveSessionId();
209
274
  if (!sessionId) {
275
+ const ended = deps.devSession.lastEnded();
276
+ if (ended) return errorResult(`${endedLine(ended)} \u2014 call dev_start to start a new one`);
210
277
  return errorResult(
211
278
  "No session to target. Call dev_start first, or pass session_id (e.g. from a running `vibeview dev --detach`)."
212
279
  );
@@ -224,7 +291,12 @@ async function handleToolCall(toolName, args, deps) {
224
291
  try {
225
292
  envelope = await deps.agentTool(sessionId, call.tool, call.args);
226
293
  } catch (err) {
227
- return errorResult(`error: ${err instanceof Error ? err.message : String(err)}`);
294
+ const message = err instanceof Error ? err.message : String(err);
295
+ if (/Session is not active/i.test(message)) {
296
+ const explained = await explainInactive(sessionId, deps);
297
+ if (explained) return explained;
298
+ }
299
+ return errorResult(`error: ${message}`);
228
300
  }
229
301
  if (cmd.verb === "screenshot" && envelope.ok) {
230
302
  return screenshotToolResult(envelope, args);
@@ -290,32 +362,94 @@ async function handleDevStart(args, deps) {
290
362
  );
291
363
  }
292
364
  }
365
+ const standalone = args.standalone === true;
293
366
  try {
294
367
  const result = await deps.devSession.start({
295
368
  platform,
296
369
  app: typeof args.app === "string" ? args.app : void 0,
297
370
  metroPort: typeof args.metro_port === "number" ? args.metro_port : void 0,
298
371
  model: typeof args.model === "string" && args.model.trim() ? args.model : void 0,
299
- ...buildId !== void 0 ? { buildId } : {}
372
+ ...buildId !== void 0 ? { buildId } : {},
373
+ ...standalone ? { standalone } : {}
300
374
  });
301
- const payload = { session_id: result.sessionId, page_url: result.pageUrl };
302
- return textResult(`dev session started: ${payload.session_id}
303
- ${payload.page_url}`, payload);
375
+ const warnings = result.warnings ?? [];
376
+ const payload = {
377
+ session_id: result.sessionId,
378
+ page_url: result.pageUrl,
379
+ standalone,
380
+ device: result.device ?? null,
381
+ warnings
382
+ };
383
+ const lines = [`${standalone ? "session" : "dev session"} started: ${payload.session_id}`];
384
+ const device = result.device ? deviceLine(result.device) : null;
385
+ if (device) lines.push(device);
386
+ lines.push(payload.page_url, ...warnings.map((w) => `warning: ${w}`));
387
+ return textResult(lines.join("\n"), payload);
304
388
  } catch (err) {
305
389
  return errorResult(`error: ${err instanceof Error ? err.message : String(err)}`);
306
390
  }
307
391
  }
392
+ function deviceLine(d) {
393
+ const name = d.model || d.name;
394
+ if (!name) return null;
395
+ const parts = [name];
396
+ if (d.os_version) parts.push(`OS ${d.os_version}`);
397
+ if (d.category) parts.push(d.category);
398
+ if (d.form_factor) {
399
+ const fold = [d.posture ? `posture ${d.posture}` : null, d.screen ? `${d.screen} screen` : null].filter(Boolean).join(", ");
400
+ parts.push(fold ? `${d.form_factor} (${fold})` : d.form_factor);
401
+ }
402
+ return `device: ${parts.join(", ")}`;
403
+ }
404
+ function startedDevice(info) {
405
+ if (!info) return null;
406
+ const posture = info.posture ?? null;
407
+ return {
408
+ name: info.device_name ?? null,
409
+ model: info.device_model ?? null,
410
+ os_version: info.os_version ?? null,
411
+ category: info.device_category ?? null,
412
+ form_factor: info.form_factor ?? null,
413
+ posture,
414
+ screen: info.form_factor === "foldable" && posture ? posture === "closed" ? "cover" : "inner" : null
415
+ };
416
+ }
308
417
  async function handleListDeviceModels(args, deps) {
309
418
  const list = deps.listDeviceModels ?? (() => new ApiClient(deps.auth.baseUrl, deps.auth.token).listDeviceModels());
310
419
  let models;
311
420
  try {
312
421
  models = filterDeviceModels(await list(), {
313
- type: typeof args.device_type === "string" ? args.device_type : void 0
422
+ type: typeof args.device_type === "string" ? args.device_type : void 0,
423
+ availability: true
314
424
  });
315
425
  } catch (err) {
316
426
  return errorResult(`error: ${err instanceof Error ? err.message : String(err)}`);
317
427
  }
318
- return textResult(formatDeviceModelsHuman(models).trimEnd(), { models });
428
+ return textResult(formatDeviceModelsHuman(models, { availability: true }).trimEnd(), {
429
+ models: models.map(({ free, total, ...m }) => ({
430
+ ...m,
431
+ available: free ?? null,
432
+ busy: typeof free === "number" && typeof total === "number" ? total - free : null
433
+ }))
434
+ });
435
+ }
436
+ async function handleListApps(args, deps) {
437
+ const list = deps.listApps ?? (() => new ApiClient(deps.auth.baseUrl, deps.auth.token).listApps());
438
+ const platform = typeof args.platform === "string" && args.platform ? args.platform : void 0;
439
+ let apps;
440
+ try {
441
+ apps = (await list()).filter((a) => !platform || a.platform === platform);
442
+ } catch (err) {
443
+ return errorResult(`error: ${err instanceof Error ? err.message : String(err)}`);
444
+ }
445
+ const rows = apps.map((a) => ({
446
+ app_id: a.public_id,
447
+ name: a.name,
448
+ platform: a.platform,
449
+ package_name: a.package_name
450
+ }));
451
+ const text = rows.length ? rows.map((a) => `${a.app_id} ${a.name} [${a.platform}] ${a.package_name}`).join("\n") : platform ? `No ${platform} apps yet \u2014 upload a build with upload_app.` : "No apps yet \u2014 upload a build with upload_app.";
452
+ return textResult(text, { apps: rows });
319
453
  }
320
454
  async function handleListBuilds(args, deps) {
321
455
  const appId = typeof args.app === "string" ? args.app.trim() : "";
@@ -336,8 +470,28 @@ async function handleStopSession(args, deps) {
336
470
  const sessionId = typeof args.session_id === "string" ? args.session_id.trim() : "";
337
471
  if (!sessionId) return errorResult("stop_session: `session_id` is required");
338
472
  const stop = deps.stopSession ?? ((id) => new ApiClient(deps.auth.baseUrl, deps.auth.token).stopSession(id));
473
+ const held = deps.devSession.current()?.sessionId === sessionId;
474
+ let before = null;
475
+ try {
476
+ before = await readSession(deps, sessionId);
477
+ } catch (err) {
478
+ return errorResult(`error: ${err instanceof Error ? err.message : String(err)}`);
479
+ }
480
+ if (before && isEndedStatus(before.status)) {
481
+ if (held) {
482
+ deps.devSession.markEnded({
483
+ sessionId,
484
+ status: before.status,
485
+ reason: before.failure_reason ?? null
486
+ });
487
+ }
488
+ return textResult(
489
+ `session ${sessionId} was already ${before.status === "failed" ? "over (it failed)" : "stopped"}`,
490
+ { session_id: sessionId, stopped: false, already_ended: true, status: before.status }
491
+ );
492
+ }
339
493
  try {
340
- if (deps.devSession.current()?.sessionId === sessionId) {
494
+ if (held) {
341
495
  await deps.devSession.stop();
342
496
  } else {
343
497
  await stop(sessionId);
@@ -361,13 +515,21 @@ async function handleDevReload(args, deps) {
361
515
  }
362
516
  async function handleDevStop(deps) {
363
517
  const current = deps.devSession.current();
518
+ const ended = deps.devSession.lastEnded();
364
519
  try {
365
520
  await deps.devSession.stop();
366
521
  } catch (err) {
367
522
  return errorResult(`error: ${err instanceof Error ? err.message : String(err)}`);
368
523
  }
369
- return textResult(current ? `dev session stopped: ${current.sessionId}` : "dev session stopped", {
370
- session_id: current?.sessionId ?? null
524
+ if (!current) {
525
+ return textResult(
526
+ ended ? `no dev session was running \u2014 ${endedLine(ended)} already` : "no dev session was running",
527
+ { session_id: null, stopped: false }
528
+ );
529
+ }
530
+ return textResult(`dev session stopped: ${current.sessionId}`, {
531
+ session_id: current.sessionId,
532
+ stopped: true
371
533
  });
372
534
  }
373
535
  async function handleUploadApp(args, deps) {
@@ -377,7 +539,7 @@ async function handleUploadApp(args, deps) {
377
539
  }
378
540
  const name = typeof args.name === "string" && args.name ? args.name : void 0;
379
541
  const upload = deps.uploadApp ?? (async (f, n) => {
380
- const { uploadAppBuild } = await import("./upload-app-QUUXKNR2.js");
542
+ const { uploadAppBuild } = await import("./upload-app-WROTRZCA.js");
381
543
  const { result: result2 } = await uploadAppBuild(f, { ...deps.auth, name: n });
382
544
  return result2;
383
545
  });
@@ -413,6 +575,7 @@ async function handleUploadApp(args, deps) {
413
575
  function createDevSessionController(auth, webUrl) {
414
576
  let handle = null;
415
577
  let platform = null;
578
+ let ended = null;
416
579
  let presence = null;
417
580
  let reloading = null;
418
581
  const stopPresence = () => {
@@ -428,12 +591,32 @@ function createDevSessionController(auth, webUrl) {
428
591
  );
429
592
  }
430
593
  };
594
+ const markEnded = (e) => {
595
+ if (!handle || handle.sessionId !== e.sessionId) return;
596
+ const dead = handle;
597
+ stopPresence();
598
+ handle = null;
599
+ platform = null;
600
+ ended = e;
601
+ void dead.stop().catch(() => {
602
+ });
603
+ };
604
+ const api = () => new ApiClient(auth.baseUrl, auth.token);
431
605
  return {
432
606
  async start(cfg) {
433
607
  if (handle) {
434
- throw new Error(
435
- `a dev session is already running (${handle.sessionId}) \u2014 call dev_stop first`
436
- );
608
+ const info = await api().getSession(handle.sessionId).catch(() => null);
609
+ if (info && isEndedStatus(info.status)) {
610
+ markEnded({
611
+ sessionId: handle.sessionId,
612
+ status: info.status,
613
+ reason: info.failure_reason ?? null
614
+ });
615
+ } else {
616
+ throw new Error(
617
+ `a dev session is already running (${handle.sessionId}) \u2014 call dev_stop first`
618
+ );
619
+ }
437
620
  }
438
621
  let devConfig = null;
439
622
  try {
@@ -448,8 +631,26 @@ function createDevSessionController(auth, webUrl) {
448
631
  `dev_start: no app to run for platform "${cfg.platform}" \u2014 pass the app arg (an app id from list-apps) or run \`vibeview dev\` once in this project to write vibeview.json`
449
632
  );
450
633
  }
634
+ let app;
635
+ try {
636
+ app = await api().getApp(appId);
637
+ } catch (err) {
638
+ if (err instanceof NotFoundError) {
639
+ throw new Error(
640
+ `app "${appId}" not found \u2014 it may have been deleted; see list_apps for your app ids`
641
+ );
642
+ }
643
+ throw err;
644
+ }
645
+ if (app.platform !== cfg.platform) {
646
+ throw new Error(
647
+ `app "${appId}" is ${platformWithArticle(app.platform)} app, not ${platformWithArticle(cfg.platform)} app \u2014 call dev_start with platform "${app.platform}", or pick ${platformWithArticle(cfg.platform)} app from list_apps`
648
+ );
649
+ }
650
+ const standalone = cfg.standalone === true;
651
+ const usesMetro = PLATFORM_NEEDS_METRO[cfg.platform] && !standalone;
451
652
  const metroPort = cfg.metroPort ?? devConfig?.metroPort ?? 8081;
452
- if (PLATFORM_NEEDS_METRO[cfg.platform]) {
653
+ if (usesMetro) {
453
654
  const metro = await checkMetro(metroPort);
454
655
  if (metro === "down") {
455
656
  throw new Error(
@@ -458,47 +659,77 @@ function createDevSessionController(auth, webUrl) {
458
659
  }
459
660
  }
460
661
  if (cfg.buildId !== void 0) {
461
- checkDevBuild(
462
- await new ApiClient(auth.baseUrl, auth.token).getApp(appId),
463
- cfg.buildId,
464
- cfg.platform
465
- );
662
+ checkDevBuild(app, cfg.buildId, cfg.platform, { standalone });
466
663
  }
467
664
  const isTv = PLATFORM_DEVICE[cfg.platform].tv;
665
+ const warnings = [];
666
+ let started = null;
667
+ started = await startDevSession(
668
+ {
669
+ token: auth.token,
670
+ baseUrl: auth.baseUrl,
671
+ webUrl,
672
+ platform: cfg.platform,
673
+ appId,
674
+ deviceCategory: isTv ? "tv" : entry?.deviceCategory ?? "phone",
675
+ // A named model decides the category itself (startDevSession).
676
+ model: cfg.model,
677
+ buildId: cfg.buildId,
678
+ ...standalone ? { standalone } : {},
679
+ metroPort,
680
+ open: false
681
+ },
682
+ (evt) => {
683
+ if (evt.event === "warning") warnings.push(evt.message);
684
+ if (evt.event === "session_ended" && evt.code !== 0 && started) {
685
+ const id = evt.session_id;
686
+ void api().getSession(id).then(
687
+ (info) => ({ status: info.status, reason: info.failure_reason ?? null }),
688
+ () => ({ status: "stopped", reason: null })
689
+ ).then(({ status, reason }) => markEnded({ sessionId: id, status, reason }));
690
+ }
691
+ }
692
+ );
693
+ handle = started;
468
694
  platform = cfg.platform;
469
- handle = await startDevSession({
470
- token: auth.token,
471
- baseUrl: auth.baseUrl,
472
- webUrl,
473
- platform: cfg.platform,
474
- appId,
475
- deviceCategory: isTv ? "tv" : entry?.deviceCategory ?? "phone",
476
- // A named model decides the category itself (startDevSession).
477
- model: cfg.model,
478
- buildId: cfg.buildId,
479
- metroPort,
480
- open: false
481
- });
482
- if (cfg.platform === "roku") {
483
- const api = new ApiClient(auth.baseUrl, auth.token);
695
+ ended = null;
696
+ if (!usesMetro) {
697
+ const client = api();
484
698
  const sessionId = handle.sessionId;
485
699
  presence = setInterval(() => {
486
- api.touchSession(sessionId).catch(() => {
487
- });
700
+ client.touchSession(sessionId).then(
701
+ (info) => {
702
+ if (info && isEndedStatus(info.status)) {
703
+ markEnded({ sessionId, status: info.status, reason: info.failure_reason ?? null });
704
+ }
705
+ },
706
+ () => {
707
+ }
708
+ );
488
709
  }, ROKU_LIVENESS_INTERVAL_MS);
489
710
  presence.unref?.();
490
711
  }
491
- return handle;
712
+ return {
713
+ sessionId: handle.sessionId,
714
+ pageUrl: handle.pageUrl,
715
+ device: startedDevice(handle.session),
716
+ warnings
717
+ };
492
718
  },
493
719
  async stop() {
494
720
  stopPresence();
495
721
  await handle?.stop();
496
722
  handle = null;
497
723
  platform = null;
724
+ ended = null;
498
725
  },
499
726
  current() {
500
727
  return handle ? { sessionId: handle.sessionId, pageUrl: handle.pageUrl } : null;
501
728
  },
729
+ markEnded,
730
+ lastEnded() {
731
+ return ended;
732
+ },
502
733
  async reload(requested) {
503
734
  if (reloading) {
504
735
  throw new Error(
@@ -564,6 +795,13 @@ function createDevSessionController(auth, webUrl) {
564
795
  function makeSessionResolver(devSession) {
565
796
  return () => devSession.current()?.sessionId ?? readDevState(process.cwd())?.session_id;
566
797
  }
798
+ var MCP_INSTRUCTIONS = [
799
+ "VibeView runs your app on a cloud iOS simulator, Android emulator, Apple TV, Android TV or Roku (beta) device you control with these tools.",
800
+ "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.",
801
+ "Foldables: pick one by model in dev_start (list_device_models marks them foldable, with free/busy counts), then set_posture and rotate.",
802
+ "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.",
803
+ "A session stopped elsewhere is reported as ended; call dev_start again for a new one."
804
+ ].join("\n");
567
805
  async function createMcpServer(opts) {
568
806
  const [{ McpServer }, { z }] = await Promise.all([
569
807
  import("@modelcontextprotocol/sdk/server/mcp.js"),
@@ -578,7 +816,10 @@ async function createMcpServer(opts) {
578
816
  agentTool: client.agentTool.bind(client),
579
817
  devSession
580
818
  };
581
- const server = new McpServer({ name: "vibeview", version: opts.version ?? "0.0.0" });
819
+ const server = new McpServer(
820
+ { name: "vibeview", version: opts.version ?? "0.0.0" },
821
+ { instructions: MCP_INSTRUCTIONS }
822
+ );
582
823
  function zodShape(def) {
583
824
  const shape = {};
584
825
  for (const [key, prop] of Object.entries(def.properties)) {
@@ -614,8 +855,12 @@ async function runMcpServer(opts) {
614
855
  await server.connect(transport);
615
856
  }
616
857
  export {
858
+ MCP_INSTRUCTIONS,
617
859
  buildToolDefinitions,
618
860
  createDevSessionController,
861
+ deviceLine,
862
+ forMcp,
619
863
  handleToolCall,
620
- runMcpServer
864
+ runMcpServer,
865
+ startedDevice
621
866
  };
@@ -3,7 +3,7 @@ import {
3
3
  describeBuild,
4
4
  uploadApp,
5
5
  uploadAppBuild
6
- } from "./chunk-U7FCGMWG.js";
6
+ } from "./chunk-2JLTC67A.js";
7
7
  export {
8
8
  describeBuild,
9
9
  uploadApp,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vibeview",
3
- "version": "0.27.0",
4
- "description": "VibeView CLI -- run mobile tests from CI/CD pipelines",
3
+ "version": "0.28.0",
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",
7
7
  "bin": {
@@ -297,12 +297,17 @@ steps the CLI does with plain shell commands:
297
297
 
298
298
  | MCP tool | Args | Purpose |
299
299
  |----------|------|---------|
300
- | `upload_app` | `file` (required path), `name` | Upload a debug build. 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`. |
301
- | `dev_start` | `platform` (required: `ios`/`android`/`tvos`/`androidtv`/`roku`), `app`, `metro_port`, `model`, `build_id` | Start a dev-loop 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 debug build instead of the newest one (see `list_builds`). Returns `page_url` — relay it to the developer immediately, same as step 5 of the loop. |
302
- | `list_device_models` | `device_type` | List the device models you can start (model, OS, platform, category, `foldable` marker). The MCP equivalent of `vibeview list-devices --models`. |
300
+ | `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`. |
301
+ | `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. |
302
+ | `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). |
303
+ | `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`. |
303
304
  | `list_builds` | `app` (required) | List an app's uploaded builds with their kind (`debug`/`release`/`unknown`). The MCP equivalent of `vibeview list-builds`. |
304
- | `dev_stop` | — | Stop the session `dev_start` started. The MCP equivalent of `vibeview dev-stop`. |
305
- | `stop_session` | `session_id` (required) | Stop any session by id. The MCP equivalent of `vibeview stop <session-id>`. |
305
+ | `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. |
306
+ | `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. |
307
+
308
+ If the held session ends elsewhere (dashboard, idle timeout, failure), the
309
+ next tool call answers "session … has ended (…) — call dev_start"; the
310
+ server has already let it go, so `dev_start` starts a fresh one.
306
311
 
307
312
  **`dev_start` is stateful — it changes the default session for every later
308
313
  call.** Once it succeeds, any tool called without an explicit `session_id`