vibeview 0.21.0 → 0.23.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.
@@ -6,7 +6,7 @@ import {
6
6
  loadConfig,
7
7
  resolveAuth,
8
8
  uploadAppBuild
9
- } from "./chunk-KON2FOEC.js";
9
+ } from "./chunk-PSFXVUHG.js";
10
10
 
11
11
  // src/lib/dev-state.ts
12
12
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
@@ -528,10 +528,6 @@ function noAppsMessage(platform, hasBuildConfig) {
528
528
  }
529
529
  return `No ${platform} apps found. Run \`vibeview build --platform ${platform}\` to build and upload one (it will ask for your build command), or upload a build with \`vibeview upload-app\`.`;
530
530
  }
531
- function firstRunAppStrategy(apps, buildConfigured) {
532
- if (!buildConfigured) return "picker";
533
- return apps.length === 0 ? "build_now" : "ask";
534
- }
535
531
  function fillLaterHint(skipped, platform) {
536
532
  if (!skipped.build && !skipped.metro) return "";
537
533
  const lines = ["You can enable one-command builds later \u2014 add to vibeview.json:"];
@@ -1586,6 +1582,38 @@ function printOutcomeLine(build, multi) {
1586
1582
  console.error(line);
1587
1583
  }
1588
1584
 
1585
+ // src/lib/roku-push.ts
1586
+ function createRokuPush(opts) {
1587
+ const runBuild = opts.runBuild ?? runBuildForPlatform;
1588
+ return async () => {
1589
+ const started = Date.now();
1590
+ let result;
1591
+ try {
1592
+ result = await runBuild(
1593
+ opts.cwd,
1594
+ "roku",
1595
+ opts.build,
1596
+ { token: opts.token, baseUrl: opts.baseUrl },
1597
+ opts.log
1598
+ );
1599
+ } catch (err) {
1600
+ const message = err.message;
1601
+ if (/artifact not found/i.test(message)) {
1602
+ throw new Error(
1603
+ `${message}
1604
+ your build command must produce a zip \u2014 use the package/zip step, not deploy`
1605
+ );
1606
+ }
1607
+ throw err;
1608
+ }
1609
+ if (!result.build_id) {
1610
+ throw new Error(`upload returned no build id for ${result.name ?? result.public_id}`);
1611
+ }
1612
+ await opts.api.installBuild(opts.sessionId, result.build_id, true);
1613
+ opts.log(`reloaded in ${((Date.now() - started) / 1e3).toFixed(1)}s`);
1614
+ };
1615
+ }
1616
+
1589
1617
  // src/commands/dev.ts
1590
1618
  import { spawn as spawn4 } from "child_process";
1591
1619
  import { createWriteStream, mkdirSync as mkdirSync3 } from "fs";
@@ -2015,38 +2043,6 @@ function watchProjectFiles(cwd, artifact, userIgnore = []) {
2015
2043
  };
2016
2044
  }
2017
2045
 
2018
- // src/lib/roku-push.ts
2019
- function createRokuPush(opts) {
2020
- const runBuild = opts.runBuild ?? runBuildForPlatform;
2021
- return async () => {
2022
- const started = Date.now();
2023
- let result;
2024
- try {
2025
- result = await runBuild(
2026
- opts.cwd,
2027
- "roku",
2028
- opts.build,
2029
- { token: opts.token, baseUrl: opts.baseUrl },
2030
- opts.log
2031
- );
2032
- } catch (err) {
2033
- const message = err.message;
2034
- if (/artifact not found/i.test(message)) {
2035
- throw new Error(
2036
- `${message}
2037
- your build command must produce a zip \u2014 use the package/zip step, not deploy`
2038
- );
2039
- }
2040
- throw err;
2041
- }
2042
- if (!result.build_id) {
2043
- throw new Error(`upload returned no build id for ${result.name ?? result.public_id}`);
2044
- }
2045
- await opts.api.installBuild(opts.sessionId, result.build_id, true);
2046
- opts.log(`reloaded in ${((Date.now() - started) / 1e3).toFixed(1)}s`);
2047
- };
2048
- }
2049
-
2050
2046
  // src/commands/dev.ts
2051
2047
  function parseWatchSeconds(flag) {
2052
2048
  if (flag === void 0 || flag === false) return void 0;
@@ -2064,7 +2060,10 @@ async function startDevSession(cfg, onEvent = () => {
2064
2060
  }, onTunnelState) {
2065
2061
  const api = new ApiClient(cfg.baseUrl, cfg.token);
2066
2062
  const usesTunnel = PLATFORM_NEEDS_METRO[cfg.platform];
2067
- const created = await api.createSession(devSessionCreateBody(cfg));
2063
+ const pool = cfg.model ? resolveDeviceModel(await api.listDeviceModels(), cfg.platform, cfg.model) : null;
2064
+ const created = await api.createSession(
2065
+ devSessionCreateBody(pool ? { ...cfg, deviceCategory: pool.category, model: pool.model } : cfg)
2066
+ );
2068
2067
  const sessionId = created.session_id;
2069
2068
  let stopping = false;
2070
2069
  let tunnel = null;
@@ -2134,7 +2133,13 @@ async function startDevSession(cfg, onEvent = () => {
2134
2133
  }
2135
2134
  if (cfg.open !== false) await openBrowser(pageUrl).catch(() => {
2136
2135
  });
2137
- const active = await pollUntilActive(api, sessionId, 18e4);
2136
+ const active = await pollUntilActive(
2137
+ api,
2138
+ sessionId,
2139
+ 18e4,
2140
+ 2e3,
2141
+ () => onEvent({ event: "installing", session_id: sessionId })
2142
+ );
2138
2143
  for (const w of active.warnings ?? []) {
2139
2144
  if (reportedWarnings.has(w)) continue;
2140
2145
  reportedWarnings.add(w);
@@ -2152,9 +2157,23 @@ function devSessionCreateBody(cfg) {
2152
2157
  device_category: cfg.deviceCategory,
2153
2158
  app_id: cfg.appId,
2154
2159
  // Roku has no JS bundle to serve, so there is nothing to tunnel.
2155
- tunnel: PLATFORM_NEEDS_METRO[cfg.platform]
2160
+ tunnel: PLATFORM_NEEDS_METRO[cfg.platform],
2161
+ ...cfg.model ? { model: cfg.model } : {}
2156
2162
  };
2157
2163
  }
2164
+ function resolveDeviceModel(pools, platform, model) {
2165
+ const { deviceType, tv } = PLATFORM_DEVICE[platform];
2166
+ const candidates = pools.filter(
2167
+ (p) => p.device_type === deviceType && p.category === "tv" === tv
2168
+ );
2169
+ const wanted = model.trim();
2170
+ const match = candidates.find((p) => p.model === wanted) ?? candidates.find((p) => p.model.toLowerCase() === wanted.toLowerCase());
2171
+ if (match) return match;
2172
+ const names = [...new Set(candidates.map((p) => p.model))].sort();
2173
+ throw new Error(
2174
+ names.length ? `no ${platform} device model named "${model}" \u2014 available: ${names.join(", ")} (see \`vibeview list-devices --models\`)` : `no ${platform} devices are available right now`
2175
+ );
2176
+ }
2158
2177
  var BLOCKING_WARNING_PREFIXES = ["No debug build", "App install failed", "App not found"];
2159
2178
  function findBlockingWarning(info) {
2160
2179
  return (info.warnings ?? []).find((w) => BLOCKING_WARNING_PREFIXES.some((p) => w.includes(p)));
@@ -2192,13 +2211,18 @@ async function pollUntilTunnelReady(api, sessionId, timeoutMs, intervalMs = 2e3)
2192
2211
  await new Promise((r) => setTimeout(r, intervalMs));
2193
2212
  }
2194
2213
  }
2195
- async function pollUntilActive(api, sessionId, timeoutMs, intervalMs = 2e3) {
2214
+ async function pollUntilActive(api, sessionId, timeoutMs, intervalMs = 2e3, onInstallPending) {
2196
2215
  const deadline = Date.now() + timeoutMs;
2216
+ let pendingReported = false;
2197
2217
  for (; ; ) {
2198
- const info = await api.getSession(sessionId);
2218
+ const info = await api.touchSession(sessionId);
2199
2219
  const blocking = findBlockingWarning(info);
2200
2220
  if (blocking) throw new Error(blocking);
2201
- if (info.status === "active") return info;
2221
+ if (info.status === "active" && info.app_install_pending && !pendingReported) {
2222
+ pendingReported = true;
2223
+ onInstallPending?.();
2224
+ }
2225
+ if (info.status === "active" && !info.app_install_pending) return info;
2202
2226
  if (info.status === "stopped" || info.status === "failed") {
2203
2227
  throw new Error(`session ${info.status} before it became active`);
2204
2228
  }
@@ -2250,6 +2274,12 @@ function validateAppChoice(requested, fromFlag, apps) {
2250
2274
  if (apps.some((a) => a.public_id === requested)) return { appId: requested };
2251
2275
  return fromFlag ? { staleFlag: requested } : { staleCached: requested };
2252
2276
  }
2277
+ function buildAppStrategy(apps, buildFlag, json, isTTY) {
2278
+ if (buildFlag) return "build";
2279
+ if (apps.length === 0) return "build";
2280
+ if (json || !isTTY) return "pick";
2281
+ return "ask";
2282
+ }
2253
2283
  function requireNonInteractive(json) {
2254
2284
  if (!json) return;
2255
2285
  emit(json, {
@@ -2283,6 +2313,16 @@ async function ensureConfig(api, opts) {
2283
2313
  console.error(chalk3.red(`error: use --platform tvos or --platform androidtv for TV devices`));
2284
2314
  process.exit(2);
2285
2315
  }
2316
+ if (opts.model !== void 0 && opts.deviceCategory) {
2317
+ console.error(chalk3.red("error: pass --model or --device-category, not both"));
2318
+ process.exit(2);
2319
+ }
2320
+ if (opts.model !== void 0 && !opts.model.trim()) {
2321
+ console.error(
2322
+ chalk3.red("error: --model needs a model name (see `vibeview list-devices --models`)")
2323
+ );
2324
+ process.exit(2);
2325
+ }
2286
2326
  let firstRunBuild = null;
2287
2327
  let firstRunMetro = null;
2288
2328
  let isFirstRun = false;
@@ -2325,9 +2365,10 @@ Run \`vibeview dev\` without --app to pick from the list.`
2325
2365
  chalk3.yellow(`\u26A0 app "${choice.staleCached}" no longer exists \u2014 pick your debug build again`)
2326
2366
  );
2327
2367
  }
2328
- if (isFirstRun && !appId) {
2329
- const strategy = firstRunAppStrategy(apps, firstRunBuild !== null);
2330
- let action = strategy === "build_now" ? "build" : "pick";
2368
+ const buildConfig = firstRunBuild ?? entry?.build ?? null;
2369
+ if (!appId && buildConfig) {
2370
+ const strategy = buildAppStrategy(apps, Boolean(opts.build), opts.json, process.stdin.isTTY);
2371
+ let action = strategy === "ask" ? "pick" : strategy;
2331
2372
  if (strategy === "ask") {
2332
2373
  action = await select3({
2333
2374
  message: "Run against which build?",
@@ -2343,7 +2384,7 @@ Run \`vibeview dev\` without --app to pick from the list.`
2343
2384
  const result = await runBuildForPlatform(
2344
2385
  process.cwd(),
2345
2386
  platform,
2346
- firstRunBuild,
2387
+ buildConfig,
2347
2388
  opts,
2348
2389
  (l) => console.error(chalk3.dim(l))
2349
2390
  );
@@ -2352,6 +2393,7 @@ Run \`vibeview dev\` without --app to pick from the list.`
2352
2393
  } catch (err) {
2353
2394
  const message = err.message;
2354
2395
  console.error(chalk3.red(`error: ${message}`));
2396
+ emit(opts.json, { event: "error", message });
2355
2397
  process.exit(1);
2356
2398
  }
2357
2399
  }
@@ -2415,6 +2457,7 @@ Run \`vibeview dev\` without --app to pick from the list.`
2415
2457
  platform,
2416
2458
  appId,
2417
2459
  deviceCategory,
2460
+ model: opts.model,
2418
2461
  metroPort: finalMetro.port,
2419
2462
  metroCommand: finalMetro.command,
2420
2463
  platformHasMetroOverride: finalMetro.platformHasOverride,
@@ -2558,6 +2601,7 @@ async function runDevInProcess(opts) {
2558
2601
  platform: cfg.platform,
2559
2602
  appId: cfg.appId,
2560
2603
  deviceCategory: cfg.deviceCategory,
2604
+ model: cfg.model,
2561
2605
  metroPort: cfg.metroPort ?? 8081,
2562
2606
  // startDevSession's own browser-open happens before we've written the
2563
2607
  // state file / emitted session_ready — open it ourselves afterward
@@ -2583,6 +2627,10 @@ async function runDevInProcess(opts) {
2583
2627
  } else if (evt.event === "tunnel_connected") {
2584
2628
  everConnected = true;
2585
2629
  console.error(chalk3.green("\u2713 tunnel connected \u2014 edit away, Fast Refresh is live"));
2630
+ } else if (evt.event === "installing") {
2631
+ console.error(
2632
+ chalk3.dim(" Putting the channel on the Roku \u2014 the link appears once it is up.")
2633
+ );
2586
2634
  } else if (evt.event === "session_ended" && !stopping && handle) {
2587
2635
  console.error(chalk3.red("tunnel closed by server \u2014 session ended"));
2588
2636
  stopping = true;
@@ -2692,7 +2740,7 @@ async function runDevInProcess(opts) {
2692
2740
  if (stopping) return;
2693
2741
  let info = null;
2694
2742
  try {
2695
- info = await api.getSession(sessionId);
2743
+ info = await api.touchSession(sessionId);
2696
2744
  } catch {
2697
2745
  }
2698
2746
  const verdict = sessionLivenessVerdict(info);
@@ -2801,6 +2849,55 @@ async function dev(opts) {
2801
2849
  if (process.exitCode === 1) return;
2802
2850
  }
2803
2851
 
2852
+ // src/lib/formatters/devices.ts
2853
+ import chalk4 from "chalk";
2854
+ var HEADERS = ["DEVICE_ID", "NAME", "TYPE", "CATEGORY", "OS", "STATUS"];
2855
+ function formatDevicesHuman(devices) {
2856
+ if (devices.length === 0) {
2857
+ return "No devices registered.\n";
2858
+ }
2859
+ const rows = devices.map((d) => [
2860
+ d.device_id,
2861
+ d.name,
2862
+ d.type,
2863
+ d.category,
2864
+ d.os_version,
2865
+ d.status
2866
+ ]);
2867
+ const widths = HEADERS.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2868
+ const fmt = (cells) => cells.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd();
2869
+ const lines = [chalk4.bold(fmt(HEADERS))];
2870
+ for (const row of rows.sort(rowSort)) {
2871
+ const statusColored = row[5] === "available" ? chalk4.green(row[5]) : row[5] === "busy" ? chalk4.yellow(row[5]) : chalk4.dim(row[5]);
2872
+ lines.push(fmt([row[0], row[1], row[2], row[3], row[4], statusColored]));
2873
+ }
2874
+ return lines.join("\n") + "\n";
2875
+ }
2876
+ function rowSort(a, b) {
2877
+ return a[2].localeCompare(b[2]) || a[3].localeCompare(b[3]) || a[0].localeCompare(b[0]);
2878
+ }
2879
+ function filterDeviceModels(models, opts) {
2880
+ return models.filter((m) => !opts.type || m.device_type === opts.type).filter((m) => !opts.category || m.category === opts.category).map((m) => ({
2881
+ model: m.model,
2882
+ os_version: m.os_version,
2883
+ device_type: m.device_type,
2884
+ category: m.category,
2885
+ form_factor: m.form_factor ?? null
2886
+ }));
2887
+ }
2888
+ var MODEL_HEADERS = ["MODEL", "OS", "TYPE", "CATEGORY", "FORM FACTOR"];
2889
+ function formatDeviceModelsHuman(models) {
2890
+ if (models.length === 0) {
2891
+ return "No device models available right now.\n";
2892
+ }
2893
+ const rows = models.map((m) => [m.model, m.os_version, m.device_type, m.category, m.form_factor ?? "-"]).sort(
2894
+ (a, b) => a[2].localeCompare(b[2]) || a[3].localeCompare(b[3]) || a[0].localeCompare(b[0]) || a[1].localeCompare(b[1])
2895
+ );
2896
+ const widths = MODEL_HEADERS.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2897
+ const fmt = (cells) => cells.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd();
2898
+ return [chalk4.bold(fmt(MODEL_HEADERS)), ...rows.map(fmt)].join("\n") + "\n";
2899
+ }
2900
+
2804
2901
  // src/agent/registry.ts
2805
2902
  var AGENT_COMMANDS = [
2806
2903
  {
@@ -3116,6 +3213,52 @@ var AGENT_COMMANDS = [
3116
3213
  }
3117
3214
  ]
3118
3215
  },
3216
+ {
3217
+ // set_posture: { posture: 'closed' | 'partial' | 'open' } OR { angle: 0..180 }
3218
+ // — exactly one (buildToolCall enforces it CLI-side, agent_control.py's
3219
+ // validate_tool_args at the gate). The angle is a settle-once fold, the
3220
+ // same as the dashboard slider's release, not its live drag stream.
3221
+ // Foldable devices only.
3222
+ verb: "set-posture",
3223
+ tool: "set_posture",
3224
+ summary: "Fold or unfold a foldable device (iPhone Duo): a preset (closed, partial, open) or an exact hinge angle in degrees (--angle 0-180) \u2014 pass exactly one. Reports the posture it settled in, the angle, and which screen is lit. Errors on a device without a hinge.",
3225
+ mutating: true,
3226
+ params: [
3227
+ {
3228
+ name: "posture",
3229
+ cli: "positional",
3230
+ type: "string",
3231
+ required: false,
3232
+ description: "One of 'closed', 'partial', 'open'. Omit when passing an angle."
3233
+ },
3234
+ {
3235
+ name: "angle",
3236
+ cli: "--angle",
3237
+ type: "number",
3238
+ required: false,
3239
+ description: "Exact hinge angle in whole degrees, 0 (shut) to 180 (flat open). Omit when passing a posture."
3240
+ }
3241
+ ]
3242
+ },
3243
+ {
3244
+ // rotate: { degrees?: 90 | 180 | 270 } (default 90). The same control as
3245
+ // the dashboard's Rotate button. A foldable turns its body by quarter
3246
+ // turns (90 clockwise, 270 back, 180 a half turn); a rigid phone toggles
3247
+ // portrait <-> landscape and accepts only 90. Refused on TV devices.
3248
+ verb: "rotate",
3249
+ tool: "rotate",
3250
+ summary: "Rotate the device. A phone toggles between portrait and landscape; a foldable (iPhone Duo) turns a quarter clockwise, or by --degrees 180/270. Reports the new orientation. Not available on TV devices.",
3251
+ mutating: true,
3252
+ params: [
3253
+ {
3254
+ name: "degrees",
3255
+ cli: "--degrees",
3256
+ type: "number",
3257
+ required: false,
3258
+ description: "Foldable only: 90 (default, a quarter turn clockwise), 180, or 270 (a quarter turn back). A phone accepts only 90."
3259
+ }
3260
+ ]
3261
+ },
3119
3262
  {
3120
3263
  // tools.py wait: { seconds?: number } (default: 2). Note the wire arg is
3121
3264
  // "seconds", NOT "ms" — tool_dispatch.py reads args.get("seconds", 2)
@@ -3207,18 +3350,18 @@ var AGENT_COMMANDS = [
3207
3350
  ];
3208
3351
 
3209
3352
  // src/agent/render.ts
3210
- import chalk4 from "chalk";
3353
+ import chalk5 from "chalk";
3211
3354
  function renderEnvelope(env) {
3212
3355
  const lines = [];
3213
3356
  if (env.ok) {
3214
- lines.push(chalk4.green(`ok`) + chalk4.dim(` (${env.tool})`));
3357
+ lines.push(chalk5.green(`ok`) + chalk5.dim(` (${env.tool})`));
3215
3358
  } else {
3216
- lines.push(chalk4.red(`error`) + chalk4.dim(` (${env.tool})`));
3359
+ lines.push(chalk5.red(`error`) + chalk5.dim(` (${env.tool})`));
3217
3360
  }
3218
3361
  lines.push(...resultLines(env.result));
3219
3362
  if (env.observation) {
3220
3363
  if (env.observation.kind === "unavailable") {
3221
- lines.push(chalk4.yellow(`warning: ${env.observation.text}`));
3364
+ lines.push(chalk5.yellow(`warning: ${env.observation.text}`));
3222
3365
  } else {
3223
3366
  lines.push(env.observation.text);
3224
3367
  }
@@ -3231,17 +3374,17 @@ function resultLines(result) {
3231
3374
  const lines = [];
3232
3375
  if (Array.isArray(r.entries) && typeof r.cursor === "number") {
3233
3376
  if (r.entries.length === 0) {
3234
- lines.push(chalk4.dim("(no log lines)"));
3377
+ lines.push(chalk5.dim("(no log lines)"));
3235
3378
  }
3236
3379
  for (const e of r.entries) {
3237
3380
  if (!e || typeof e !== "object") continue;
3238
3381
  const entry = e;
3239
3382
  const ts = typeof entry.timestamp === "number" ? new Date(entry.timestamp).toISOString().slice(11, 23) : "??:??:??.???";
3240
3383
  const level = typeof entry.level === "string" ? entry.level : "info";
3241
- const levelTag = level === "error" ? chalk4.red(level) : level === "warn" ? chalk4.yellow(level) : chalk4.dim(level);
3242
- lines.push(`${chalk4.dim(ts)} ${levelTag} ${String(entry.message ?? "")}`);
3384
+ const levelTag = level === "error" ? chalk5.red(level) : level === "warn" ? chalk5.yellow(level) : chalk5.dim(level);
3385
+ lines.push(`${chalk5.dim(ts)} ${levelTag} ${String(entry.message ?? "")}`);
3243
3386
  }
3244
- lines.push(chalk4.dim(`cursor: ${r.cursor}`));
3387
+ lines.push(chalk5.dim(`cursor: ${r.cursor}`));
3245
3388
  }
3246
3389
  if (r.found === true && typeof r.ref === "string" && r.ref) {
3247
3390
  let found = `found: @${r.ref}`;
@@ -3253,14 +3396,43 @@ function resultLines(result) {
3253
3396
  const text = typeof a.text === "string" && a.text ? ` ${JSON.stringify(a.text)}` : "";
3254
3397
  return `${ref}${text}`;
3255
3398
  });
3256
- if (alts.length > 0) lines.push(chalk4.dim(`alternatives: ${alts.join(", ")}`));
3399
+ if (alts.length > 0) lines.push(chalk5.dim(`alternatives: ${alts.join(", ")}`));
3257
3400
  }
3258
3401
  }
3259
- if (typeof r.note === "string" && r.note) lines.push(chalk4.dim(r.note));
3402
+ if (r.action === "posture_set" || r.action === "rotated") {
3403
+ const state = deviceStateLine(r);
3404
+ if (state) lines.push(state);
3405
+ }
3406
+ if (typeof r.note === "string" && r.note) lines.push(chalk5.dim(r.note));
3260
3407
  if (typeof r.message === "string" && r.message) lines.push(r.message);
3261
3408
  if (typeof r.warning === "string" && r.warning) lines.push(r.warning);
3262
3409
  return lines;
3263
3410
  }
3411
+ function deviceStateLine(r) {
3412
+ const parts = [];
3413
+ if (r.action === "rotated") {
3414
+ if (typeof r.device_orientation === "string") {
3415
+ let o = `orientation: ${r.device_orientation}`;
3416
+ if (typeof r.screen_orientation === "string" && r.screen_orientation !== r.device_orientation) {
3417
+ o += ` (screen shows ${r.screen_orientation})`;
3418
+ }
3419
+ parts.push(o);
3420
+ }
3421
+ }
3422
+ if (typeof r.posture === "string") {
3423
+ let p = `posture: ${r.posture}`;
3424
+ if (typeof r.angle === "number") p += ` at ${r.angle}\xB0`;
3425
+ const extras = [];
3426
+ if (typeof r.screen === "string") extras.push(`${r.screen} screen`);
3427
+ const d = r.display;
3428
+ if (d && typeof d.width === "number" && typeof d.height === "number") {
3429
+ extras.push(`${d.width}x${d.height}`);
3430
+ }
3431
+ if (extras.length) p += ` (${extras.join(", ")})`;
3432
+ parts.push(p);
3433
+ }
3434
+ return parts.length ? parts.join("; ") : null;
3435
+ }
3264
3436
  function agentExitCode(env) {
3265
3437
  return env.ok ? 0 : 1;
3266
3438
  }
@@ -3382,6 +3554,15 @@ function buildToolCall(cmd, positionals, opts) {
3382
3554
  }
3383
3555
  return { tool: cmd.tool, args: args2 };
3384
3556
  }
3557
+ if (cmd.verb === "set-posture") {
3558
+ const hasPosture = positionals[0] !== void 0 && positionals[0] !== "";
3559
+ const hasAngle = opts.angle !== void 0 && opts.angle !== "";
3560
+ if (hasPosture === hasAngle) {
3561
+ throw new FlagError(
3562
+ hasPosture ? "set-posture takes a posture or --angle, not both" : "set-posture requires a posture (closed, partial, open) or --angle <0-180>"
3563
+ );
3564
+ }
3565
+ }
3385
3566
  const args = {};
3386
3567
  let positionalIndex = 0;
3387
3568
  for (const param of cmd.params) {
@@ -3488,6 +3669,7 @@ async function runAgentVerb(cmd, positionals, opts) {
3488
3669
  export {
3489
3670
  readDevState,
3490
3671
  clearDevState,
3672
+ DEV_PLATFORMS,
3491
3673
  PLATFORM_NEEDS_METRO,
3492
3674
  isDevPlatform,
3493
3675
  loadDevConfig,
@@ -3501,9 +3683,14 @@ export {
3501
3683
  newTransitionState,
3502
3684
  printTransition,
3503
3685
  printOutcomeLine,
3686
+ createRokuPush,
3504
3687
  startDevSession,
3688
+ ROKU_LIVENESS_INTERVAL_MS,
3505
3689
  PLATFORM_DEVICE,
3506
3690
  dev,
3691
+ formatDevicesHuman,
3692
+ filterDeviceModels,
3693
+ formatDeviceModelsHuman,
3507
3694
  AGENT_COMMANDS,
3508
3695
  renderEnvelope,
3509
3696
  buildToolCall,
@@ -238,6 +238,10 @@ var ApiClient = class {
238
238
  }
239
239
  return all;
240
240
  }
241
+ /** The startable device models (see `DeviceModelPool`). */
242
+ async listDeviceModels() {
243
+ return this.requestRaw("GET", "/devices/pools");
244
+ }
241
245
  async getDevice(deviceId) {
242
246
  return this.requestRaw("GET", `/devices/${deviceId}`);
243
247
  }
@@ -250,6 +254,13 @@ var ApiClient = class {
250
254
  async getSession(sessionId) {
251
255
  return this.requestRaw("GET", `/sessions/${sessionId}`);
252
256
  }
257
+ /** Report presence on a session and read it back in one call. A Roku dev
258
+ * loop has no tunnel and no browser, so this is what tells the backend
259
+ * someone is still using the session between device commands. Same
260
+ * response shape as `getSession`. */
261
+ async touchSession(sessionId) {
262
+ return this.requestRaw("POST", `/sessions/${sessionId}/presence`);
263
+ }
253
264
  async stopSession(sessionId) {
254
265
  await this.requestRaw("DELETE", `/sessions/${sessionId}`);
255
266
  }