@runuai/host 0.9.163 → 0.9.165

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.
@@ -34,7 +34,7 @@ ENV ASDF_VERSION=v0.14.1
34
34
  # freshness is requalified before any of them moves.
35
35
  ENV UAI_CODEX_CLI_VERSION=0.157.0
36
36
  ENV UAI_CURSOR_AGENT_VERSION=2026.08.25-3e8eec8
37
- ENV UAI_GROK_CLI_VERSION=1.0.5
37
+ ENV UAI_GROK_CLI_VERSION=1.0.67
38
38
 
39
39
  # ---------------------------------------------------------------------------
40
40
  # 1. System packages.
package/lib/agent-cli.ts CHANGED
@@ -321,6 +321,7 @@ async function main() {
321
321
  const handleOf = (v) => (typeof v === "string" && v.startsWith("@") ? v.slice(1) : v);
322
322
  const launch = (a) => a.kind + (a.model ? "/" + a.model : "") + (a.effort ? "/" + a.effort : "");
323
323
  if (action === "list") { out(await api("GET", "/api/agent/crew")); return; }
324
+ if (action === "status") { out(await api("GET", "/api/agent/crew-status")); return; }
324
325
  if (action === "add") {
325
326
  const personaId = pos[0];
326
327
  if (!personaId || !flags.why) {
@@ -332,14 +333,15 @@ async function main() {
332
333
  if (flags.engine) body.kind = flags.engine;
333
334
  if (flags.model) body.model = flags.model;
334
335
  if (flags.effort) body.effort = flags.effort;
336
+ if (flags.account) body.account = flags.account === "default" ? null : flags.account;
335
337
  const r = await api("POST", "/api/agent/crew", body);
336
338
  out("added @" + r.agent.id + " (" + r.agent.label + ") on " + launch(r.agent) + ". It stays idle until you dispatch work to it.");
337
339
  return;
338
340
  }
339
341
  if (action === "model") {
340
342
  const id = handleOf(pos[0] || "");
341
- if (!id || !flags.why || !(flags.model || flags.engine || flags.effort)) {
342
- console.error('uai: crew model needs @agent, at least one of --engine, --model and --effort, and --why "<reason>"');
343
+ if (!id || !flags.why || !(flags.model || flags.engine || flags.effort || flags.account)) {
344
+ console.error('uai: crew model needs @agent, at least one of --engine, --model, --effort and --account, and --why "<reason>"');
343
345
  process.exit(1);
344
346
  }
345
347
  // "default" clears the setting back to the engine's own default (null).
@@ -348,6 +350,7 @@ async function main() {
348
350
  if (flags.engine) body.kind = flags.engine;
349
351
  if (flags.model) body.model = cleared(flags.model);
350
352
  if (flags.effort) body.effort = cleared(flags.effort);
353
+ if (flags.account) body.account = cleared(flags.account);
351
354
  const r = await api("PATCH", "/api/agent/crew/" + encodeURIComponent(id), body);
352
355
  out(r.changed
353
356
  ? "@" + id + " now runs on " + launch(r.agent) + "; its session restarts after its current turn"
@@ -364,7 +367,7 @@ async function main() {
364
367
  out("removed @" + id);
365
368
  return;
366
369
  }
367
- console.error("uai: crew commands are list, add, model and remove (Astronaut only)");
370
+ console.error("uai: crew commands are list, status, add, model and remove (Astronaut only)");
368
371
  process.exit(1);
369
372
  }
370
373
  const key = group + " " + (action || "");
@@ -570,8 +573,9 @@ async function main() {
570
573
  " uai task read <taskId> (full chat of a past task you were on)",
571
574
  ' uai dispatch @agent [@agent…] "instruction" (Secretary only)',
572
575
  " uai crew list (Astronaut only: personas, engines, models, limits, roster)",
573
- ' uai crew add <personaId> [--as <handle>] [--engine <kind>] [--model <m>] [--effort <e>] --why "<reason>"',
574
- ' uai crew model @agent [--engine <kind>] [--model <m|default>] [--effort <e|default>] --why "<reason>" (at least one of the three)',
576
+ " uai crew status (Astronaut only: each agent's engine, model, account; account usage; credits)",
577
+ ' uai crew add <personaId> [--as <handle>] [--engine <kind>] [--model <m>] [--effort <e>] [--account <id|label|default>] --why "<reason>"',
578
+ ' uai crew model @agent [--engine <kind>] [--model <m|default>] [--effort <e|default>] [--account <id|label|default>] --why "<reason>"',
575
579
  ' uai crew remove @agent --why "<reason>"',
576
580
  " uai react <heart|check|x> [--msg #id] (no --msg = the message you were last handed)",
577
581
  " uai whoami",
@@ -25,6 +25,7 @@ import { newId } from "../ulid";
25
25
  import { createAgentTransport, type LineTransport } from "./transport";
26
26
  import { rateLimitErrorUsage, resetSecondsToEpochMs } from "./rate-limit";
27
27
  import { register } from "./registry";
28
+ import { CLAUDE_MODELS } from "../../src/engine-models";
28
29
  import {
29
30
  DISPATCH_INPUT_SCHEMA,
30
31
  DISPATCH_RECIPIENT_ID_PATTERN,
@@ -49,7 +50,7 @@ import type {
49
50
  // resolves these aliases to the current dated snapshots, so this list is
50
51
  // stable across point releases. UPDATE WHEN MODELS CHANGE (new family or a
51
52
  // retired alias). Order is display order in the cloud picker.
52
- const CLAUDE_MODELS = ["fable", "opus", "sonnet", "haiku"];
53
+ // The model list is host-agent/src/engine-models.ts (CLAUDE_MODELS).
53
54
 
54
55
  // Opus ("opus" alias = Opus 4.8) stays the default; "fable" (Claude
55
56
  // Fable 5, the Mythos-class flagship) is opt-in per agent. Update
@@ -19,6 +19,7 @@
19
19
  * item/agentMessage/delta → message_delta
20
20
  * item/completed (agentMessage) → message_complete
21
21
  * item/completed (tool item) → tool_call
22
+ * thread/tokenUsage/updated → accumulated for its turn
22
23
  * turn/completed → turn_complete (+ error if failed)
23
24
  * error → error
24
25
  *
@@ -42,32 +43,18 @@ import { attachCompatibilityKeysMatch } from "./attach-compatibility";
42
43
  import { createAgentTransport, type LineTransport } from "./transport";
43
44
  import { rateLimitErrorUsage, resetSecondsToEpochMs } from "./rate-limit";
44
45
  import { register } from "./registry";
46
+ import { CODEX_MODELS } from "../../src/engine-models";
45
47
  import type {
46
48
  AgentEvent,
47
49
  AgentAccountUsage,
48
50
  AgentEventHandler,
49
51
  AgentKind,
50
52
  AgentSession,
53
+ AgentUsage,
51
54
  RosterAgent,
52
55
  } from "./types";
53
56
 
54
- // Codex model slugs accepted via `-c model=<slug>`. Hardcoded: Codex has no
55
- // machine-readable "list models" command — the canonical list is the
56
- // interactive `codex` model picker (and `codex -m <slug>` / config.toml).
57
- // Sourced from that picker; UPDATE WHEN MODELS CHANGE. Order = display order
58
- // in the cloud picker. Legacy models remain reachable via config.toml.
59
- const CODEX_MODELS = [
60
- "gpt-6-astra",
61
- "gpt-5.6-sol",
62
- "gpt-5.6-terra",
63
- "gpt-5.6-luna",
64
- "gpt-5.5",
65
- "gpt-5.4",
66
- "gpt-5.4-mini",
67
- "gpt-5.3-codex",
68
- "gpt-5.3-codex-spark",
69
- "gpt-5.2",
70
- ];
57
+ // The model list is host-agent/src/engine-models.ts (CODEX_MODELS).
71
58
 
72
59
  // gpt-5.6-sol is Codex's current frontier coding model (the CLI default).
73
60
  // Update alongside CODEX_MODELS. When an agent's model is null the adapter
@@ -102,6 +89,120 @@ function str(v: unknown, fallback = ""): string {
102
89
  return typeof v === "string" ? v : fallback;
103
90
  }
104
91
 
92
+ function nonNegativeInt(v: unknown): number | undefined {
93
+ return typeof v === "number" &&
94
+ Number.isSafeInteger(v) &&
95
+ v >= 0
96
+ ? v
97
+ : undefined;
98
+ }
99
+
100
+ interface CodexTokenBreakdown {
101
+ inputTokens: number;
102
+ outputTokens: number;
103
+ cachedInputTokens: number;
104
+ }
105
+
106
+ function codexTokenBreakdown(value: unknown): CodexTokenBreakdown | null {
107
+ if (!isObj(value)) return null;
108
+ const inputTokens = nonNegativeInt(value.inputTokens);
109
+ const outputTokens = nonNegativeInt(value.outputTokens);
110
+ const cachedInputTokens = nonNegativeInt(value.cachedInputTokens);
111
+ if (
112
+ inputTokens === undefined ||
113
+ outputTokens === undefined ||
114
+ cachedInputTokens === undefined
115
+ ) {
116
+ return null;
117
+ }
118
+ return {
119
+ inputTokens,
120
+ outputTokens,
121
+ cachedInputTokens,
122
+ };
123
+ }
124
+
125
+ function addBreakdowns(
126
+ left: CodexTokenBreakdown | undefined,
127
+ right: CodexTokenBreakdown,
128
+ ): CodexTokenBreakdown {
129
+ return {
130
+ inputTokens: (left?.inputTokens ?? 0) + right.inputTokens,
131
+ outputTokens: (left?.outputTokens ?? 0) + right.outputTokens,
132
+ cachedInputTokens:
133
+ (left?.cachedInputTokens ?? 0) + right.cachedInputTokens,
134
+ };
135
+ }
136
+
137
+ function breakdownDelta(
138
+ current: CodexTokenBreakdown,
139
+ previous: CodexTokenBreakdown,
140
+ ): CodexTokenBreakdown | null {
141
+ const delta = {
142
+ inputTokens: current.inputTokens - previous.inputTokens,
143
+ outputTokens: current.outputTokens - previous.outputTokens,
144
+ cachedInputTokens:
145
+ current.cachedInputTokens - previous.cachedInputTokens,
146
+ };
147
+ return Object.values(delta).every((value) => value >= 0) ? delta : null;
148
+ }
149
+
150
+ /**
151
+ * Codex 0.157 emits one token update after every model call. `last` is that
152
+ * call, not the whole turn; `total` is the running thread total. A tool-using
153
+ * turn therefore emits several updates. Seed an unseen thread from `last`,
154
+ * then add positive `total` deltas so repeated snapshots add zero. Usage is
155
+ * kept by turn id and consumed exactly once at its turn/completed boundary.
156
+ */
157
+ class CodexTurnUsage {
158
+ private readonly threadTotals = new Map<string, CodexTokenBreakdown>();
159
+ private readonly turns = new Map<string, CodexTokenBreakdown>();
160
+
161
+ observe(params: unknown): void {
162
+ if (!isObj(params)) return;
163
+ const threadId = str(params.threadId);
164
+ const turnId = str(params.turnId);
165
+ const tokenUsage = isObj(params.tokenUsage) ? params.tokenUsage : null;
166
+ if (!threadId || !turnId || !tokenUsage) return;
167
+ const last = codexTokenBreakdown(tokenUsage.last);
168
+ const total = codexTokenBreakdown(tokenUsage.total);
169
+ if (!last || !total) return;
170
+
171
+ const previousTotal = this.threadTotals.get(threadId);
172
+ const contribution = previousTotal
173
+ ? breakdownDelta(total, previousTotal) ?? last
174
+ : last;
175
+ this.threadTotals.set(threadId, total);
176
+ this.turns.set(
177
+ turnId,
178
+ addBreakdowns(this.turns.get(turnId), contribution),
179
+ );
180
+ }
181
+
182
+ complete(params: unknown, model: string | undefined): AgentUsage | undefined {
183
+ const p = isObj(params) ? params : {};
184
+ const turn = isObj(p.turn) ? p.turn : {};
185
+ const turnId = str(turn.id);
186
+ if (!turnId) return undefined;
187
+ const usage = this.turns.get(turnId);
188
+ this.turns.delete(turnId);
189
+ if (!usage) return undefined;
190
+ return {
191
+ ...(model ? { model } : {}),
192
+ // OpenAI includes cached input inside inputTokens. Uai's shared shape,
193
+ // like Claude's, keeps fresh input and cache reads in separate columns.
194
+ // Codex 0.157 does not say whether cacheWriteInputTokens is already in
195
+ // inputTokens. Leave it in fresh input and do not also report a cache
196
+ // create until the protocol makes that relationship explicit.
197
+ inputTokens: Math.max(0, usage.inputTokens - usage.cachedInputTokens),
198
+ cacheReadTokens: usage.cachedInputTokens,
199
+ // reasoningOutputTokens is a subset of outputTokens in this protocol
200
+ // (totalTokens = inputTokens + outputTokens), so do not add it again.
201
+ outputTokens: usage.outputTokens,
202
+ };
203
+ }
204
+ }
205
+
105
206
  /** `item/completed` item types rendered as tool-call cards. */
106
207
  const TOOL_ITEM_TYPES = new Set([
107
208
  "commandExecution",
@@ -111,6 +212,10 @@ const TOOL_ITEM_TYPES = new Set([
111
212
  "webSearch",
112
213
  ]);
113
214
 
215
+ function turnComplete(usage: AgentUsage | undefined): AgentEvent {
216
+ return usage ? { type: "turn_complete", usage } : { type: "turn_complete" };
217
+ }
218
+
114
219
  /**
115
220
  * Map one Codex JSON-RPC *notification* (method + params) to AgentEvents.
116
221
  * Pure and total — an unrecognised method yields [].
@@ -119,6 +224,7 @@ export function mapCodexNotification(
119
224
  method: string,
120
225
  params: unknown,
121
226
  now: number = Date.now(),
227
+ turnUsage?: AgentUsage,
122
228
  ): AgentEvent[] {
123
229
  const p = isObj(params) ? params : {};
124
230
 
@@ -170,10 +276,10 @@ export function mapCodexNotification(
170
276
  : { resetsAt: accountUsage.resetsAt }),
171
277
  }
172
278
  : { type: "error", message },
173
- { type: "turn_complete" },
279
+ turnComplete(turnUsage),
174
280
  ];
175
281
  }
176
- return [{ type: "turn_complete" }];
282
+ return [turnComplete(turnUsage)];
177
283
  }
178
284
 
179
285
  if (method === "error") {
@@ -483,6 +589,8 @@ export class CodexSession implements AgentSession {
483
589
  private turnRunning = false;
484
590
  private lastRateLimitsReadAt = 0;
485
591
  private rateLimitsTimer: ReturnType<typeof setTimeout> | null = null;
592
+ private readonly turnUsage = new CodexTurnUsage();
593
+ private actualModel: string | undefined;
486
594
 
487
595
  constructor(args: {
488
596
  taskId: string;
@@ -652,6 +760,13 @@ export class CodexSession implements AgentSession {
652
760
  // Notification (method, no id).
653
761
  if ("method" in msg) {
654
762
  const method = str(msg.method);
763
+ if (method === "thread/tokenUsage/updated") {
764
+ this.turnUsage.observe(msg.params);
765
+ }
766
+ const completedUsage =
767
+ method === "turn/completed"
768
+ ? this.turnUsage.complete(msg.params, this.actualModel)
769
+ : undefined;
655
770
  if (method === "turn/completed") {
656
771
  this.turnRunning = false;
657
772
  this.clearRateLimitsTimer();
@@ -664,9 +779,17 @@ export class CodexSession implements AgentSession {
664
779
  if (isObj(thread) && typeof thread.id === "string") {
665
780
  this.threadId = thread.id;
666
781
  this.persistThreadId(thread.id);
782
+ if (typeof thread.model === "string" && thread.model.length > 0) {
783
+ this.actualModel = thread.model;
784
+ }
667
785
  }
668
786
  }
669
- for (const ev of mapCodexNotification(method, msg.params)) {
787
+ for (const ev of mapCodexNotification(
788
+ method,
789
+ msg.params,
790
+ Date.now(),
791
+ completedUsage,
792
+ )) {
670
793
  this.emit(ev);
671
794
  }
672
795
  }
@@ -833,8 +956,14 @@ export class CodexSession implements AgentSession {
833
956
  threadParams.developerInstructions = this.systemPreamble;
834
957
  }
835
958
  const result = await this.request("thread/start", threadParams);
959
+ if (isObj(result) && typeof result.model === "string") {
960
+ this.actualModel = result.model;
961
+ }
836
962
  if (isObj(result) && isObj(result.thread)) {
837
963
  const id = str(result.thread.id) || this.threadId;
964
+ if (!this.actualModel && typeof result.thread.model === "string") {
965
+ this.actualModel = result.thread.model;
966
+ }
838
967
  this.threadId = id;
839
968
  if (id) this.persistThreadId(id);
840
969
  }
@@ -861,6 +990,15 @@ export class CodexSession implements AgentSession {
861
990
  isObj(result) && isObj(result.thread) && str(result.thread.id)
862
991
  ? str(result.thread.id)
863
992
  : threadId;
993
+ if (isObj(result) && typeof result.model === "string") {
994
+ this.actualModel = result.model;
995
+ } else if (
996
+ isObj(result) &&
997
+ isObj(result.thread) &&
998
+ typeof result.thread.model === "string"
999
+ ) {
1000
+ this.actualModel = result.thread.model;
1001
+ }
864
1002
  this.threadId = id;
865
1003
  this.persistThreadId(id);
866
1004
  return true;
@@ -15,11 +15,13 @@
15
15
  *
16
16
  * Stream-json vocabulary (`--output-format streaming-json`, verified):
17
17
  * {"type":"thought","data":"…"} → skipped (internal reasoning)
18
- * {"type":"text","data":"…"} → message_delta (streamed)
19
- * {"type":"end","stopReason","sessionId",…} → message_complete + turn end
20
- * Tool calls happen (num_turns>1) but aren't surfaced as events in headless
21
- * mode, so there are no tool cards — the agent works, you get the streamed
22
- * answer.
18
+ * {"type":"text","data":"…"} → message_delta (streamed)
19
+ * {"type":"tool_call"|"tool_call_update",…} → safely skipped (no cards yet)
20
+ * {"type":"usage",…} → skipped (per model call)
21
+ * {"type":"available_commands",…} → skipped
22
+ * {"type":"end","stopReason","sessionId",…} → message_complete + turn usage/end
23
+ * Grok 1.0.67 surfaces tool calls, but tool cards are deliberately outside
24
+ * this adapter slice. The final `end.usage` aggregates the invocation.
23
25
  *
24
26
  * AUTH is the operator's Grok subscription: `~/.grok/{auth.json, config.toml}`
25
27
  * is docker-cp'd into the container at task-up (the Codex/Kimi config-dir
@@ -43,11 +45,13 @@ import {
43
45
  type AgentEnvironmentProcess,
44
46
  } from "./proc";
45
47
  import { register } from "./registry";
48
+ import { GROK_MODELS } from "../../src/engine-models";
46
49
  import type {
47
50
  AgentEvent,
48
51
  AgentEventHandler,
49
52
  AgentKind,
50
53
  AgentSession,
54
+ AgentUsage,
51
55
  RosterAgent,
52
56
  } from "./types";
53
57
 
@@ -56,7 +60,7 @@ export const GROK_LOADER_LOGIN_ENV = "0";
56
60
 
57
61
  // `grok models` lists what the account can run. grok-4.5 is the current
58
62
  // default; UPDATE WHEN xAI CHANGES the lineup.
59
- const GROK_MODELS = ["grok-4.5"];
63
+ // The model list is host-agent/src/engine-models.ts (GROK_MODELS).
60
64
  const GROK_DEFAULT_MODEL = "grok-4.5";
61
65
  // Grok has no per-invocation reasoning-effort flag.
62
66
  const GROK_EFFORTS: string[] = [];
@@ -76,9 +80,68 @@ export interface MappedGrokLine {
76
80
  end?: boolean;
77
81
  /** The session id from the end event, for `-r` on the next turn. */
78
82
  sessionId?: string;
83
+ /** Per-invocation usage from the final end event. */
84
+ usage?: AgentUsage;
79
85
  }
80
86
 
81
- export function mapGrokLine(line: string): MappedGrokLine {
87
+ function nonNegativeInt(value: unknown): number | undefined {
88
+ return typeof value === "number" &&
89
+ Number.isSafeInteger(value) &&
90
+ value >= 0
91
+ ? value
92
+ : undefined;
93
+ }
94
+
95
+ function grokEndUsage(
96
+ msg: Record<string, unknown>,
97
+ model: string | undefined,
98
+ ): AgentUsage | undefined {
99
+ if (typeof msg.usage !== "object" || msg.usage === null) return undefined;
100
+ const raw = msg.usage as Record<string, unknown>;
101
+ const inputTokens = nonNegativeInt(raw.input_tokens);
102
+ const cacheReadTokens = nonNegativeInt(raw.cache_read_input_tokens);
103
+ const cacheCreateTokens = nonNegativeInt(raw.cache_creation_input_tokens);
104
+ const outputTokens = nonNegativeInt(raw.output_tokens);
105
+ const reasoningTokens = nonNegativeInt(raw.reasoning_tokens);
106
+ if (
107
+ inputTokens === undefined ||
108
+ cacheReadTokens === undefined ||
109
+ cacheCreateTokens === undefined ||
110
+ outputTokens === undefined ||
111
+ reasoningTokens === undefined
112
+ ) {
113
+ return undefined;
114
+ }
115
+ const reportedCost = msg.total_cost_usd;
116
+ const costUsd =
117
+ typeof reportedCost === "number" &&
118
+ Number.isFinite(reportedCost) &&
119
+ reportedCost >= 0
120
+ ? reportedCost
121
+ : undefined;
122
+ return {
123
+ // `modelUsage` exposes the backend build id (for example
124
+ // grok-4.5-build), while `model` is the semantic id passed to `-m` and
125
+ // therefore the id Uai's model guide resolves.
126
+ ...(model ? { model } : {}),
127
+ // Headless input_tokens is already uncached; both cache buckets are
128
+ // additive according to the 1.0.67 headless contract.
129
+ inputTokens,
130
+ cacheReadTokens,
131
+ cacheCreateTokens,
132
+ // The documented total excludes reasoning_tokens even though it includes
133
+ // output_tokens. Uai has no separate reasoning column, so include that
134
+ // distinct billed output once here.
135
+ outputTokens: outputTokens + reasoningTokens,
136
+ // Missing cost means unreported/partial, never free.
137
+ ...(costUsd === undefined ? {} : { costUsd }),
138
+ };
139
+ }
140
+
141
+ export function mapGrokLine(
142
+ line: string,
143
+ model?: string,
144
+ ): MappedGrokLine {
82
145
  const trimmed = line.trim();
83
146
  if (!trimmed.startsWith("{")) return {};
84
147
  let msg: Record<string, unknown>;
@@ -91,9 +154,11 @@ export function mapGrokLine(line: string): MappedGrokLine {
91
154
  return { textDelta: msg.data };
92
155
  }
93
156
  if (msg.type === "end") {
157
+ const usage = grokEndUsage(msg, model);
94
158
  return {
95
159
  end: true,
96
160
  sessionId: typeof msg.sessionId === "string" ? msg.sessionId : undefined,
161
+ ...(usage ? { usage } : {}),
97
162
  };
98
163
  }
99
164
  // "thought" (reasoning) and any other types: no user-facing event.
@@ -195,9 +260,10 @@ export class GrokSession implements AgentSession {
195
260
  // the same reasoning as --permission-mode bypassPermissions above
196
261
  // (live 2026-09-08, first Grok agents with MCP servers).
197
262
  GROK_FOLDER_TRUST: "0",
198
- // grok 1.0.5 otherwise captures a login-shell environment once and
199
- // reuses that static state for LocalNonPersistent commands. Loader
200
- // tasks refresh per command, so disable that stale snapshot path.
263
+ // Grok 1.0.67 still enables login-shell capture by default and honors
264
+ // GROK_LOGIN_ENV. That capture reuses static state for
265
+ // LocalNonPersistent commands; loader tasks refresh per command, so
266
+ // keep the stale snapshot path disabled.
201
267
  ...(this.environment.descriptor.envMode === "loader"
202
268
  ? { GROK_LOGIN_ENV: GROK_LOADER_LOGIN_ENV }
203
269
  : {}),
@@ -214,6 +280,7 @@ export class GrokSession implements AgentSession {
214
280
 
215
281
  let acc = "";
216
282
  let sawText = false;
283
+ let turnUsage: AgentUsage | undefined;
217
284
  let buf = "";
218
285
  const consume = (chunk: string): void => {
219
286
  buf += chunk;
@@ -221,8 +288,9 @@ export class GrokSession implements AgentSession {
221
288
  while ((nl = buf.indexOf("\n")) >= 0) {
222
289
  const line = buf.slice(0, nl);
223
290
  buf = buf.slice(nl + 1);
224
- const m = mapGrokLine(line);
291
+ const m = mapGrokLine(line, this.model);
225
292
  if (m.sessionId) this.sessionId = m.sessionId;
293
+ if (m.end) turnUsage = m.usage;
226
294
  if (typeof m.textDelta === "string") {
227
295
  sawText = true;
228
296
  acc += m.textDelta;
@@ -252,7 +320,11 @@ export class GrokSession implements AgentSession {
252
320
  }
253
321
  // Finalize the streamed message (no-op text if the turn produced none).
254
322
  if (sawText) this.emit({ type: "message_complete", text: acc });
255
- this.emit({ type: "turn_complete" });
323
+ this.emit(
324
+ turnUsage
325
+ ? { type: "turn_complete", usage: turnUsage }
326
+ : { type: "turn_complete" },
327
+ );
256
328
  resolve();
257
329
  };
258
330
  child.on("exit", (code) => finish(code));
@@ -51,6 +51,13 @@ export const RosterAgent = z.object({
51
51
  kind: AgentKind,
52
52
  model: z.string().min(1).optional(),
53
53
  effort: z.string().min(1).optional(), // CLI reasoning level, passed through
54
+ // ADR-157 addendum §7: preferred cloud-leased AI account.
55
+ accountId: z
56
+ .string()
57
+ .min(1)
58
+ .max(128)
59
+ .regex(/^[A-Za-z0-9_.:-]+$/)
60
+ .optional(),
54
61
  defaultPrompt: z.string().optional(), // persona
55
62
  initialPrompt: z.string().optional(), // first-turn instructions, this task only
56
63
  skills: z.array(RosterSkill).optional(), // ADR-046 reference material
@@ -558,7 +558,7 @@ export function noteEngineAccountUsed(id: string, now: number = Date.now()): voi
558
558
  */
559
559
  export function selectAccount(
560
560
  accounts: ResolvedEngineAccount[],
561
- opts: { exclude?: Set<string>; now?: number } = {},
561
+ opts: { exclude?: Set<string>; now?: number; preferredId?: string } = {},
562
562
  ): ResolvedEngineAccount | null {
563
563
  const now = opts.now ?? Date.now();
564
564
  const exclude = opts.exclude ?? new Set<string>();
@@ -566,6 +566,10 @@ export function selectAccount(
566
566
  if (candidates.length === 0) return null;
567
567
  const lru = (a: ResolvedEngineAccount): number => state(a.id).lastUsedAt;
568
568
  const healthy = candidates.filter((a) => accountCooldownUntil(a.id) <= now);
569
+ const preferred = opts.preferredId
570
+ ? healthy.find((account) => account.id === opts.preferredId)
571
+ : undefined;
572
+ if (preferred) return preferred;
569
573
  // Manage-v1: the list arrives in PRIORITY order (default slot, then extras
570
574
  // by position) — the first healthy account wins, so the operator's
571
575
  // ordering decides which account a task uses and which are fallbacks.
@@ -585,6 +589,8 @@ export function pickEngineAccount(
585
589
  exclude?: Set<string>;
586
590
  seams?: Partial<AccountSeams>;
587
591
  now?: number;
592
+ /** One agent's durable preference; cooling or absent falls through. */
593
+ preferredId?: string;
588
594
  /** ADR-131 D3: the task's scope; default = the host's own accounts. */
589
595
  scope?: string;
590
596
  } = {},
@@ -594,7 +600,11 @@ export function pickEngineAccount(
594
600
  opts.seams ?? {},
595
601
  opts.scope ?? HOST_ACCOUNT_SCOPE,
596
602
  );
597
- return selectAccount(accounts, { exclude: opts.exclude, now: opts.now });
603
+ return selectAccount(accounts, {
604
+ exclude: opts.exclude,
605
+ now: opts.now,
606
+ preferredId: opts.preferredId,
607
+ });
598
608
  }
599
609
 
600
610
  /**
@@ -166,6 +166,7 @@ import {
166
166
  selectAccount,
167
167
  type ResolvedEngineAccount,
168
168
  } from "./engine-accounts";
169
+ import { buildEngineUsageFrame } from "../src/engine-usage";
169
170
  import {
170
171
  ENGINE_ACCOUNT_HOMES,
171
172
  EngineCredentialWriteRefused,
@@ -1746,11 +1747,19 @@ export class Orchestrator {
1746
1747
  channel: Channel,
1747
1748
  kind: string,
1748
1749
  exclude?: Set<string>,
1750
+ preferredId?: string,
1749
1751
  ): ResolvedEngineAccount | null {
1750
1752
  if (!this.isLeasedChannel(channel)) {
1751
- return pickEngineAccount(kind, { scope: channel.engineAccountScope, exclude });
1753
+ return pickEngineAccount(kind, {
1754
+ scope: channel.engineAccountScope,
1755
+ exclude,
1756
+ preferredId,
1757
+ });
1752
1758
  }
1753
- return selectAccount(this.engineAccounts(channel, kind), { exclude });
1759
+ return selectAccount(this.engineAccounts(channel, kind), {
1760
+ exclude,
1761
+ preferredId,
1762
+ });
1754
1763
  }
1755
1764
 
1756
1765
  private noteProvisionMutation(
@@ -2789,6 +2798,7 @@ export class Orchestrator {
2789
2798
  if (this.isLeasedChannel(channel)) return { ...floor, ...base };
2790
2799
  const account = pickEngineAccount(agent.kind, {
2791
2800
  scope: channel.engineAccountScope,
2801
+ preferredId: agent.accountId,
2792
2802
  });
2793
2803
  if (!account) {
2794
2804
  channel.accountByAgent.delete(agent.id);
@@ -3381,8 +3391,14 @@ export class Orchestrator {
3381
3391
  account.engineKind === kind && isLeasedEngineAccount(account),
3382
3392
  );
3383
3393
  const bound = channel.accountByAgent.get(agentId);
3394
+ const preferredId = channel.roster.find(
3395
+ (candidate) => candidate.id === agentId,
3396
+ )?.accountId;
3384
3397
  const kept = accounts.find((account) => account.credentialId === bound);
3385
- if (kept) return kept;
3398
+ // Without a pin, preserve the live binding. With one, every fresh session
3399
+ // re-evaluates it: a cooling/missing pin falls through now, and wins again
3400
+ // automatically after its bench lifts.
3401
+ if (!preferredId && kept) return kept;
3386
3402
  const picked = selectAccount(
3387
3403
  accounts.map((account) => ({
3388
3404
  id: account.credentialId,
@@ -3393,6 +3409,7 @@ export class Orchestrator {
3393
3409
  authKind: account.authKind,
3394
3410
  execEnv: {},
3395
3411
  })),
3412
+ { preferredId },
3396
3413
  );
3397
3414
  return accounts.find((account) => account.credentialId === picked?.id);
3398
3415
  }
@@ -5002,6 +5019,24 @@ export class Orchestrator {
5002
5019
  if (!identity || identity.subject !== "account") break;
5003
5020
  const agent = channel.roster.find((candidate) => candidate.id === agentId);
5004
5021
  if (!agent || !isEngineUsageKind(agent.kind)) break;
5022
+ // The session's own revision: a refreshed lease may already hold a
5023
+ // newer one, and its variable name says nothing about this session.
5024
+ const leasedAccount = leasedEnginesFor(channel.taskId)?.accounts.find(
5025
+ (candidate): candidate is LeasedEngineAccount =>
5026
+ candidate.credentialId === identity.credentialId &&
5027
+ candidate.revision === identity.revision &&
5028
+ isLeasedEngineAccount(candidate),
5029
+ );
5030
+ const frame = buildEngineUsageFrame({
5031
+ reportId: newId(),
5032
+ taskId: channel.taskId,
5033
+ agentId,
5034
+ engine: agent.kind,
5035
+ credentialId: identity.credentialId,
5036
+ revision: identity.revision,
5037
+ usage: event.usage,
5038
+ leasedAccount,
5039
+ });
5005
5040
 
5006
5041
  const coverage = event.usage.coverage;
5007
5042
  if (event.usage.status === "limited") {
@@ -5048,6 +5083,8 @@ export class Orchestrator {
5048
5083
  taskId: channel.taskId,
5049
5084
  engine: agent.kind,
5050
5085
  credentialId: identity.credentialId,
5086
+ billing: frame.billing,
5087
+ revision: frame.revision,
5051
5088
  status: event.usage.status,
5052
5089
  resetsAt: event.usage.resetsAt,
5053
5090
  usingOverage: event.usage.usingOverage,
@@ -5062,24 +5099,7 @@ export class Orchestrator {
5062
5099
  ) {
5063
5100
  break;
5064
5101
  }
5065
- const delivered = this.emitEngineUsage({
5066
- kind: "engine.usage",
5067
- reportId: newId(),
5068
- taskId: channel.taskId,
5069
- agentId,
5070
- engine: agent.kind,
5071
- credentialId: identity.credentialId,
5072
- status: event.usage.status,
5073
- ...(event.usage.resetsAt === undefined
5074
- ? {}
5075
- : { resetsAt: event.usage.resetsAt }),
5076
- ...(event.usage.usingOverage === undefined
5077
- ? {}
5078
- : { usingOverage: event.usage.usingOverage }),
5079
- windows: event.usage.windows,
5080
- source: event.usage.source,
5081
- observedAt: event.usage.observedAt,
5082
- });
5102
+ const delivered = this.emitEngineUsage(frame);
5083
5103
  // A disconnected bridge returns false. Do not suppress the next
5084
5104
  // identical observation unless this one actually reached the socket.
5085
5105
  if (delivered) {
@@ -5779,6 +5799,7 @@ export class Orchestrator {
5779
5799
  channel,
5780
5800
  agent.kind,
5781
5801
  current ? new Set([current]) : undefined,
5802
+ agent.accountId,
5782
5803
  );
5783
5804
  if (!next) return false;
5784
5805
 
@@ -6646,6 +6667,9 @@ function rosterLaunchChangeReason(
6646
6667
  if (previous.kind !== next.kind) changed.push("engine");
6647
6668
  if ((previous.model ?? "") !== (next.model ?? "")) changed.push("model");
6648
6669
  if ((previous.effort ?? "") !== (next.effort ?? "")) changed.push("effort");
6670
+ if ((previous.accountId ?? "") !== (next.accountId ?? "")) {
6671
+ changed.push("account");
6672
+ }
6649
6673
  // ADR-157 §3: the session holds a token minted for its generation, so a new
6650
6674
  // one is a new agent under the handle (or one an older cloud release lost),
6651
6675
  // and only a fresh launch carries a token the cloud accepts.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.163",
3
+ "version": "0.9.165",
4
4
  "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",
@@ -72,6 +72,14 @@
72
72
  "./project-env-wording": {
73
73
  "types": "./src/project-env-wording.ts",
74
74
  "default": "./src/project-env-wording.ts"
75
+ },
76
+ "./engine-models": {
77
+ "types": "./src/engine-models.ts",
78
+ "default": "./src/engine-models.ts"
79
+ },
80
+ "./engine-usage": {
81
+ "types": "./src/engine-usage.ts",
82
+ "default": "./src/engine-usage.ts"
75
83
  }
76
84
  },
77
85
  "scripts": {
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The models each engine adapter offers, by the id the runner passes its
3
+ * CLI: one list per engine, read by the adapters (`supportedModels`) and by
4
+ * the cloud's model guide (lib/model-guide.ts), whose test fails when a model
5
+ * here has no guide entry. Pure data: no runtime imports.
6
+ */
7
+
8
+ // Claude Code's aliases, which follow its own model table (2.1.307:
9
+ // fable = Claude Fable 5.1, opus = Opus 5.5, sonnet = Sonnet 5.5,
10
+ // haiku = Haiku 5.5). Update alongside the CLI.
11
+ export const CLAUDE_MODELS: readonly string[] = ["fable", "opus", "sonnet", "haiku"];
12
+
13
+ // Codex model slugs accepted via `-c model=<slug>`. Hardcoded: Codex has no
14
+ // machine-readable "list models" command — the canonical list is the
15
+ // interactive `codex` model picker (and `codex -m <slug>` / config.toml).
16
+ // Sourced from that picker; UPDATE WHEN MODELS CHANGE. Order = display order
17
+ // in the cloud picker. Legacy models remain reachable via config.toml.
18
+ export const CODEX_MODELS: readonly string[] = [
19
+ "gpt-6-astra",
20
+ "gpt-5.6-sol",
21
+ "gpt-5.6-terra",
22
+ "gpt-5.6-luna",
23
+ "gpt-5.5",
24
+ "gpt-5.4",
25
+ "gpt-5.4-mini",
26
+ "gpt-5.3-codex",
27
+ "gpt-5.3-codex-spark",
28
+ "gpt-5.2",
29
+ ];
30
+
31
+ export const GROK_MODELS: readonly string[] = ["grok-4.5"];
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Build the secret-free account usage report sent to Uai Cloud.
3
+ *
4
+ * A leased env account carries both a variable name and its secret value in
5
+ * runner memory. Billing is deliberately inferred from the NAME alone. Some
6
+ * file-backed accounts use the same `auth.json` name for a subscription and
7
+ * an API key, so they carry no hint.
8
+ *
9
+ * The hint names the revision it was read from, and is given only when that is
10
+ * the revision the reporting session runs on: after a re-paste, a lease
11
+ * refresh can hand the runner newer material than an agent is still using.
12
+ */
13
+
14
+ import type { EngineUsageFrame, EngineUsageWindow } from "./protocol";
15
+
16
+ type EngineUsageBilling = NonNullable<EngineUsageFrame["billing"]>;
17
+
18
+ export interface LeasedBillingAccount {
19
+ credentialId: string;
20
+ revision: string;
21
+ engineKind: string;
22
+ authKind: "env" | "config-dir";
23
+ env: Readonly<Record<string, string>> | null;
24
+ }
25
+
26
+ export interface EngineUsageObservation {
27
+ status: EngineUsageFrame["status"];
28
+ resetsAt?: number;
29
+ usingOverage?: boolean;
30
+ windows: EngineUsageWindow[];
31
+ source: EngineUsageFrame["source"];
32
+ observedAt: number;
33
+ }
34
+
35
+ const BILLING_BY_VARIABLE: Readonly<
36
+ Record<string, Readonly<Record<string, EngineUsageBilling>>>
37
+ > = Object.freeze({
38
+ claude: Object.freeze({
39
+ // Capture files every pasted Claude secret that is NOT `sk-ant-api…` under
40
+ // this name (engine-accounts.ts `pastedSecretMaterial`), so the name alone
41
+ // also covers a token whose prefix told capture nothing, which the cloud
42
+ // left unknown. Subscription is still right for a report: usage is read
43
+ // from a session that worked, and Claude Code authenticates this variable
44
+ // only as an OAuth (subscription) token, so a session that ran on it was
45
+ // one. Do not "correct" it to unknown.
46
+ CLAUDE_CODE_OAUTH_TOKEN: "subscription",
47
+ ANTHROPIC_API_KEY: "api-key",
48
+ }),
49
+ cursor: Object.freeze({ CURSOR_API_KEY: "api-key" }),
50
+ grok: Object.freeze({ XAI_API_KEY: "api-key" }),
51
+ });
52
+
53
+ /** The billing lane when one leased variable name tells it unambiguously. */
54
+ export function billingFromLeasedVariableName(
55
+ account: LeasedBillingAccount | null | undefined,
56
+ ): EngineUsageBilling | undefined {
57
+ if (!account || account.authKind !== "env" || account.env === null) {
58
+ return undefined;
59
+ }
60
+ const names = Object.keys(account.env);
61
+ if (names.length !== 1) return undefined;
62
+ return BILLING_BY_VARIABLE[account.engineKind]?.[names[0]!];
63
+ }
64
+
65
+ /** Construct the exact wire frame for one adapter usage observation.
66
+ * `revision` is the one the reporting session runs on; a leased account at
67
+ * any other revision gives no hint. */
68
+ export function buildEngineUsageFrame(input: {
69
+ reportId: string;
70
+ taskId: string;
71
+ agentId: string;
72
+ engine: EngineUsageFrame["engine"];
73
+ credentialId: string;
74
+ revision?: string;
75
+ usage: EngineUsageObservation;
76
+ leasedAccount?: LeasedBillingAccount | null;
77
+ }): EngineUsageFrame {
78
+ const { usage } = input;
79
+ const account =
80
+ input.revision !== undefined &&
81
+ input.leasedAccount?.credentialId === input.credentialId &&
82
+ input.leasedAccount.revision === input.revision &&
83
+ input.leasedAccount.engineKind === input.engine
84
+ ? input.leasedAccount
85
+ : null;
86
+ const billing = billingFromLeasedVariableName(account);
87
+ return {
88
+ kind: "engine.usage",
89
+ reportId: input.reportId,
90
+ taskId: input.taskId,
91
+ agentId: input.agentId,
92
+ engine: input.engine,
93
+ credentialId: input.credentialId,
94
+ ...(billing === undefined ? {} : { billing, revision: account!.revision }),
95
+ status: usage.status,
96
+ ...(usage.resetsAt === undefined ? {} : { resetsAt: usage.resetsAt }),
97
+ ...(usage.usingOverage === undefined
98
+ ? {}
99
+ : { usingOverage: usage.usingOverage }),
100
+ windows: usage.windows,
101
+ source: usage.source,
102
+ observedAt: usage.observedAt,
103
+ };
104
+ }
package/src/main.ts CHANGED
@@ -5536,6 +5536,14 @@ function expectTaskAgents(value: unknown): TaskAgent[] {
5536
5536
  // effort was previously dropped here even though the protocol + adapters
5537
5537
  // carry it — fixed alongside skills (ADR-046).
5538
5538
  if (typeof row.effort === "string") out.effort = row.effort;
5539
+ if (
5540
+ typeof row.accountId === "string" &&
5541
+ row.accountId.length > 0 &&
5542
+ row.accountId.length <= 128 &&
5543
+ /^[A-Za-z0-9_.:-]+$/.test(row.accountId)
5544
+ ) {
5545
+ out.accountId = row.accountId;
5546
+ }
5539
5547
  if (row.uaiProvider === "anthropic" || row.uaiProvider === "openai") {
5540
5548
  out.uaiProvider = row.uaiProvider;
5541
5549
  }
package/src/protocol.ts CHANGED
@@ -194,6 +194,14 @@ export interface EngineUsageFrame {
194
194
  engine: "claude" | "codex" | "grok" | "cursor" | "opencode";
195
195
  /** The `uai_credentials` id the agent's session runs on. */
196
196
  credentialId: string;
197
+ /** How this account is paid for, when the leased variable's NAME tells the
198
+ * runner unambiguously. Derived only from that name, never from its value. */
199
+ billing?: "subscription" | "api-key";
200
+ /** The credential revision whose variable name gave `billing`: the one the
201
+ * agent's session runs on. Sent only with `billing`; an id, not a secret.
202
+ * The cloud records the hint only while this is still the account's active
203
+ * revision, so a task on an older lease cannot describe newer material. */
204
+ revision?: string;
197
205
  /** `allowed`: working; `warning`: the engine says it is close to a limit;
198
206
  * `limited`: cut off until `resetsAt` (or an unknown time). */
199
207
  status: "allowed" | "warning" | "limited";
@@ -3121,6 +3129,8 @@ export interface TaskAgent {
3121
3129
  kind: string;
3122
3130
  model?: string;
3123
3131
  effort?: string;
3132
+ /** ADR-157 addendum §7: preferred cloud-leased AI account. */
3133
+ accountId?: string;
3124
3134
  defaultPrompt?: string;
3125
3135
  initialPrompt?: string;
3126
3136
  /** ADR-046 reference material resolved from the agent's persona + team.