@runuai/host 0.9.162 → 0.9.163

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,
@@ -227,6 +233,7 @@ import {
227
233
  import { env } from "./env";
228
234
  import type {
229
235
  ChannelEnsureInput,
236
+ EngineUsageFrame,
230
237
  ChannelHuman,
231
238
  HostEvent,
232
239
  } from "../src/protocol";
@@ -247,6 +254,17 @@ import {
247
254
  import { forgetTaskEnvSnapshot } from "./task-env-source";
248
255
 
249
256
  export type HostEventSubscriber = (event: HostEvent) => void;
257
+ /** True means the frame reached the current cloud transport generation. */
258
+ export type EngineUsageSubscriber = (frame: EngineUsageFrame) => boolean;
259
+
260
+ export const ENGINE_USAGE_REFRESH_MS = 15 * 60_000;
261
+
262
+ interface DeliveredEngineUsage {
263
+ payload: string;
264
+ sentAt: number;
265
+ taskId: string;
266
+ agentId: string;
267
+ }
250
268
 
251
269
  // Rolling upgrade fence: v0 Secretary runners were launched under the
252
270
  // communicator tool-denial profile and may survive a host restart. Durable
@@ -568,6 +586,18 @@ const LEASED_CREDENTIAL_BLANKS: Readonly<Record<string, string>> = Object.freeze
568
586
  * again. */
569
587
  const LEASED_SYNC_RETRY_MS = 30_000;
570
588
 
589
+ const ENGINE_USAGE_KINDS = new Set<EngineUsageFrame["engine"]>([
590
+ "claude",
591
+ "codex",
592
+ "grok",
593
+ "cursor",
594
+ "opencode",
595
+ ]);
596
+
597
+ function isEngineUsageKind(kind: string): kind is EngineUsageFrame["engine"] {
598
+ return ENGINE_USAGE_KINDS.has(kind as EngineUsageFrame["engine"]);
599
+ }
600
+
571
601
  /** ADR-154 §6: the exact lease material a spawn starts on: an AI account's,
572
602
  * or, for the Managed Engine, the task's open run alone (its agent's task
573
603
  * token is its credential). */
@@ -762,6 +792,10 @@ export class Orchestrator {
762
792
  * later command cannot overtake an earlier one after channel close. */
763
793
  private readonly taskLifecycleTails = new Map<string, Promise<void>>();
764
794
  private readonly hostSubscribers = new Set<HostEventSubscriber>();
795
+ private readonly engineUsageSubscribers = new Set<EngineUsageSubscriber>();
796
+ /** Last payload (excluding report id/time) for each leased account + agent.
797
+ * Structured Claude events can repeat without any state change. */
798
+ private readonly lastEngineUsage = new Map<string, DeliveredEngineUsage>();
765
799
  /** ADR-154 §6: launch slots retired whose retirement has not yet reached
766
800
  * their runtime, per task, whatever channel they were in: an agent's next
767
801
  * spawn launches there only once they have (`retireLeasedLaunch`). */
@@ -779,6 +813,11 @@ export class Orchestrator {
779
813
  return () => this.hostSubscribers.delete(fn);
780
814
  }
781
815
 
816
+ subscribeEngineUsageFrames(fn: EngineUsageSubscriber): () => void {
817
+ this.engineUsageSubscribers.add(fn);
818
+ return () => this.engineUsageSubscribers.delete(fn);
819
+ }
820
+
782
821
  /**
783
822
  * The cloud credential composer changed the MCP view it serves for a task.
784
823
  * Its committed lease view, not the independently delivered channel spec,
@@ -896,6 +935,30 @@ export class Orchestrator {
896
935
  }
897
936
  }
898
937
 
938
+ private emitEngineUsage(frame: EngineUsageFrame): boolean {
939
+ let delivered = false;
940
+ for (const fn of this.engineUsageSubscribers) {
941
+ try {
942
+ delivered = fn(frame) || delivered;
943
+ } catch {
944
+ // Usage is best-effort; a broken bridge subscriber cannot affect a
945
+ // running agent turn.
946
+ }
947
+ }
948
+ return delivered;
949
+ }
950
+
951
+ private forgetEngineUsage(taskId: string, agentId?: string): void {
952
+ for (const [key, delivered] of this.lastEngineUsage) {
953
+ if (
954
+ delivered.taskId === taskId &&
955
+ (agentId === undefined || delivered.agentId === agentId)
956
+ ) {
957
+ this.lastEngineUsage.delete(key);
958
+ }
959
+ }
960
+ }
961
+
899
962
  // -- channel lifecycle ----------------------------------------------------
900
963
 
901
964
  registerChannelSpec(spec: ChannelEnsureInput): void {
@@ -4586,6 +4649,7 @@ export class Orchestrator {
4586
4649
  * could silently restart canceled work on the new generation.
4587
4650
  */
4588
4651
  private retireRunnerGeneration(channel: Channel, agentId: string): void {
4652
+ this.forgetEngineUsage(channel.taskId, agentId);
4589
4653
  const aborted = channel.interrupted.delete(agentId);
4590
4654
  channel.activeTurns.delete(agentId);
4591
4655
  channel.acceptedPrompts.delete(agentId);
@@ -4929,6 +4993,105 @@ export class Orchestrator {
4929
4993
  ): Promise<void> {
4930
4994
  if (!this.isActiveChannel(channel)) return;
4931
4995
  switch (event.type) {
4996
+ case "account_usage": {
4997
+ // Only the exact live session's cloud-leased ACCOUNT may be named on
4998
+ // the wire. A runner default slot and the Managed Engine's task-token
4999
+ // identity are deliberately unreportable.
5000
+ if (channel.sessions.get(agentId) !== session) break;
5001
+ const identity = channel.leasedIdentityByAgent.get(agentId);
5002
+ if (!identity || identity.subject !== "account") break;
5003
+ const agent = channel.roster.find((candidate) => candidate.id === agentId);
5004
+ if (!agent || !isEngineUsageKind(agent.kind)) break;
5005
+
5006
+ const coverage = event.usage.coverage;
5007
+ if (event.usage.status === "limited") {
5008
+ const limitedWindows =
5009
+ coverage?.kind === "all-windows"
5010
+ ? event.usage.windows
5011
+ .filter((window) => (window.usedPercent ?? 0) >= 100)
5012
+ .map((window) => window.window)
5013
+ : coverage?.kind === "window"
5014
+ ? [coverage.window]
5015
+ : [];
5016
+ const causes = limitedWindows.length > 0 ? limitedWindows : [null];
5017
+ for (const window of causes) {
5018
+ if (window === null) {
5019
+ if (event.usage.resetsAt === undefined) {
5020
+ cooldownEngineAccount(identity.credentialId);
5021
+ } else {
5022
+ cooldownEngineAccountUntil(
5023
+ identity.credentialId,
5024
+ event.usage.resetsAt,
5025
+ );
5026
+ }
5027
+ } else if (event.usage.resetsAt === undefined) {
5028
+ cooldownEngineAccountWindow(identity.credentialId, window);
5029
+ } else {
5030
+ cooldownEngineAccountWindowUntil(
5031
+ identity.credentialId,
5032
+ window,
5033
+ event.usage.resetsAt,
5034
+ );
5035
+ }
5036
+ }
5037
+ } else if (coverage?.kind === "window") {
5038
+ clearEngineAccountCooldownWindow(
5039
+ identity.credentialId,
5040
+ coverage.window,
5041
+ );
5042
+ } else if (coverage?.kind === "all-windows") {
5043
+ clearEngineAccountCooldowns(identity.credentialId);
5044
+ }
5045
+
5046
+ const key = `${identity.credentialId}\0${agentId}`;
5047
+ const payload = JSON.stringify({
5048
+ taskId: channel.taskId,
5049
+ engine: agent.kind,
5050
+ credentialId: identity.credentialId,
5051
+ status: event.usage.status,
5052
+ resetsAt: event.usage.resetsAt,
5053
+ usingOverage: event.usage.usingOverage,
5054
+ windows: event.usage.windows,
5055
+ source: event.usage.source,
5056
+ });
5057
+ const now = Date.now();
5058
+ const previous = this.lastEngineUsage.get(key);
5059
+ if (
5060
+ previous?.payload === payload &&
5061
+ now - previous.sentAt < ENGINE_USAGE_REFRESH_MS
5062
+ ) {
5063
+ break;
5064
+ }
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
+ });
5083
+ // A disconnected bridge returns false. Do not suppress the next
5084
+ // identical observation unless this one actually reached the socket.
5085
+ if (delivered) {
5086
+ this.lastEngineUsage.set(key, {
5087
+ payload,
5088
+ sentAt: now,
5089
+ taskId: channel.taskId,
5090
+ agentId,
5091
+ });
5092
+ }
5093
+ break;
5094
+ }
4932
5095
  case "message_delta": {
4933
5096
  this.markActiveTurn(channel, agentId);
4934
5097
  channel.openTurns.add(agentId);
@@ -5063,6 +5226,7 @@ export class Orchestrator {
5063
5226
  agentId,
5064
5227
  agent,
5065
5228
  abortBoundaryOnRosterChange,
5229
+ event.resetsAt,
5066
5230
  ))
5067
5231
  ) {
5068
5232
  break;
@@ -5583,6 +5747,7 @@ export class Orchestrator {
5583
5747
  agentId: string,
5584
5748
  agent: RosterAgent,
5585
5749
  abortBoundaryOnRosterChange = false,
5750
+ resetsAt?: number,
5586
5751
  ): Promise<boolean> {
5587
5752
  if (!this.isActiveChannel(channel)) return true;
5588
5753
  if (channel.rosterSessionChanges.has(agentId)) {
@@ -5606,7 +5771,10 @@ export class Orchestrator {
5606
5771
  if (budget > MAX_ROTATIONS_PER_AGENT) return false;
5607
5772
 
5608
5773
  const current = channel.accountByAgent.get(agentId);
5609
- if (current) cooldownEngineAccount(current);
5774
+ if (current) {
5775
+ if (resetsAt === undefined) cooldownEngineAccount(current);
5776
+ else cooldownEngineAccountUntil(current, resetsAt);
5777
+ }
5610
5778
  const next = this.pickAccount(
5611
5779
  channel,
5612
5780
  agent.kind,
@@ -6156,6 +6324,7 @@ export class Orchestrator {
6156
6324
  this.channelSpecs.delete(taskId);
6157
6325
  const ch = this.channels.get(taskId);
6158
6326
  if (!ch) return Promise.resolve();
6327
+ this.forgetEngineUsage(taskId);
6159
6328
  // Invalidate the generation BEFORE the first await. Browser setup and the
6160
6329
  // start/reconcile factory continuations all check this identity, so none
6161
6330
  // can repopulate an orphan channel while its sessions close.
@@ -6888,8 +7057,8 @@ export function buildSystemPreamble(
6888
7057
  " (at least one of the three; `default` as the model or effort clears it to",
6889
7058
  " the engine's own default)",
6890
7059
  "- `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.",
7060
+ "Use the smallest crew the job needs, often none, and hire only when",
7061
+ "parallel work or a different skill or model pays for itself.",
6893
7062
  "A new agent is idle until you `dispatch` to it, so brief it then. Remove an",
6894
7063
  "agent when its part is done; its handle is not reused, so hiring that",
6895
7064
  "persona again gives it a new one. A model change restarts that agent and drops",
@@ -6914,18 +7083,24 @@ export function buildSystemPreamble(
6914
7083
  "connections and persona permissions of any task agent, except native",
6915
7084
  "subagents: helpers your engine starts on its own would work outside the",
6916
7085
  "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,",
7086
+ "for another agent goes to a crew agent through `dispatch`.",
7087
+ // One rule for who does the work, the plan's, so this section and
7088
+ // "Your crew" cannot disagree: the crew may be nobody.
7089
+ "Who does each step is your plan's call: do it yourself when one agent",
7090
+ "is enough, hand it to the crew when parallel work or another skill or",
7091
+ "model pays for itself. A human's request for you to do something",
7092
+ "yourself overrides the plan.",
7093
+ "",
6919
7094
  ]
6920
7095
  : [
6921
7096
  "Your Secretary role is prompt-guided, not tool-restricted. You have the",
6922
7097
  "same normal tools, approved MCP connections, model choice, and persona",
6923
7098
  "permissions as any other task agent. Your default job is to communicate,",
7099
+ "coordinate, and dispatch implementation work rather than doing it yourself.",
7100
+ "If the human explicitly asks YOU to make a change, you may use those tools",
7101
+ "and do it directly; otherwise hand mutations to a crew agent.",
7102
+ "",
6924
7103
  ]),
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
7104
  ]
6930
7105
  : [];
6931
7106
  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.163",
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>",
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.
package/src/protocol.ts CHANGED
@@ -156,6 +156,60 @@ 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
+ /** `allowed`: working; `warning`: the engine says it is close to a limit;
198
+ * `limited`: cut off until `resetsAt` (or an unknown time). */
199
+ status: "allowed" | "warning" | "limited";
200
+ /** When a `limited` account should work again, epoch ms, if known. */
201
+ resetsAt?: number;
202
+ /** Whether the engine says it is now billing past the plan (Claude
203
+ * "overage"), when it says. */
204
+ usingOverage?: boolean;
205
+ windows: EngineUsageWindow[];
206
+ /** What the report was read from. */
207
+ source: "claude-rate-limit-event" | "codex-rate-limits" | "error-text";
208
+ /** When the engine said it, epoch ms (host clock). The cloud keeps the
209
+ * newest per account, so a late report never overwrites a newer one. */
210
+ observedAt: number;
211
+ }
212
+
159
213
  /** ADR-049 addendum: live roster edits. On channel.ensure the host diffs the
160
214
  * live roster against the spec — an agent whose kind, model or effort changed
161
215
  * has its session recycled so the reconciler respawns it on the new settings,
@@ -4806,6 +4860,7 @@ export type HostToCloud =
4806
4860
  // cloud's replay watermark only applies within a matching boot. Optional
4807
4861
  // for pre-103 hosts (which also never send seq'd events).
4808
4862
  | { kind: "auth"; token: string; hostId: string; bootId?: string }
4863
+ | EngineUsageFrame
4809
4864
  | { kind: "host.capabilities"; capabilities: HostCapabilities }
4810
4865
  | {
4811
4866
  kind: "host.inventory.page";