@yoqa/runner 0.3.18 → 0.3.20

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.
Files changed (2) hide show
  1. package/dist/index.js +433 -109
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -16801,7 +16801,7 @@ function getCatalogDb() {
16801
16801
  // package.json
16802
16802
  var package_default = {
16803
16803
  name: "@yoqa/runner",
16804
- version: "0.3.18",
16804
+ version: "0.3.20",
16805
16805
  description: "Yoqa local runner \u2014 Appium + catalog HTTP API (Bun). Start with yoqa serve or the yoqa-runner bin.",
16806
16806
  license: "UNLICENSED",
16807
16807
  type: "module",
@@ -17353,10 +17353,6 @@ var MJPEG_SETTINGS_BASE = {
17353
17353
  mjpegScalingFactor: 50
17354
17354
  };
17355
17355
  var openByDeviceId = new Map;
17356
- var exclusiveReleaseListener = null;
17357
- function onDeviceSessionExclusiveRelease(listener) {
17358
- exclusiveReleaseListener = listener;
17359
- }
17360
17356
  var DEAD_SESSION_RE = /session does not exist|invalid session id|no such session|terminated or not started|session is either terminated/i;
17361
17357
 
17362
17358
  class DeadSessionError extends Error {
@@ -17441,7 +17437,6 @@ async function releaseExistingSession(deviceId) {
17441
17437
  if (!existing)
17442
17438
  return;
17443
17439
  openByDeviceId.delete(deviceId);
17444
- exclusiveReleaseListener?.(deviceId);
17445
17440
  try {
17446
17441
  await existing.quit();
17447
17442
  } catch (error) {
@@ -17975,21 +17970,32 @@ async function createDeviceSession(options) {
17975
17970
 
17976
17971
  // src/domains/devices/active-session.ts
17977
17972
  var active = null;
17973
+
17974
+ class SessionBusyError extends Error {
17975
+ constructor(message) {
17976
+ super(message);
17977
+ this.name = "SessionBusyError";
17978
+ }
17979
+ }
17978
17980
  var isMissingAppiumSessionError = isDeadSessionError;
17981
+ function toInfo(current) {
17982
+ return {
17983
+ deviceId: current.deviceId,
17984
+ platform: current.platform,
17985
+ connectedAt: current.connectedAt,
17986
+ mjpegPort: current.mjpegPort,
17987
+ streamReady: current.streamReady,
17988
+ streamUrl: current.streamUrl,
17989
+ heldByRun: current.heldByRunId != null
17990
+ };
17991
+ }
17979
17992
  function getActiveSession() {
17980
17993
  return active;
17981
17994
  }
17982
17995
  function getActiveSessionInfo() {
17983
17996
  if (!active)
17984
17997
  return null;
17985
- return {
17986
- deviceId: active.deviceId,
17987
- platform: active.platform,
17988
- connectedAt: active.connectedAt,
17989
- mjpegPort: active.mjpegPort,
17990
- streamReady: active.streamReady,
17991
- streamUrl: active.streamUrl
17992
- };
17998
+ return toInfo(active);
17993
17999
  }
17994
18000
  function requireActiveSession() {
17995
18001
  if (!active) {
@@ -17997,6 +18003,9 @@ function requireActiveSession() {
17997
18003
  }
17998
18004
  return active;
17999
18005
  }
18006
+ function isActiveSessionHeldByRun() {
18007
+ return active?.heldByRunId != null;
18008
+ }
18000
18009
  function abandonActiveSession() {
18001
18010
  if (!active)
18002
18011
  return null;
@@ -18006,20 +18015,12 @@ function abandonActiveSession() {
18006
18015
  console.warn(`[yoqa-runner] abandoned dead Appium session for ${info?.platform} ${info?.deviceId}`);
18007
18016
  return info;
18008
18017
  }
18009
- onDeviceSessionExclusiveRelease((deviceId) => {
18010
- if (active?.deviceId === deviceId) {
18011
- abandonActiveSession();
18012
- }
18013
- });
18014
- async function connectDevice(options) {
18015
- if (active) {
18016
- await disconnectDevice();
18017
- }
18018
+ async function createAndRegister(options) {
18018
18019
  const session = await createDeviceSession({
18019
18020
  platform: options.platform,
18020
18021
  deviceId: options.deviceId,
18021
- appCaps: [],
18022
- caseCaps: [],
18022
+ appCaps: options.appCaps ?? [],
18023
+ caseCaps: options.caseCaps ?? [],
18023
18024
  bundleId: options.bundleId,
18024
18025
  appPackage: options.appPackage,
18025
18026
  onSessionDead: () => {
@@ -18033,8 +18034,19 @@ async function connectDevice(options) {
18033
18034
  mjpegPort: session.mjpegPort,
18034
18035
  streamReady: session.streamReady,
18035
18036
  streamUrl: "/stream.mjpeg",
18036
- session
18037
+ session,
18038
+ heldByRunId: options.heldByRunId
18037
18039
  };
18040
+ return session;
18041
+ }
18042
+ async function connectDevice(options) {
18043
+ if (isActiveSessionHeldByRun()) {
18044
+ throw new SessionBusyError("A run is using the device session. Cancel the run before connecting another device.");
18045
+ }
18046
+ if (active) {
18047
+ await disconnectDevice();
18048
+ }
18049
+ await createAndRegister({ ...options, heldByRunId: null });
18038
18050
  const info = getActiveSessionInfo();
18039
18051
  if (!info) {
18040
18052
  throw new Error("Failed to read active session after connect");
@@ -18044,6 +18056,9 @@ async function connectDevice(options) {
18044
18056
  async function disconnectDevice() {
18045
18057
  if (!active)
18046
18058
  return null;
18059
+ if (isActiveSessionHeldByRun()) {
18060
+ throw new SessionBusyError("A run is using the device session. Cancel the run first.");
18061
+ }
18047
18062
  const info = getActiveSessionInfo();
18048
18063
  const session = active.session;
18049
18064
  active = null;
@@ -18053,6 +18068,58 @@ async function disconnectDevice() {
18053
18068
  } catch {}
18054
18069
  return info;
18055
18070
  }
18071
+ async function acquireSessionForRun(options) {
18072
+ const current = active;
18073
+ if (current?.heldByRunId && current.heldByRunId !== options.runId) {
18074
+ const session2 = await createDeviceSession({
18075
+ platform: options.platform,
18076
+ deviceId: options.deviceId,
18077
+ appCaps: options.appCaps ?? [],
18078
+ caseCaps: options.caseCaps ?? [],
18079
+ bundleId: options.bundleId,
18080
+ appPackage: options.appPackage,
18081
+ onSessionDead: () => {
18082
+ return;
18083
+ }
18084
+ });
18085
+ return { session: session2, shared: false };
18086
+ }
18087
+ if (current && current.deviceId === options.deviceId) {
18088
+ const healthy = await current.session.getWindowSize().then(() => true).catch(() => false);
18089
+ if (healthy) {
18090
+ current.heldByRunId = options.runId;
18091
+ return { session: current.session, shared: true };
18092
+ }
18093
+ console.warn(`[yoqa-runner] active session for ${options.deviceId} is dead \u2014 creating a fresh one for the run`);
18094
+ await disconnectDevice().catch(() => {
18095
+ return;
18096
+ });
18097
+ }
18098
+ if (current) {
18099
+ await disconnectDevice();
18100
+ }
18101
+ const session = await createAndRegister({
18102
+ deviceId: options.deviceId,
18103
+ platform: options.platform,
18104
+ appCaps: options.appCaps,
18105
+ caseCaps: options.caseCaps,
18106
+ bundleId: options.bundleId,
18107
+ appPackage: options.appPackage,
18108
+ heldByRunId: options.runId
18109
+ });
18110
+ return { session, shared: true };
18111
+ }
18112
+ async function releaseSessionFromRun(runId, session, shared) {
18113
+ if (!shared) {
18114
+ await session.quit().catch(() => {
18115
+ return;
18116
+ });
18117
+ return;
18118
+ }
18119
+ if (active && active.heldByRunId === runId) {
18120
+ active.heldByRunId = null;
18121
+ }
18122
+ }
18056
18123
 
18057
18124
  // src/domains/servers/application.ts
18058
18125
  function appiumLabel(info) {
@@ -24258,7 +24325,7 @@ var probeProviderResponseSchema = exports_external.object({
24258
24325
  var providerModelSchema = exports_external.object({
24259
24326
  id: exports_external.string().min(1),
24260
24327
  name: exports_external.string().min(1),
24261
- tier: exports_external.enum(["free", "paid"]).optional()
24328
+ provider: exports_external.string().min(1).optional()
24262
24329
  });
24263
24330
  var listProviderModelsResponseSchema = exports_external.object({
24264
24331
  models: exports_external.array(providerModelSchema),
@@ -24351,7 +24418,8 @@ var activeDeviceResponseSchema = exports_external.object({
24351
24418
  connectedAt: exports_external.number().int().nonnegative(),
24352
24419
  mjpegPort: exports_external.number().int().positive().optional(),
24353
24420
  streamReady: exports_external.boolean().optional(),
24354
- streamUrl: exports_external.string().min(1).optional()
24421
+ streamUrl: exports_external.string().min(1).optional(),
24422
+ heldByRun: exports_external.boolean().optional()
24355
24423
  });
24356
24424
  var controlPointerMessageSchema = exports_external.object({
24357
24425
  type: exports_external.literal("pointer"),
@@ -57545,6 +57613,9 @@ function formatProviderHttpError(label, status, body) {
57545
57613
  if (label === "OpenCode" && (body.includes("unknown variant `image_url`") || body.includes("No endpoints found that support image input") || body.includes("expected `text`"))) {
57546
57614
  return `This OpenCode model does not accept screenshots (text-only). In Settings \u2192 Provider, set the default model to ${EXAMPLE_OPENCODE_VISION_MODEL} (or another vision-capable model). deepseek-v4-flash-free and big-pickle are text-only on Zen.`;
57547
57615
  }
57616
+ if (label === "OpenCode" && (body.includes("output_config.format") || body.includes("BedrockException") && body.includes("Extra inputs are not permitted"))) {
57617
+ return "LiteLLM/Bedrock rejected JSON schema mode for this model (output_config.format). Restart the Yoqa runner and retry \u2014 LiteLLM vision now asks for JSON in the prompt only.";
57618
+ }
57548
57619
  if (label === "OpenCode" && status >= 500 && (body.includes("Internal server error") || body.includes('"type":"error"'))) {
57549
57620
  return `OpenCode Zen returned an internal error for this vision request. Many free models reject screenshots. In Settings \u2192 Provider, set the default model to ${EXAMPLE_OPENCODE_VISION_MODEL} (or another vision-capable model).`;
57550
57621
  }
@@ -57578,6 +57649,16 @@ function mapProviderError(label, error40) {
57578
57649
  }
57579
57650
  throw new AgentProviderError(String(error40));
57580
57651
  }
57652
+ function repairModelJsonText(text3) {
57653
+ const trimmed = text3.trim();
57654
+ const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/);
57655
+ const candidate = fenced?.[1]?.trim() ?? trimmed;
57656
+ const start = candidate.indexOf("{");
57657
+ const end = candidate.lastIndexOf("}");
57658
+ if (start < 0 || end <= start)
57659
+ return trimmed;
57660
+ return candidate.slice(start, end + 1);
57661
+ }
57581
57662
  async function completeWithAiSdk(input) {
57582
57663
  const run = async (prompt) => {
57583
57664
  try {
@@ -57586,6 +57667,7 @@ async function completeWithAiSdk(input) {
57586
57667
  schema: input.schema,
57587
57668
  system: input.system,
57588
57669
  maxOutputTokens: VISION_MAX_TOKENS,
57670
+ experimental_repairText: async ({ text: text3 }) => repairModelJsonText(text3),
57589
57671
  messages: [
57590
57672
  {
57591
57673
  role: "user",
@@ -57602,6 +57684,11 @@ async function completeWithAiSdk(input) {
57602
57684
  });
57603
57685
  return object3;
57604
57686
  } catch (error40) {
57687
+ if (NoObjectGeneratedError.isInstance(error40) && error40.text?.trim()) {
57688
+ try {
57689
+ return input.schema.parse(JSON.parse(repairModelJsonText(error40.text)));
57690
+ } catch {}
57691
+ }
57605
57692
  mapProviderError(input.label, error40);
57606
57693
  }
57607
57694
  };
@@ -57644,41 +57731,58 @@ function openCodeAuthPaths() {
57644
57731
  paths.push(join9(homedir8(), ".local", "share", "opencode", "auth.json"));
57645
57732
  return paths;
57646
57733
  }
57647
- async function readOpenCodeCliAuthKey() {
57734
+ function apiKeyFromOpenCodeAuthEntry(value) {
57735
+ if (!value || typeof value !== "object")
57736
+ return null;
57737
+ const entry = value;
57738
+ for (const field of ["key", "apiKey", "token"]) {
57739
+ const candidate = entry[field];
57740
+ if (typeof candidate === "string" && candidate.trim())
57741
+ return candidate.trim();
57742
+ }
57743
+ return null;
57744
+ }
57745
+ async function readOpenCodeCliAuthJson() {
57648
57746
  for (const path of openCodeAuthPaths()) {
57649
57747
  try {
57650
57748
  const file2 = Bun.file(path);
57651
57749
  if (!await file2.exists())
57652
57750
  continue;
57653
57751
  const json3 = await file2.json();
57654
- if (!json3 || typeof json3 !== "object")
57752
+ if (!json3 || typeof json3 !== "object" || Array.isArray(json3))
57655
57753
  continue;
57656
- const record2 = json3;
57657
- const opencode = record2.opencode;
57658
- if (opencode && typeof opencode === "object") {
57659
- const entry = opencode;
57660
- for (const field of ["key", "apiKey", "token"]) {
57661
- const value = entry[field];
57662
- if (typeof value === "string" && value.trim())
57663
- return value.trim();
57664
- }
57665
- }
57666
- for (const value of Object.values(record2)) {
57667
- if (!value || typeof value !== "object")
57668
- continue;
57669
- const entry = value;
57670
- const key = entry.key ?? entry.apiKey ?? entry.token;
57671
- if (typeof key === "string" && key.trim()) {
57672
- const type = typeof entry.type === "string" ? entry.type.toLowerCase() : "";
57673
- if (!type || type.includes("api") || type.includes("key") || type === "opencode") {
57674
- return key.trim();
57675
- }
57676
- }
57677
- }
57754
+ return json3;
57678
57755
  } catch {}
57679
57756
  }
57680
57757
  return null;
57681
57758
  }
57759
+ async function readOpenCodeCliAuthKeyForProvider(providerId) {
57760
+ const record2 = await readOpenCodeCliAuthJson();
57761
+ if (!record2)
57762
+ return null;
57763
+ return apiKeyFromOpenCodeAuthEntry(record2[providerId]);
57764
+ }
57765
+ async function readOpenCodeCliAuthKey() {
57766
+ const record2 = await readOpenCodeCliAuthJson();
57767
+ if (!record2)
57768
+ return null;
57769
+ const zen = apiKeyFromOpenCodeAuthEntry(record2.opencode);
57770
+ if (zen)
57771
+ return zen;
57772
+ for (const value of Object.values(record2)) {
57773
+ const key = apiKeyFromOpenCodeAuthEntry(value);
57774
+ if (!key)
57775
+ continue;
57776
+ if (!value || typeof value !== "object")
57777
+ continue;
57778
+ const entry = value;
57779
+ const type = typeof entry.type === "string" ? entry.type.toLowerCase() : "";
57780
+ if (!type || type.includes("api") || type.includes("key") || type === "opencode") {
57781
+ return key;
57782
+ }
57783
+ }
57784
+ return null;
57785
+ }
57682
57786
  async function resolveOpenAiCompatibleKey(auth) {
57683
57787
  if (auth.apiKey?.trim())
57684
57788
  return auth.apiKey.trim();
@@ -57721,18 +57825,34 @@ function withOpenCodeRequestHooks(opts) {
57721
57825
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
57722
57826
  return async (input, init) => {
57723
57827
  const headers = new Headers(init?.headers);
57724
- if (opts.authHeaders?.Authorization) {
57725
- headers.set("Authorization", opts.authHeaders.Authorization);
57828
+ if (opts.authHeaders) {
57829
+ for (const [key, value] of Object.entries(opts.authHeaders)) {
57830
+ if (value)
57831
+ headers.set(key, value);
57832
+ }
57833
+ }
57834
+ if (opts.extraHeaders) {
57835
+ for (const [key, value] of Object.entries(opts.extraHeaders)) {
57836
+ if (value)
57837
+ headers.set(key, value);
57838
+ }
57726
57839
  }
57727
- if (!opts.disableThinking || !init?.body || typeof init.body !== "string") {
57840
+ const rawBody = init?.body;
57841
+ const shouldRewriteBody = (opts.disableThinking || Boolean(opts.stripResponseFormat)) && typeof rawBody === "string";
57842
+ if (!shouldRewriteBody || typeof rawBody !== "string") {
57728
57843
  return fetchImpl(input, { ...init, headers });
57729
57844
  }
57730
57845
  try {
57731
- const parsed = JSON.parse(init.body);
57846
+ const parsed = JSON.parse(rawBody);
57732
57847
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
57733
57848
  return fetchImpl(input, { ...init, headers });
57734
57849
  }
57735
- const body = { ...parsed, thinking: { type: "disabled" } };
57850
+ const parsedBody = { ...parsed };
57851
+ if (opts.disableThinking) {
57852
+ parsedBody.thinking = { type: "disabled" };
57853
+ }
57854
+ const { response_format: _responseFormat, ...withoutFormat } = parsedBody;
57855
+ const body = opts.stripResponseFormat ? withoutFormat : parsedBody;
57736
57856
  return fetchImpl(input, {
57737
57857
  ...init,
57738
57858
  headers,
@@ -96236,12 +96356,144 @@ var openaiDriver = {
96236
96356
  }
96237
96357
  };
96238
96358
 
96359
+ // src/domains/providers/drivers/opencode-config.ts
96360
+ import { homedir as homedir10 } from "os";
96361
+ import { join as join13 } from "path";
96362
+ var OPENAI_COMPATIBLE_PACKAGES = new Set([
96363
+ "@ai-sdk/openai-compatible",
96364
+ "@ai-sdk/openai",
96365
+ "@ai-sdk/azure"
96366
+ ]);
96367
+ function asRecord3(value) {
96368
+ if (!value || typeof value !== "object" || Array.isArray(value))
96369
+ return null;
96370
+ return value;
96371
+ }
96372
+ function isOpenAiCompatibleNpm(npm) {
96373
+ const trimmed = npm.trim();
96374
+ if (!trimmed)
96375
+ return true;
96376
+ if (OPENAI_COMPATIBLE_PACKAGES.has(trimmed))
96377
+ return true;
96378
+ return trimmed.includes("openai-compatible");
96379
+ }
96380
+ function interpolateOpenCodeTemplate(value, env) {
96381
+ return value.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_full, name31) => {
96382
+ return env[name31]?.trim() || process.env[name31]?.trim() || "";
96383
+ }).trim();
96384
+ }
96385
+ function openCodeConfigPaths() {
96386
+ const paths = [];
96387
+ const xdg = process.env.XDG_CONFIG_HOME?.trim();
96388
+ if (xdg)
96389
+ paths.push(join13(xdg, "opencode", "opencode.json"));
96390
+ paths.push(join13(homedir10(), ".config", "opencode", "opencode.json"));
96391
+ return paths;
96392
+ }
96393
+ function parseOpenCodeCompatibleProvider(config2, providerId) {
96394
+ const root = asRecord3(config2);
96395
+ const providers2 = asRecord3(root?.provider);
96396
+ const entry = asRecord3(providers2?.[providerId]);
96397
+ if (!entry)
96398
+ return null;
96399
+ const npm = typeof entry.npm === "string" ? entry.npm : "";
96400
+ if (!isOpenAiCompatibleNpm(npm))
96401
+ return null;
96402
+ const options = asRecord3(entry.options);
96403
+ const baseURLRaw = typeof options?.baseURL === "string" ? options.baseURL.trim() : "";
96404
+ if (!baseURLRaw)
96405
+ return null;
96406
+ const apiKeyTemplate = typeof options?.apiKey === "string" ? options.apiKey : null;
96407
+ const headers = {};
96408
+ const headerBag = asRecord3(options?.headers);
96409
+ if (headerBag) {
96410
+ for (const [key, value] of Object.entries(headerBag)) {
96411
+ if (typeof value === "string" && value.trim())
96412
+ headers[key] = value;
96413
+ }
96414
+ }
96415
+ return {
96416
+ providerId,
96417
+ baseURL: baseURLRaw.replace(/\/$/, ""),
96418
+ apiKeyTemplate,
96419
+ headers
96420
+ };
96421
+ }
96422
+ function resolveOpenCodeCompatibleAuth(input) {
96423
+ const parsed = parseOpenCodeCompatibleProvider(input.config, input.providerId);
96424
+ const override = input.overrideBaseUrl?.trim().replace(/\/$/, "") || null;
96425
+ if (!parsed && !(override && input.providerId === "litellm"))
96426
+ return null;
96427
+ const baseURL = override || parsed?.baseURL || null;
96428
+ if (!baseURL)
96429
+ return null;
96430
+ const headers = {};
96431
+ if (parsed) {
96432
+ for (const [key, value] of Object.entries(parsed.headers)) {
96433
+ const interpolated = interpolateOpenCodeTemplate(value, input.env);
96434
+ if (interpolated)
96435
+ headers[key] = interpolated;
96436
+ }
96437
+ }
96438
+ const fromTemplate = parsed?.apiKeyTemplate ? interpolateOpenCodeTemplate(parsed.apiKeyTemplate, input.env) : "";
96439
+ const envKeyName = `${input.providerId.replace(/-/g, "_").toUpperCase()}_API_KEY`;
96440
+ const apiKey = fromTemplate || input.env[envKeyName]?.trim() || process.env[envKeyName]?.trim() || input.storedKey?.trim() || null;
96441
+ return { baseURL, apiKey, headers };
96442
+ }
96443
+ async function loadOpenCodeUserConfig() {
96444
+ for (const path of openCodeConfigPaths()) {
96445
+ try {
96446
+ const file2 = Bun.file(path);
96447
+ if (!await file2.exists())
96448
+ continue;
96449
+ return await file2.json();
96450
+ } catch {}
96451
+ }
96452
+ return null;
96453
+ }
96454
+
96239
96455
  // src/domains/providers/drivers/opencode.ts
96240
- var OPENCODE_FREE_MODEL_IDS = new Set(["big-pickle"]);
96241
96456
  var OPENCODE_DEFAULT_VISION_MODEL = "mimo-v2.5-free";
96242
- function isOpenCodeFreeModel(id) {
96243
- const normalized = id.trim().toLowerCase();
96244
- return OPENCODE_FREE_MODEL_IDS.has(normalized) || normalized.endsWith("-free");
96457
+ var OPENCODE_PROVIDER_LABELS = {
96458
+ "amazon-bedrock": "Amazon Bedrock",
96459
+ anthropic: "Anthropic",
96460
+ azure: "Azure OpenAI",
96461
+ "github-copilot": "GitHub Copilot",
96462
+ google: "Google",
96463
+ "google-vertex": "Google Vertex AI",
96464
+ groq: "Groq",
96465
+ litellm: "LiteLLM",
96466
+ ollama: "Ollama",
96467
+ openai: "OpenAI",
96468
+ opencode: "OpenCode Zen",
96469
+ "opencode-go": "OpenCode Go",
96470
+ openrouter: "OpenRouter",
96471
+ xai: "xAI",
96472
+ zenmux: "ZenMux"
96473
+ };
96474
+ function humanizeOpenCodeProvider(providerId) {
96475
+ const known = OPENCODE_PROVIDER_LABELS[providerId];
96476
+ if (known)
96477
+ return known;
96478
+ return providerId.split(/[-_]/).filter(Boolean).map((part) => {
96479
+ if (part.toLowerCase() === "ai")
96480
+ return "AI";
96481
+ return part.charAt(0).toUpperCase() + part.slice(1);
96482
+ }).join(" ");
96483
+ }
96484
+ function parseOpenCodeModelSlug(slug) {
96485
+ const trimmed = slug.trim();
96486
+ const sep = trimmed.indexOf("/");
96487
+ if (sep <= 0 || sep === trimmed.length - 1) {
96488
+ return { providerId: "opencode", modelId: trimmed };
96489
+ }
96490
+ return {
96491
+ providerId: trimmed.slice(0, sep),
96492
+ modelId: trimmed.slice(sep + 1)
96493
+ };
96494
+ }
96495
+ function isOpenCodeZenModel(slug) {
96496
+ return parseOpenCodeModelSlug(slug).providerId === "opencode";
96245
96497
  }
96246
96498
  function humanizeModelId(id) {
96247
96499
  return id.split(/[-_]/).filter(Boolean).map((part) => {
@@ -96252,11 +96504,13 @@ function humanizeModelId(id) {
96252
96504
  return part.charAt(0).toUpperCase() + part.slice(1);
96253
96505
  }).join(" ");
96254
96506
  }
96255
- function toOpenCodeModelEntry(id) {
96507
+ function toOpenCodeModelEntry(slug) {
96508
+ const trimmed = slug.trim();
96509
+ const { providerId, modelId } = parseOpenCodeModelSlug(trimmed);
96256
96510
  return {
96257
- id,
96258
- name: humanizeModelId(id),
96259
- tier: isOpenCodeFreeModel(id) ? "free" : "paid"
96511
+ id: trimmed,
96512
+ name: humanizeModelId(modelId),
96513
+ provider: humanizeOpenCodeProvider(providerId)
96260
96514
  };
96261
96515
  }
96262
96516
  function openCodeServerAuthHeaders(password) {
@@ -96270,18 +96524,14 @@ function resolveOpenCodeServerPassword(env) {
96270
96524
  return env.OPENCODE_SERVER_PASSWORD?.trim() || env.SERVER_PASSWORD?.trim() || process.env.OPENCODE_SERVER_PASSWORD?.trim() || process.env.SERVER_PASSWORD?.trim() || null;
96271
96525
  }
96272
96526
  function stripOpenCodeModelSlug(slug) {
96273
- const trimmed = slug.trim();
96274
- const sep = trimmed.indexOf("/");
96275
- if (sep <= 0 || sep === trimmed.length - 1)
96276
- return trimmed;
96277
- return trimmed.slice(sep + 1);
96527
+ return parseOpenCodeModelSlug(slug).modelId;
96278
96528
  }
96279
96529
  function parseModelsPayload(json3) {
96280
96530
  const models = [];
96281
96531
  if (json3 && typeof json3 === "object" && "data" in json3 && Array.isArray(json3.data)) {
96282
96532
  for (const item of json3.data) {
96283
96533
  if (item && typeof item === "object" && "id" in item && typeof item.id === "string") {
96284
- models.push(toOpenCodeModelEntry(stripOpenCodeModelSlug(item.id)));
96534
+ models.push(toOpenCodeModelEntry(item.id));
96285
96535
  }
96286
96536
  }
96287
96537
  }
@@ -96310,44 +96560,84 @@ async function listOpenCodeModelsFromCli(binaryPath) {
96310
96560
  const slug = line.trim();
96311
96561
  if (!slug || /\s/.test(slug))
96312
96562
  continue;
96313
- const id = stripOpenCodeModelSlug(slug);
96314
- if (!id || seen.has(id))
96563
+ if (seen.has(slug))
96315
96564
  continue;
96316
- seen.add(id);
96317
- models.push(toOpenCodeModelEntry(id));
96565
+ seen.add(slug);
96566
+ models.push(toOpenCodeModelEntry(slug));
96318
96567
  }
96319
96568
  return {
96320
96569
  models,
96321
96570
  detail: `${models.length} models from opencode CLI`
96322
96571
  };
96323
96572
  }
96573
+ async function resolveCompatibleOpenCodeVision(providerId, auth) {
96574
+ const config2 = await loadOpenCodeUserConfig();
96575
+ const storedKey = await readOpenCodeCliAuthKeyForProvider(providerId);
96576
+ return resolveOpenCodeCompatibleAuth({
96577
+ providerId,
96578
+ config: config2,
96579
+ env: auth.env,
96580
+ storedKey,
96581
+ overrideBaseUrl: auth.baseUrl
96582
+ });
96583
+ }
96324
96584
  var opencodeDriver = {
96325
96585
  kind: "opencode",
96326
96586
  label: "OpenCode",
96327
- description: "OpenCode Zen (OpenAI-compatible). Paste a Zen API key for vision runs.",
96587
+ description: "OpenCode Zen or LiteLLM (OpenAI-compatible). Paste a Zen API key for Zen; LiteLLM uses opencode.json.",
96328
96588
  defaultBinary: "opencode",
96329
96589
  authModes: ["cli", "api_key"],
96330
- envHints: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY", "OPENCODE_SERVER_PASSWORD"],
96331
- loginInstructions: "Paste a Zen API key from https://opencode.ai for vision. Local `opencode serve` is not OpenAI-compatible.",
96590
+ envHints: [
96591
+ "OPENCODE_API_KEY",
96592
+ "OPENCODE_ZEN_API_KEY",
96593
+ "OPENCODE_SERVER_PASSWORD",
96594
+ "LITELLM_API_KEY"
96595
+ ],
96596
+ loginInstructions: "Paste a Zen API key from https://opencode.ai for Zen vision. LiteLLM uses ~/.config/opencode/opencode.json plus CLI auth or LITELLM_API_KEY. Local `opencode serve` is not OpenAI-compatible.",
96332
96597
  capabilities: { vision: true },
96333
96598
  vision: createSdkVisionPort({
96334
96599
  label: "OpenCode",
96335
96600
  defaultModel: OPENCODE_DEFAULT_VISION_MODEL,
96336
96601
  createModel: async (auth, modelId) => {
96337
- const apiKey = await resolveOpenAiCompatibleKey(auth);
96338
- if (!apiKey) {
96339
- throw new AgentProviderError("OpenCode vision needs a Zen API key. Local `opencode serve` returns a web UI for /v1 (not chat completions). Paste a key from https://opencode.ai in Settings \u2192 Provider, or set OPENCODE_API_KEY.");
96602
+ if (isOpenCodeZenModel(modelId)) {
96603
+ const apiKey = await resolveOpenAiCompatibleKey(auth);
96604
+ if (!apiKey) {
96605
+ throw new AgentProviderError("OpenCode vision needs a Zen API key. Local `opencode serve` returns a web UI for /v1 (not chat completions). Paste a key from https://opencode.ai in Settings \u2192 Provider, or set OPENCODE_API_KEY.");
96606
+ }
96607
+ const baseURL = auth.baseUrl?.trim().replace(/\/$/, "") || "https://opencode.ai/zen/v1";
96608
+ return createOpenAI({
96609
+ apiKey,
96610
+ baseURL,
96611
+ name: "opencode",
96612
+ fetch: withOpenCodeRequestHooks({
96613
+ disableThinking: true,
96614
+ authHeaders: null
96615
+ })
96616
+ }).chat(stripOpenCodeModelSlug(modelId));
96617
+ }
96618
+ const { providerId } = parseOpenCodeModelSlug(modelId);
96619
+ const providerLabel = humanizeOpenCodeProvider(providerId);
96620
+ const compatible = await resolveCompatibleOpenCodeVision(providerId, auth);
96621
+ if (compatible?.apiKey) {
96622
+ return createOpenAI({
96623
+ apiKey: compatible.apiKey,
96624
+ baseURL: compatible.baseURL,
96625
+ name: providerId,
96626
+ fetch: withOpenCodeRequestHooks({
96627
+ disableThinking: false,
96628
+ stripResponseFormat: true,
96629
+ authHeaders: null,
96630
+ extraHeaders: Object.keys(compatible.headers).length > 0 ? compatible.headers : null
96631
+ })
96632
+ }).chat(stripOpenCodeModelSlug(modelId));
96340
96633
  }
96341
- const baseURL = auth.baseUrl?.trim().replace(/\/$/, "") || "https://opencode.ai/zen/v1";
96342
- return createOpenAI({
96343
- apiKey,
96344
- baseURL,
96345
- name: "opencode",
96346
- fetch: withOpenCodeRequestHooks({
96347
- disableThinking: true,
96348
- authHeaders: null
96349
- })
96350
- }).chat(modelId);
96634
+ if (compatible && !compatible.apiKey) {
96635
+ throw new AgentProviderError(`${providerLabel} vision needs an API key. OpenCode stores it in auth.json after login, or set ${providerId.replace(/-/g, "_").toUpperCase()}_API_KEY in Settings \u2192 Provider.`);
96636
+ }
96637
+ if (providerId === "litellm") {
96638
+ throw new AgentProviderError("Yoqa vision can use LiteLLM via OpenCode. Add provider.litellm.options.baseURL in ~/.config/opencode/opencode.json, then pick litellm/\u2026 in Settings \u2192 Provider.");
96639
+ }
96640
+ throw new AgentProviderError(`Yoqa vision uses OpenCode Zen or LiteLLM. "${modelId}" is a ${providerLabel} model. Pick an OpenCode Zen or LiteLLM model in Settings \u2192 Provider.`);
96351
96641
  }
96352
96642
  }),
96353
96643
  async probe(binaryPath) {
@@ -96438,6 +96728,10 @@ var opencodeDriver = {
96438
96728
  async listModels(input) {
96439
96729
  const key = resolveOpenCodeKey(input);
96440
96730
  const serverUrl = input.serverUrl?.trim() || null;
96731
+ const cli = await listOpenCodeModelsFromCli(input.binaryPath);
96732
+ if (cli.models.length > 0) {
96733
+ return { models: cli.models, message: cli.detail };
96734
+ }
96441
96735
  if (serverUrl) {
96442
96736
  try {
96443
96737
  const password = resolveOpenCodeServerPassword(input.env);
@@ -96463,14 +96757,10 @@ var opencodeDriver = {
96463
96757
  label: "OpenCode"
96464
96758
  });
96465
96759
  return {
96466
- models: (result.models ?? []).map((id) => toOpenCodeModelEntry(stripOpenCodeModelSlug(id))),
96760
+ models: (result.models ?? []).map((id) => toOpenCodeModelEntry(id)),
96467
96761
  message: result.ok ? `${result.models?.length ?? 0} models available` : result.message
96468
96762
  };
96469
96763
  }
96470
- const cli = await listOpenCodeModelsFromCli(input.binaryPath);
96471
- if (cli.models.length > 0) {
96472
- return { models: cli.models, message: cli.detail };
96473
- }
96474
96764
  return {
96475
96765
  models: [],
96476
96766
  message: "Add OPENCODE_API_KEY, a server URL, or install the opencode CLI to list models"
@@ -96543,10 +96833,10 @@ import {
96543
96833
  randomBytes
96544
96834
  } from "crypto";
96545
96835
  import { chmodSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, writeFileSync } from "fs";
96546
- import { homedir as homedir10 } from "os";
96547
- import { dirname as dirname3, join as join13 } from "path";
96548
- var YOQA_ROOT5 = join13(homedir10(), ".yoqa");
96549
- var KEY_PATH = join13(YOQA_ROOT5, ".provider-key");
96836
+ import { homedir as homedir11 } from "os";
96837
+ import { dirname as dirname3, join as join14 } from "path";
96838
+ var YOQA_ROOT5 = join14(homedir11(), ".yoqa");
96839
+ var KEY_PATH = join14(YOQA_ROOT5, ".provider-key");
96550
96840
  var ALGORITHM = "aes-256-gcm";
96551
96841
  var IV_LENGTH = 12;
96552
96842
  var KEY_LENGTH = 32;
@@ -98107,6 +98397,7 @@ async function executeRun(runId) {
98107
98397
  return;
98108
98398
  registerControl(runId);
98109
98399
  let session = null;
98400
+ let sharedSession = true;
98110
98401
  try {
98111
98402
  if (isAborted2(runId)) {
98112
98403
  await persistCancelled(runId);
@@ -98147,14 +98438,17 @@ async function executeRun(runId) {
98147
98438
  }
98148
98439
  }
98149
98440
  const firstCase = run.tests[0] ? await getCase(run.tests[0].caseId) : null;
98150
- session = await createDeviceSession({
98151
- platform: run.platform,
98441
+ const acquired = await acquireSessionForRun({
98442
+ runId,
98152
98443
  deviceId: run.deviceId,
98444
+ platform: run.platform,
98153
98445
  appCaps: app.capabilities,
98154
98446
  caseCaps: firstCase?.capabilities ?? [],
98155
98447
  bundleId: app.iosBundleId || undefined,
98156
98448
  appPackage: app.androidApplicationId || undefined
98157
98449
  });
98450
+ session = acquired.session;
98451
+ sharedSession = acquired.shared;
98158
98452
  if (isAborted2(runId)) {
98159
98453
  await persistCancelled(runId);
98160
98454
  return;
@@ -98229,14 +98523,15 @@ async function executeRun(runId) {
98229
98523
  }
98230
98524
  } finally {
98231
98525
  if (session) {
98232
- await session.quit().catch(() => {
98233
- return;
98234
- });
98526
+ await releaseSessionFromRun(runId, session, sharedSession);
98235
98527
  }
98236
98528
  clearControl(runId);
98237
98529
  }
98238
98530
  }
98239
98531
  async function createRun(input) {
98532
+ if (runControls.size > 0) {
98533
+ throw new RunValidationError("A run is already in progress. Cancel it before starting another.");
98534
+ }
98240
98535
  const uniqueCaseIds = [...new Set(input.caseIds)];
98241
98536
  if (uniqueCaseIds.length === 0) {
98242
98537
  throw new RunValidationError("At least one case id is required");
@@ -98594,6 +98889,12 @@ import { mkdir as mkdir4, rename as rename2 } from "fs/promises";
98594
98889
  import { dirname as dirname4 } from "path";
98595
98890
  function sessionErrorResponse(error40) {
98596
98891
  const message = error40 instanceof Error ? error40.message : String(error40);
98892
+ if (error40 instanceof SessionBusyError) {
98893
+ return {
98894
+ status: 409,
98895
+ body: { error: error40.message }
98896
+ };
98897
+ }
98597
98898
  if (isMissingAppiumSessionError(error40)) {
98598
98899
  abandonActiveSession();
98599
98900
  return {
@@ -98623,6 +98924,9 @@ function createSessionRoutes() {
98623
98924
  const info = await connectDevice(parsed.data);
98624
98925
  return c.json(activeDeviceResponseSchema.parse(info));
98625
98926
  } catch (error40) {
98927
+ const mapped = sessionErrorResponse(error40);
98928
+ if (mapped)
98929
+ return c.json(mapped.body, mapped.status);
98626
98930
  const message = error40 instanceof Error ? error40.message : String(error40);
98627
98931
  return c.json({ error: "Failed to connect device", detail: message }, 500);
98628
98932
  }
@@ -98635,11 +98939,19 @@ function createSessionRoutes() {
98635
98939
  return c.json(activeDeviceResponseSchema.parse(info));
98636
98940
  });
98637
98941
  app.post("/devices/disconnect", async (c) => {
98638
- const info = await disconnectDevice();
98639
- if (!info) {
98640
- return c.json({ error: "No active device session" }, 404);
98942
+ try {
98943
+ const info = await disconnectDevice();
98944
+ if (!info) {
98945
+ return c.json({ error: "No active device session" }, 404);
98946
+ }
98947
+ return c.json(activeDeviceResponseSchema.parse(info));
98948
+ } catch (error40) {
98949
+ const mapped = sessionErrorResponse(error40);
98950
+ if (mapped)
98951
+ return c.json(mapped.body, mapped.status);
98952
+ const message = error40 instanceof Error ? error40.message : String(error40);
98953
+ return c.json({ error: "Failed to disconnect device", detail: message }, 500);
98641
98954
  }
98642
- return c.json(activeDeviceResponseSchema.parse(info));
98643
98955
  });
98644
98956
  app.get("/screen", async (c) => {
98645
98957
  try {
@@ -98760,6 +99072,11 @@ function createSessionRoutes() {
98760
99072
  }
98761
99073
  try {
98762
99074
  const { session } = requireActiveSession();
99075
+ if (isActiveSessionHeldByRun()) {
99076
+ return c.json({
99077
+ error: "A run is using this device session. Cancel the run to interact manually."
99078
+ }, 409);
99079
+ }
98763
99080
  const result = await performAction(session, parsed.data);
98764
99081
  return c.json(actionResponseSchema.parse(result));
98765
99082
  } catch (error40) {
@@ -98888,6 +99205,13 @@ var controlWebSocket = {
98888
99205
  return;
98889
99206
  }
98890
99207
  const msg = result.data;
99208
+ if (isActiveSessionHeldByRun()) {
99209
+ ws.send(JSON.stringify({
99210
+ ok: false,
99211
+ error: "A run is using this device session. Cancel the run to interact manually."
99212
+ }));
99213
+ return;
99214
+ }
98891
99215
  try {
98892
99216
  await active2.session.pointerEvent(msg.phase, msg.x, msg.y, msg.seq);
98893
99217
  if (msg.phase === "end" || msg.phase === "begin") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoqa/runner",
3
- "version": "0.3.18",
3
+ "version": "0.3.20",
4
4
  "description": "Yoqa local runner — Appium + catalog HTTP API (Bun). Start with yoqa serve or the yoqa-runner bin.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",