@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.
- package/lib/agent-cli.ts +9 -5
- package/lib/agents/claude.ts +112 -5
- package/lib/agents/codex.ts +177 -25
- package/lib/agents/grok.ts +2 -1
- package/lib/agents/opencode.ts +35 -12
- package/lib/agents/rate-limit.ts +299 -3
- package/lib/agents/types.ts +36 -1
- package/lib/engine-accounts.ts +94 -6
- package/lib/orchestrator.ts +211 -12
- package/package.json +9 -1
- package/src/engine-models.ts +31 -0
- package/src/engine-usage.ts +104 -0
- package/src/main.ts +16 -0
- package/src/protocol.ts +65 -0
package/lib/orchestrator.ts
CHANGED
|
@@ -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, {
|
|
1753
|
+
return pickEngineAccount(kind, {
|
|
1754
|
+
scope: channel.engineAccountScope,
|
|
1755
|
+
exclude,
|
|
1756
|
+
preferredId,
|
|
1757
|
+
});
|
|
1689
1758
|
}
|
|
1690
|
-
return selectAccount(this.engineAccounts(channel, kind), {
|
|
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
|
-
|
|
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)
|
|
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
|
|
6892
|
-
"
|
|
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`.
|
|
6918
|
-
|
|
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.
|
|
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";
|