@runuai/host 0.8.35 → 0.8.36

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.
@@ -270,21 +270,24 @@ export function startMcpGateway(): void {
270
270
 
271
271
  // --- task container wiring (ADR-057 task-up writers) -------------------------
272
272
 
273
- /** Idempotent node -e merge of entries (argv[2]) into an mcpServers file
274
- * (argv[1]) — reused for Claude's /workspace/.mcp.json and Cursor's
275
- * ~/.cursor/mcp.json. Creates the parent dir when missing. */
273
+ /** Idempotent node -e merge of entries (argv[2]) into a JSON file (argv[1])
274
+ * under a top-level object key (argv[3], default "mcpServers") — reused for
275
+ * Claude's /workspace/.mcp.json + Cursor's ~/.cursor/mcp.json (mcpServers)
276
+ * and OpenCode's ~/.config/opencode/opencode.json (mcp). Creates the parent
277
+ * dir when missing. */
276
278
  const MERGE_MCP_JSON = `
277
279
  const fs = require("fs");
278
280
  const path = require("path");
279
281
  const p = process.argv[1];
282
+ const key = process.argv[3] || "mcpServers";
280
283
  try { fs.mkdirSync(path.dirname(p), { recursive: true }); } catch {}
281
284
  let j = {};
282
285
  let existed = true;
283
286
  try { j = JSON.parse(fs.readFileSync(p, "utf8")); } catch { existed = false; }
284
- j.mcpServers = j.mcpServers || {};
287
+ j[key] = j[key] || {};
285
288
  let changed = false;
286
289
  for (const [k, v] of Object.entries(JSON.parse(process.argv[2]))) {
287
- if (JSON.stringify(j.mcpServers[k]) !== JSON.stringify(v)) { j.mcpServers[k] = v; changed = true; }
290
+ if (JSON.stringify(j[key][k]) !== JSON.stringify(v)) { j[key][k] = v; changed = true; }
288
291
  }
289
292
  if (changed || !existed) fs.writeFileSync(p, JSON.stringify(j, null, 2) + "\\n");
290
293
  `.trim();
@@ -302,6 +305,9 @@ function shellQuote(value: string): string {
302
305
  * - Cursor — ~/.cursor/mcp.json ({ url } form; the adapter passes
303
306
  * `--approve-mcps`).
304
307
  * - Grok — `grok mcp add` writes ~/.grok/config.toml (idempotent per slug).
308
+ * - OpenCode — ~/.config/opencode/opencode.json `mcp` block (type:"remote",
309
+ * oauth:false so it treats the gateway URL as a plain server; the path
310
+ * token is the auth). Written only when the task has connections.
305
311
  * Safe to re-run every ensure. `engineKinds` is the roster's agent kinds.
306
312
  */
307
313
  export async function setupMcpTaskConfig(
@@ -321,10 +327,20 @@ export async function setupMcpTaskConfig(
321
327
 
322
328
  const claudeEntries: Record<string, unknown> = {};
323
329
  const cursorEntries: Record<string, unknown> = {};
330
+ const opencodeEntries: Record<string, unknown> = {};
324
331
  for (const c of connections) {
325
332
  claudeEntries[c.slug] = { type: "http", url: urlFor(c.slug) };
326
333
  // Cursor's mcp.json wants a bare { url } for remote (http/sse) servers.
327
334
  cursorEntries[c.slug] = { url: urlFor(c.slug) };
335
+ // OpenCode: type:"remote" + oauth:false — the gateway needs no client
336
+ // auth (the path token is the secret), so suppress OpenCode's own OAuth
337
+ // flow that would otherwise fire on a gateway 401 and hang headless.
338
+ opencodeEntries[c.slug] = {
339
+ type: "remote",
340
+ url: urlFor(c.slug),
341
+ enabled: true,
342
+ oauth: false,
343
+ };
328
344
  }
329
345
  const steps = [
330
346
  "mkdir -p /workspace/.claude",
@@ -360,6 +376,15 @@ export async function setupMcpTaskConfig(
360
376
  )} -t http -s user >/dev/null 2>&1 || true`,
361
377
  )
362
378
  : []),
379
+ // OpenCode: global config `mcp` block (auto-discovered regardless of the
380
+ // exec cwd, and independent of any per-account OPENCODE_DATA_DIR).
381
+ ...(has("opencode") && connections.length > 0
382
+ ? [
383
+ `node -e ${shellQuote(MERGE_MCP_JSON)} /home/node/.config/opencode/opencode.json ${shellQuote(
384
+ JSON.stringify(opencodeEntries),
385
+ )} mcp`,
386
+ ]
387
+ : []),
363
388
  ].join(" && ");
364
389
  const result = await dockerCli(
365
390
  ["exec", containerName, "sh", "-lc", steps],
@@ -52,6 +52,13 @@ import {
52
52
  } from "./agent-cli";
53
53
  import { setupBrowserTesting } from "./browser-testing";
54
54
  import { injectCodexIntoContainer } from "./codex-auth";
55
+ import {
56
+ cooldownEngineAccount,
57
+ noteEngineAccountUsed,
58
+ pickEngineAccount,
59
+ provisionEngineAccounts,
60
+ resolveEngineAccounts,
61
+ } from "./engine-accounts";
55
62
  import { clearTaskGatewayAcl, setupMcpTaskConfig } from "./mcp-gateway";
56
63
  import { env } from "./env";
57
64
  import type {
@@ -112,10 +119,21 @@ interface Channel {
112
119
  /** Agents with a reconcile-spawn in flight (ADR-049 mid-task adds) — guards
113
120
  * against a concurrent ensure double-spawning the same new agent. */
114
121
  spawning: Set<string>;
122
+ /** ADR-076: which engine account each agent's live session is bound to, so a
123
+ * rate-limit can cool THAT account and rotate off it. */
124
+ accountByAgent: Map<string, string>;
125
+ /** ADR-076: the last prompt delivered to each agent, re-sent after an
126
+ * account rotation so the interrupted turn resumes on the new account. */
127
+ lastPrompt: Map<string, string>;
128
+ /** ADR-076: per-agent account-rotation budget (reset on a successful turn) —
129
+ * bounds a rate-limit storm across accounts, mirroring the respawn budget. */
130
+ rotations: Map<string, number>;
115
131
  }
116
132
 
117
133
  /** Hard cap on automatic respawns per agent per channel lifetime. */
118
134
  const MAX_RESPAWNS_PER_AGENT = 5;
135
+ /** ADR-076: hard cap on account rotations per agent between successful turns. */
136
+ const MAX_ROTATIONS_PER_AGENT = 6;
119
137
  /** A burned respawn budget resets after this quiet period (see reconcile). */
120
138
  const RESPAWN_COOLDOWN_MS = 10 * 60_000;
121
139
 
@@ -266,6 +284,9 @@ class Orchestrator {
266
284
  sharedFiles: spec.sharedFiles ?? "ro",
267
285
  mcpConnections: spec.mcpConnections ?? [],
268
286
  spawning: new Set(),
287
+ accountByAgent: new Map(),
288
+ lastPrompt: new Map(),
289
+ rotations: new Map(),
269
290
  };
270
291
  this.channels.set(taskId, channel);
271
292
  return channel;
@@ -410,18 +431,28 @@ class Orchestrator {
410
431
  const cliSecret = loadTaskCliSecret(channel.taskId);
411
432
  writeAgentCli(channel.taskId, channel.roster, apiUrl);
412
433
 
434
+ // ADR-076: extra config-dir accounts for the newly-spawning agents.
435
+ await provisionEngineAccounts(
436
+ channel.containerName,
437
+ missing.map((a) => a.kind),
438
+ );
439
+
413
440
  for (const agent of missing) {
414
441
  const session = await this.factory.create({
415
442
  taskId: channel.taskId,
416
443
  agent,
417
444
  containerName: channel.containerName,
418
445
  systemPreamble: channel.preambles.get(agent.id) ?? "",
419
- agentEnv: agentCliEnv(
420
- channel.taskId,
446
+ agentEnv: this.accountAgentEnv(
447
+ channel,
421
448
  agent,
422
- task.ownerUserId,
423
- apiUrl,
424
- cliSecret,
449
+ agentCliEnv(
450
+ channel.taskId,
451
+ agent,
452
+ task.ownerUserId,
453
+ apiUrl,
454
+ cliSecret,
455
+ ),
425
456
  ),
426
457
  });
427
458
  channel.sessions.set(agent.id, session);
@@ -434,6 +465,28 @@ class Orchestrator {
434
465
  }
435
466
  }
436
467
 
468
+ /**
469
+ * ADR-076: per-agent exec env = the uai token base + the SELECTED engine
470
+ * account's env. Picks the least-recently-used, non-cooling account for the
471
+ * agent's kind and records it so a rate-limit can rotate off it. With zero
472
+ * configured accounts (unknown kind) this is a no-op — `base` passes through
473
+ * unchanged, so behavior is identical to before.
474
+ */
475
+ private accountAgentEnv(
476
+ channel: Channel,
477
+ agent: RosterAgent,
478
+ base: Record<string, string>,
479
+ ): Record<string, string> {
480
+ const account = pickEngineAccount(agent.kind);
481
+ if (!account) {
482
+ channel.accountByAgent.delete(agent.id);
483
+ return base;
484
+ }
485
+ channel.accountByAgent.set(agent.id, account.id);
486
+ noteEngineAccountUsed(account.id);
487
+ return { ...base, ...account.execEnv };
488
+ }
489
+
437
490
  private async startSessions(channel: Channel): Promise<boolean> {
438
491
  const task = getHostTask(channel.taskId);
439
492
  if (!task || task.statusMirror !== "running") return false;
@@ -490,13 +543,25 @@ class Orchestrator {
490
543
  const cliSecret = loadTaskCliSecret(channel.taskId);
491
544
  writeAgentCli(channel.taskId, channel.roster, apiUrl);
492
545
 
546
+ // ADR-076: copy EXTRA config-dir engine accounts into their per-account
547
+ // container dirs (chowned) before sessions spawn — the default account's
548
+ // dir is already handled by task-up.sh. Best-effort per account.
549
+ await provisionEngineAccounts(
550
+ channel.containerName,
551
+ channel.roster.map((a) => a.kind),
552
+ );
553
+
493
554
  for (const agent of channel.roster) {
494
555
  const session = await this.factory.create({
495
556
  taskId: channel.taskId,
496
557
  agent,
497
558
  containerName: channel.containerName,
498
559
  systemPreamble: channel.preambles.get(agent.id) ?? "",
499
- agentEnv: agentCliEnv(channel.taskId, agent, task.ownerUserId, apiUrl, cliSecret),
560
+ agentEnv: this.accountAgentEnv(
561
+ channel,
562
+ agent,
563
+ agentCliEnv(channel.taskId, agent, task.ownerUserId, apiUrl, cliSecret),
564
+ ),
500
565
  });
501
566
  channel.sessions.set(agent.id, session);
502
567
  session.onEvent((event) => {
@@ -553,7 +618,11 @@ class Orchestrator {
553
618
  const session = channel.sessions.get(agentId);
554
619
  if (!session) return { ok: false, error: `no such agent: ${agentId}` };
555
620
 
556
- void session.send(rewriteAttachmentRefs(text));
621
+ const prompt = rewriteAttachmentRefs(text);
622
+ // ADR-076: remember the in-flight prompt so an account rotation on a
623
+ // rate-limit can re-deliver it to the fresh session.
624
+ channel.lastPrompt.set(agentId, prompt);
625
+ void session.send(prompt);
557
626
  return { ok: true };
558
627
  }
559
628
 
@@ -674,6 +743,16 @@ class Orchestrator {
674
743
  // session so the user keeps working instead of staring at a dead
675
744
  // chat.
676
745
  const agent = channel.roster.find((a) => a.id === agentId);
746
+ // ADR-076: a rate-limit/usage-cap error fails over to another account
747
+ // for this kind (cool the current one, recreate the session on the
748
+ // next, re-deliver the in-flight prompt). No-op with <2 accounts.
749
+ if (
750
+ event.retryable === "rate_limit" &&
751
+ agent &&
752
+ (await this.rotateAccount(channel, agentId, agent))
753
+ ) {
754
+ break;
755
+ }
677
756
  const isClaude = agent?.kind === "claude";
678
757
  const configMissing = CLAUDE_CONFIG_MISSING_PATTERNS.some((re) =>
679
758
  re.test(event.message),
@@ -710,6 +789,9 @@ class Orchestrator {
710
789
  // the cloud discards the buffer instead of handing it to peers.
711
790
  const aborted = channel.interrupted.delete(agentId);
712
791
  channel.openTurns.delete(agentId);
792
+ // ADR-076: a turn that completed means the current account is healthy —
793
+ // reset its rotation budget so a LATER rate-limit gets a fresh failover.
794
+ channel.rotations.delete(agentId);
713
795
  this.emitHost({
714
796
  kind: "agent.turn_complete",
715
797
  taskId: channel.taskId,
@@ -793,6 +875,85 @@ class Orchestrator {
793
875
  });
794
876
  }
795
877
 
878
+ /**
879
+ * ADR-076: fail over an agent to another account for its kind after a
880
+ * rate-limit. Cools the current account, recreates the session bound to the
881
+ * next healthy account (provisioning its config-dir first), and re-delivers
882
+ * the in-flight prompt. Bounded by MAX_ROTATIONS_PER_AGENT. Returns true when
883
+ * it rotated (the error is handled); false to let normal error handling run
884
+ * (no second account, budget exhausted, or the container is gone).
885
+ */
886
+ private async rotateAccount(
887
+ channel: Channel,
888
+ agentId: string,
889
+ agent: RosterAgent,
890
+ ): Promise<boolean> {
891
+ if (resolveEngineAccounts(agent.kind).length < 2) return false;
892
+
893
+ const budget = (channel.rotations.get(agentId) ?? 0) + 1;
894
+ channel.rotations.set(agentId, budget);
895
+ if (budget > MAX_ROTATIONS_PER_AGENT) return false;
896
+
897
+ const current = channel.accountByAgent.get(agentId);
898
+ if (current) cooldownEngineAccount(current);
899
+ const next = pickEngineAccount(agent.kind, {
900
+ exclude: current ? new Set([current]) : undefined,
901
+ });
902
+ if (!next) return false;
903
+
904
+ const task = getHostTask(channel.taskId);
905
+ if (!task || task.statusMirror !== "running") return false;
906
+
907
+ // Tear the rate-limited session down (its `closed` flag suppresses its own
908
+ // exit, so no respawn-budget noise races this rotation).
909
+ const old = channel.sessions.get(agentId);
910
+ channel.sessions.delete(agentId);
911
+ if (old) {
912
+ try {
913
+ await old.close();
914
+ } catch {
915
+ /* already gone — close is idempotent for our adapters */
916
+ }
917
+ }
918
+
919
+ // Extra config-dir accounts need their dir copied in before use.
920
+ await provisionEngineAccounts(channel.containerName, [agent.kind]);
921
+
922
+ const apiUrl = apiUrlFromCloudUrl(env.UAI_CLOUD_URL);
923
+ const cliSecret = loadTaskCliSecret(channel.taskId);
924
+ const base = agentCliEnv(
925
+ channel.taskId,
926
+ agent,
927
+ task.ownerUserId,
928
+ apiUrl,
929
+ cliSecret,
930
+ );
931
+ channel.accountByAgent.set(agentId, next.id);
932
+ noteEngineAccountUsed(next.id);
933
+
934
+ const session = await this.factory.create({
935
+ taskId: channel.taskId,
936
+ agent,
937
+ containerName: channel.containerName,
938
+ systemPreamble: channel.preambles.get(agentId) ?? "",
939
+ agentEnv: { ...base, ...next.execEnv },
940
+ });
941
+ channel.sessions.set(agentId, session);
942
+ session.onEvent((event) => {
943
+ void this.handleAgentEvent(channel, agentId, event);
944
+ });
945
+
946
+ this.emitSystemNote(
947
+ channel.taskId,
948
+ `${agentId}: hit a rate limit — switched to another ${agent.kind} account and retried.`,
949
+ );
950
+
951
+ // Re-deliver the in-flight prompt so the interrupted turn resumes.
952
+ const prompt = channel.lastPrompt.get(agentId);
953
+ if (prompt) void session.send(prompt);
954
+ return true;
955
+ }
956
+
796
957
  // -- permission resolution ------------------------------------------------
797
958
 
798
959
  async resolvePermission(
@@ -81,11 +81,13 @@ export function configuredOptionalEngines(): {
81
81
  kimi: boolean;
82
82
  grok: boolean;
83
83
  cursor: boolean;
84
+ opencode: boolean;
84
85
  } {
85
86
  return {
86
87
  kimi: detectEngine("kimi"),
87
88
  grok: detectEngine("grok"),
88
89
  cursor: detectEngine("cursor"),
90
+ opencode: detectEngine("opencode"),
89
91
  };
90
92
  }
91
93
 
@@ -397,9 +399,11 @@ async function ensureStandardImageInner(): Promise<StandardImageResult> {
397
399
  `INSTALL_GROK=${engines.grok ? 1 : 0}`,
398
400
  "--build-arg",
399
401
  `INSTALL_CURSOR=${engines.cursor ? 1 : 0}`,
402
+ "--build-arg",
403
+ `INSTALL_OPENCODE=${engines.opencode ? 1 : 0}`,
400
404
  ];
401
405
  const contextHash = await hashBuildContext(
402
- `kimi=${engines.kimi ? 1 : 0};grok=${engines.grok ? 1 : 0};cursor=${engines.cursor ? 1 : 0}`,
406
+ `kimi=${engines.kimi ? 1 : 0};grok=${engines.grok ? 1 : 0};cursor=${engines.cursor ? 1 : 0};opencode=${engines.opencode ? 1 : 0}`,
403
407
  );
404
408
  const inspect = await run("docker", [
405
409
  "image",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.8.35",
3
+ "version": "0.8.36",
4
4
  "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Diogo Perillo <diogo.perillo@gmail.com>",
@@ -554,6 +554,23 @@ done
554
554
  docker exec -u root "$app_container" \
555
555
  chown -R node:node /home/node/.grok >/dev/null 2>&1 || true
556
556
 
557
+ # Copy OpenCode provider auth into a task-private /home/node/.local/share/opencode
558
+ # (ADR-077), same per-task-writable-copy pattern as Codex. The Linux `opencode`
559
+ # binary is baked into the image; here we copy the arch-independent auth+config.
560
+ # ADR-076 EXTRA accounts (isolated dirs) are copied later from the host-agent
561
+ # (provisionEngineAccounts) since they live in the sealed DB, not on disk here.
562
+ docker exec -u root "$app_container" \
563
+ mkdir -p /home/node/.local/share/opencode >/dev/null 2>&1 || true
564
+ for oc_item in auth.json config.json; do
565
+ if [ -e "$UAI_OWNER_HOME/.local/share/opencode/$oc_item" ]; then
566
+ docker cp "$UAI_OWNER_HOME/.local/share/opencode/$oc_item" \
567
+ "$app_container":/home/node/.local/share/opencode/ >/dev/null 2>&1 \
568
+ || log "warning: docker cp of opencode/$oc_item failed; opencode may need re-login"
569
+ fi
570
+ done
571
+ docker exec -u root "$app_container" \
572
+ chown -R node:node /home/node/.local/share/opencode >/dev/null 2>&1 || true
573
+
557
574
  # Copy the same resolved SSH identity (task creator's per-user key when present,
558
575
  # else the operator identity — see above) into the container, so the agent signs
559
576
  # + pushes with the key whose .pub the user registered on GitHub. ADR-027 drops
package/src/ui/server.ts CHANGED
@@ -36,6 +36,11 @@ import {
36
36
  engineStatuses,
37
37
  isEngineKind,
38
38
  } from "../../lib/engines";
39
+ import {
40
+ addEngineAccount,
41
+ listEngineAccounts,
42
+ removeEngineAccount,
43
+ } from "../../lib/engine-accounts";
39
44
  import { ensureStandardImage } from "../../lib/standard-image";
40
45
  import {
41
46
  CloudResponse,
@@ -157,6 +162,10 @@ async function handle(
157
162
  return await handleEngineInstall(req, res);
158
163
  case "/api/engines/disconnect":
159
164
  return await handleEngineDisconnect(req, res, opts);
165
+ case "/api/engines/accounts/add":
166
+ return await handleEngineAccountAdd(req, res, opts);
167
+ case "/api/engines/accounts/remove":
168
+ return await handleEngineAccountRemove(req, res, opts);
160
169
  case "/api/tasks/stop":
161
170
  return await handleTaskStop(req, res, opts);
162
171
  }
@@ -198,11 +207,19 @@ async function handle(
198
207
 
199
208
  // --- engines ----------------------------------------------------------------
200
209
 
210
+ /** Engine kinds that participate in the multi-account model (ADR-076). */
211
+ const ACCOUNT_KINDS = ["claude", "codex", "opencode"] as const;
212
+
201
213
  async function enginesBody(): Promise<EnginesResponse> {
214
+ const accounts: Record<string, ReturnType<typeof listEngineAccounts>> = {};
215
+ for (const kind of ACCOUNT_KINDS) {
216
+ accounts[kind] = listEngineAccounts(kind);
217
+ }
202
218
  return {
203
219
  catalog: engineCatalog(),
204
220
  statuses: engineStatuses(),
205
221
  cli: await engineCliStatuses(),
222
+ accounts,
206
223
  };
207
224
  }
208
225
 
@@ -311,6 +328,54 @@ async function handleEngineDisconnect(
311
328
  return sendJson(res, EngineOpResponse, { ok: true });
312
329
  }
313
330
 
331
+ /**
332
+ * POST /api/engines/accounts/add `{kind, label, apiKey?|token?}` → register an
333
+ * EXTRA account (ADR-076) by pasting a token / API key, then re-advertise (the
334
+ * kind may not have been available before) + rebuild the image if needed.
335
+ */
336
+ async function handleEngineAccountAdd(
337
+ req: IncomingMessage,
338
+ res: ServerResponse,
339
+ opts: UiServerOptions,
340
+ ): Promise<void> {
341
+ const body = await readJsonBody(req);
342
+ const kind = body?.kind;
343
+ if (!isEngineKind(kind)) {
344
+ return sendError(res, 400, "unknown or missing engine kind");
345
+ }
346
+ const label = typeof body?.label === "string" ? body.label : "";
347
+ const apiKey = typeof body?.apiKey === "string" ? body.apiKey : undefined;
348
+ const token = typeof body?.token === "string" ? body.token : undefined;
349
+ const result = addEngineAccount(kind, label, { apiKey, token });
350
+ if (result.ok) {
351
+ opts.readvertise?.();
352
+ void ensureStandardImage();
353
+ }
354
+ return sendJson(res, EngineOpResponse, {
355
+ ok: result.ok,
356
+ message: result.message,
357
+ });
358
+ }
359
+
360
+ /** POST /api/engines/accounts/remove `{id}` → forget an EXTRA account. */
361
+ async function handleEngineAccountRemove(
362
+ req: IncomingMessage,
363
+ res: ServerResponse,
364
+ opts: UiServerOptions,
365
+ ): Promise<void> {
366
+ const body = await readJsonBody(req);
367
+ const id = body?.id;
368
+ if (typeof id !== "string" || id.length === 0) {
369
+ return sendError(res, 400, "missing account id");
370
+ }
371
+ const result = removeEngineAccount(id);
372
+ if (result.ok) opts.readvertise?.();
373
+ return sendJson(res, EngineOpResponse, {
374
+ ok: result.ok,
375
+ message: result.message,
376
+ });
377
+ }
378
+
314
379
  /** POST /api/tasks/stop `{taskId}` → compose-stop the task (resumable). */
315
380
  async function handleTaskStop(
316
381
  req: IncomingMessage,
package/src/ui/types.ts CHANGED
@@ -76,9 +76,14 @@ export type UsersResponse = z.infer<typeof UsersResponse>;
76
76
 
77
77
  // GET /api/engines — the engine catalog + which are connected on this host.
78
78
  export const EngineCatalogEntry = z.object({
79
- kind: z.enum(["claude", "codex", "kimi", "grok", "cursor"]),
79
+ kind: z.enum(["claude", "codex", "kimi", "grok", "cursor", "opencode"]),
80
80
  label: z.string(),
81
- authMode: z.enum(["token-command", "login-command", "api-key"]),
81
+ authMode: z.enum([
82
+ "token-command",
83
+ "login-command",
84
+ "api-key",
85
+ "external-login",
86
+ ]),
82
87
  notes: z.string().nullable(),
83
88
  getKeyUrl: z.string().nullable(),
84
89
  // Pasted-API-key alternative to the login/token flow (null = not offered).
@@ -86,13 +91,28 @@ export const EngineCatalogEntry = z.object({
86
91
  apiKeyUrl: z.string().nullable(),
87
92
  // Install command shown when the CLI is missing (null = nothing to install).
88
93
  installHint: z.string().nullable(),
94
+ // external-login: the command the owner runs in their terminal (null else).
95
+ loginCmd: z.string().nullable(),
89
96
  });
90
97
  export type EngineCatalogEntry = z.infer<typeof EngineCatalogEntry>;
91
98
 
99
+ // One engine account (ADR-076) — secret-free descriptor for the host UI.
100
+ export const EngineAccountInfo = z.object({
101
+ id: z.string(),
102
+ kind: z.string(),
103
+ label: z.string(),
104
+ authKind: z.enum(["env", "config-dir"]),
105
+ isDefault: z.boolean(),
106
+ });
107
+ export type EngineAccountInfo = z.infer<typeof EngineAccountInfo>;
108
+
92
109
  export const EnginesResponse = z.object({
93
110
  catalog: z.array(EngineCatalogEntry),
94
111
  statuses: z.record(z.boolean()), // kind → connected
95
112
  cli: z.record(z.boolean()), // kind → CLI resolvable on this host
113
+ // kind → its accounts (default + extras), ADR-076. Kinds with no account
114
+ // model (kimi/grok/cursor) are absent.
115
+ accounts: z.record(z.array(EngineAccountInfo)),
96
116
  });
97
117
  export type EnginesResponse = z.infer<typeof EnginesResponse>;
98
118