@runuai/host 0.9.162 → 0.9.164

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.
@@ -31,6 +31,7 @@ import { dirname, join } from "node:path";
31
31
  import { inArray } from "drizzle-orm";
32
32
 
33
33
  import { getDb, schema } from "./db";
34
+ import { newId } from "./ulid";
34
35
  import { mockAgentFactory } from "./agents/mock";
35
36
  import { realAgentFactory } from "./agents/factory";
36
37
  import { resolveAgentMode } from "./agents/mode";
@@ -152,7 +153,12 @@ import {
152
153
  codexHomeIsolatable,
153
154
  codexHomeSeedCommand,
154
155
  codexPerAgentHome,
156
+ clearEngineAccountCooldowns,
157
+ clearEngineAccountCooldownWindow,
155
158
  cooldownEngineAccount,
159
+ cooldownEngineAccountUntil,
160
+ cooldownEngineAccountWindow,
161
+ cooldownEngineAccountWindowUntil,
156
162
  noteEngineAccountUsed,
157
163
  pickEngineAccount,
158
164
  provisionEngineAccounts,
@@ -160,6 +166,7 @@ import {
160
166
  selectAccount,
161
167
  type ResolvedEngineAccount,
162
168
  } from "./engine-accounts";
169
+ import { buildEngineUsageFrame } from "../src/engine-usage";
163
170
  import {
164
171
  ENGINE_ACCOUNT_HOMES,
165
172
  EngineCredentialWriteRefused,
@@ -227,6 +234,7 @@ import {
227
234
  import { env } from "./env";
228
235
  import type {
229
236
  ChannelEnsureInput,
237
+ EngineUsageFrame,
230
238
  ChannelHuman,
231
239
  HostEvent,
232
240
  } from "../src/protocol";
@@ -247,6 +255,17 @@ import {
247
255
  import { forgetTaskEnvSnapshot } from "./task-env-source";
248
256
 
249
257
  export type HostEventSubscriber = (event: HostEvent) => void;
258
+ /** True means the frame reached the current cloud transport generation. */
259
+ export type EngineUsageSubscriber = (frame: EngineUsageFrame) => boolean;
260
+
261
+ export const ENGINE_USAGE_REFRESH_MS = 15 * 60_000;
262
+
263
+ interface DeliveredEngineUsage {
264
+ payload: string;
265
+ sentAt: number;
266
+ taskId: string;
267
+ agentId: string;
268
+ }
250
269
 
251
270
  // Rolling upgrade fence: v0 Secretary runners were launched under the
252
271
  // communicator tool-denial profile and may survive a host restart. Durable
@@ -568,6 +587,18 @@ const LEASED_CREDENTIAL_BLANKS: Readonly<Record<string, string>> = Object.freeze
568
587
  * again. */
569
588
  const LEASED_SYNC_RETRY_MS = 30_000;
570
589
 
590
+ const ENGINE_USAGE_KINDS = new Set<EngineUsageFrame["engine"]>([
591
+ "claude",
592
+ "codex",
593
+ "grok",
594
+ "cursor",
595
+ "opencode",
596
+ ]);
597
+
598
+ function isEngineUsageKind(kind: string): kind is EngineUsageFrame["engine"] {
599
+ return ENGINE_USAGE_KINDS.has(kind as EngineUsageFrame["engine"]);
600
+ }
601
+
571
602
  /** ADR-154 §6: the exact lease material a spawn starts on: an AI account's,
572
603
  * or, for the Managed Engine, the task's open run alone (its agent's task
573
604
  * token is its credential). */
@@ -762,6 +793,10 @@ export class Orchestrator {
762
793
  * later command cannot overtake an earlier one after channel close. */
763
794
  private readonly taskLifecycleTails = new Map<string, Promise<void>>();
764
795
  private readonly hostSubscribers = new Set<HostEventSubscriber>();
796
+ private readonly engineUsageSubscribers = new Set<EngineUsageSubscriber>();
797
+ /** Last payload (excluding report id/time) for each leased account + agent.
798
+ * Structured Claude events can repeat without any state change. */
799
+ private readonly lastEngineUsage = new Map<string, DeliveredEngineUsage>();
765
800
  /** ADR-154 §6: launch slots retired whose retirement has not yet reached
766
801
  * their runtime, per task, whatever channel they were in: an agent's next
767
802
  * spawn launches there only once they have (`retireLeasedLaunch`). */
@@ -779,6 +814,11 @@ export class Orchestrator {
779
814
  return () => this.hostSubscribers.delete(fn);
780
815
  }
781
816
 
817
+ subscribeEngineUsageFrames(fn: EngineUsageSubscriber): () => void {
818
+ this.engineUsageSubscribers.add(fn);
819
+ return () => this.engineUsageSubscribers.delete(fn);
820
+ }
821
+
782
822
  /**
783
823
  * The cloud credential composer changed the MCP view it serves for a task.
784
824
  * Its committed lease view, not the independently delivered channel spec,
@@ -896,6 +936,30 @@ export class Orchestrator {
896
936
  }
897
937
  }
898
938
 
939
+ private emitEngineUsage(frame: EngineUsageFrame): boolean {
940
+ let delivered = false;
941
+ for (const fn of this.engineUsageSubscribers) {
942
+ try {
943
+ delivered = fn(frame) || delivered;
944
+ } catch {
945
+ // Usage is best-effort; a broken bridge subscriber cannot affect a
946
+ // running agent turn.
947
+ }
948
+ }
949
+ return delivered;
950
+ }
951
+
952
+ private forgetEngineUsage(taskId: string, agentId?: string): void {
953
+ for (const [key, delivered] of this.lastEngineUsage) {
954
+ if (
955
+ delivered.taskId === taskId &&
956
+ (agentId === undefined || delivered.agentId === agentId)
957
+ ) {
958
+ this.lastEngineUsage.delete(key);
959
+ }
960
+ }
961
+ }
962
+
899
963
  // -- channel lifecycle ----------------------------------------------------
900
964
 
901
965
  registerChannelSpec(spec: ChannelEnsureInput): void {
@@ -1683,11 +1747,19 @@ export class Orchestrator {
1683
1747
  channel: Channel,
1684
1748
  kind: string,
1685
1749
  exclude?: Set<string>,
1750
+ preferredId?: string,
1686
1751
  ): ResolvedEngineAccount | null {
1687
1752
  if (!this.isLeasedChannel(channel)) {
1688
- return pickEngineAccount(kind, { scope: channel.engineAccountScope, exclude });
1753
+ return pickEngineAccount(kind, {
1754
+ scope: channel.engineAccountScope,
1755
+ exclude,
1756
+ preferredId,
1757
+ });
1689
1758
  }
1690
- return selectAccount(this.engineAccounts(channel, kind), { exclude });
1759
+ return selectAccount(this.engineAccounts(channel, kind), {
1760
+ exclude,
1761
+ preferredId,
1762
+ });
1691
1763
  }
1692
1764
 
1693
1765
  private noteProvisionMutation(
@@ -2726,6 +2798,7 @@ export class Orchestrator {
2726
2798
  if (this.isLeasedChannel(channel)) return { ...floor, ...base };
2727
2799
  const account = pickEngineAccount(agent.kind, {
2728
2800
  scope: channel.engineAccountScope,
2801
+ preferredId: agent.accountId,
2729
2802
  });
2730
2803
  if (!account) {
2731
2804
  channel.accountByAgent.delete(agent.id);
@@ -3318,8 +3391,14 @@ export class Orchestrator {
3318
3391
  account.engineKind === kind && isLeasedEngineAccount(account),
3319
3392
  );
3320
3393
  const bound = channel.accountByAgent.get(agentId);
3394
+ const preferredId = channel.roster.find(
3395
+ (candidate) => candidate.id === agentId,
3396
+ )?.accountId;
3321
3397
  const kept = accounts.find((account) => account.credentialId === bound);
3322
- 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;
3323
3402
  const picked = selectAccount(
3324
3403
  accounts.map((account) => ({
3325
3404
  id: account.credentialId,
@@ -3330,6 +3409,7 @@ export class Orchestrator {
3330
3409
  authKind: account.authKind,
3331
3410
  execEnv: {},
3332
3411
  })),
3412
+ { preferredId },
3333
3413
  );
3334
3414
  return accounts.find((account) => account.credentialId === picked?.id);
3335
3415
  }
@@ -4586,6 +4666,7 @@ export class Orchestrator {
4586
4666
  * could silently restart canceled work on the new generation.
4587
4667
  */
4588
4668
  private retireRunnerGeneration(channel: Channel, agentId: string): void {
4669
+ this.forgetEngineUsage(channel.taskId, agentId);
4589
4670
  const aborted = channel.interrupted.delete(agentId);
4590
4671
  channel.activeTurns.delete(agentId);
4591
4672
  channel.acceptedPrompts.delete(agentId);
@@ -4929,6 +5010,108 @@ export class Orchestrator {
4929
5010
  ): Promise<void> {
4930
5011
  if (!this.isActiveChannel(channel)) return;
4931
5012
  switch (event.type) {
5013
+ case "account_usage": {
5014
+ // Only the exact live session's cloud-leased ACCOUNT may be named on
5015
+ // the wire. A runner default slot and the Managed Engine's task-token
5016
+ // identity are deliberately unreportable.
5017
+ if (channel.sessions.get(agentId) !== session) break;
5018
+ const identity = channel.leasedIdentityByAgent.get(agentId);
5019
+ if (!identity || identity.subject !== "account") break;
5020
+ const agent = channel.roster.find((candidate) => candidate.id === agentId);
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
+ });
5040
+
5041
+ const coverage = event.usage.coverage;
5042
+ if (event.usage.status === "limited") {
5043
+ const limitedWindows =
5044
+ coverage?.kind === "all-windows"
5045
+ ? event.usage.windows
5046
+ .filter((window) => (window.usedPercent ?? 0) >= 100)
5047
+ .map((window) => window.window)
5048
+ : coverage?.kind === "window"
5049
+ ? [coverage.window]
5050
+ : [];
5051
+ const causes = limitedWindows.length > 0 ? limitedWindows : [null];
5052
+ for (const window of causes) {
5053
+ if (window === null) {
5054
+ if (event.usage.resetsAt === undefined) {
5055
+ cooldownEngineAccount(identity.credentialId);
5056
+ } else {
5057
+ cooldownEngineAccountUntil(
5058
+ identity.credentialId,
5059
+ event.usage.resetsAt,
5060
+ );
5061
+ }
5062
+ } else if (event.usage.resetsAt === undefined) {
5063
+ cooldownEngineAccountWindow(identity.credentialId, window);
5064
+ } else {
5065
+ cooldownEngineAccountWindowUntil(
5066
+ identity.credentialId,
5067
+ window,
5068
+ event.usage.resetsAt,
5069
+ );
5070
+ }
5071
+ }
5072
+ } else if (coverage?.kind === "window") {
5073
+ clearEngineAccountCooldownWindow(
5074
+ identity.credentialId,
5075
+ coverage.window,
5076
+ );
5077
+ } else if (coverage?.kind === "all-windows") {
5078
+ clearEngineAccountCooldowns(identity.credentialId);
5079
+ }
5080
+
5081
+ const key = `${identity.credentialId}\0${agentId}`;
5082
+ const payload = JSON.stringify({
5083
+ taskId: channel.taskId,
5084
+ engine: agent.kind,
5085
+ credentialId: identity.credentialId,
5086
+ billing: frame.billing,
5087
+ revision: frame.revision,
5088
+ status: event.usage.status,
5089
+ resetsAt: event.usage.resetsAt,
5090
+ usingOverage: event.usage.usingOverage,
5091
+ windows: event.usage.windows,
5092
+ source: event.usage.source,
5093
+ });
5094
+ const now = Date.now();
5095
+ const previous = this.lastEngineUsage.get(key);
5096
+ if (
5097
+ previous?.payload === payload &&
5098
+ now - previous.sentAt < ENGINE_USAGE_REFRESH_MS
5099
+ ) {
5100
+ break;
5101
+ }
5102
+ const delivered = this.emitEngineUsage(frame);
5103
+ // A disconnected bridge returns false. Do not suppress the next
5104
+ // identical observation unless this one actually reached the socket.
5105
+ if (delivered) {
5106
+ this.lastEngineUsage.set(key, {
5107
+ payload,
5108
+ sentAt: now,
5109
+ taskId: channel.taskId,
5110
+ agentId,
5111
+ });
5112
+ }
5113
+ break;
5114
+ }
4932
5115
  case "message_delta": {
4933
5116
  this.markActiveTurn(channel, agentId);
4934
5117
  channel.openTurns.add(agentId);
@@ -5063,6 +5246,7 @@ export class Orchestrator {
5063
5246
  agentId,
5064
5247
  agent,
5065
5248
  abortBoundaryOnRosterChange,
5249
+ event.resetsAt,
5066
5250
  ))
5067
5251
  ) {
5068
5252
  break;
@@ -5583,6 +5767,7 @@ export class Orchestrator {
5583
5767
  agentId: string,
5584
5768
  agent: RosterAgent,
5585
5769
  abortBoundaryOnRosterChange = false,
5770
+ resetsAt?: number,
5586
5771
  ): Promise<boolean> {
5587
5772
  if (!this.isActiveChannel(channel)) return true;
5588
5773
  if (channel.rosterSessionChanges.has(agentId)) {
@@ -5606,11 +5791,15 @@ export class Orchestrator {
5606
5791
  if (budget > MAX_ROTATIONS_PER_AGENT) return false;
5607
5792
 
5608
5793
  const current = channel.accountByAgent.get(agentId);
5609
- if (current) cooldownEngineAccount(current);
5794
+ if (current) {
5795
+ if (resetsAt === undefined) cooldownEngineAccount(current);
5796
+ else cooldownEngineAccountUntil(current, resetsAt);
5797
+ }
5610
5798
  const next = this.pickAccount(
5611
5799
  channel,
5612
5800
  agent.kind,
5613
5801
  current ? new Set([current]) : undefined,
5802
+ agent.accountId,
5614
5803
  );
5615
5804
  if (!next) return false;
5616
5805
 
@@ -6156,6 +6345,7 @@ export class Orchestrator {
6156
6345
  this.channelSpecs.delete(taskId);
6157
6346
  const ch = this.channels.get(taskId);
6158
6347
  if (!ch) return Promise.resolve();
6348
+ this.forgetEngineUsage(taskId);
6159
6349
  // Invalidate the generation BEFORE the first await. Browser setup and the
6160
6350
  // start/reconcile factory continuations all check this identity, so none
6161
6351
  // can repopulate an orphan channel while its sessions close.
@@ -6477,6 +6667,9 @@ function rosterLaunchChangeReason(
6477
6667
  if (previous.kind !== next.kind) changed.push("engine");
6478
6668
  if ((previous.model ?? "") !== (next.model ?? "")) changed.push("model");
6479
6669
  if ((previous.effort ?? "") !== (next.effort ?? "")) changed.push("effort");
6670
+ if ((previous.accountId ?? "") !== (next.accountId ?? "")) {
6671
+ changed.push("account");
6672
+ }
6480
6673
  // ADR-157 §3: the session holds a token minted for its generation, so a new
6481
6674
  // one is a new agent under the handle (or one an older cloud release lost),
6482
6675
  // and only a fresh launch carries a token the cloud accepts.
@@ -6888,8 +7081,8 @@ export function buildSystemPreamble(
6888
7081
  " (at least one of the three; `default` as the model or effort clears it to",
6889
7082
  " the engine's own default)",
6890
7083
  "- `crew remove @agent --why \"…\"`",
6891
- "Use the smallest crew the job needs, often none: do small work yourself and",
6892
- "hire only when parallel work or a different skill or model pays for itself.",
7084
+ "Use the smallest crew the job needs, often none, and hire only when",
7085
+ "parallel work or a different skill or model pays for itself.",
6893
7086
  "A new agent is idle until you `dispatch` to it, so brief it then. Remove an",
6894
7087
  "agent when its part is done; its handle is not reused, so hiring that",
6895
7088
  "persona again gives it a new one. A model change restarts that agent and drops",
@@ -6914,18 +7107,24 @@ export function buildSystemPreamble(
6914
7107
  "connections and persona permissions of any task agent, except native",
6915
7108
  "subagents: helpers your engine starts on its own would work outside the",
6916
7109
  "crew, unpriced and unseen by the humans, so they are switched off. Work",
6917
- "for another agent goes to a crew agent through `dispatch`. Your default",
6918
- "job is to communicate,",
7110
+ "for another agent goes to a crew agent through `dispatch`.",
7111
+ // One rule for who does the work, the plan's, so this section and
7112
+ // "Your crew" cannot disagree: the crew may be nobody.
7113
+ "Who does each step is your plan's call: do it yourself when one agent",
7114
+ "is enough, hand it to the crew when parallel work or another skill or",
7115
+ "model pays for itself. A human's request for you to do something",
7116
+ "yourself overrides the plan.",
7117
+ "",
6919
7118
  ]
6920
7119
  : [
6921
7120
  "Your Secretary role is prompt-guided, not tool-restricted. You have the",
6922
7121
  "same normal tools, approved MCP connections, model choice, and persona",
6923
7122
  "permissions as any other task agent. Your default job is to communicate,",
7123
+ "coordinate, and dispatch implementation work rather than doing it yourself.",
7124
+ "If the human explicitly asks YOU to make a change, you may use those tools",
7125
+ "and do it directly; otherwise hand mutations to a crew agent.",
7126
+ "",
6924
7127
  ]),
6925
- "coordinate, and dispatch implementation work rather than doing it yourself.",
6926
- "If the human explicitly asks YOU to make a change, you may use those tools",
6927
- "and do it directly; otherwise hand mutations to a crew agent.",
6928
- "",
6929
7128
  ]
6930
7129
  : [];
6931
7130
  const groupMessageBrief = isSecretary
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.162",
3
+ "version": "0.9.164",
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
@@ -285,6 +285,7 @@ import { hostCommands, hostEvents, setManagedHostRestartHook } from "./index";
285
285
  import { EventOutbox, transmitEventOutbox } from "./event-outbox";
286
286
  import {
287
287
  HostErrorCode,
288
+ ENGINE_USAGE_PROTOCOL_FEATURE,
288
289
  EVENT_REPLAY_PROTOCOL_FEATURE,
289
290
  GITHUB_CREDENTIAL_GENERATION_PROTOCOL_FEATURE,
290
291
  GITHUB_INSTALLATION_VERIFICATION_PROTOCOL_FEATURE,
@@ -1486,6 +1487,7 @@ function buildCapabilities(): HostCapabilities {
1486
1487
  HOST_ENGINE_ACCOUNT_SCOPES_PROTOCOL_FEATURE,
1487
1488
  HOST_ENGINE_ACCOUNT_MANAGE_PROTOCOL_FEATURE,
1488
1489
  HOST_ENGINE_DISCONNECT_PROTOCOL_FEATURE,
1490
+ ENGINE_USAGE_PROTOCOL_FEATURE,
1489
1491
  LIVE_ROSTER_EDIT_PROTOCOL_FEATURE,
1490
1492
  AGENT_TOKEN_GENERATION_PROTOCOL_FEATURE,
1491
1493
  // The echo adapter cannot execute the in-task CLI. Advertising typed
@@ -2874,6 +2876,11 @@ async function connect(): Promise<void> {
2874
2876
  return false;
2875
2877
  }
2876
2878
  };
2879
+ const stopEngineUsageSubscription = getOrchestrator().subscribeEngineUsageFrames(
2880
+ (frame) => {
2881
+ return sendRemoteOperationFrame(frame);
2882
+ },
2883
+ );
2877
2884
 
2878
2885
  let projectEnvOperationTail = Promise.resolve();
2879
2886
  const projectEnvGenerationIsCurrent = (): boolean =>
@@ -2916,6 +2923,7 @@ async function connect(): Promise<void> {
2916
2923
  };
2917
2924
 
2918
2925
  const cleanup = (): void => {
2926
+ stopEngineUsageSubscription();
2919
2927
  // Connection loss revokes the serving gate before deferred transport
2920
2928
  // cleanup yields. A late close from a superseded socket must not revoke a
2921
2929
  // replacement generation that has already committed its fresh snapshot.
@@ -5528,6 +5536,14 @@ function expectTaskAgents(value: unknown): TaskAgent[] {
5528
5536
  // effort was previously dropped here even though the protocol + adapters
5529
5537
  // carry it — fixed alongside skills (ADR-046).
5530
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
+ }
5531
5547
  if (row.uaiProvider === "anthropic" || row.uaiProvider === "openai") {
5532
5548
  out.uaiProvider = row.uaiProvider;
5533
5549
  }
package/src/protocol.ts CHANGED
@@ -156,6 +156,68 @@ export const HOST_ENGINE_ACCOUNT_MANAGE_PROTOCOL_FEATURE =
156
156
  "host-engine-account-manage-v1";
157
157
  export const HOST_ENGINE_ACCOUNT_SCOPES_PROTOCOL_FEATURE =
158
158
  "host-engine-account-scopes-v1";
159
+ /** AI-account usage (ADR-157 slice 3, amended 2026-10-21): the host reports
160
+ * what an engine CLI said about the account an agent runs on — Claude Code's
161
+ * `rate_limit_event`, Codex's `account/rateLimits` — and the reset time of a
162
+ * cut-off, as `engine.usage` frames. Reports are best-effort and
163
+ * self-superseding: a lost one is replaced by the next. The cloud keeps the
164
+ * latest per account and never asks the provider itself (subscription
165
+ * sign-ins are for the engine CLIs only). A host advertises this once it
166
+ * sends the frames; the cloud needs nothing from a host without it. */
167
+ export const ENGINE_USAGE_PROTOCOL_FEATURE = "engine-usage-v1";
168
+ export const MAX_ENGINE_USAGE_WINDOWS = 8;
169
+
170
+ /** One usage window of an AI account, in the engine's own terms. Every
171
+ * measurement is optional: an engine reports what it reports. */
172
+ export interface EngineUsageWindow {
173
+ /** The engine's name for the window: Claude's `rateLimitType`
174
+ * (`five_hour`, `seven_day`, `seven_day_opus`, …) or Codex's `primary` /
175
+ * `secondary`. At most 64 characters. */
176
+ window: string;
177
+ /** Window length in minutes. */
178
+ windowMinutes?: number;
179
+ /** Share of the window used, 0–100. */
180
+ usedPercent?: number;
181
+ /** When the window resets, epoch ms. */
182
+ resetsAt?: number;
183
+ }
184
+
185
+ /** One usage report for one leased AI account (ADR-154 `credentialId`).
186
+ * Accounts that are not cloud credentials (a host's own default slot) are
187
+ * never reported. */
188
+ export interface EngineUsageFrame {
189
+ kind: "engine.usage";
190
+ /** Host-minted, unique per report; a repeat is ignored. */
191
+ reportId: string;
192
+ taskId: string;
193
+ agentId: string;
194
+ engine: "claude" | "codex" | "grok" | "cursor" | "opencode";
195
+ /** The `uai_credentials` id the agent's session runs on. */
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;
205
+ /** `allowed`: working; `warning`: the engine says it is close to a limit;
206
+ * `limited`: cut off until `resetsAt` (or an unknown time). */
207
+ status: "allowed" | "warning" | "limited";
208
+ /** When a `limited` account should work again, epoch ms, if known. */
209
+ resetsAt?: number;
210
+ /** Whether the engine says it is now billing past the plan (Claude
211
+ * "overage"), when it says. */
212
+ usingOverage?: boolean;
213
+ windows: EngineUsageWindow[];
214
+ /** What the report was read from. */
215
+ source: "claude-rate-limit-event" | "codex-rate-limits" | "error-text";
216
+ /** When the engine said it, epoch ms (host clock). The cloud keeps the
217
+ * newest per account, so a late report never overwrites a newer one. */
218
+ observedAt: number;
219
+ }
220
+
159
221
  /** ADR-049 addendum: live roster edits. On channel.ensure the host diffs the
160
222
  * live roster against the spec — an agent whose kind, model or effort changed
161
223
  * has its session recycled so the reconciler respawns it on the new settings,
@@ -3067,6 +3129,8 @@ export interface TaskAgent {
3067
3129
  kind: string;
3068
3130
  model?: string;
3069
3131
  effort?: string;
3132
+ /** ADR-157 addendum §7: preferred cloud-leased AI account. */
3133
+ accountId?: string;
3070
3134
  defaultPrompt?: string;
3071
3135
  initialPrompt?: string;
3072
3136
  /** ADR-046 reference material resolved from the agent's persona + team.
@@ -4806,6 +4870,7 @@ export type HostToCloud =
4806
4870
  // cloud's replay watermark only applies within a matching boot. Optional
4807
4871
  // for pre-103 hosts (which also never send seq'd events).
4808
4872
  | { kind: "auth"; token: string; hostId: string; bootId?: string }
4873
+ | EngineUsageFrame
4809
4874
  | { kind: "host.capabilities"; capabilities: HostCapabilities }
4810
4875
  | {
4811
4876
  kind: "host.inventory.page";