@runuai/host 0.9.170 → 0.9.172

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/src/main.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  #!/usr/bin/env tsx
2
+ import "./load-env";
3
+ import { discoverCustomConnection, saveCustomConnection, saveCustomConnectionManual } from "../lib/custom-connection-capture";
4
+ import { OpenRouterBudgetPoller } from "../lib/openrouter-budget";
5
+ import { onLeasedEnginesChanged, leasedEnginesFor, isLeasedEngineAccount } from "../lib/credential-consumers/engines";
6
+ import { isOwnedInternalConnectionRuntime } from "../lib/internal-connections";
7
+ import { CUSTOM_CONNECTIONS_FEATURE, ENGINE_CONNECT_CUSTOM_FEATURE, ENGINE_CONNECT_CUSTOM_MANUAL_FEATURE, INTERNAL_CONNECTIONS_FEATURE, connectionOpId, parseCustomConnectionMaterial, parseCustomConnectionEnabled, parseEngineConnectCustomDiscoverFrame, parseEngineConnectCustomSaveFrame, parseEngineConnectCustomManualSaveFrame } from "./custom-connections";
2
8
  import type { AssignmentDelivery, DeliveryOrigin } from "./protocol";
9
+ import { parseCaptureFlowWire } from "./cloud-credentials";
3
10
 
4
- import "./load-env";
5
11
  import { sampleDiskPressure } from "../lib/disk-pressure";
6
12
  import { TASK_SECRETS_FEATURE, isTaskSecretStagingPut } from "./task-secrets";
7
13
  import { writeTaskSecretStaging } from "../lib/task-secret-staging";
@@ -181,7 +187,7 @@ import {
181
187
  rejectContainerRuntimeSelection,
182
188
  reprobeContainerRuntimeMachineIdentity,
183
189
  } from "../lib/container-runtime";
184
- import { persistedTaskEnvironmentsMatchMachine, reconstructPersistedTaskEnvironment } from "../lib/task-environment";
190
+ import { machineTasksEnabled, persistedTaskEnvironmentsMatchMachine, reconstructPersistedTaskEnvironment } from "../lib/task-environment";
185
191
  import { reconcileRunningTaskPersonCredentials } from "../lib/task-person-credentials";
186
192
  import { logVnodePressure } from "../lib/vnode-pressure";
187
193
  import { resolveAppleTunnelRoute } from "./apple-tunnel-route";
@@ -509,6 +515,15 @@ const bridgeUrl =
509
515
 
510
516
  let ws: WebSocket | null = null;
511
517
  let stopping = false;
518
+ const openRouterBudgets = new OpenRouterBudgetPoller({ emit: frame => {
519
+ if (!stopping && credentialFramesReady && ws?.readyState === WebSocket.OPEN) send(ws, frame);
520
+ } });
521
+ onLeasedEnginesChanged(taskId => {
522
+ if (stopping) { openRouterBudgets.stop(taskId); return; }
523
+ const lease = leasedEnginesFor(taskId);
524
+ openRouterBudgets.sync(taskId, lease?.open ? lease.accounts.flatMap(account => isLeasedEngineAccount(account) && account.authKind === "custom-connection" && account.connectionMaterial?.providerType === "openrouter" ? [{ credentialId: account.credentialId, revision: account.revision, apiKey: account.connectionMaterial.apiKey }] : []) : []);
525
+ });
526
+
512
527
  let fatal = false;
513
528
  /** Auth-rejected (4001) retry cadence. A genuinely revoked credential costs
514
529
  * one connection attempt per interval; a transient rejection (the bridge's
@@ -523,6 +538,17 @@ let shutdownRequested = false;
523
538
  let shutdownExitCode = 0;
524
539
  const managedMutationDrain = new ManagedOperationDrain(maybeExit);
525
540
  const hostLogManager = createHostLogManager();
541
+ const customConnectionOperations = new Map<AbortController, Promise<void>>();
542
+
543
+ function runCustomConnectionOperation(operation: (signal: AbortSignal) => Promise<void>): void {
544
+ void managedMutationDrain.track(async () => {
545
+ const controller = new AbortController();
546
+ const work = Promise.resolve().then(() => operation(controller.signal));
547
+ customConnectionOperations.set(controller, work);
548
+ try { await work; } finally { customConnectionOperations.delete(controller); }
549
+ });
550
+ }
551
+
526
552
  const pendingRemoteOperationCleanups = new Set<Promise<void>>();
527
553
  let stopActiveRemoteOperations = (): Promise<void> => Promise.resolve();
528
554
 
@@ -545,8 +571,10 @@ function trackRemoteOperationCleanup(cleanup: Promise<void>): Promise<void> {
545
571
  function stopRemoteOperationManagers(): Promise<void> {
546
572
  hostLogManager.stopAll();
547
573
  engineConnectManager.stopAll();
574
+ for (const controller of customConnectionOperations.keys()) controller.abort();
548
575
  return trackRemoteOperationCleanup(
549
576
  Promise.all([
577
+ ...customConnectionOperations.values(),
550
578
  engineLoginManager.stopAll(),
551
579
  // Tunnel upstreams are generation-scoped resources just like login
552
580
  // containers; a bridge replacement or shutdown must join their
@@ -784,6 +812,9 @@ const credentialsActivation = new CredentialsActivationAuthority<CredentialsReti
784
812
  requestManagedHostRestart();
785
813
  },
786
814
  log: (line) => console.warn(line),
815
+ // What stops the switch reaches the cloud too, not only this log.
816
+ onStatusChange: () => sendCapabilities(),
817
+ redact: redactHomeDirectory,
787
818
  });
788
819
  setCredentialsModeSource(() => credentialsActivation.mode());
789
820
  /** ADR-154 §13: the steady/operator cadence. Active runners report their
@@ -1030,6 +1061,7 @@ const taskFiles = sidecarMode()
1030
1061
  : null;
1031
1062
  const cloudCredentials = new CloudCredentialRuntime({
1032
1063
  runnerKind: sidecarMode() ? "task-machine" : "owned",
1064
+ internalRuntimeProvider: (assignment) => internalRuntimeProvider(assignment.taskId),
1033
1065
  send: sendCredentialFrame,
1034
1066
  quiesceProcesses: (request) => getOrchestrator().quiesceLeasedEngineAgents(request),
1035
1067
  mcpServedChanged: (taskId) => getOrchestrator().mcpServedChanged(taskId),
@@ -1489,12 +1521,30 @@ void refreshDiskSnapshot();
1489
1521
  const diskTimer = setInterval(() => void refreshDiskSnapshot(), 60_000);
1490
1522
  diskTimer.unref?.();
1491
1523
 
1524
+ /** A persisted provider wins over this runner's default selection. Legacy
1525
+ * resources without a proven provider are unknown; only a new/empty task
1526
+ * may use the selected provisioning backend as its authoritative plan. */
1527
+ function internalRuntimeProvider(taskId?: string): string | null {
1528
+ if (sidecarMode() || signInBoxMode()) return null;
1529
+ const task = taskId ? getHostTask(taskId) : undefined;
1530
+ if (task?.environmentProvider !== null && task?.environmentProvider !== undefined) return task.environmentProvider;
1531
+ if (task && (task.composeProject !== null || task.worktreePath !== null)) return null;
1532
+ if (machineTasksEnabled()) return null;
1533
+ const runtime = containerRuntimeCapabilityState();
1534
+ if (runtime.status !== "ready") return null;
1535
+ return runtime.provider === "docker" ? "docker-compose" : runtime.provider === "apple-container" ? "apple-container" : null;
1536
+ }
1537
+
1492
1538
  /** Build the current host capability advertisement (ADR-021). */
1493
1539
  function buildCapabilities(): HostCapabilities {
1494
1540
  const runtime = containerRuntimeCapabilityState();
1495
1541
  const projectEnvBindings = projectEnvBindingCapability();
1496
1542
  return {
1497
1543
  version: packageVersion(),
1544
+ customConnections: credentialsMode() === "active",
1545
+ engineConnectCustom: signInBoxMode(),
1546
+ engineConnectCustomManual: signInBoxMode(),
1547
+ internalConnections: isOwnedInternalConnectionRuntime(internalRuntimeProvider()),
1498
1548
  ...(diskSnapshot ? { disk: diskSnapshot } : {}),
1499
1549
  protocolFeatures: [
1500
1550
  "host-workspace-file-read-v1",
@@ -1540,6 +1590,9 @@ function buildCapabilities(): HostCapabilities {
1540
1590
  // ADR-161 §1.11: this image's `uai files` reads cloud links. The cloud
1541
1591
  // captures the advertisement at container creation.
1542
1592
  HOST_AGENT_FILES_FEATURE,
1593
+ ...(credentialsMode() === "active" ? [CUSTOM_CONNECTIONS_FEATURE] : []),
1594
+ ...(signInBoxMode() ? [ENGINE_CONNECT_CUSTOM_FEATURE, ENGINE_CONNECT_CUSTOM_MANUAL_FEATURE] : []),
1595
+ ...(isOwnedInternalConnectionRuntime(internalRuntimeProvider()) ? [INTERNAL_CONNECTIONS_FEATURE] : []),
1543
1596
  // ADR-154: only the sign-in box writes cloud credentials.
1544
1597
  ...(signInBoxMode()
1545
1598
  ? [
@@ -1581,9 +1634,18 @@ function buildCapabilities(): HostCapabilities {
1581
1634
  engineAccounts: engineAccountSummaries(),
1582
1635
  engines: engineCatalogCapabilities(),
1583
1636
  githubUsers: connectedUserIds(),
1637
+ // ADR-154 §13: where the switch to cloud credentials stands, and why.
1638
+ credentialsSwitch: credentialsActivation.status(),
1584
1639
  };
1585
1640
  }
1586
1641
 
1642
+ /** A path under the home directory, as `~/…`: a status line leaves the
1643
+ * machine, and the account name in a path is not the cloud's to keep. */
1644
+ function redactHomeDirectory(text: string): string {
1645
+ const home = homedir();
1646
+ return home.length > 1 ? text.split(home).join("~") : text;
1647
+ }
1648
+
1587
1649
  // engineCliStatuses probes binaries asynchronously; capabilities build
1588
1650
  // synchronously. The snapshot starts empty (cliInstalled: false) and each
1589
1651
  // build kicks a refresh that re-advertises only when the answer changed.
@@ -3046,7 +3108,7 @@ async function connect(): Promise<void> {
3046
3108
  }
3047
3109
 
3048
3110
  const frame = parseCloudFrame(data);
3049
- if (!frame) return;
3111
+ if (!frame) { replyToInvalidCustomConnectionFrame(socket, data); return; }
3050
3112
  if (stopping && rejectFrameDuringShutdown(socket, frame)) return;
3051
3113
  if (signInBoxMode() && rejectFrameOnSignInBox(socket, frame)) return;
3052
3114
  if (!onOwnCredentials() && rejectCredentialFrameOffOwnCredentials(socket, frame)) return;
@@ -3597,6 +3659,21 @@ async function connect(): Promise<void> {
3597
3659
  );
3598
3660
  break;
3599
3661
  }
3662
+ case "engine.connect.custom.discover": {
3663
+ if (!signInBoxMode()) { send(socket, { kind: "engine.connect.custom.discovered", opId: frame.opId, result: { ok: false, reason: "unsupported" } }); break; }
3664
+ runCustomConnectionOperation(signal => discoverCustomConnection(frame, { signal }).catch(() => ({ ok: false as const, reason: "upstream-error" as const })).then(result => send(socket, { kind: "engine.connect.custom.discovered", opId: frame.opId, result })));
3665
+ break;
3666
+ }
3667
+ case "engine.connect.custom.manual-save": {
3668
+ if (!signInBoxMode()) { send(socket, { kind: "engine.connect.custom.saved", opId: frame.opId, result: { ok: false, reason: "unsupported" } }); break; }
3669
+ runCustomConnectionOperation(signal => saveCustomConnectionManual(frame, configuredCredentialStore(), { signal }).catch(() => ({ ok: false as const, reason: "upstream-error" as const })).then(result => send(socket, { kind: "engine.connect.custom.saved", opId: frame.opId, result })));
3670
+ break;
3671
+ }
3672
+ case "engine.connect.custom.save": {
3673
+ if (!signInBoxMode()) { send(socket, { kind: "engine.connect.custom.saved", opId: frame.opId, result: { ok: false, reason: "unsupported" } }); break; }
3674
+ runCustomConnectionOperation(signal => saveCustomConnection(frame, configuredCredentialStore(), { signal }).catch(() => ({ ok: false as const, reason: "upstream-error" as const })).then(result => send(socket, { kind: "engine.connect.custom.saved", opId: frame.opId, result })));
3675
+ break;
3676
+ }
3600
3677
  case "mcp.capture.probe":
3601
3678
  case "mcp.capture.complete": {
3602
3679
  // ADR-154 §11: only the sign-in box runs an MCP handshake as a
@@ -4819,6 +4896,24 @@ const PICKER_FAILURE_TEXT: Record<GitHubRepositoryAccessErrorCode, string> = {
4819
4896
  invalid_response: "GitHub answered with something unexpected",
4820
4897
  };
4821
4898
 
4899
+ function replyToInvalidCustomConnectionFrame(socket: WebSocket, data: RawData): void {
4900
+ let value: unknown;
4901
+ try { value = JSON.parse(rawDataToBuffer(data).toString("utf8")); } catch { return; }
4902
+ if (!value || typeof value !== "object" || Array.isArray(value)) return;
4903
+ const frame = value as Record<string, unknown>;
4904
+ if (!connectionOpId(frame.opId)) return;
4905
+ if (frame.kind === "engine.connect.custom.discover") {
4906
+ send(socket, { kind: "engine.connect.custom.discovered", opId: frame.opId, result: { ok: false, reason: "unsupported" } });
4907
+ } else if (frame.kind === "engine.connect.custom.save" || frame.kind === "engine.connect.custom.manual-save") {
4908
+ const settings = parseCustomConnectionMaterial(frame.settings);
4909
+ const flow = parseCaptureFlowWire(frame.flow);
4910
+ const supported = settings && (frame.kind !== "engine.connect.custom.manual-save" || (settings.providerType === "custom" && settings.internal === true)) && parseCustomConnectionEnabled(frame.enabled) && flow &&
4911
+ flow.location.class === "engine" && flow.location.engineKind === settings.engine && flow.brokerParameter === null;
4912
+ send(socket, { kind: "engine.connect.custom.saved", opId: frame.opId,
4913
+ result: { ok: false, reason: supported ? "invalid-models" : "unsupported" } });
4914
+ }
4915
+ }
4916
+
4822
4917
  function parseCloudFrame(data: RawData): CloudToHost | null {
4823
4918
  let parsed: unknown;
4824
4919
  try {
@@ -5080,6 +5175,9 @@ function parseCloudFrame(data: RawData): CloudToHost | null {
5080
5175
  // ADR-154: the v1 lease, credential and task env replica frames, parsed
5081
5176
  // to their exact shapes here; only a runner on cloud credentials acts on
5082
5177
  // them.
5178
+ if (frame.kind === "engine.connect.custom.discover") return parseEngineConnectCustomDiscoverFrame(frame);
5179
+ if (frame.kind === "engine.connect.custom.manual-save") return parseEngineConnectCustomManualSaveFrame(frame);
5180
+ if (frame.kind === "engine.connect.custom.save") return parseEngineConnectCustomSaveFrame(frame);
5083
5181
  if (frame.kind === "lease.grant.v1") return parseLeaseGrantV1Frame(frame);
5084
5182
  if (frame.kind === "lease.unavailable") return parseLeaseUnavailableFrame(frame);
5085
5183
  if (frame.kind === "lease.stale") return parseLeaseStaleFrame(frame);
@@ -5201,6 +5299,13 @@ function rejectFrameDuringShutdown(
5201
5299
  ): boolean {
5202
5300
  const error = "managed host is restarting; retry the operation";
5203
5301
  switch (frame.kind) {
5302
+ case "engine.connect.custom.discover":
5303
+ send(socket, { kind: "engine.connect.custom.discovered", opId: frame.opId, result: { ok: false, reason: "unreachable" } });
5304
+ return true;
5305
+ case "engine.connect.custom.manual-save":
5306
+ case "engine.connect.custom.save":
5307
+ send(socket, { kind: "engine.connect.custom.saved", opId: frame.opId, result: { ok: false, reason: "unreachable" } });
5308
+ return true;
5204
5309
  case "command":
5205
5310
  send(socket, {
5206
5311
  kind: "result",
@@ -5614,6 +5719,12 @@ function expectTaskAgents(value: unknown): TaskAgent[] {
5614
5719
  ) {
5615
5720
  out.accountId = row.accountId;
5616
5721
  }
5722
+ if (row.accountBinding !== undefined) {
5723
+ if (row.accountBinding !== "required" && row.accountBinding !== "preferred") throw new Error("invalid command args: account binding");
5724
+ if (row.accountBinding === "required" && row.kind !== "claude" && row.kind !== "codex") throw new Error("invalid command args: unsupported required account engine");
5725
+ if (row.accountBinding === "required" && !out.accountId) throw new Error("invalid command args: required account missing");
5726
+ out.accountBinding = row.accountBinding;
5727
+ }
5617
5728
  if (row.uaiProvider === "anthropic" || row.uaiProvider === "openai") {
5618
5729
  out.uaiProvider = row.uaiProvider;
5619
5730
  }
@@ -6095,6 +6206,7 @@ function reconnectDelay(): number {
6095
6206
  function requestShutdown(signal: NodeJS.Signals): void {
6096
6207
  console.log(`[host-agent] ${signal} received; shutting down`);
6097
6208
  stopping = true;
6209
+ openRouterBudgets.stop();
6098
6210
  // Cancel human-paced operations without invalidating this socket's exact
6099
6211
  // generation: already-admitted one-shot config writes may still ack while
6100
6212
  // the ordinary in-flight drain completes.
@@ -6107,6 +6219,7 @@ function requestShutdown(signal: NodeJS.Signals): void {
6107
6219
  // it). Exit waits for it.
6108
6220
  void cloudCredentials
6109
6221
  .shutdown()
6222
+ .then(() => openRouterBudgets.drain())
6110
6223
  .catch((error: unknown) =>
6111
6224
  console.warn(`[credentials] shutdown failed: ${error instanceof Error ? error.message : String(error)}`),
6112
6225
  )
package/src/protocol.ts CHANGED
@@ -1,3 +1,5 @@
1
+ export * from "./custom-connections";
2
+ import type { AccountBinding, EngineConnectCustomDiscoverFrame, EngineConnectCustomDiscoveredFrame, EngineConnectCustomSaveFrame, EngineConnectCustomManualSaveFrame, EngineConnectCustomSavedFrame, OpenRouterBudgetFrame } from "./custom-connections";
1
3
  import type { TaskSecretApplied, TaskSecretStagingPut, TaskSecretStagingAck } from "./task-secrets";
2
4
  import {
3
5
  parseCaptureFlowWire,
@@ -3154,6 +3156,7 @@ export interface TaskAgent {
3154
3156
  effort?: string;
3155
3157
  /** ADR-157 addendum §7: preferred cloud-leased AI account. */
3156
3158
  accountId?: string;
3159
+ accountBinding?: AccountBinding;
3157
3160
  defaultPrompt?: string;
3158
3161
  initialPrompt?: string;
3159
3162
  /** ADR-046 reference material resolved from the agent's persona + team.
@@ -4068,6 +4071,10 @@ export function isHostConfigAckFrame(
4068
4071
  * changes. The cloud caches it per hostId to drive the task-creation pickers.
4069
4072
  */
4070
4073
  export interface HostCapabilities {
4074
+ customConnections?: boolean;
4075
+ engineConnectCustom?: boolean;
4076
+ engineConnectCustomManual?: boolean;
4077
+ internalConnections?: boolean;
4071
4078
  /** Filesystem capacity, refreshed each minute. Null means unmeasurable
4072
4079
  * (for example Docker in a VM); available excludes root-reserved blocks. */
4073
4080
  disk?: {
@@ -4140,6 +4147,99 @@ export interface HostCapabilities {
4140
4147
  // the per-host gh-connected state shown on the host detail page. Re-advertised
4141
4148
  // whenever a token is added/removed. Optional: older hosts omit it.
4142
4149
  githubUsers?: string[];
4150
+ /** ADR-154 §13: where this runner's switch to cloud credentials stands, and
4151
+ * what stops it. Optional: hosts before 0.9.171 omit it, and the cloud
4152
+ * then says only that the runner has not switched. */
4153
+ credentialsSwitch?: CredentialsSwitchCapability;
4154
+ }
4155
+
4156
+ /**
4157
+ * ADR-154 §13: the runner's switch to cloud credentials, as its capability
4158
+ * frame reports it. `state` is the runner's credentials mode. `stuck` is the
4159
+ * latest substantive refusal or blocker while the switch has not finished:
4160
+ * `by: "cloud"` the cloud's answer, `by: "runner"` something on the machine.
4161
+ * A transient one (the container runtime still preparing after a reconnect,
4162
+ * the bridge down) never replaces a substantive one; it shows only when
4163
+ * there is nothing else to say. Bounded and secret-free: the runner's own log
4164
+ * words (task ids, Compose project names, folder paths with the home
4165
+ * directory as `~`), never a credential.
4166
+ */
4167
+ export interface CredentialsSwitchCapability {
4168
+ state: "legacy" | "pending" | "active";
4169
+ stuck?: CredentialsSwitchStuck;
4170
+ }
4171
+
4172
+ export interface CredentialsSwitchStuck {
4173
+ by: "cloud" | "runner";
4174
+ /** The refusal or blocker in one line. */
4175
+ detail: string;
4176
+ /** Each blocker on its own, when the runner found several. */
4177
+ blockers?: string[];
4178
+ /** The `task-*` Compose projects nothing proves Uai's, which the operator
4179
+ * removes (`docker compose -p <name> down`) or renames: the first
4180
+ * `MAX_CREDENTIALS_SWITCH_PROJECTS` names, and how many there are. */
4181
+ composeProjects?: { names: string[]; total: number };
4182
+ /** Nothing is wrong yet: the runner could not look (see above). */
4183
+ transient?: boolean;
4184
+ /** Epoch ms this refusal or blocker was first seen, unchanged since. */
4185
+ since: number;
4186
+ }
4187
+
4188
+ export const MAX_CREDENTIALS_SWITCH_DETAIL_CHARS = 600;
4189
+ export const MAX_CREDENTIALS_SWITCH_BLOCKERS = 20;
4190
+ export const MAX_CREDENTIALS_SWITCH_BLOCKER_CHARS = 300;
4191
+ export const MAX_CREDENTIALS_SWITCH_PROJECTS = 100;
4192
+
4193
+ /** The capability, exact and bounded; undefined when it is not one. The
4194
+ * cloud drops a malformed one alone, never the frame around it. */
4195
+ export function parseCredentialsSwitchCapability(value: unknown): CredentialsSwitchCapability | undefined {
4196
+ if (!isPlainRecord(value)) return undefined;
4197
+ const { state, stuck } = value;
4198
+ if (state !== "legacy" && state !== "pending" && state !== "active") return undefined;
4199
+ if (Object.keys(value).some((key) => key !== "state" && key !== "stuck")) return undefined;
4200
+ if (stuck === undefined) return { state };
4201
+ if (!isPlainRecord(stuck)) return undefined;
4202
+ const allowed = new Set(["by", "detail", "blockers", "composeProjects", "transient", "since"]);
4203
+ if (Object.keys(stuck).some((key) => !allowed.has(key))) return undefined;
4204
+ const boundedText = (text: unknown, max: number): text is string =>
4205
+ typeof text === "string" && text.length >= 1 && text.length <= max;
4206
+ if (stuck.by !== "cloud" && stuck.by !== "runner") return undefined;
4207
+ if (!boundedText(stuck.detail, MAX_CREDENTIALS_SWITCH_DETAIL_CHARS)) return undefined;
4208
+ if (typeof stuck.since !== "number" || !Number.isSafeInteger(stuck.since) || stuck.since < 0) return undefined;
4209
+ if (stuck.transient !== undefined && typeof stuck.transient !== "boolean") return undefined;
4210
+ const out: CredentialsSwitchStuck = { by: stuck.by, detail: stuck.detail, since: stuck.since };
4211
+ if (stuck.blockers !== undefined) {
4212
+ if (
4213
+ !Array.isArray(stuck.blockers) ||
4214
+ stuck.blockers.length > MAX_CREDENTIALS_SWITCH_BLOCKERS ||
4215
+ !stuck.blockers.every((blocker) => boundedText(blocker, MAX_CREDENTIALS_SWITCH_BLOCKER_CHARS))
4216
+ ) {
4217
+ return undefined;
4218
+ }
4219
+ out.blockers = [...stuck.blockers];
4220
+ }
4221
+ if (stuck.composeProjects !== undefined) {
4222
+ const projects = stuck.composeProjects;
4223
+ if (!isPlainRecord(projects) || Object.keys(projects).some((key) => key !== "names" && key !== "total")) return undefined;
4224
+ const { names, total } = projects;
4225
+ if (
4226
+ !Array.isArray(names) ||
4227
+ names.length > MAX_CREDENTIALS_SWITCH_PROJECTS ||
4228
+ !names.every((name) => typeof name === "string" && /^task-[a-z0-9][a-z0-9_-]{0,127}$/.test(name)) ||
4229
+ typeof total !== "number" ||
4230
+ !Number.isSafeInteger(total) ||
4231
+ total < names.length
4232
+ ) {
4233
+ return undefined;
4234
+ }
4235
+ out.composeProjects = { names: [...names] as string[], total };
4236
+ }
4237
+ if (stuck.transient === true) out.transient = true;
4238
+ return { state, stuck: out };
4239
+ }
4240
+
4241
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
4242
+ return value !== null && typeof value === "object" && !Array.isArray(value);
4143
4243
  }
4144
4244
 
4145
4245
  /** One transcript projection the cloud asks the host to append to (ADR-083). */
@@ -4811,6 +4911,9 @@ export interface TaskMachineRetireAckFrame {
4811
4911
  }
4812
4912
 
4813
4913
  export type CloudToHost =
4914
+ | EngineConnectCustomDiscoverFrame
4915
+ | EngineConnectCustomSaveFrame
4916
+ | EngineConnectCustomManualSaveFrame
4814
4917
  | {
4815
4918
  kind: "command";
4816
4919
  commandId: string;
@@ -4990,6 +5093,9 @@ export interface AssignmentDelivery {
4990
5093
  recipientGeneration: string | null;
4991
5094
  }
4992
5095
  export interface AssignmentTurnEvidence {
5096
+ credentialId?: string | null;
5097
+ credentialRevision?: string | null;
5098
+ connection?: boolean | null;
4993
5099
  leadRunId?: string | null;
4994
5100
  turnId: string;
4995
5101
  assignmentId: string | null;
@@ -5063,6 +5169,9 @@ export type McpOp =
5063
5169
  | { kind: "disconnect"; connectionId: string };
5064
5170
 
5065
5171
  export type HostToCloud =
5172
+ | EngineConnectCustomDiscoveredFrame
5173
+ | EngineConnectCustomSavedFrame
5174
+ | OpenRouterBudgetFrame
5066
5175
  // ADR-103: `bootId` scopes event seqs to one host process lifetime — the
5067
5176
  // cloud's replay watermark only applies within a matching boot. Optional
5068
5177
  // for pre-103 hosts (which also never send seq'd events).
@@ -5265,6 +5374,8 @@ export type HostEvent =
5265
5374
  * `credentialId` is the leased AI account the turn ran on (host
5266
5375
  * 0.9.167+); older runners omit it and the cloud stores null. */
5267
5376
  usage?: {
5377
+ connection?: boolean;
5378
+ credentialRevision?: string;
5268
5379
  model?: string;
5269
5380
  inputTokens?: number;
5270
5381
  outputTokens?: number;