vibeview 0.35.0 → 0.36.1

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.
@@ -585,6 +585,73 @@ function clearDevState(cwd) {
585
585
  if (existsSync(path)) {
586
586
  rmSync2(path, { force: true });
587
587
  }
588
+ rmSync2(join2(cwd, STATE_DIR, "dev-use.json"), { force: true });
589
+ }
590
+ function clearDevStateIfOwned(cwd, pid) {
591
+ const found = readDevStateFile(cwd);
592
+ if (found && found.state.pid === pid) clearDevState(cwd);
593
+ }
594
+
595
+ // src/lib/dev-use.ts
596
+ import {
597
+ existsSync as existsSync2,
598
+ mkdirSync as mkdirSync2,
599
+ readFileSync as readFileSync3,
600
+ readdirSync,
601
+ rmSync as rmSync3,
602
+ statSync,
603
+ writeFileSync as writeFileSync3
604
+ } from "fs";
605
+ import { homedir } from "os";
606
+ import { join as join3 } from "path";
607
+ var STALE_MS = 7 * 24 * 60 * 60 * 1e3;
608
+ function useDir() {
609
+ return join3(homedir(), ".vibeview", "dev-use");
610
+ }
611
+ function notePath(sessionId) {
612
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(sessionId)) return null;
613
+ return join3(useDir(), `${sessionId}.json`);
614
+ }
615
+ function registerDevUse(sessionId) {
616
+ const path = notePath(sessionId);
617
+ if (!path) return;
618
+ try {
619
+ mkdirSync2(useDir(), { recursive: true });
620
+ writeFileSync3(path, JSON.stringify({ at: 0 }) + "\n");
621
+ const now = Date.now();
622
+ for (const name of readdirSync(useDir())) {
623
+ const p = join3(useDir(), name);
624
+ try {
625
+ if (now - statSync(p).mtimeMs > STALE_MS) rmSync3(p, { force: true });
626
+ } catch {
627
+ }
628
+ }
629
+ } catch {
630
+ }
631
+ }
632
+ function recordDevUse(sessionId, at = Date.now()) {
633
+ const path = notePath(sessionId);
634
+ if (!path) return;
635
+ try {
636
+ if (!existsSync2(path)) return;
637
+ writeFileSync3(path, JSON.stringify({ at }) + "\n");
638
+ } catch {
639
+ }
640
+ }
641
+ function readDevUse(sessionId) {
642
+ const path = notePath(sessionId);
643
+ if (!path) return null;
644
+ try {
645
+ const parsed = JSON.parse(readFileSync3(path, "utf-8"));
646
+ return typeof parsed?.at === "number" ? parsed.at : null;
647
+ } catch {
648
+ return null;
649
+ }
650
+ }
651
+ function clearDevUse(sessionId) {
652
+ const path = notePath(sessionId);
653
+ if (!path) return;
654
+ rmSync3(path, { force: true });
588
655
  }
589
656
 
590
657
  // src/lib/metro-check.ts
@@ -601,17 +668,17 @@ async function checkMetro(port, timeoutMs = 1500) {
601
668
  }
602
669
 
603
670
  // src/lib/local-config.ts
604
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, renameSync as renameSync2, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "fs";
605
- import { join as join3 } from "path";
671
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, renameSync as renameSync2, rmSync as rmSync4, writeFileSync as writeFileSync4 } from "fs";
672
+ import { join as join4 } from "path";
606
673
  var DIR = ".vibeview";
607
674
  var FILE2 = "local.json";
608
675
  function localConfigPath(cwd) {
609
- return join3(cwd, DIR, FILE2);
676
+ return join4(cwd, DIR, FILE2);
610
677
  }
611
678
  function loadLocalDevConfig(cwd) {
612
679
  let parsed;
613
680
  try {
614
- parsed = JSON.parse(readFileSync3(localConfigPath(cwd), "utf-8"));
681
+ parsed = JSON.parse(readFileSync4(localConfigPath(cwd), "utf-8"));
615
682
  } catch {
616
683
  return {};
617
684
  }
@@ -651,16 +718,16 @@ function saveLocalDevConfig(cwd, cfg) {
651
718
  const pruned = prune(cfg);
652
719
  const target = localConfigPath(cwd);
653
720
  if (!pruned.defaultPlatform && !pruned.platforms) {
654
- rmSync3(target, { force: true });
721
+ rmSync4(target, { force: true });
655
722
  return;
656
723
  }
657
- mkdirSync2(join3(cwd, DIR), { recursive: true });
724
+ mkdirSync3(join4(cwd, DIR), { recursive: true });
658
725
  const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
659
726
  try {
660
- writeFileSync3(tmp, JSON.stringify(pruned, null, 2) + "\n");
727
+ writeFileSync4(tmp, JSON.stringify(pruned, null, 2) + "\n");
661
728
  renameSync2(tmp, target);
662
729
  } catch (err) {
663
- rmSync3(tmp, { force: true });
730
+ rmSync4(tmp, { force: true });
664
731
  throw err;
665
732
  }
666
733
  ensureGitignored(cwd);
@@ -714,19 +781,19 @@ import { input, select } from "@inquirer/prompts";
714
781
 
715
782
  // src/lib/project-detect.ts
716
783
  import { execFile } from "child_process";
717
- import { existsSync as existsSync2, readFileSync as readFileSync4, readdirSync, statSync } from "fs";
718
- import { join as join4 } from "path";
784
+ import { existsSync as existsSync3, readFileSync as readFileSync5, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
785
+ import { join as join5 } from "path";
719
786
  import ignore from "ignore";
720
787
  function readJson(cwd, name) {
721
788
  try {
722
- return JSON.parse(readFileSync4(join4(cwd, name), "utf-8"));
789
+ return JSON.parse(readFileSync5(join5(cwd, name), "utf-8"));
723
790
  } catch {
724
791
  return null;
725
792
  }
726
793
  }
727
794
  function isFile(path) {
728
795
  try {
729
- return statSync(path).isFile();
796
+ return statSync2(path).isFile();
730
797
  } catch {
731
798
  return false;
732
799
  }
@@ -734,19 +801,19 @@ function isFile(path) {
734
801
  function detectFlavor(cwd) {
735
802
  let deps = {};
736
803
  try {
737
- const pkg = JSON.parse(readFileSync4(join4(cwd, "package.json"), "utf-8"));
804
+ const pkg = JSON.parse(readFileSync5(join5(cwd, "package.json"), "utf-8"));
738
805
  deps = { ...pkg.dependencies, ...pkg.devDependencies };
739
806
  } catch {
740
807
  }
741
808
  if ("expo" in deps) return "expo";
742
- if (existsSync2(join4(cwd, "android", "gradlew")) || iosWorkspaceName(cwd)) return "bare-rn";
743
- if (isFile(join4(cwd, "manifest"))) return "roku";
809
+ if (existsSync3(join5(cwd, "android", "gradlew")) || iosWorkspaceName(cwd)) return "bare-rn";
810
+ if (isFile(join5(cwd, "manifest"))) return "roku";
744
811
  return "unknown";
745
812
  }
746
813
  function nativeDirShipped(cwd, dir) {
747
- if (!existsSync2(join4(cwd, dir))) return false;
814
+ if (!existsSync3(join5(cwd, dir))) return false;
748
815
  try {
749
- const ig = ignore().add(readFileSync4(join4(cwd, ".gitignore"), "utf-8"));
816
+ const ig = ignore().add(readFileSync5(join5(cwd, ".gitignore"), "utf-8"));
750
817
  return !ig.ignores(`${dir}/`);
751
818
  } catch {
752
819
  return true;
@@ -762,7 +829,7 @@ function isManagedExpo(cwd, platform) {
762
829
  }
763
830
  function iosWorkspaceName(cwd) {
764
831
  try {
765
- const found = readdirSync(join4(cwd, "ios")).find((f) => f.endsWith(".xcworkspace"));
832
+ const found = readdirSync2(join5(cwd, "ios")).find((f) => f.endsWith(".xcworkspace"));
766
833
  return found ? found.replace(/\.xcworkspace$/, "") : null;
767
834
  } catch {
768
835
  return null;
@@ -771,9 +838,9 @@ function iosWorkspaceName(cwd) {
771
838
  var IOS_ARTIFACT = "ios/build/Build/Products/Debug-iphonesimulator/*.app";
772
839
  var ANDROID_ARTIFACT = "android/app/build/outputs/apk/debug/*.apk";
773
840
  function installCommand(cwd) {
774
- if (existsSync2(join4(cwd, "yarn.lock"))) return "yarn install --immutable";
775
- if (existsSync2(join4(cwd, "pnpm-lock.yaml"))) return "pnpm install --frozen-lockfile";
776
- if (existsSync2(join4(cwd, "package-lock.json"))) return "npm ci";
841
+ if (existsSync3(join5(cwd, "yarn.lock"))) return "yarn install --immutable";
842
+ if (existsSync3(join5(cwd, "pnpm-lock.yaml"))) return "pnpm install --frozen-lockfile";
843
+ if (existsSync3(join5(cwd, "package-lock.json"))) return "npm ci";
777
844
  return "yarn install --immutable";
778
845
  }
779
846
  function managedExpoAndroidCommand(cwd) {
@@ -787,7 +854,7 @@ var ROKU_ZIP_COMMAND = "zip -r out/channel.zip manifest source components images
787
854
  function rokuDefaults(cwd) {
788
855
  const pkg = readJson(cwd, "package.json");
789
856
  const scripts = pkg?.scripts ?? {};
790
- const runner = existsSync2(join4(cwd, "yarn.lock")) ? "yarn" : "npm run";
857
+ const runner = existsSync3(join5(cwd, "yarn.lock")) ? "yarn" : "npm run";
791
858
  const packageScript = Object.entries(scripts).find(
792
859
  ([name, body]) => !name.startsWith("deploy") && (name.startsWith("package") || body.includes("--package-only") || body.includes("zipPackage"))
793
860
  );
@@ -845,7 +912,7 @@ function expoSanitizedName(name) {
845
912
  return name.replace(/[\W_]+/g, "").normalize("NFD").replace(/[\u0300-\u036f]/g, "");
846
913
  }
847
914
  function managedExpoIosName(cwd) {
848
- if (existsSync2(join4(cwd, "app.config.js")) || existsSync2(join4(cwd, "app.config.ts"))) {
915
+ if (existsSync3(join5(cwd, "app.config.js")) || existsSync3(join5(cwd, "app.config.ts"))) {
849
916
  return null;
850
917
  }
851
918
  const raw = readJson(cwd, "app.config.json") ?? readJson(cwd, "app.json");
@@ -900,22 +967,22 @@ function uniqueMatch(text, re) {
900
967
  }
901
968
  function readText(path) {
902
969
  try {
903
- return readFileSync4(path, "utf-8");
970
+ return readFileSync5(path, "utf-8");
904
971
  } catch {
905
972
  return null;
906
973
  }
907
974
  }
908
975
  function pbxprojText(cwd) {
909
976
  try {
910
- const proj = readdirSync(join4(cwd, "ios")).find((f) => f.endsWith(".xcodeproj"));
911
- return proj ? readText(join4(cwd, "ios", proj, "project.pbxproj")) : null;
977
+ const proj = readdirSync2(join5(cwd, "ios")).find((f) => f.endsWith(".xcodeproj"));
978
+ return proj ? readText(join5(cwd, "ios", proj, "project.pbxproj")) : null;
912
979
  } catch {
913
980
  return null;
914
981
  }
915
982
  }
916
983
  var readExpoConfig = (cwd) => {
917
- const cli = join4(cwd, "node_modules", "expo", "bin", "cli");
918
- if (!existsSync2(cli)) {
984
+ const cli = join5(cwd, "node_modules", "expo", "bin", "cli");
985
+ if (!existsSync3(cli)) {
919
986
  return Promise.reject(new Error("Expo is not installed in this project (node_modules/expo)"));
920
987
  }
921
988
  return new Promise((resolve, reject) => {
@@ -954,12 +1021,12 @@ async function localAppIdentity(cwd, platform, readConfig = readExpoConfig) {
954
1021
  let bundleId = null;
955
1022
  let versionUnreadable = null;
956
1023
  const dynamicName = ["app.config.ts", "app.config.js", "app.config.mjs", "app.config.cjs"].find(
957
- (f) => existsSync2(join4(cwd, f))
1024
+ (f) => existsSync3(join5(cwd, f))
958
1025
  );
959
1026
  const staticRaw = readJson(cwd, "app.config.json") ?? readJson(cwd, "app.json");
960
- const isExpo = Boolean(dynamicName) || staticRaw !== null && "expo" in staticRaw || existsSync2(join4(cwd, "node_modules", "expo", "package.json"));
1027
+ const isExpo = Boolean(dynamicName) || staticRaw !== null && "expo" in staticRaw || existsSync3(join5(cwd, "node_modules", "expo", "package.json"));
961
1028
  let expo = null;
962
- if (isExpo && (dynamicName || existsSync2(join4(cwd, "node_modules", "expo", "bin", "cli")))) {
1029
+ if (isExpo && (dynamicName || existsSync3(join5(cwd, "node_modules", "expo", "bin", "cli")))) {
963
1030
  try {
964
1031
  expo = await readConfig(cwd);
965
1032
  } catch (err) {
@@ -979,7 +1046,7 @@ async function localAppIdentity(cwd, platform, readConfig = readExpoConfig) {
979
1046
  if (typeof id === "string") bundleId = id;
980
1047
  }
981
1048
  if (android && (!version || !bundleId)) {
982
- const gradle = readText(join4(cwd, "android", "app", "build.gradle")) ?? readText(join4(cwd, "android", "app", "build.gradle.kts"));
1049
+ const gradle = readText(join5(cwd, "android", "app", "build.gradle")) ?? readText(join5(cwd, "android", "app", "build.gradle.kts"));
983
1050
  if (gradle) {
984
1051
  version ??= uniqueMatch(gradle, /versionName\s*=?\s*["']([^"']+)["']/g);
985
1052
  bundleId ??= uniqueMatch(gradle, /applicationId\s*=?\s*["']([^"']+)["']/g);
@@ -1003,7 +1070,7 @@ function platformChoices(cwd) {
1003
1070
  { value: "tvos", name: "Apple TV simulator" },
1004
1071
  { value: "androidtv", name: "Android TV emulator" }
1005
1072
  ];
1006
- if (detectFlavor(cwd) === "roku") choices.unshift({ value: "roku", name: "Roku (beta)" });
1073
+ if (detectFlavor(cwd) === "roku") choices.unshift({ value: "roku", name: "Roku" });
1007
1074
  return choices;
1008
1075
  }
1009
1076
  function appsForPlatform(apps, platform) {
@@ -1094,9 +1161,9 @@ async function promptMetroCommand(cwd, platform) {
1094
1161
 
1095
1162
  // src/lib/source-pack.ts
1096
1163
  import { execFile as execFile2, spawn } from "child_process";
1097
- import { lstatSync, mkdtempSync, readFileSync as readFileSync5, rmSync as rmSync4, statSync as statSync2, writeFileSync as writeFileSync4 } from "fs";
1164
+ import { lstatSync, mkdtempSync, readFileSync as readFileSync6, rmSync as rmSync5, statSync as statSync3, writeFileSync as writeFileSync5 } from "fs";
1098
1165
  import { tmpdir } from "os";
1099
- import { join as join5 } from "path";
1166
+ import { join as join6 } from "path";
1100
1167
  import ignore2 from "ignore";
1101
1168
  var NotAGitRepoError = class extends Error {
1102
1169
  constructor(message) {
@@ -1122,15 +1189,15 @@ async function listSourceFiles(projectRoot) {
1122
1189
  const stdout = await runGitLsFiles(projectRoot);
1123
1190
  const rawPaths = stdout.length ? stdout.split("\0").filter((p) => p.length > 0) : [];
1124
1191
  let filtered = rawPaths;
1125
- const vvignorePath = join5(projectRoot, ".vvignore");
1192
+ const vvignorePath = join6(projectRoot, ".vvignore");
1126
1193
  if (statOrNull(vvignorePath)?.isFile()) {
1127
- const ig = ignore2().add(readFileSync5(vvignorePath, "utf-8"));
1194
+ const ig = ignore2().add(readFileSync6(vvignorePath, "utf-8"));
1128
1195
  filtered = filtered.filter((p) => !ig.ignores(p));
1129
1196
  }
1130
1197
  const result = [];
1131
1198
  for (const relPath of filtered) {
1132
1199
  if (isForceExcluded(relPath)) continue;
1133
- const st = statOrNull(join5(projectRoot, relPath));
1200
+ const st = statOrNull(join6(projectRoot, relPath));
1134
1201
  if (!st) continue;
1135
1202
  result.push(relPath);
1136
1203
  }
@@ -1180,7 +1247,7 @@ async function packSource(projectRoot, files, opts = {}) {
1180
1247
  let totalBytes = 0;
1181
1248
  const sized = [];
1182
1249
  for (const relPath of files) {
1183
- const bytes = lstatSync(join5(projectRoot, relPath)).size;
1250
+ const bytes = lstatSync(join6(projectRoot, relPath)).size;
1184
1251
  totalBytes += bytes;
1185
1252
  sized.push({ path: relPath, bytes });
1186
1253
  }
@@ -1191,14 +1258,14 @@ async function packSource(projectRoot, files, opts = {}) {
1191
1258
  largest
1192
1259
  );
1193
1260
  }
1194
- const tmpDir = mkdtempSync(join5(tmpdir(), "vibeview-source-pack-"));
1195
- const cleanup = () => rmSync4(tmpDir, { recursive: true, force: true });
1261
+ const tmpDir = mkdtempSync(join6(tmpdir(), "vibeview-source-pack-"));
1262
+ const cleanup = () => rmSync5(tmpDir, { recursive: true, force: true });
1196
1263
  try {
1197
1264
  const supportsNull = await probeTarNullSupport();
1198
- const tarPath = join5(tmpDir, "source.tgz");
1265
+ const tarPath = join6(tmpDir, "source.tgz");
1199
1266
  if (supportsNull) {
1200
- const listPath = join5(tmpDir, "files.list");
1201
- writeFileSync4(listPath, files.map((f) => `${f}\0`).join(""));
1267
+ const listPath = join6(tmpDir, "files.list");
1268
+ writeFileSync5(listPath, files.map((f) => `${f}\0`).join(""));
1202
1269
  await runTar(["-czf", tarPath, "-C", projectRoot, "--null", "-T", listPath]);
1203
1270
  } else {
1204
1271
  const badName = files.find((f) => f.includes("\n"));
@@ -1207,12 +1274,12 @@ async function packSource(projectRoot, files, opts = {}) {
1207
1274
  `cannot pack source: filename contains a newline (${JSON.stringify(badName)}) and the installed tar doesn't support --null -T; rename the file or install a tar that supports --null`
1208
1275
  );
1209
1276
  }
1210
- const listPath = join5(tmpDir, "files.list");
1211
- writeFileSync4(listPath, files.map((f) => `${f}
1277
+ const listPath = join6(tmpDir, "files.list");
1278
+ writeFileSync5(listPath, files.map((f) => `${f}
1212
1279
  `).join(""));
1213
1280
  await runTar(["-czf", tarPath, "-C", projectRoot, "-T", listPath]);
1214
1281
  }
1215
- const sizeBytes = statSync2(tarPath).size;
1282
+ const sizeBytes = statSync3(tarPath).size;
1216
1283
  return { tarPath, sizeBytes, cleanup };
1217
1284
  } catch (err) {
1218
1285
  cleanup();
@@ -1228,12 +1295,12 @@ var nullSupportCache = null;
1228
1295
  function probeTarNullSupport() {
1229
1296
  if (!nullSupportCache) {
1230
1297
  nullSupportCache = new Promise((resolve) => {
1231
- const tmp = mkdtempSync(join5(tmpdir(), "vibeview-tar-probe-"));
1232
- const listPath = join5(tmp, "empty.list");
1233
- writeFileSync4(listPath, "");
1234
- const out = join5(tmp, "probe.tgz");
1298
+ const tmp = mkdtempSync(join6(tmpdir(), "vibeview-tar-probe-"));
1299
+ const listPath = join6(tmp, "empty.list");
1300
+ writeFileSync5(listPath, "");
1301
+ const out = join6(tmp, "probe.tgz");
1235
1302
  execFile2("tar", ["-czf", out, "-C", tmp, "--null", "-T", listPath], (err) => {
1236
- rmSync4(tmp, { recursive: true, force: true });
1303
+ rmSync5(tmp, { recursive: true, force: true });
1237
1304
  resolve(!err);
1238
1305
  });
1239
1306
  });
@@ -2105,7 +2172,7 @@ your build command must produce a zip \u2014 use the package/zip step, not deplo
2105
2172
 
2106
2173
  // src/commands/dev.ts
2107
2174
  import { spawn as spawn4 } from "child_process";
2108
- import { createWriteStream, mkdirSync as mkdirSync4 } from "fs";
2175
+ import { createWriteStream, mkdirSync as mkdirSync5 } from "fs";
2109
2176
  import { dirname as dirname4 } from "path";
2110
2177
  import { createInterface as createInterface3 } from "readline";
2111
2178
  import { select as select3 } from "@inquirer/prompts";
@@ -2156,7 +2223,8 @@ function redactSecrets(text) {
2156
2223
  }
2157
2224
 
2158
2225
  // src/lib/tunnel-client.ts
2159
- var FATAL_CLOSE_CODES = /* @__PURE__ */ new Set([4008, 4009]);
2226
+ var TUNNEL_REPLACED_CLOSE_CODE = 4008;
2227
+ var FATAL_CLOSE_CODES = /* @__PURE__ */ new Set([TUNNEL_REPLACED_CLOSE_CODE, 4009]);
2160
2228
  var MAX_BACKOFF_MS = 8e3;
2161
2229
  var STABLE_MS = 2e3;
2162
2230
  var TunnelClient = class {
@@ -2222,12 +2290,18 @@ var TunnelClient = class {
2222
2290
  const heldMs = Date.now() - openedAt;
2223
2291
  const why = reason?.length ? ` reason="${reason.toString()}"` : "";
2224
2292
  this.debug(`ws#${conn} close code=${code}${why} heldMs=${heldMs}`);
2293
+ if (this.ws !== ws) return;
2225
2294
  if (this.stableTimer) {
2226
2295
  clearTimeout(this.stableTimer);
2227
2296
  this.stableTimer = null;
2228
2297
  }
2229
2298
  for (const s of this.sockets.values()) s.destroy();
2230
2299
  this.sockets.clear();
2300
+ if (!this.stopped && code === TUNNEL_REPLACED_CLOSE_CODE) {
2301
+ this.stopped = true;
2302
+ this.setState("replaced");
2303
+ return;
2304
+ }
2231
2305
  if (this.stopped || FATAL_CLOSE_CODES.has(code)) {
2232
2306
  this.stopped = true;
2233
2307
  this.setState("closed");
@@ -2301,15 +2375,15 @@ import { spawn as spawn3 } from "child_process";
2301
2375
  import { createInterface as createInterface2 } from "readline";
2302
2376
 
2303
2377
  // src/lib/metro-state.ts
2304
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync6, rmSync as rmSync5, writeFileSync as writeFileSync5 } from "fs";
2305
- import { dirname as dirname2, join as join6 } from "path";
2378
+ import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync7, rmSync as rmSync6, writeFileSync as writeFileSync6 } from "fs";
2379
+ import { dirname as dirname2, join as join7 } from "path";
2306
2380
  var STATE_DIR2 = ".vibeview";
2307
2381
  var STATE_FILE2 = "metro-state.json";
2308
2382
  function statePath2(cwd) {
2309
- return join6(cwd, STATE_DIR2, STATE_FILE2);
2383
+ return join7(cwd, STATE_DIR2, STATE_FILE2);
2310
2384
  }
2311
2385
  function metroLogPath(cwd) {
2312
- return join6(cwd, STATE_DIR2, "metro.log");
2386
+ return join7(cwd, STATE_DIR2, "metro.log");
2313
2387
  }
2314
2388
  function isPidAlive2(pid) {
2315
2389
  try {
@@ -2321,13 +2395,13 @@ function isPidAlive2(pid) {
2321
2395
  }
2322
2396
  function writeMetroState(cwd, state) {
2323
2397
  const path = statePath2(cwd);
2324
- mkdirSync3(dirname2(path), { recursive: true });
2325
- writeFileSync5(path, JSON.stringify(state, null, 2) + "\n");
2398
+ mkdirSync4(dirname2(path), { recursive: true });
2399
+ writeFileSync6(path, JSON.stringify(state, null, 2) + "\n");
2326
2400
  }
2327
2401
  function readMetroState(cwd) {
2328
2402
  let parsed;
2329
2403
  try {
2330
- parsed = JSON.parse(readFileSync6(statePath2(cwd), "utf-8"));
2404
+ parsed = JSON.parse(readFileSync7(statePath2(cwd), "utf-8"));
2331
2405
  } catch {
2332
2406
  return null;
2333
2407
  }
@@ -2339,10 +2413,11 @@ function readMetroState(cwd) {
2339
2413
  }
2340
2414
  function clearMetroState(cwd) {
2341
2415
  const path = statePath2(cwd);
2342
- if (existsSync3(path)) rmSync5(path, { force: true });
2416
+ if (existsSync4(path)) rmSync6(path, { force: true });
2343
2417
  }
2344
- function killOwnedMetro(cwd) {
2418
+ function killOwnedMetro(cwd, expectedPid) {
2345
2419
  const state = readMetroState(cwd);
2420
+ if (expectedPid !== void 0 && state?.pid !== expectedPid) return false;
2346
2421
  clearMetroState(cwd);
2347
2422
  if (!state) return false;
2348
2423
  try {
@@ -2352,6 +2427,18 @@ function killOwnedMetro(cwd) {
2352
2427
  return false;
2353
2428
  }
2354
2429
  }
2430
+ function handOffMetro(cwd, metroPid, sessionId) {
2431
+ const state = readMetroState(cwd);
2432
+ if (!state || state.pid !== metroPid) return;
2433
+ try {
2434
+ writeMetroState(cwd, { ...state, handed_off_session: sessionId });
2435
+ } catch {
2436
+ }
2437
+ }
2438
+ function metroHandedOffTo(cwd, metroPid, sessionId) {
2439
+ const state = readMetroState(cwd);
2440
+ return state !== null && state.pid === metroPid && state.handed_off_session === sessionId;
2441
+ }
2355
2442
 
2356
2443
  // src/lib/metro-manager.ts
2357
2444
  var TAIL_LINES2 = 20;
@@ -2367,12 +2454,12 @@ async function ensureMetro(cwd, metro, log, timeoutMs = 9e4) {
2367
2454
  const ownHoldsPort = own !== null && own.port === metro.port;
2368
2455
  if (status !== "down") {
2369
2456
  if (ownHoldsPort && metro.command && own.command !== metro.command) {
2370
- killOwnedMetro(cwd);
2457
+ killOwnedMetro(cwd, own.pid);
2371
2458
  await waitFor(async () => await checkMetro(metro.port) === "down", 1e4, 250);
2372
- await spawnMetro(cwd, metro.command, metro.port, log, timeoutMs);
2373
- return { status: "respawned", warnings };
2459
+ const pid2 = await spawnMetro(cwd, metro.command, metro.port, log, timeoutMs);
2460
+ return { status: "respawned", warnings, pid: pid2 };
2374
2461
  }
2375
- if (ownHoldsPort) return { status: "reused-own", warnings };
2462
+ if (ownHoldsPort) return { status: "reused-own", warnings, pid: own.pid };
2376
2463
  if (status === "listening") {
2377
2464
  warnings.push(
2378
2465
  `something is listening on localhost:${metro.port} but it doesn't look like Metro \u2014 continuing anyway`
@@ -2393,8 +2480,8 @@ Or set "metroCommand" in vibeview.json and vibeview dev will start it for you.
2393
2480
  If Metro uses another port, pass --metro-port <port>.`
2394
2481
  );
2395
2482
  }
2396
- await spawnMetro(cwd, metro.command, metro.port, log, timeoutMs);
2397
- return { status: "spawned", warnings };
2483
+ const pid = await spawnMetro(cwd, metro.command, metro.port, log, timeoutMs);
2484
+ return { status: "spawned", warnings, pid };
2398
2485
  }
2399
2486
  async function spawnMetro(cwd, command, port, log, timeoutMs) {
2400
2487
  const child = spawn3(command, {
@@ -2422,16 +2509,16 @@ async function spawnMetro(cwd, command, port, log, timeoutMs) {
2422
2509
  const deadline = Date.now() + timeoutMs;
2423
2510
  for (; ; ) {
2424
2511
  if (exited !== null) {
2425
- killOwnedMetro(cwd);
2512
+ killOwnedMetro(cwd, child.pid);
2426
2513
  throw new Error(
2427
2514
  `Metro command exited (code ${exited}) before localhost:${port} came up.
2428
2515
  ` + (tail.length ? `Last output:
2429
2516
  ${tail.join("\n ")}` : "")
2430
2517
  );
2431
2518
  }
2432
- if (await checkMetro(port, 1e3) === "running") return;
2519
+ if (await checkMetro(port, 1e3) === "running") return child.pid;
2433
2520
  if (Date.now() > deadline) {
2434
- killOwnedMetro(cwd);
2521
+ killOwnedMetro(cwd, child.pid);
2435
2522
  throw new Error(`timed out waiting for Metro on localhost:${port} (command: ${command})`);
2436
2523
  }
2437
2524
  await new Promise((r) => setTimeout(r, 500));
@@ -2568,6 +2655,60 @@ function leaseLapsedReason(gapMs) {
2568
2655
  if (gapMs <= AGENT_LEASE_TTL_MS) return void 0;
2569
2656
  return `the session ended because this computer did not check in with VibeView for ${Math.round(gapMs / 1e3)}s (asleep, offline, or the process was paused) \u2014 start a new session to continue`;
2570
2657
  }
2658
+ var TUNNEL_REPLACED_REASON = "another VibeView dev connection took over this session \u2014 it keeps running there";
2659
+ var SessionReplacedError = class extends Error {
2660
+ /** The session the newer client took over (it keeps running there). */
2661
+ sessionId;
2662
+ constructor(sessionId) {
2663
+ super(TUNNEL_REPLACED_REASON);
2664
+ this.name = "SessionReplacedError";
2665
+ this.sessionId = sessionId;
2666
+ }
2667
+ };
2668
+ var IDLE_WARNING_EPISODE_SLACK_MS = 5e3;
2669
+ function parseIdleWarningSeconds(raw) {
2670
+ if (typeof raw !== "number" || !Number.isFinite(raw)) return null;
2671
+ const seconds = Math.round(raw);
2672
+ return seconds > 0 ? seconds : null;
2673
+ }
2674
+ function createIdleWarningTracker() {
2675
+ let endsAt = null;
2676
+ let issued = 0;
2677
+ let accepted = 0;
2678
+ return {
2679
+ begin: () => ++issued,
2680
+ observe(info, now = Date.now(), stamp) {
2681
+ if (!info || info.idle_warning === void 0) return null;
2682
+ if (stamp !== void 0) {
2683
+ if (stamp < accepted) return null;
2684
+ accepted = stamp;
2685
+ }
2686
+ const w = info.idle_warning;
2687
+ if (w === null) {
2688
+ if (endsAt === null) return null;
2689
+ endsAt = null;
2690
+ return { kind: "cleared" };
2691
+ }
2692
+ if (typeof w !== "object") return null;
2693
+ const seconds = parseIdleWarningSeconds(w.seconds_remaining);
2694
+ if (seconds === null) return null;
2695
+ const at = now + seconds * 1e3;
2696
+ if (endsAt !== null && at <= endsAt + IDLE_WARNING_EPISODE_SLACK_MS) return null;
2697
+ endsAt = at;
2698
+ return { kind: "warn", seconds_remaining: seconds };
2699
+ },
2700
+ noteUse() {
2701
+ accepted = ++issued;
2702
+ if (endsAt === null) return null;
2703
+ endsAt = null;
2704
+ return { kind: "cleared" };
2705
+ }
2706
+ };
2707
+ }
2708
+ function idleWarningLine(seconds, mode) {
2709
+ const keep = mode === "metro" ? "use the device or save a file" : mode === "roku" ? "use the device or reload the channel" : "use the device";
2710
+ return `Session ends in ${seconds} s: ${keep} to keep it`;
2711
+ }
2571
2712
  function queuedLine(position, reasonMessage) {
2572
2713
  const where = position ? ` (position ${position})` : "";
2573
2714
  const why = reasonMessage ?? "Every device is busy.";
@@ -2580,8 +2721,28 @@ function devBuildLines(choice, standalone) {
2580
2721
  listBuilds: "vibeview list-builds"
2581
2722
  });
2582
2723
  }
2724
+ var stdoutClosed = false;
2725
+ var stdoutGuarded = false;
2726
+ function guardStdout() {
2727
+ if (stdoutGuarded) return;
2728
+ stdoutGuarded = true;
2729
+ process.stdout.on("error", (err) => {
2730
+ if (err?.code === "EPIPE") {
2731
+ stdoutClosed = true;
2732
+ return;
2733
+ }
2734
+ throw err;
2735
+ });
2736
+ }
2583
2737
  function emit(json, evt) {
2584
- if (json) console.log(JSON.stringify(evt));
2738
+ if (!json || stdoutClosed) return;
2739
+ guardStdout();
2740
+ try {
2741
+ console.log(JSON.stringify(evt));
2742
+ } catch (err) {
2743
+ if (err?.code === "EPIPE") stdoutClosed = true;
2744
+ else throw err;
2745
+ }
2585
2746
  }
2586
2747
  async function startDevSession(cfg, onEvent = () => {
2587
2748
  }, onTunnelState) {
@@ -2619,6 +2780,43 @@ async function startDevSession(cfg, onEvent = () => {
2619
2780
  let queueEntryId = null;
2620
2781
  let lastLeaseAt = Date.now();
2621
2782
  let leaseTimer = null;
2783
+ const idle = createIdleWarningTracker();
2784
+ let lastOutsideUse = Date.now();
2785
+ registerDevUse(sessionId);
2786
+ const noteOutsideUse = () => {
2787
+ const at = readDevUse(sessionId);
2788
+ if (at === null || at <= lastOutsideUse) return;
2789
+ lastOutsideUse = at;
2790
+ reportIdleChange(idle.noteUse());
2791
+ };
2792
+ const noteSessionInfo = (info, stamp) => {
2793
+ if (stopping) return;
2794
+ noteOutsideUse();
2795
+ reportIdleChange(idle.observe(info, void 0, stamp));
2796
+ };
2797
+ const noteUse = () => {
2798
+ if (stopping) return;
2799
+ reportIdleChange(idle.noteUse());
2800
+ };
2801
+ const reportIdleChange = (change) => {
2802
+ if (!change) return;
2803
+ try {
2804
+ if (change.kind === "cleared") {
2805
+ cfg.onIdleWarning?.(sessionId, null);
2806
+ return;
2807
+ }
2808
+ cfg.onIdleWarning?.(sessionId, { seconds_remaining: change.seconds_remaining });
2809
+ onEvent({
2810
+ event: "idle_warning",
2811
+ session_id: sessionId,
2812
+ seconds_remaining: change.seconds_remaining
2813
+ });
2814
+ } catch (err) {
2815
+ console.error(
2816
+ `idle warning hook failed: ${err instanceof Error ? err.message : String(err)}`
2817
+ );
2818
+ }
2819
+ };
2622
2820
  const stopSession = async (code) => {
2623
2821
  if (stopping) return;
2624
2822
  stopping = true;
@@ -2633,11 +2831,29 @@ async function startDevSession(cfg, onEvent = () => {
2633
2831
  }
2634
2832
  await api.stopSession(sessionId).catch(() => {
2635
2833
  });
2834
+ clearDevUse(sessionId);
2636
2835
  onEvent({ event: "session_ended", session_id: sessionId, code, ...reason ? { reason } : {} });
2637
2836
  };
2837
+ let replaced = false;
2838
+ const releaseSession = () => {
2839
+ if (stopping) return;
2840
+ stopping = true;
2841
+ replaced = true;
2842
+ if (leaseTimer) clearInterval(leaseTimer);
2843
+ leaseTimer = null;
2844
+ tunnel?.stop();
2845
+ onEvent({
2846
+ event: "session_ended",
2847
+ session_id: sessionId,
2848
+ code: 0,
2849
+ reason: TUNNEL_REPLACED_REASON,
2850
+ replaced: true
2851
+ });
2852
+ };
2638
2853
  const renewLease = async () => {
2639
2854
  if (stopping) return;
2640
2855
  let info;
2856
+ const stamp = idle.begin();
2641
2857
  try {
2642
2858
  info = await api.touchSession(sessionId, { lease: true });
2643
2859
  } catch {
@@ -2649,6 +2865,7 @@ async function startDevSession(cfg, onEvent = () => {
2649
2865
  return;
2650
2866
  }
2651
2867
  lastLeaseAt = Date.now();
2868
+ noteSessionInfo(info, stamp);
2652
2869
  };
2653
2870
  void renewLease();
2654
2871
  leaseTimer = setInterval(() => void renewLease(), AGENT_LEASE_RENEW_MS);
@@ -2700,6 +2917,8 @@ async function startDevSession(cfg, onEvent = () => {
2700
2917
  event: "warning",
2701
2918
  message: `can't reach Metro on localhost:${cfg.metroPort} \u2014 is it running? (yarn start)`
2702
2919
  });
2920
+ } else if (s === "replaced") {
2921
+ releaseSession();
2703
2922
  } else if (s === "closed" && !stopping) {
2704
2923
  void stopSession(1);
2705
2924
  }
@@ -2714,8 +2933,10 @@ async function startDevSession(cfg, onEvent = () => {
2714
2933
  sessionId,
2715
2934
  18e4,
2716
2935
  2e3,
2717
- () => onEvent({ event: "installing", session_id: sessionId })
2936
+ () => onEvent({ event: "installing", session_id: sessionId }),
2937
+ () => replaced
2718
2938
  );
2939
+ if (replaced) throw new SessionReplacedError(sessionId);
2719
2940
  for (const w of active.warnings ?? []) {
2720
2941
  if (reportedWarnings.has(w)) continue;
2721
2942
  reportedWarnings.add(w);
@@ -2728,9 +2949,13 @@ async function startDevSession(cfg, onEvent = () => {
2728
2949
  reportedWarnings,
2729
2950
  session: active,
2730
2951
  lapsedReason: () => leaseLapsedReason(Date.now() - lastLeaseAt),
2952
+ noteSessionInfo,
2953
+ beginPresence: () => idle.begin(),
2954
+ noteUse,
2731
2955
  build: usesTunnel && cfg.projectDir ? describeDevBuild(app, active, { ...pick, pinned: cfg.buildId !== void 0 }, project) : null
2732
2956
  };
2733
2957
  } catch (err) {
2958
+ if (replaced) throw new SessionReplacedError(sessionId);
2734
2959
  await stopSession(1);
2735
2960
  throw err;
2736
2961
  }
@@ -2827,10 +3052,11 @@ async function pollUntilTunnelReady(api, sessionId, timeoutMs, intervalMs = 2e3)
2827
3052
  await new Promise((r) => setTimeout(r, intervalMs));
2828
3053
  }
2829
3054
  }
2830
- async function pollUntilActive(api, sessionId, timeoutMs, intervalMs = 2e3, onInstallPending) {
3055
+ async function pollUntilActive(api, sessionId, timeoutMs, intervalMs = 2e3, onInstallPending, shouldAbort) {
2831
3056
  const deadline = Date.now() + timeoutMs;
2832
3057
  let pendingReported = false;
2833
3058
  for (; ; ) {
3059
+ if (shouldAbort?.()) throw new Error("wait for the session aborted");
2834
3060
  const info = await api.touchSession(sessionId);
2835
3061
  const blocking = findBlockingWarning(info);
2836
3062
  if (blocking) throw new Error(blocking);
@@ -3253,6 +3479,7 @@ async function runDevInProcess(opts) {
3253
3479
  }
3254
3480
  }
3255
3481
  let metroOwnedThisRun = false;
3482
+ let metroPid = null;
3256
3483
  let rokuBuild;
3257
3484
  let watchIgnore = [];
3258
3485
  if (standalone && cfg.platform === "roku") {
@@ -3270,7 +3497,7 @@ async function runDevInProcess(opts) {
3270
3497
  const captureMetroLine = (line) => {
3271
3498
  try {
3272
3499
  if (!metroLogStream) {
3273
- mkdirSync4(dirname4(metroLogPath(process.cwd())), { recursive: true });
3500
+ mkdirSync5(dirname4(metroLogPath(process.cwd())), { recursive: true });
3274
3501
  metroLogStream = createWriteStream(metroLogPath(process.cwd()), { flags: "w" });
3275
3502
  }
3276
3503
  metroLogStream.write(line.replace(/^\[metro\] /, "") + "\n");
@@ -3301,6 +3528,7 @@ async function runDevInProcess(opts) {
3301
3528
  );
3302
3529
  }
3303
3530
  metroOwnedThisRun = metroResult.status === "spawned" || metroResult.status === "respawned";
3531
+ metroPid = metroResult.pid ?? null;
3304
3532
  } else {
3305
3533
  const rokuEntry = loadDevConfig(process.cwd())?.platforms?.[cfg.platform];
3306
3534
  rokuBuild = rokuEntry?.build;
@@ -3320,14 +3548,38 @@ async function runDevInProcess(opts) {
3320
3548
  let stopping = false;
3321
3549
  let handle = null;
3322
3550
  let reloadLoop = null;
3323
- const stopSession = async (code) => {
3551
+ const ownsMetro = (sessionId) => metroPid !== null && (metroOwnedThisRun || sessionId !== void 0 && metroHandedOffTo(process.cwd(), metroPid, sessionId));
3552
+ const stopOwnedMetro = () => {
3553
+ if (metroPid === null) return;
3554
+ const other = readDevStateFile(process.cwd());
3555
+ if (other?.alive && other.state.pid !== process.pid && other.state.metro_port === (cfg.metroPort ?? 8081) && PLATFORM_NEEDS_METRO[other.state.platform]) {
3556
+ handOffMetro(process.cwd(), metroPid, other.state.session_id);
3557
+ console.error(chalk4.dim(" Metro keeps running for the other vibeview dev in this project."));
3558
+ return;
3559
+ }
3560
+ killOwnedMetro(process.cwd(), metroPid);
3561
+ };
3562
+ const handOffOwnedMetro = (sessionId) => {
3563
+ if (metroPid !== null && sessionId !== void 0 && ownsMetro(sessionId)) {
3564
+ handOffMetro(process.cwd(), metroPid, sessionId);
3565
+ }
3566
+ };
3567
+ const stopSession = async (code, how = {}) => {
3324
3568
  if (stopping) return;
3325
3569
  stopping = true;
3570
+ if (how.replaced) {
3571
+ reloadLoop?.stop();
3572
+ handOffOwnedMetro(how.sessionId);
3573
+ clearDevStateIfOwned(process.cwd(), process.pid);
3574
+ process.exit(code);
3575
+ return;
3576
+ }
3326
3577
  console.error(chalk4.dim("\nStopping session..."));
3327
3578
  reloadLoop?.stop();
3579
+ const ownMetro = ownsMetro(handle?.sessionId);
3328
3580
  await handle?.stop();
3329
- if (metroOwnedThisRun) killOwnedMetro(process.cwd());
3330
- clearDevState(process.cwd());
3581
+ if (ownMetro) stopOwnedMetro();
3582
+ clearDevStateIfOwned(process.cwd(), process.pid);
3331
3583
  process.exit(code);
3332
3584
  };
3333
3585
  process.on("SIGINT", () => void stopSession(0));
@@ -3336,6 +3588,7 @@ async function runDevInProcess(opts) {
3336
3588
  let waitingPrinted = false;
3337
3589
  let nextWarningIsMetroUnreachable = false;
3338
3590
  let endedEmitted = false;
3591
+ const idleMode = cfg.platform === "roku" ? "roku" : standalone ? "standalone" : "metro";
3339
3592
  try {
3340
3593
  handle = await startDevSession(
3341
3594
  {
@@ -3373,12 +3626,29 @@ async function runDevInProcess(opts) {
3373
3626
  console.error(
3374
3627
  chalk4.dim(" Putting the channel on the Roku \u2014 the link appears once it is up.")
3375
3628
  );
3629
+ } else if (evt.event === "idle_warning") {
3630
+ console.error(chalk4.yellow(`\u26A0 ${idleWarningLine(evt.seconds_remaining, idleMode)}`));
3631
+ } else if (evt.event === "session_ended" && evt.replaced && !stopping && handle) {
3632
+ console.error(chalk4.dim(evt.reason ?? TUNNEL_REPLACED_REASON));
3633
+ if (ownsMetro(evt.session_id)) {
3634
+ console.error(
3635
+ chalk4.dim(" Metro keeps running for that connection, which stops it when it exits.")
3636
+ );
3637
+ }
3638
+ stopping = true;
3639
+ endedEmitted = true;
3640
+ emit(opts.json, evt);
3641
+ handOffOwnedMetro(evt.session_id);
3642
+ clearDevStateIfOwned(process.cwd(), process.pid);
3643
+ process.exit(0);
3376
3644
  } else if (evt.event === "session_ended" && !stopping && handle) {
3377
3645
  console.error(chalk4.red(evt.reason ?? "session ended by the server"));
3378
3646
  stopping = true;
3379
3647
  endedEmitted = true;
3380
3648
  emit(opts.json, evt);
3381
- clearDevState(process.cwd());
3649
+ reloadLoop?.stop();
3650
+ if (ownsMetro(evt.session_id)) stopOwnedMetro();
3651
+ clearDevStateIfOwned(process.cwd(), process.pid);
3382
3652
  process.exit(1);
3383
3653
  }
3384
3654
  if (evt.event === "session_ended") {
@@ -3430,19 +3700,26 @@ async function runDevInProcess(opts) {
3430
3700
  if (opts.open !== false) await openBrowser(handle.pageUrl).catch(() => {
3431
3701
  });
3432
3702
  if (rokuBuild) {
3703
+ const rokuPush = createRokuPush({
3704
+ cwd: process.cwd(),
3705
+ sessionId: handle.sessionId,
3706
+ build: rokuBuild,
3707
+ token,
3708
+ baseUrl,
3709
+ api,
3710
+ // Build output and loop notices are human output, so they go to
3711
+ // stderr like every other line this command prints — stdout stays
3712
+ // reserved for the `--json` event stream.
3713
+ log: (line) => console.error(chalk4.dim(line))
3714
+ });
3715
+ const live = handle;
3433
3716
  reloadLoop = createReloadLoop({
3434
- push: createRokuPush({
3435
- cwd: process.cwd(),
3436
- sessionId: handle.sessionId,
3437
- build: rokuBuild,
3438
- token,
3439
- baseUrl,
3440
- api,
3441
- // Build output and loop notices are human output, so they go to
3442
- // stderr like every other line this command prints — stdout stays
3443
- // reserved for the `--json` event stream.
3444
- log: (line) => console.error(chalk4.dim(line))
3445
- }),
3717
+ // A reloaded channel is use: once the push succeeds, drop an idle
3718
+ // warning from a presence request begun before it.
3719
+ push: async () => {
3720
+ await rokuPush();
3721
+ live.noteUse?.();
3722
+ },
3446
3723
  log: (line) => console.error(chalk4.yellow(line)),
3447
3724
  // A failed reload is an error, not a notice — same red as every other
3448
3725
  // failure this command prints, so it doesn't read like the yellow
@@ -3486,6 +3763,7 @@ async function runDevInProcess(opts) {
3486
3763
  void (async () => {
3487
3764
  if (stopping) return;
3488
3765
  let info = null;
3766
+ const stamp = handle?.beginPresence?.();
3489
3767
  try {
3490
3768
  info = await api.touchSession(sessionId);
3491
3769
  } catch {
@@ -3504,6 +3782,7 @@ async function runDevInProcess(opts) {
3504
3782
  await stopSession(1);
3505
3783
  return;
3506
3784
  }
3785
+ handle?.noteSessionInfo?.(info, stamp);
3507
3786
  const warningVerdict = lateWarningVerdict(info, reportedWarnings);
3508
3787
  for (const w of warningVerdict.toReport) {
3509
3788
  reportedWarnings.add(w);
@@ -3527,7 +3806,10 @@ async function runDevInProcess(opts) {
3527
3806
  const message = err.message;
3528
3807
  console.error(chalk4.red(`error: ${message}`));
3529
3808
  emit(opts.json, { event: "error", message });
3530
- await stopSession(1);
3809
+ await stopSession(
3810
+ 1,
3811
+ err instanceof SessionReplacedError ? { replaced: true, sessionId: err.sessionId } : {}
3812
+ );
3531
3813
  }
3532
3814
  }
3533
3815
  async function dev(opts) {
@@ -3717,7 +3999,7 @@ var PRESS_PLATFORM_LABELS = [
3717
3999
  ["android", "Android phone / tablet"],
3718
4000
  ["appleTv", "Apple TV"],
3719
4001
  ["androidTv", "Android TV"],
3720
- ["roku", "Roku (beta)"]
4002
+ ["roku", "Roku"]
3721
4003
  ];
3722
4004
  var PRESS_BUTTONS_BY_PLATFORM = PRESS_PLATFORM_LABELS.map(([key, label]) => [label, PRESS_BUTTONS[key]]);
3723
4005
  function pressButtonLines(indent) {
@@ -4312,7 +4594,7 @@ function agentExitCode(env) {
4312
4594
  }
4313
4595
 
4314
4596
  // src/commands/agent.ts
4315
- import { writeFileSync as writeFileSync6 } from "fs";
4597
+ import { writeFileSync as writeFileSync7 } from "fs";
4316
4598
  var REF_RE = /^@?(e\d+)$/;
4317
4599
  var COORDS_RE = /^(-?\d+),\s*(-?\d+)$/;
4318
4600
  var FlagError = class extends Error {
@@ -4482,6 +4764,16 @@ function decodeScreenshotResult(result) {
4482
4764
  }
4483
4765
  return Buffer.from(b64, "base64");
4484
4766
  }
4767
+ var NON_USE_TOOLS = /* @__PURE__ */ new Set([
4768
+ "ui_tree",
4769
+ "screenshot",
4770
+ "logs",
4771
+ "find_element",
4772
+ "wait"
4773
+ ]);
4774
+ function countsAsUse(tool) {
4775
+ return !NON_USE_TOOLS.has(tool);
4776
+ }
4485
4777
  async function runAgentVerb(cmd, positionals, opts) {
4486
4778
  const { token, baseUrl } = resolveAuth(opts);
4487
4779
  const sessionId = opts.session || readDevState(process.cwd())?.session_id;
@@ -4512,6 +4804,7 @@ async function runAgentVerb(cmd, positionals, opts) {
4512
4804
  }
4513
4805
  process.exit(errorExitCode(err));
4514
4806
  }
4807
+ if (countsAsUse(call.tool)) recordDevUse(sessionId);
4515
4808
  if (cmd.verb === "screenshot" && envelope.ok && envelope.result) {
4516
4809
  const outPath = opts.out || defaultScreenshotPath();
4517
4810
  let imageBytes;
@@ -4522,7 +4815,7 @@ async function runAgentVerb(cmd, positionals, opts) {
4522
4815
  `);
4523
4816
  process.exit(1);
4524
4817
  }
4525
- writeFileSync6(outPath, imageBytes);
4818
+ writeFileSync7(outPath, imageBytes);
4526
4819
  if (opts.json) {
4527
4820
  console.log(
4528
4821
  JSON.stringify({ ...envelope, result: { written_to: outPath, bytes: imageBytes.length } })
@@ -4563,6 +4856,7 @@ export {
4563
4856
  clearDevState,
4564
4857
  readMetroState,
4565
4858
  killOwnedMetro,
4859
+ recordDevUse,
4566
4860
  checkMetro,
4567
4861
  loadLocalDevConfig,
4568
4862
  effectiveDevConfig,
@@ -4574,6 +4868,7 @@ export {
4574
4868
  printTransition,
4575
4869
  printOutcomeLine,
4576
4870
  createRokuPush,
4871
+ parseIdleWarningSeconds,
4577
4872
  startDevSession,
4578
4873
  PLATFORM_DEVICE,
4579
4874
  dev,
@@ -4585,5 +4880,6 @@ export {
4585
4880
  renderEnvelope,
4586
4881
  buildToolCall,
4587
4882
  decodeScreenshotResult,
4883
+ countsAsUse,
4588
4884
  runAgentVerb
4589
4885
  };
package/dist/cli.js CHANGED
@@ -23,8 +23,9 @@ import {
23
23
  readDevState,
24
24
  readDevStateFile,
25
25
  readMetroState,
26
+ recordDevUse,
26
27
  runAgentVerb
27
- } from "./chunk-Z6UVEAYO.js";
28
+ } from "./chunk-VFCG4U7V.js";
28
29
  import {
29
30
  ApiClient,
30
31
  ApiStatusError,
@@ -1787,6 +1788,7 @@ async function devReload(opts) {
1787
1788
  } catch (err) {
1788
1789
  fail(opts, "reload_failed", err instanceof Error ? err.message : String(err), 1);
1789
1790
  }
1791
+ recordDevUse(state.session_id);
1790
1792
  if (opts.json) {
1791
1793
  console.log(
1792
1794
  JSON.stringify({
@@ -3660,8 +3662,10 @@ var CLAUDE_MD_SUGGESTION = "After changing UI or app behavior, verify it on a li
3660
3662
  function claudeCliAvailable() {
3661
3663
  return spawnSync("claude", ["--version"], { stdio: "ignore" }).error === void 0;
3662
3664
  }
3665
+ var MCP_COMMAND = ["npx", "-y", "vibeview", "mcp"];
3666
+ var MCP_ADD_HINT = `claude mcp add vibeview -- ${MCP_COMMAND.join(" ")}`;
3663
3667
  function registerMcp() {
3664
- const res = spawnSync("claude", ["mcp", "add", "vibeview", "--", "vibeview", "mcp"], {
3668
+ const res = spawnSync("claude", ["mcp", "add", "vibeview", "--", ...MCP_COMMAND], {
3665
3669
  encoding: "utf8"
3666
3670
  });
3667
3671
  if (res.status === 0) {
@@ -3674,7 +3678,7 @@ function registerMcp() {
3674
3678
  return;
3675
3679
  }
3676
3680
  console.error(chalk21.yellow(`\u26A0 could not register the MCP server: ${out.trim() || res.error}`));
3677
- console.error(chalk21.yellow(" register it manually: claude mcp add vibeview -- vibeview mcp"));
3681
+ console.error(chalk21.yellow(` register it manually: ${MCP_ADD_HINT}`));
3678
3682
  }
3679
3683
  async function agentSetup(opts) {
3680
3684
  const moduleDir = dirname(fileURLToPath(import.meta.url));
@@ -3700,9 +3704,7 @@ async function agentSetup(opts) {
3700
3704
  if (!claudeCliAvailable()) {
3701
3705
  if (opts.mcp === true) {
3702
3706
  console.error(
3703
- chalk21.yellow(
3704
- "\u26A0 claude CLI not found \u2014 install Claude Code, then run: claude mcp add vibeview -- vibeview mcp"
3705
- )
3707
+ chalk21.yellow(`\u26A0 claude CLI not found \u2014 install Claude Code, then run: ${MCP_ADD_HINT}`)
3706
3708
  );
3707
3709
  }
3708
3710
  } else if (opts.mcp === true || process.stdin.isTTY && await confirm({
@@ -3835,7 +3837,7 @@ function resolveMetadata(opts) {
3835
3837
  }
3836
3838
  var program = new Command();
3837
3839
  program.name("vibeview").description(
3838
- "VibeView CLI -- run your app on cloud iOS simulators, Android emulators, Apple TV, Android TV and Roku (beta) devices: a React Native dev loop (dev), device control for you or your coding agent (ui-tree, tap, mcp), and tests from CI (run-test, run-suites)"
3840
+ "VibeView CLI -- run your app on cloud iOS simulators, Android emulators, Apple TV, Android TV and Roku devices: a React Native dev loop (dev), device control for you or your coding agent (ui-tree, tap, mcp), and tests from CI (run-test, run-suites)"
3839
3841
  ).version(pkg.version);
3840
3842
  program.command("run-suite <suite-id>").description("Run all test cases in a suite").option("--token <token>", "API token (or set VIBEVIEW_API_TOKEN)").option("--output <format>", "Output format: human, json, junit", "human").option("--verbose", "Show per-case breakdown", false).option("--timeout <seconds>", "Max wait time in seconds (queue wait plus execution)", "1800").option("--commit-sha <sha>", "Git commit SHA for GitHub status checks").option("--model <model>", "AI model to use").option("--provider <provider>", "AI provider").option("--mode <mode>", "Execution mode: hybrid, ai, replay").option(
3841
3843
  "--device-category <category>",
@@ -4145,7 +4147,7 @@ program.command("dev-reload").description(
4145
4147
  program.command("mcp").description(
4146
4148
  "Run an MCP stdio server exposing the agent verbs (tap, swipe, type, ...) plus dev_start/dev_stop/dev_reload as tools"
4147
4149
  ).option("--token <token>", "API token (or set VIBEVIEW_API_TOKEN)").action(async (opts) => {
4148
- const { runMcpServer } = await import("./server-EKFF2JBU.js");
4150
+ const { runMcpServer } = await import("./server-2CDIRTBV.js");
4149
4151
  await runMcpServer({ ...opts, version: pkg.version });
4150
4152
  });
4151
4153
  program.command("agent-setup").description(
@@ -9,6 +9,7 @@ import {
9
9
  buildToolCall,
10
10
  checkDevBuild,
11
11
  checkMetro,
12
+ countsAsUse,
12
13
  createRokuPush,
13
14
  decodeScreenshotResult,
14
15
  describeDevBuild,
@@ -20,12 +21,14 @@ import {
20
21
  loadDevConfig,
21
22
  loadLocalDevConfig,
22
23
  parseBuildId,
24
+ parseIdleWarningSeconds,
23
25
  platformWithArticle,
24
26
  readDevState,
27
+ recordDevUse,
25
28
  renderEnvelope,
26
29
  resolveMetro,
27
30
  startDevSession
28
- } from "./chunk-Z6UVEAYO.js";
31
+ } from "./chunk-VFCG4U7V.js";
29
32
  import {
30
33
  ApiClient,
31
34
  NotFoundError,
@@ -241,9 +244,15 @@ function capitalize(text) {
241
244
  return text.charAt(0).toUpperCase() + text.slice(1);
242
245
  }
243
246
  function endedLine(ended) {
247
+ if (ended.status === "replaced") {
248
+ return `session ${ended.sessionId} was taken over by another VibeView dev connection and is no longer held here (it keeps running there)`;
249
+ }
244
250
  const why = ended.reason ? `: ${ended.reason}` : "";
245
251
  return `session ${ended.sessionId} has ended (${ended.status}${why})`;
246
252
  }
253
+ function idleNoticeText(sessionId, seconds) {
254
+ return `Session ${sessionId} ends in ${seconds} s for inactivity: a device action (tap, type, swipe, press, \u2026) keeps it; ui_tree, screenshot, logs, find and wait do not.`;
255
+ }
247
256
  async function explainInactive(sessionId, deps) {
248
257
  let info;
249
258
  try {
@@ -266,7 +275,7 @@ async function explainInactive(sessionId, deps) {
266
275
  function findCommand(toolName) {
267
276
  return AGENT_COMMANDS.find((c) => toolNameFor(c.verb) === toolName);
268
277
  }
269
- async function handleToolCall(toolName, args, deps, extra = {}) {
278
+ async function dispatchToolCall(toolName, args, deps, extra = {}) {
270
279
  if (toolName === "dev_start") {
271
280
  return handleDevStart(args, deps, extra);
272
281
  }
@@ -331,6 +340,55 @@ async function handleToolCall(toolName, args, deps, extra = {}) {
331
340
  }
332
341
  return textResult(renderEnvelope(envelope, toolNameFor(cmd.verb)), envelope, !envelope.ok);
333
342
  }
343
+ async function handleToolCall(toolName, args, deps, extra = {}) {
344
+ const held = deps.devSession.current?.()?.sessionId;
345
+ const dispatched = typeof args.session_id === "string" ? args.session_id.trim() : deps.resolveSessionId();
346
+ const usesBefore = deps.devSession.useGeneration?.();
347
+ const result = await dispatchToolCall(toolName, args, deps, extra);
348
+ noteDeviceUse(toolName, result, deps, held, dispatched);
349
+ const usedSince = usesBefore !== void 0 && (deps.devSession.useGeneration?.() ?? usesBefore) > usesBefore;
350
+ return withIdleNotice(toolName, args, result, deps, held, dispatched, usedSince);
351
+ }
352
+ function noteDeviceUse(toolName, result, deps, held, dispatched) {
353
+ const cmd = findCommand(toolName);
354
+ if (!cmd || !dispatched || !result.structuredContent) return;
355
+ if (!countsAsUse(cmd.tool)) return;
356
+ if (dispatched === held) deps.devSession.noteUse?.(dispatched);
357
+ else recordDevUse(dispatched);
358
+ }
359
+ var IDLE_NOTICE_DISCARD = /* @__PURE__ */ new Set(["dev_start", "dev_stop", "dev_reload"]);
360
+ function withIdleNotice(toolName, args, result, deps, held, dispatched, usedSince = false) {
361
+ const dev = deps.devSession;
362
+ const stopsHeld = toolName === "stop_session" && held !== void 0 && (typeof args.session_id === "string" ? args.session_id.trim() : held) === held;
363
+ if ((IDLE_NOTICE_DISCARD.has(toolName) || stopsHeld) && !result.isError) {
364
+ const about = toolName === "dev_start" ? dev.current?.()?.sessionId : held;
365
+ if (about !== void 0) dev.takeIdleWarning?.(about);
366
+ return result;
367
+ }
368
+ if (findCommand(toolName) && result.structuredContent && typeof result.structuredContent === "object") {
369
+ const own = result.structuredContent.idle_warning;
370
+ if (own !== void 0) {
371
+ const target = dispatched;
372
+ if (target !== void 0 && target === held) dev.takeIdleWarning?.(target);
373
+ const seconds = parseIdleWarningSeconds(own?.seconds_remaining);
374
+ if (usedSince && target === held) return result;
375
+ if (target && seconds !== null) return appendNotice(result, idleNoticeText(target, seconds));
376
+ return result;
377
+ }
378
+ }
379
+ const notice = dev.takeIdleWarning?.() ?? null;
380
+ return notice ? appendNotice(result, notice) : result;
381
+ }
382
+ function appendNotice(result, notice) {
383
+ const i = result.content.findIndex((b) => b.type === "text");
384
+ if (i === -1) return { ...result, content: [...result.content, { type: "text", text: notice }] };
385
+ const content = result.content.slice();
386
+ const block = content[i];
387
+ content[i] = { type: "text", text: `${block.text}
388
+
389
+ ${notice}` };
390
+ return { ...result, content };
391
+ }
334
392
  function screenshotToolResult(envelope, args) {
335
393
  const { image, preview } = splitScreenshotResult(envelope.result);
336
394
  let imageBytes;
@@ -613,7 +671,7 @@ async function handleDevStop(deps) {
613
671
  }
614
672
  if (!current) {
615
673
  return textResult(
616
- ended ? `no dev session was running \u2014 it had already ended. ${capitalize(endedLine(ended))}` : "no dev session was running",
674
+ ended ? ended.status === "replaced" ? `no dev session was running here. ${capitalize(endedLine(ended))}` : `no dev session was running \u2014 it had already ended. ${capitalize(endedLine(ended))}` : "no dev session was running",
617
675
  { session_id: null, stopped: false }
618
676
  );
619
677
  }
@@ -725,6 +783,7 @@ function createDevSessionController(auth, webUrl) {
725
783
  let ended = null;
726
784
  let reloading = null;
727
785
  let pending = null;
786
+ let idle = null;
728
787
  const readRokuBuild = () => {
729
788
  try {
730
789
  return loadDevConfig(process.cwd())?.platforms?.roku?.build ?? null;
@@ -739,10 +798,25 @@ function createDevSessionController(auth, webUrl) {
739
798
  const dead = handle;
740
799
  handle = null;
741
800
  platform = null;
801
+ idle = null;
742
802
  ended = { ...e, reason: e.reason ?? dead.lapsedReason?.() ?? null };
743
803
  void dead.stop().catch(() => {
744
804
  });
745
805
  };
806
+ const markReplaced = (sessionId) => {
807
+ if (!handle || handle.sessionId !== sessionId) return;
808
+ handle = null;
809
+ platform = null;
810
+ idle = null;
811
+ ended = { sessionId, status: "replaced", reason: null };
812
+ };
813
+ let uses = 0;
814
+ const noteHeldUse = (sessionId) => {
815
+ if (!handle || handle.sessionId !== sessionId) return;
816
+ uses++;
817
+ handle.noteUse?.();
818
+ if (idle?.sessionId === sessionId) idle = null;
819
+ };
746
820
  const api = () => new ApiClient(auth.baseUrl, auth.token);
747
821
  return {
748
822
  async start(cfg) {
@@ -832,11 +906,18 @@ function createDevSessionController(auth, webUrl) {
832
906
  onCreated: (created) => {
833
907
  pending = created;
834
908
  },
835
- onDequeued: () => progress.dequeued()
909
+ onDequeued: () => progress.dequeued(),
910
+ onIdleWarning: (id, warning) => {
911
+ idle = warning ? { sessionId: id, endsAt: Date.now() + warning.seconds_remaining * 1e3 } : null;
912
+ }
836
913
  },
837
914
  (evt) => {
838
915
  progress.event(evt);
839
916
  if (evt.event === "warning") warnings.push(evt.message);
917
+ if (evt.event === "session_ended" && evt.replaced && started) {
918
+ markReplaced(evt.session_id);
919
+ return;
920
+ }
840
921
  if (evt.event === "session_ended" && evt.code !== 0 && started) {
841
922
  const id = evt.session_id;
842
923
  void api().getSession(id).then(
@@ -882,6 +963,7 @@ function createDevSessionController(auth, webUrl) {
882
963
  await handle?.stop();
883
964
  handle = null;
884
965
  platform = null;
966
+ idle = null;
885
967
  ended = null;
886
968
  },
887
969
  async shutdown() {
@@ -890,6 +972,7 @@ function createDevSessionController(auth, webUrl) {
890
972
  const starting = pending;
891
973
  handle = null;
892
974
  platform = null;
975
+ idle = null;
893
976
  pending = null;
894
977
  await Promise.all([
895
978
  held ? held.stop().then(
@@ -912,6 +995,17 @@ function createDevSessionController(auth, webUrl) {
912
995
  lastEnded() {
913
996
  return ended;
914
997
  },
998
+ noteUse: noteHeldUse,
999
+ useGeneration: () => uses,
1000
+ takeIdleWarning(sessionId) {
1001
+ const w = idle;
1002
+ if (sessionId !== void 0 && w?.sessionId !== sessionId) return null;
1003
+ idle = null;
1004
+ if (!w || !handle || handle.sessionId !== w.sessionId) return null;
1005
+ const seconds = Math.ceil((w.endsAt - Date.now()) / 1e3);
1006
+ if (seconds <= 0) return null;
1007
+ return idleNoticeText(w.sessionId, seconds);
1008
+ },
915
1009
  async reload(requested) {
916
1010
  if (reloading) {
917
1011
  throw new Error(
@@ -970,6 +1064,8 @@ function createDevSessionController(auth, webUrl) {
970
1064
  log: (line) => console.error(line)
971
1065
  });
972
1066
  await push();
1067
+ if (handle?.sessionId === target.sessionId) noteHeldUse(target.sessionId);
1068
+ else recordDevUse(target.sessionId);
973
1069
  return { sessionId: target.sessionId, seconds: (Date.now() - started) / 1e3 };
974
1070
  }
975
1071
  }
@@ -978,12 +1074,13 @@ function makeSessionResolver(devSession) {
978
1074
  return () => devSession.current()?.sessionId ?? readDevState(process.cwd())?.session_id;
979
1075
  }
980
1076
  var MCP_INSTRUCTIONS = [
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.",
1077
+ "VibeView runs your app on a cloud iOS simulator, Android emulator, Apple TV, Android TV or Roku device you control with these tools.",
982
1078
  "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.",
983
1079
  "Foldables: pick one by model in dev_start (list_device_models marks them foldable, with free/busy counts), then set_posture and rotate.",
984
1080
  "set_location sets the simulated GPS position (phones and tablets) for location-based flows.",
985
1081
  "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.",
986
- "A session stopped elsewhere is reported as ended; call dev_start again for a new one."
1082
+ "A session stopped elsewhere is reported as ended; call dev_start again for a new one.",
1083
+ 'A session nobody uses ends after the idle timeout. A result that ends with "Session \u2026 ends in N s for inactivity" means act (tap, type, swipe, press) to keep it, or dev_stop if you are done; ui_tree, screenshot, logs, find and wait do not count as use.'
987
1084
  ].join("\n");
988
1085
  async function createMcpServer(opts) {
989
1086
  const [{ McpServer }, { z }] = await Promise.all([
@@ -1100,6 +1197,7 @@ export {
1100
1197
  deviceLine,
1101
1198
  forMcp,
1102
1199
  handleToolCall,
1200
+ idleNoticeText,
1103
1201
  mcpText,
1104
1202
  progressReporter,
1105
1203
  runMcpServer,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vibeview",
3
- "version": "0.35.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",
3
+ "version": "0.36.1",
4
+ "description": "VibeView CLI -- run your app on cloud iOS simulators, Android emulators, Apple TV, Android TV and Roku 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": {
@@ -211,7 +211,7 @@ appear unexplained in their `git status`.
211
211
  `ui-tree` afterwards — old refs are stale. On other phones and tablets
212
212
  `rotate` toggles portrait/landscape (only 90 is accepted); TV devices don't
213
213
  rotate.
214
- - **Roku channels (beta)**: start the session with
214
+ - **Roku channels**: start the session with
215
215
  `vibeview dev --platform roku --detach` (or `dev_start` with platform
216
216
  `roku` over MCP); after an edit run `vibeview dev-reload` (MCP:
217
217
  `dev_reload`) to re-package, upload and restart the channel in the same
@@ -254,6 +254,10 @@ vibeview dev-stop
254
254
  If the `dev --detach` process is killed or the computer sleeps, its session
255
255
  ends on its own within about 90 seconds (unless a browser tab is watching
256
256
  it) — but don't rely on that; stop it.
257
+ A session nobody uses also ends on its own after the organization's idle
258
+ timeout (30 minutes by default). Only device actions (tap, type, swipe,
259
+ press, …) and app reloads count as use; `ui-tree`, `screenshot`, `logs`,
260
+ `find` and `wait` do not, and neither does keeping `dev --detach` running.
257
261
  A session you did not start with `dev` (one created through the API, the
258
262
  sandbox, or a collaboration link) is stopped by id:
259
263
  ```bash
@@ -332,6 +336,10 @@ steps the CLI does with plain shell commands:
332
336
  If the held session ends elsewhere (dashboard, idle timeout, failure), the
333
337
  next tool call answers "session … has ended (…) — call dev_start"; the
334
338
  server has already let it go, so `dev_start` starts a fresh one.
339
+ A tool result that ends with `Session … ends in N s for inactivity` means
340
+ the held session is about to time out: act on the device to keep it, or call
341
+ `dev_stop` if you are done. A session another VibeView dev connection took
342
+ over is reported as taken over; it keeps running there, so don't stop it.
335
343
 
336
344
  **`dev_start` is stateful — it changes the default session for every later
337
345
  call.** Once it succeeds, any tool called without an explicit `session_id`