From d1f995db76e7b5cbd769b2f3f8bb0440f25c9929 Mon Sep 17 00:00:00 2001 From: Nikita Snetkov Date: Fri, 7 Aug 2026 19:16:13 +0500 Subject: [PATCH 1/9] fix(harness): ask a token-billed operator to pay rather than to wait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A spent allowance was reported as a subscription usage limit whatever paid for the harness. DeepSeek and Muse Code are billed by the token, so an operator whose wallet ran dry was told to wait for an allowance that never resets, to raise a plan that does not exist, and that usage-based API billing is forbidden — which is what they are running. All three sentences reach the operator through the capability card. The refusal now takes the advice from HARNESS_BILLING: a subscription is waited out, a wallet is topped up. The module is named for what it detects rather than for the billing it used to assume. Co-Authored-By: Claude Opus 5 (1M context) --- .../subscription-block.ts | 4 +- packages/harness/package.json | 9 ++- .../src/codex-cli/codex-harness.test.ts | 2 +- .../spent-allowance/spent-allowance.const.ts | 14 ++++ .../spent-allowance/spent-allowance.test.ts | 55 ++++++++++++++ .../src/spent-allowance/spent-allowance.ts | 73 +++++++++++++++++++ .../subscription-cli-harness.ts | 19 ++--- .../subscription-usage-limit.const.ts | 13 ---- .../subscription-usage-limit.test.ts | 33 --------- .../subscription-usage-limit.ts | 41 ----------- pnpm-lock.yaml | 3 + 11 files changed, 161 insertions(+), 105 deletions(-) create mode 100644 packages/harness/src/spent-allowance/spent-allowance.const.ts create mode 100644 packages/harness/src/spent-allowance/spent-allowance.test.ts create mode 100644 packages/harness/src/spent-allowance/spent-allowance.ts delete mode 100644 packages/harness/src/subscription-usage-limit/subscription-usage-limit.const.ts delete mode 100644 packages/harness/src/subscription-usage-limit/subscription-usage-limit.test.ts delete mode 100644 packages/harness/src/subscription-usage-limit/subscription-usage-limit.ts diff --git a/apps/daemon/src/subscription-allowance/subscription-block.ts b/apps/daemon/src/subscription-allowance/subscription-block.ts index c2742d50..833a283e 100644 --- a/apps/daemon/src/subscription-allowance/subscription-block.ts +++ b/apps/daemon/src/subscription-allowance/subscription-block.ts @@ -1,5 +1,5 @@ import type { HarnessKind } from "@openlab/harness/agent-harness.const"; -import { subscriptionUsageLimitError } from "@openlab/harness/subscription-usage-limit"; +import { spentAllowanceError } from "@openlab/harness/spent-allowance"; import { windowSpendCap, withheldWindows } from "@openlab/protocol/spend-caps/spend-cap"; import type { SpendCaps } from "@openlab/protocol/spend-caps/spend-cap.types"; import { @@ -78,7 +78,7 @@ function spentBlock(kind: HarnessKind, allowance: SubscriptionAllowance): Subscr * through to the next subscription, asks the operator about this one, and hibernates on the * real reason exactly as it already did. */ - capabilityError: subscriptionUsageLimitError(kind, reason) + capabilityError: spentAllowanceError(kind, reason) }; } diff --git a/packages/harness/package.json b/packages/harness/package.json index 2230c58f..d0fa3c0e 100644 --- a/packages/harness/package.json +++ b/packages/harness/package.json @@ -41,8 +41,8 @@ "./subscription-allowance": "./dist/subscription-allowance/subscription-allowance.js", "./subscription-allowance.types": "./dist/subscription-allowance/subscription-allowance.types.js", "./subscription-environment": "./dist/cli-execution/subscription-environment.js", - "./subscription-usage-limit": "./dist/subscription-usage-limit/subscription-usage-limit.js", - "./subscription-usage-limit.const": "./dist/subscription-usage-limit/subscription-usage-limit.const.js" + "./spent-allowance": "./dist/spent-allowance/spent-allowance.js", + "./spent-allowance.const": "./dist/spent-allowance/spent-allowance.const.js" }, "imports": { "#src/*": "./dist/*.js" @@ -81,8 +81,8 @@ "./subscription-allowance": "./src/subscription-allowance/subscription-allowance.ts", "./subscription-allowance.types": "./src/subscription-allowance/subscription-allowance.types.ts", "./subscription-environment": "./src/cli-execution/subscription-environment.ts", - "./subscription-usage-limit": "./src/subscription-usage-limit/subscription-usage-limit.ts", - "./subscription-usage-limit.const": "./src/subscription-usage-limit/subscription-usage-limit.const.ts" + "./spent-allowance": "./src/spent-allowance/spent-allowance.ts", + "./spent-allowance.const": "./src/spent-allowance/spent-allowance.const.ts" }, "imports": { "#src/*": "./src/*.ts" @@ -95,6 +95,7 @@ "format": "biome format --write ." }, "dependencies": { + "@openlab/protocol": "workspace:*", "execa": "10.0.1", "write-file-atomic": "8.0.0", "zod": "4.4.3" diff --git a/packages/harness/src/codex-cli/codex-harness.test.ts b/packages/harness/src/codex-cli/codex-harness.test.ts index 561a4e0a..b3ba44a9 100644 --- a/packages/harness/src/codex-cli/codex-harness.test.ts +++ b/packages/harness/src/codex-cli/codex-harness.test.ts @@ -272,6 +272,6 @@ describe("CodexHarness", () => { }); const completed = lastCompleted(events); expect(completed.result.status).toBe(HarnessRunStatuses.FAILED); - expect(completed.result.error).toContain("subscription usage limit reached"); + expect(completed.result.error).toContain(CodexTestUsageLimitMessage); }); }); diff --git a/packages/harness/src/spent-allowance/spent-allowance.const.ts b/packages/harness/src/spent-allowance/spent-allowance.const.ts new file mode 100644 index 00000000..8f379b9e --- /dev/null +++ b/packages/harness/src/spent-allowance/spent-allowance.const.ts @@ -0,0 +1,14 @@ +/** + * A vendor that has stopped serving the account ends the run with a plain sentence on its error + * channel instead of a protocol signal. Each CLI words the refusal differently, so it is recognised + * by the vocabulary the vendors share: an exhausted limit, a throttle, or an empty prepaid balance. + * The last of those is why this is not called a subscription limit — a wallet billed by the token + * runs out in exactly the same place, and says so in the same channel. + */ +export const SPENT_ALLOWANCE_PATTERNS: readonly RegExp[] = [ + /usage limit/iu, + /rate[\s_-]?limit/iu, + /quota/iu, + /too many requests/iu, + /\b(?:out of|insufficient|purchase more) credits?\b/iu +]; diff --git a/packages/harness/src/spent-allowance/spent-allowance.test.ts b/packages/harness/src/spent-allowance/spent-allowance.test.ts new file mode 100644 index 00000000..8b878e92 --- /dev/null +++ b/packages/harness/src/spent-allowance/spent-allowance.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { HarnessKinds } from "#src/agent-harness/agent-harness.const"; +import { HarnessCapabilityGaps, HarnessErrorCodes } from "#src/cli-execution/harness-error.const"; +import { CodexTestUsageLimitMessage } from "#src/codex-cli/codex-cli.fixture"; +import { isSpentAllowance, spentAllowanceError } from "#src/spent-allowance/spent-allowance"; + +describe("spent allowance", () => { + it("recognises the refusal a vendor sends when the account is spent", () => { + expect(isSpentAllowance(CodexTestUsageLimitMessage)).toBe(true); + expect(isSpentAllowance("Claude AI usage limit reached|1786000000")).toBe(true); + expect(isSpentAllowance("Request failed: 429 too many requests")).toBe(true); + expect(isSpentAllowance("coding plan quota exhausted")).toBe(true); + expect(isSpentAllowance("Error: insufficient credits on this account")).toBe(true); + }); + + it("leaves an ordinary run failure alone", () => { + expect(isSpentAllowance("Codex item event is missing its item payload")).toBe(false); + expect(isSpentAllowance("apply_patch failed: file not found")).toBe(false); + }); + + it("tells a subscription operator to wait, keeping the reset time the vendor named", () => { + const error = spentAllowanceError(HarnessKinds.CODEX, CodexTestUsageLimitMessage); + + expect(error.code).toBe(HarnessErrorCodes.SUBSCRIPTION_AUTH_REQUIRED); + expect(error.gap).toBe(HarnessCapabilityGaps.ALLOWANCE); + expect(error.capabilityRequest.reason).toContain("try again at Aug 9th, 2026 6:50 PM"); + expect(error.capabilityRequest.provisioningHint).toContain("reset"); + }); + + /** + * The wallet harnesses are the reason this refusal branches at all. Nothing resets for them, so + * an operator sent to wait for an allowance or to raise a plan is sent to do something that + * cannot happen — which is what the lab used to tell them. + */ + it("tells a token-billed operator to pay rather than to wait for a plan", () => { + const error = spentAllowanceError( + HarnessKinds.DEEPSEEK, + "Error: insufficient credits on this account" + ); + + expect(error.capabilityRequest.provisioningHint).toContain("Add balance"); + expect(error.capabilityRequest.provisioningHint).not.toMatch(/subscription|plan|reset/iu); + expect(error.message).not.toMatch(/subscription/iu); + expect(error.capabilityRequest.need).not.toMatch(/subscription|plan/iu); + }); + + /** The clause that told a DeepSeek operator their own billing was forbidden by the lab. */ + it("never tells an operator that usage billing is forbidden", () => { + for (const harness of [HarnessKinds.CODEX, HarnessKinds.DEEPSEEK, HarnessKinds.MUSE]) { + const error = spentAllowanceError(harness, "quota exhausted"); + + expect(error.capabilityRequest.provisioningHint).not.toMatch(/forbidden/iu); + } + }); +}); diff --git a/packages/harness/src/spent-allowance/spent-allowance.ts b/packages/harness/src/spent-allowance/spent-allowance.ts new file mode 100644 index 00000000..d54eab5a --- /dev/null +++ b/packages/harness/src/spent-allowance/spent-allowance.ts @@ -0,0 +1,73 @@ +import { AgentHarnessBilling, HARNESS_BILLING } from "@openlab/protocol/agents/agent-billing.const"; +import type { HarnessKind } from "#src/agent-harness/agent-harness.const"; +import { HarnessDiagnosticLevels, HarnessEventTypes } from "#src/agent-harness/harness-event.const"; +import type { ParsedHarnessEvent } from "#src/agent-harness/harness-event-parser.types"; +import { HarnessCapabilityError } from "#src/cli-execution/harness-error"; +import { HarnessCapabilityGaps } from "#src/cli-execution/harness-error.const"; +import { SPENT_ALLOWANCE_PATTERNS } from "#src/spent-allowance/spent-allowance.const"; + +export function isSpentAllowance(message: string): boolean { + return SPENT_ALLOWANCE_PATTERNS.some((pattern) => pattern.test(message)); +} + +/** The vendor sentence behind an error diagnostic that reports an account it will not serve. */ +export function spentAllowanceMessage(event: ParsedHarnessEvent): string | undefined { + return event.type === HarnessEventTypes.DIAGNOSTIC && + event.level === HarnessDiagnosticLevels.ERROR && + isSpentAllowance(event.message) + ? event.message + : undefined; +} + +/** + * A spent allowance is an operator-held account problem, never a broken event stream. Reporting it + * as a capability error is what lets the daemon fall back to another harness, ask the operator for + * the one that ran out, and hibernate on the real reason instead of on a parser complaint. The + * vendor sentence is carried through verbatim because it is the part that names what ran out. + * + * What the operator is asked to do about it depends on what pays for the harness, so it is taken + * from the billing rather than assumed: a plan comes back on its own and can be waited out, and a + * wallet does not come back at all until someone puts money in it. + */ +export function spentAllowanceError( + harness: HarnessKind, + vendorMessage: string +): HarnessCapabilityError { + return HARNESS_BILLING[harness] === AgentHarnessBilling.SUBSCRIPTION + ? spentSubscriptionError(harness, vendorMessage) + : spentWalletError(harness, vendorMessage); +} + +function spentSubscriptionError( + harness: HarnessKind, + vendorMessage: string +): HarnessCapabilityError { + return new HarnessCapabilityError( + harness, + HarnessCapabilityGaps.ALLOWANCE, + `${harness} subscription allowance is spent: ${vendorMessage}`, + { + need: `Remaining allowance on the ${harness} subscription`, + reason: vendorMessage, + provisioningHint: `Wait for the ${harness} allowance to reset, raise the plan, or sign in to another subscription, then retry` + } + ); +} + +/** + * A token-billed vendor stops for two different reasons — an empty balance and a throttle — and the + * lab cannot tell them apart from the sentence it was given. Both are named rather than guessed at, + * because the vendor's own sentence is carried directly above this and already says which one it is. + */ +function spentWalletError(harness: HarnessKind, vendorMessage: string): HarnessCapabilityError { + return new HarnessCapabilityError( + harness, + HarnessCapabilityGaps.ALLOWANCE, + `${harness} is billed by the token and the vendor stopped serving the account: ${vendorMessage}`, + { + need: `A ${harness} account the vendor will still serve`, + reason: vendorMessage, + provisioningHint: `Add balance to the ${harness} account or wait out the vendor's rate limit, then retry` + } + ); +} diff --git a/packages/harness/src/subscription-cli-harness/subscription-cli-harness.ts b/packages/harness/src/subscription-cli-harness/subscription-cli-harness.ts index d3d8ca0b..11a7ec16 100644 --- a/packages/harness/src/subscription-cli-harness/subscription-cli-harness.ts +++ b/packages/harness/src/subscription-cli-harness/subscription-cli-harness.ts @@ -34,6 +34,7 @@ import { removedHarnessEnvironmentVariables, sanitizeHarnessEnvironment } from "#src/cli-execution/subscription-environment"; +import { spentAllowanceError, spentAllowanceMessage } from "#src/spent-allowance/spent-allowance"; import { closeRunFiles, collectArtifacts, @@ -64,10 +65,6 @@ import type { SubscriptionHarnessOptions } from "#src/subscription-cli-harness/subscription-cli-harness.types"; import { runSubscriptionPreflight } from "#src/subscription-cli-harness/subscription-preflight"; -import { - subscriptionUsageLimitError, - subscriptionUsageLimitMessage -} from "#src/subscription-usage-limit/subscription-usage-limit"; export abstract class SubscriptionCliHarness implements AgentHarness { abstract readonly kind: HarnessKind; @@ -205,7 +202,7 @@ export abstract class SubscriptionCliHarness implements AgentHarness { let sequence = 0; let streamCompleted = false; let semanticError: Error | undefined; - let usageLimitMessage: string | undefined; + let spentAllowance: string | undefined; let structuredOutput: unknown; let hasStructuredOutput = false; const tailEvents: HarnessEvent[] = []; @@ -234,7 +231,7 @@ export abstract class SubscriptionCliHarness implements AgentHarness { await appendNativeEventLine(files.nativeEventsHandle, line); const nativeEvent = parseNativeEvent(this.kind, line); for (const parsedEvent of parser.parse(nativeEvent)) { - usageLimitMessage ??= subscriptionUsageLimitMessage(parsedEvent); + spentAllowance ??= spentAllowanceMessage(parsedEvent); const event = stampEvent(parsedEvent, ++sequence, this.kind, parser.sessionId); await appendEvent(files.eventsHandle, event); yield event; @@ -246,15 +243,15 @@ export abstract class SubscriptionCliHarness implements AgentHarness { throw new HarnessTimeoutError(this.kind, HarnessTimeoutPhases.RUN, timeoutMs); } /** - * A CLI that stopped because its subscription allowance is spent never reaches the end - * of its protocol, so every later check would report a symptom: a missing final message, - * an absent structured output. The refusal itself is the finding. + * A CLI that stopped because the vendor will no longer serve its account never reaches + * the end of its protocol, so every later check would report a symptom: a missing final + * message, an absent structured output. The refusal itself is the finding. */ if ( - usageLimitMessage !== undefined && + spentAllowance !== undefined && (processExit.failed || processExit.exitCode !== 0) ) { - throw subscriptionUsageLimitError(this.kind, usageLimitMessage); + throw spentAllowanceError(this.kind, spentAllowance); } for (const parsedEvent of parser.finish()) { const event = stampEvent(parsedEvent, ++sequence, this.kind, parser.sessionId); diff --git a/packages/harness/src/subscription-usage-limit/subscription-usage-limit.const.ts b/packages/harness/src/subscription-usage-limit/subscription-usage-limit.const.ts deleted file mode 100644 index 78bcdef1..00000000 --- a/packages/harness/src/subscription-usage-limit/subscription-usage-limit.const.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * A vendor that has served the whole subscription allowance ends the run with a plain sentence on - * its error channel instead of a protocol signal. Each CLI words the refusal differently, so a spent - * allowance is recognised by the vocabulary the vendors share: an exhausted limit, a throttle, or an - * empty prepaid balance. - */ -export const SUBSCRIPTION_USAGE_LIMIT_PATTERNS: readonly RegExp[] = [ - /usage limit/iu, - /rate[\s_-]?limit/iu, - /quota/iu, - /too many requests/iu, - /\b(?:out of|insufficient|purchase more) credits?\b/iu -]; diff --git a/packages/harness/src/subscription-usage-limit/subscription-usage-limit.test.ts b/packages/harness/src/subscription-usage-limit/subscription-usage-limit.test.ts deleted file mode 100644 index 9b9715da..00000000 --- a/packages/harness/src/subscription-usage-limit/subscription-usage-limit.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { HarnessKinds } from "#src/agent-harness/agent-harness.const"; -import { HarnessErrorCodes } from "#src/cli-execution/harness-error.const"; -import { CodexTestUsageLimitMessage } from "#src/codex-cli/codex-cli.fixture"; -import { - isSubscriptionUsageLimit, - subscriptionUsageLimitError -} from "#src/subscription-usage-limit/subscription-usage-limit"; - -describe("subscription usage limit", () => { - it("recognises the refusal a vendor sends when the allowance is spent", () => { - expect(isSubscriptionUsageLimit(CodexTestUsageLimitMessage)).toBe(true); - expect(isSubscriptionUsageLimit("Claude AI usage limit reached|1786000000")).toBe(true); - expect(isSubscriptionUsageLimit("Request failed: 429 too many requests")).toBe(true); - expect(isSubscriptionUsageLimit("coding plan quota exhausted")).toBe(true); - }); - - it("leaves an ordinary run failure alone", () => { - expect(isSubscriptionUsageLimit("Codex item event is missing its item payload")).toBe( - false - ); - expect(isSubscriptionUsageLimit("apply_patch failed: file not found")).toBe(false); - }); - - it("asks the operator for the account and keeps the reset time the vendor named", () => { - const error = subscriptionUsageLimitError(HarnessKinds.CODEX, CodexTestUsageLimitMessage); - - expect(error.code).toBe(HarnessErrorCodes.SUBSCRIPTION_AUTH_REQUIRED); - expect(error.harness).toBe(HarnessKinds.CODEX); - expect(error.capabilityRequest.reason).toContain("try again at Aug 9th, 2026 6:50 PM"); - expect(error.capabilityRequest.provisioningHint).toContain("API billing is forbidden"); - }); -}); diff --git a/packages/harness/src/subscription-usage-limit/subscription-usage-limit.ts b/packages/harness/src/subscription-usage-limit/subscription-usage-limit.ts deleted file mode 100644 index 6a9fb1fb..00000000 --- a/packages/harness/src/subscription-usage-limit/subscription-usage-limit.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { HarnessKind } from "#src/agent-harness/agent-harness.const"; -import { HarnessDiagnosticLevels, HarnessEventTypes } from "#src/agent-harness/harness-event.const"; -import type { ParsedHarnessEvent } from "#src/agent-harness/harness-event-parser.types"; -import { HarnessCapabilityError } from "#src/cli-execution/harness-error"; -import { HarnessCapabilityGaps } from "#src/cli-execution/harness-error.const"; -import { SUBSCRIPTION_USAGE_LIMIT_PATTERNS } from "#src/subscription-usage-limit/subscription-usage-limit.const"; - -export function isSubscriptionUsageLimit(message: string): boolean { - return SUBSCRIPTION_USAGE_LIMIT_PATTERNS.some((pattern) => pattern.test(message)); -} - -/** The vendor sentence behind an error diagnostic that reports a spent subscription allowance. */ -export function subscriptionUsageLimitMessage(event: ParsedHarnessEvent): string | undefined { - return event.type === HarnessEventTypes.DIAGNOSTIC && - event.level === HarnessDiagnosticLevels.ERROR && - isSubscriptionUsageLimit(event.message) - ? event.message - : undefined; -} - -/** - * A spent allowance is an operator-held account problem, never a broken event stream. Reporting it - * as a capability error is what lets the daemon fall back to another subscription, ask the operator - * for the one that ran out, and hibernate on the real reason instead of on a parser complaint. The - * vendor sentence is carried through verbatim because it names when the allowance returns. - */ -export function subscriptionUsageLimitError( - harness: HarnessKind, - vendorMessage: string -): HarnessCapabilityError { - return new HarnessCapabilityError( - harness, - HarnessCapabilityGaps.ALLOWANCE, - `${harness} subscription usage limit reached: ${vendorMessage}`, - { - need: `Remaining allowance on the ${harness} product subscription`, - reason: vendorMessage, - provisioningHint: `Wait for the ${harness} allowance to reset, raise the plan, or sign in to another subscription, then retry; usage-based API billing is forbidden` - } - ); -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 831066c9..418e6295 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -287,6 +287,9 @@ importers: packages/harness: dependencies: + '@openlab/protocol': + specifier: workspace:* + version: link:../protocol execa: specifier: 10.0.1 version: 10.0.1 From 813ba09eeb5c11ae510a7d6d17d95f4334a63bf0 Mon Sep 17 00:00:00 2001 From: Nikita Snetkov Date: Fri, 7 Aug 2026 19:24:03 +0500 Subject: [PATCH 2/9] test(harness): stop the watchdog fixture failing the run it belongs to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The streaming fixture rejects its process promise when the watchdog aborts, and the run only awaits that promise after it has written the events before it to disk. In between, the rejection has nothing watching it, so Node reported it as unhandled and vitest failed the whole suite on a run whose every test passed — intermittently, under the load of the full workspace test, which is what the pre-push hook runs. The rejection is now marked as observed where it is created. The run awaits the same promise and still sees the same rejection. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/cli-execution/cli-process-runner.fixture.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/harness/src/cli-execution/cli-process-runner.fixture.ts b/packages/harness/src/cli-execution/cli-process-runner.fixture.ts index f4bf93f1..f474bf62 100644 --- a/packages/harness/src/cli-execution/cli-process-runner.fixture.ts +++ b/packages/harness/src/cli-execution/cli-process-runner.fixture.ts @@ -127,6 +127,14 @@ export function streamUntilAbort( } request.signal?.addEventListener("abort", reject, { once: true }); }); + /** + * The run awaits this promise, but only after the events above have been written to disk, so the + * watchdog can reject it a macrotask before anything is watching. Node reports that window as an + * unhandled rejection and the test run fails on it even though the run went on to handle it. This + * marks the rejection as observed at the moment it can first happen; the run still awaits the same + * promise and still sees the same rejection. + */ + completed.catch(() => {}); return { stdout: Readable.from(events.map((event) => `${JSON.stringify(event)}\n`)), completed From e4df4cf5e9507aee05526a1d921f2f4117f74b87 Mon Sep 17 00:00:00 2001 From: Nikita Snetkov Date: Fri, 7 Aug 2026 19:25:52 +0500 Subject: [PATCH 3/9] fix(harness): stop the preflight refusal demanding a subscription of every harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CLI that cannot answer for its own version is a missing installation, and the sentence saying so asked for "an active product subscription login" whatever the harness authenticates with. DeepSeek is handed a key and Muse Code logs in to Meta, so both were sent after something that does not exist — and the capability card shows the sentence verbatim. The refusal now names only what this gap is about, the CLI itself. Which credential is missing is already the authentication check's to say, and each harness says it in its own words. The README requirement is corrected the same way: it still named two of the five harnesses. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 4 ++- .../subscription-preflight.test.ts | 35 +++++++++++++++++++ .../subscription-preflight.ts | 15 ++++---- 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 3520c580..852c732e 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,9 @@ Requirements: - Node.js `24.18.1` through `fnm`; - pnpm `11.18.0`; -- a locally authenticated Codex CLI or Claude CLI product subscription. +- at least one locally authenticated agent CLI harness — `codex`, `claude`, `glm`, `deepseek` or + `muse`. Three run on a subscription and two on a wallet billed by the token; the harness setup page + says which before you hand any of them a credential. ```bash fnm use diff --git a/packages/harness/src/subscription-cli-harness/subscription-preflight.test.ts b/packages/harness/src/subscription-cli-harness/subscription-preflight.test.ts index 46eb4ced..6eaf0472 100644 --- a/packages/harness/src/subscription-cli-harness/subscription-preflight.test.ts +++ b/packages/harness/src/subscription-cli-harness/subscription-preflight.test.ts @@ -8,6 +8,7 @@ import { HarnessCapabilityGaps } from "#src/cli-execution/harness-error.const"; import { testEnvironment } from "#src/cli-execution/subscription-environment.fixture"; import { CodexTestCliValues, CodexTestLoginMarkers } from "#src/codex-cli/codex-cli.fixture"; import { CodexHarness } from "#src/codex-cli/codex-harness"; +import { DeepseekHarness } from "#src/deepseek-cli/deepseek-harness"; function preflightRefusal(...captures: readonly ReturnType[]) { const harness = new CodexHarness({ @@ -47,4 +48,38 @@ describe("runSubscriptionPreflight", () => { expect(refusal).toBeInstanceOf(HarnessCapabilityError); expect(refusal).toMatchObject({ gap: HarnessCapabilityGaps.SUBSCRIPTION }); }); + + /** + * This refusal is the one sentence all five harnesses share, and two of them have no subscription + * to be asked for: DeepSeek authenticates with a key the operator hands over and Muse Code with a + * Meta login. Asking either to sign in to a product subscription sends them after something that + * does not exist, and the capability card shows the sentence verbatim. + */ + it("asks a token-billed harness for its CLI rather than for a subscription", async () => { + const harness = new DeepseekHarness({ + runner: new FakeHarnessProcessRunner([ + { ...captureSuccess(""), failed: true, exitCode: null, error: "spawn codex ENOENT" } + ]), + environment: testEnvironment(), + resolveWallet: () => + Promise.resolve({ apiKey: "sk-test", available: true, balance: "1.00 USD" }) + }); + + const refusal = await harness.preflight().then( + () => undefined, + (error: unknown) => error + ); + + expect(refusal).toBeInstanceOf(HarnessCapabilityError); + const capability = (refusal as HarnessCapabilityError).capabilityRequest; + expect(capability.provisioningHint).toContain("Install"); + for (const sentence of [ + (refusal as HarnessCapabilityError).message, + capability.need, + capability.reason, + capability.provisioningHint + ]) { + expect(sentence).not.toMatch(/subscription/iu); + } + }); }); diff --git a/packages/harness/src/subscription-cli-harness/subscription-preflight.ts b/packages/harness/src/subscription-cli-harness/subscription-preflight.ts index d9bc1ce6..bb8c7c82 100644 --- a/packages/harness/src/subscription-cli-harness/subscription-preflight.ts +++ b/packages/harness/src/subscription-cli-harness/subscription-preflight.ts @@ -100,18 +100,21 @@ function throwIfPreflightStopped( /** * The version command is what decides whether the CLI is there at all, so everything that goes wrong - * before the authentication check is answered is a missing installation. Whether the login behind it - * is the right one is the authentication check's to say, and it says so with its own gap. + * before the authentication check is answered is a missing installation. Whether the credential + * behind it is the right one is the authentication check's to say, and it says so with its own gap + * and in its own words — which is why nothing here names a subscription. Three harnesses are signed + * in interactively, one is handed a key and one logs in to Meta, and this sentence is the one all + * five get. */ function unavailableCapability(kind: HarnessKind, cause: unknown): HarnessCapabilityError { return new HarnessCapabilityError( kind, HarnessCapabilityGaps.INSTALLATION, - `${kind} CLI is unavailable or cannot report subscription authentication`, + `${kind} CLI is unavailable or cannot report its version`, { - need: `${kind} CLI with an active product subscription login`, - reason: `The ${kind} agent cannot run without proven subscription authentication`, - provisioningHint: `Install ${kind}, log in interactively with the product subscription, then retry` + need: `The ${kind} CLI on this machine`, + reason: `The ${kind} agent cannot run until its own CLI answers`, + provisioningHint: `Install the ${kind} CLI, make sure it is on PATH, then retry` }, { cause } ); From 007f5d9fbacd7d6fdfd4d3eda9b2cd4148a5e99f Mon Sep 17 00:00:00 2001 From: Nikita Snetkov Date: Fri, 7 Aug 2026 19:32:27 +0500 Subject: [PATCH 4/9] refactor(harness)!: name the harness layer after harnesses, not subscriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base class every harness extends, the preflight it runs, the environment it sanitizes and the gap it reports when a credential is missing were all named for a subscription. Three harnesses hold one; DeepSeek is handed an API key and Muse Code logs in to a Meta account, and both extended SubscriptionCliHarness and raised HarnessCapabilityGaps.SUBSCRIPTION to say they had no key. The readiness state that gap maps to has always been NOT_SIGNED_IN, so the surrounding code already disagreed with the name. SubscriptionCliHarness is CliAgentHarness, subscription-preflight is harness-preflight, subscription-environment is harness-environment, the gap is CREDENTIAL and the error code every capability error carries is CAPABILITY_REQUIRED rather than SUBSCRIPTION_AUTH_REQUIRED — it is set for a spent allowance too. Three more operator-facing sentences went with them. Dispatch asked for "a responsive Codex, Claude or GLM CLI with an active product subscription" and told the operator that "API billing is forbidden" — the second copy of a clause already removed from the spent-allowance refusal, and both are shown on the capability card. Hibernation reported "No subscription-authenticated agent CLI harness is available" for a roster of five. BREAKING CHANGE: @openlab/harness subpaths ./subscription-cli-harness, ./subscription-cli-harness.types, ./subscription-environment and ./subscription-usage-limit are now ./cli-agent-harness, ./cli-agent-harness.types, ./harness-environment and ./spent-allowance. The package is not published. Co-Authored-By: Claude Opus 5 (1M context) --- .../lab-installation/render-installation.ts | 3 +- .../agent-harness/agent-harness.fixture.ts | 20 +++--- .../harness-readiness/harness-readiness.ts | 2 +- .../src/research-cycle/agent-dispatch.ts | 14 ++-- .../src/research-cycle/harness-roster.ts | 6 +- .../src/research-cycle/research-loop.const.ts | 2 +- .../src/research-cycle/research-loop.ts | 4 +- .../src/harness-setup/harness-setup.i18n.ts | 4 +- packages/harness/package.json | 28 ++++---- .../src/claude-cli/claude-harness.test.ts | 16 ++--- .../harness/src/claude-cli/claude-harness.ts | 18 ++--- .../src/claude-cli/claude-run-arguments.ts | 2 +- .../cli-agent-harness.test.ts} | 16 ++--- .../cli-agent-harness.ts} | 66 +++++++++---------- .../cli-agent-harness.types.ts} | 2 +- .../harness-preflight.test.ts} | 6 +- .../harness-preflight.ts} | 12 ++-- .../harness-preflight.types.ts} | 2 +- .../harness-run-artifacts.const.ts | 0 .../harness-run-artifacts.ts | 8 +-- .../harness-run-artifacts.types.ts | 0 .../harness-run-events.ts | 0 .../harness-run-manifest.const.ts | 0 .../harness-run-manifest.ts | 8 +-- .../harness-run-manifest.types.ts | 2 +- .../harness-run-outcome.test.ts | 20 +++--- .../harness-run-outcome.ts | 4 +- .../harness-run-outcome.types.ts | 0 .../harness-run-request-validation.ts | 2 +- .../harness-run-status.ts | 0 .../harness-run-watchdog.const.ts | 0 .../harness-run-watchdog.ts | 4 +- .../harness-run-watchdog.types.ts | 0 .../harness-run.fixture.ts | 0 .../response-schema.test.ts | 5 +- .../response-schema.ts | 0 ....const.ts => harness-environment.const.ts} | 0 ...ture.ts => harness-environment.fixture.ts} | 0 ...nt.test.ts => harness-environment.test.ts} | 6 +- ...-environment.ts => harness-environment.ts} | 2 +- .../src/cli-execution/harness-error.const.ts | 14 ++-- .../src/cli-execution/harness-error.ts | 2 +- .../src/codex-cli/codex-harness.test.ts | 18 ++--- .../harness/src/codex-cli/codex-harness.ts | 18 ++--- .../src/deepseek-cli/deepseek-harness.test.ts | 12 ++-- .../src/deepseek-cli/deepseek-harness.ts | 14 ++-- .../deepseek-cli/deepseek-harness.types.ts | 4 +- .../harness/src/glm-cli/glm-harness.test.ts | 14 ++-- packages/harness/src/glm-cli/glm-harness.ts | 16 ++--- .../harness/src/glm-cli/glm-harness.types.ts | 4 +- .../harness/src/muse-cli/muse-harness.test.ts | 14 ++-- packages/harness/src/muse-cli/muse-harness.ts | 14 ++-- .../src/muse-cli/muse-harness.types.ts | 4 +- .../src/muse-cli/muse-structured-response.ts | 2 +- .../spent-allowance/spent-allowance.test.ts | 2 +- 55 files changed, 220 insertions(+), 216 deletions(-) rename packages/harness/src/{subscription-cli-harness/subscription-cli-harness.test.ts => cli-agent-harness/cli-agent-harness.test.ts} (97%) rename packages/harness/src/{subscription-cli-harness/subscription-cli-harness.ts => cli-agent-harness/cli-agent-harness.ts} (92%) rename packages/harness/src/{subscription-cli-harness/subscription-cli-harness.types.ts => cli-agent-harness/cli-agent-harness.types.ts} (94%) rename packages/harness/src/{subscription-cli-harness/subscription-preflight.test.ts => cli-agent-harness/harness-preflight.test.ts} (95%) rename packages/harness/src/{subscription-cli-harness/subscription-preflight.ts => cli-agent-harness/harness-preflight.ts} (90%) rename packages/harness/src/{subscription-cli-harness/subscription-preflight.types.ts => cli-agent-harness/harness-preflight.types.ts} (93%) rename packages/harness/src/{subscription-cli-harness => cli-agent-harness}/harness-run-artifacts.const.ts (100%) rename packages/harness/src/{subscription-cli-harness => cli-agent-harness}/harness-run-artifacts.ts (95%) rename packages/harness/src/{subscription-cli-harness => cli-agent-harness}/harness-run-artifacts.types.ts (100%) rename packages/harness/src/{subscription-cli-harness => cli-agent-harness}/harness-run-events.ts (100%) rename packages/harness/src/{subscription-cli-harness => cli-agent-harness}/harness-run-manifest.const.ts (100%) rename packages/harness/src/{subscription-cli-harness => cli-agent-harness}/harness-run-manifest.ts (76%) rename packages/harness/src/{subscription-cli-harness => cli-agent-harness}/harness-run-manifest.types.ts (92%) rename packages/harness/src/{subscription-cli-harness => cli-agent-harness}/harness-run-outcome.test.ts (87%) rename packages/harness/src/{subscription-cli-harness => cli-agent-harness}/harness-run-outcome.ts (90%) rename packages/harness/src/{subscription-cli-harness => cli-agent-harness}/harness-run-outcome.types.ts (100%) rename packages/harness/src/{subscription-cli-harness => cli-agent-harness}/harness-run-request-validation.ts (92%) rename packages/harness/src/{subscription-cli-harness => cli-agent-harness}/harness-run-status.ts (100%) rename packages/harness/src/{subscription-cli-harness => cli-agent-harness}/harness-run-watchdog.const.ts (100%) rename packages/harness/src/{subscription-cli-harness => cli-agent-harness}/harness-run-watchdog.ts (81%) rename packages/harness/src/{subscription-cli-harness => cli-agent-harness}/harness-run-watchdog.types.ts (100%) rename packages/harness/src/{subscription-cli-harness => cli-agent-harness}/harness-run.fixture.ts (100%) rename packages/harness/src/{subscription-cli-harness => cli-agent-harness}/response-schema.test.ts (93%) rename packages/harness/src/{subscription-cli-harness => cli-agent-harness}/response-schema.ts (100%) rename packages/harness/src/cli-execution/{subscription-environment.const.ts => harness-environment.const.ts} (100%) rename packages/harness/src/cli-execution/{subscription-environment.fixture.ts => harness-environment.fixture.ts} (100%) rename packages/harness/src/cli-execution/{subscription-environment.test.ts => harness-environment.test.ts} (85%) rename packages/harness/src/cli-execution/{subscription-environment.ts => harness-environment.ts} (94%) diff --git a/apps/cli/src/lab-installation/render-installation.ts b/apps/cli/src/lab-installation/render-installation.ts index f517f597..a1d2620f 100644 --- a/apps/cli/src/lab-installation/render-installation.ts +++ b/apps/cli/src/lab-installation/render-installation.ts @@ -64,7 +64,8 @@ export function renderInstallationReport(report: InstallationReport): string { lines.push( "", "The lab dispatches every agent to one of these CLIs and cannot research without one.", - "Install one and authenticate it with your own subscription." + "Install one and give it a credential of your own — a subscription for codex, claude or", + "glm, a key or a Meta login for deepseek and muse." ); } diff --git a/apps/daemon/src/agent-harness/agent-harness.fixture.ts b/apps/daemon/src/agent-harness/agent-harness.fixture.ts index 5bebf56a..2c800a1e 100644 --- a/apps/daemon/src/agent-harness/agent-harness.fixture.ts +++ b/apps/daemon/src/agent-harness/agent-harness.fixture.ts @@ -64,29 +64,29 @@ export function harnessNotInstalled(kind: AgentHarnessKind): StubHarness { new HarnessCapabilityError( kind, HarnessCapabilityGaps.INSTALLATION, - `${kind} CLI is unavailable`, + `${kind} CLI is unavailable or cannot report its version`, { - need: `${kind} CLI with an active product subscription login`, - reason: `The ${kind} agent cannot run without proven subscription authentication`, - provisioningHint: `Install ${kind}, log in interactively, then retry` + need: `The ${kind} CLI on this machine`, + reason: `The ${kind} agent cannot run until its own CLI answers`, + provisioningHint: `Install the ${kind} CLI, make sure it is on PATH, then retry` } ) ) ); } -/** An installed CLI whose login is not one the lab may bill: an API key, a console account. */ +/** An installed CLI holding no credential the lab can spend: signed out, or never given a key. */ export function harnessNotSignedIn(kind: AgentHarnessKind): StubHarness { return new StubHarness(kind, () => Promise.reject( new HarnessCapabilityError( kind, - HarnessCapabilityGaps.SUBSCRIPTION, - `${kind} CLI is not authenticated through a product subscription`, + HarnessCapabilityGaps.CREDENTIAL, + `${kind} holds no credential the lab can spend`, { - need: `${kind} CLI logged in through an active product subscription`, - reason: "Usage-billed authentication is forbidden", - provisioningHint: `Log in to ${kind} interactively and retry` + need: `A ${kind} credential the lab can spend`, + reason: `${kind} authenticates every run against an account the operator provides`, + provisioningHint: `Give ${kind} its credential on the harness setup page and retry` } ) ) diff --git a/apps/daemon/src/harness-readiness/harness-readiness.ts b/apps/daemon/src/harness-readiness/harness-readiness.ts index bbdc24c2..40a86963 100644 --- a/apps/daemon/src/harness-readiness/harness-readiness.ts +++ b/apps/daemon/src/harness-readiness/harness-readiness.ts @@ -15,7 +15,7 @@ import type { HarnessReadiness } from "@openlab/protocol/harness-readiness/harne */ const READINESS_FOR_GAP: Record = { [HarnessCapabilityGaps.INSTALLATION]: HarnessReadinessState.NOT_INSTALLED, - [HarnessCapabilityGaps.SUBSCRIPTION]: HarnessReadinessState.NOT_SIGNED_IN, + [HarnessCapabilityGaps.CREDENTIAL]: HarnessReadinessState.NOT_SIGNED_IN, [HarnessCapabilityGaps.ALLOWANCE]: HarnessReadinessState.UNREADABLE }; diff --git a/apps/daemon/src/research-cycle/agent-dispatch.ts b/apps/daemon/src/research-cycle/agent-dispatch.ts index 471f218e..2267c8d5 100644 --- a/apps/daemon/src/research-cycle/agent-dispatch.ts +++ b/apps/daemon/src/research-cycle/agent-dispatch.ts @@ -41,7 +41,7 @@ export class DispatchBlockedError extends Error { readonly blocks: readonly SubscriptionBlock[]; constructor(role: string, blocks: readonly SubscriptionBlock[]) { - super(`No subscription could take the ${role} work`); + super(`No agent CLI harness could take the ${role} work`); this.name = "DispatchBlockedError"; this.blocks = blocks; } @@ -131,14 +131,14 @@ export async function runAgentWithFallback } if (error instanceof HarnessCapabilityError) { blocks.push(unavailableBlock(harness.kind, error.message)); - await requestSubscriptionCapability(input.workspace, error.capabilityRequest); + await requestHarnessCapability(input.workspace, error.capabilityRequest); } } } if (blocks.length === input.available.length) { throw new DispatchBlockedError(input.role, blocks); } - throw lastError ?? new Error("All subscription CLI harness attempts failed"); + throw lastError ?? new Error("All agent CLI harness attempts failed"); } /** @@ -151,7 +151,7 @@ async function recordSubscriptionBlock( block: SubscriptionBlock ): Promise { if (block.capabilityError !== undefined) { - await requestSubscriptionCapability(workspace, block.capabilityError.capabilityRequest); + await requestHarnessCapability(workspace, block.capabilityError.capabilityRequest); return; } await workspace.appendEvent(EventType.HARNESS_WITHHELD, { @@ -203,10 +203,10 @@ export async function persistAgentCapabilityRequests( } /** - * A harness that cannot reach its product subscription genuinely stops the investigation: no agent runs at - * all until the operator restores the session, so this is the one request the daemon raises itself. + * A harness that cannot reach the account it spends genuinely stops the investigation: no agent runs + * at all until the operator restores it, so this is the one request the daemon raises itself. */ -export function requestSubscriptionCapability( +export function requestHarnessCapability( workspace: InvestigationWorkspace, request: HarnessCapabilityRequest ): Promise { diff --git a/apps/daemon/src/research-cycle/harness-roster.ts b/apps/daemon/src/research-cycle/harness-roster.ts index 9a9bca09..f8421652 100644 --- a/apps/daemon/src/research-cycle/harness-roster.ts +++ b/apps/daemon/src/research-cycle/harness-roster.ts @@ -2,7 +2,7 @@ import type { AgentHarness } from "@openlab/harness/agent-harness.types"; import { HarnessCapabilityError } from "@openlab/harness/harness-error"; import { EventType } from "@openlab/protocol/investigation-events/event-type.const"; import type { InvestigationWorkspace } from "#src/investigation-workspace/investigation-workspace"; -import { requestSubscriptionCapability } from "#src/research-cycle/agent-dispatch"; +import { requestHarnessCapability } from "#src/research-cycle/agent-dispatch"; import type { AvailableHarness } from "#src/research-cycle/research-loop.types"; export async function preflightHarnesses( @@ -30,7 +30,7 @@ export async function preflightHarnesses( error: error instanceof Error ? error.message : String(error) }); if (error instanceof HarnessCapabilityError) { - await requestSubscriptionCapability(workspace, error.capabilityRequest); + await requestHarnessCapability(workspace, error.capabilityRequest); } return undefined; } @@ -45,7 +45,7 @@ export function selectHarness( ): AvailableHarness { const selected = available[index % available.length]; if (selected === undefined) { - throw new Error("No subscription-authenticated harness is available"); + throw new Error("No authenticated agent CLI harness is available"); } return selected; } diff --git a/apps/daemon/src/research-cycle/research-loop.const.ts b/apps/daemon/src/research-cycle/research-loop.const.ts index b047d18a..1627890b 100644 --- a/apps/daemon/src/research-cycle/research-loop.const.ts +++ b/apps/daemon/src/research-cycle/research-loop.const.ts @@ -15,7 +15,7 @@ export const PromiseSettlement = { export const DEFAULT_CYCLE_BACKOFF_MS = 5_000; export const HibernationReason = { - NO_HARNESS: "No subscription-authenticated agent CLI harness is available", + NO_HARNESS: "No agent CLI harness could take the work", NO_DIRECTION: "The director could not name anywhere else worth looking", SPEND_CAP: "Every subscription this investigation may use has reached the spend cap set on it" } as const; diff --git a/apps/daemon/src/research-cycle/research-loop.ts b/apps/daemon/src/research-cycle/research-loop.ts index cfb0f61d..b3191f18 100644 --- a/apps/daemon/src/research-cycle/research-loop.ts +++ b/apps/daemon/src/research-cycle/research-loop.ts @@ -150,10 +150,10 @@ async function hibernateOnBlockedDispatch( const reason = blockedDispatchReason(blocks); if (!heldBySpendCaps(blocks) && workspace.getSnapshot().capability_requests.length === 0) { await workspace.requestCapability({ - need: "A responsive Codex, Claude or GLM CLI with an active product subscription", + need: "A responsive agent CLI harness this investigation can dispatch to", reason, provisioningHint: - "Restore a local product-subscription CLI session and retry; API billing is forbidden", + "Sign a harness back in on the setup page, or give the lab a credential it can spend, then retry", blocking: true }); } diff --git a/apps/dashboard/src/harness-setup/harness-setup.i18n.ts b/apps/dashboard/src/harness-setup/harness-setup.i18n.ts index 82f9eb03..816f2ff2 100644 --- a/apps/dashboard/src/harness-setup/harness-setup.i18n.ts +++ b/apps/dashboard/src/harness-setup/harness-setup.i18n.ts @@ -5,7 +5,7 @@ export const HARNESS_SETUP_NAMESPACE = "harness-setup" as const; export const HARNESS_SETUP_EN = { title: "Give the lab something to think with", - lead: "The lab has no model of its own. It drives the coding CLIs you already pay for, on this machine, under your own subscription.", + lead: "The lab has no model of its own. It drives the coding CLIs you already pay for, on this machine, under your own account.", noKeys: "Subscriptions are the way to run this. A plan you already bought costs the same whether the lab works for ten minutes or all night. Two harnesses do not work that way — DeepSeek and Muse Code are billed by the token, and their cards say so.", enough: "One is enough to start. A second is what lets the lab check its own work — a claim only counts once a different vendor reproduces it.", @@ -60,7 +60,7 @@ export const HARNESS_SETUP_EN = { export const HARNESS_SETUP_RU = { title: "Дай лаборатории, чем думать", - lead: "У лаборатории нет своей модели. Она запускает те CLI, за которые ты уже платишь, — на этой машине и по твоей подписке.", + lead: "У лаборатории нет своей модели. Она запускает те CLI, за которые ты уже платишь, — на этой машине и под твоей учётной записью.", noKeys: "Правильный способ — подписки. План, который уже куплен, стоит одинаково, работает лаборатория десять минут или всю ночь. Иначе устроены две оболочки: DeepSeek и Muse Code платят по токенам, и на их карточках это написано.", enough: "Чтобы начать, хватит одного. Второй нужен, чтобы лаборатория проверяла саму себя: заявление засчитывается, только когда его воспроизводит другой вендор.", diff --git a/packages/harness/package.json b/packages/harness/package.json index d0fa3c0e..1b72e3d4 100644 --- a/packages/harness/package.json +++ b/packages/harness/package.json @@ -28,19 +28,19 @@ "./harness-error": "./dist/cli-execution/harness-error.js", "./harness-error.const": "./dist/cli-execution/harness-error.const.js", "./harness-event-parser.types": "./dist/agent-harness/harness-event-parser.types.js", - "./harness-run-artifacts.const": "./dist/subscription-cli-harness/harness-run-artifacts.const.js", - "./harness-run-outcome": "./dist/subscription-cli-harness/harness-run-outcome.js", - "./harness-run-outcome.types": "./dist/subscription-cli-harness/harness-run-outcome.types.js", + "./harness-run-artifacts.const": "./dist/cli-agent-harness/harness-run-artifacts.const.js", + "./harness-run-outcome": "./dist/cli-agent-harness/harness-run-outcome.js", + "./harness-run-outcome.types": "./dist/cli-agent-harness/harness-run-outcome.types.js", "./harness-event.const": "./dist/agent-harness/harness-event.const.js", "./harness-event.types": "./dist/agent-harness/harness-event.types.js", "./muse-cli.const": "./dist/muse-cli/muse-cli.const.js", "./muse-harness": "./dist/muse-cli/muse-harness.js", - "./response-schema": "./dist/subscription-cli-harness/response-schema.js", - "./subscription-cli-harness": "./dist/subscription-cli-harness/subscription-cli-harness.js", - "./subscription-cli-harness.types": "./dist/subscription-cli-harness/subscription-cli-harness.types.js", + "./response-schema": "./dist/cli-agent-harness/response-schema.js", + "./cli-agent-harness": "./dist/cli-agent-harness/cli-agent-harness.js", + "./cli-agent-harness.types": "./dist/cli-agent-harness/cli-agent-harness.types.js", "./subscription-allowance": "./dist/subscription-allowance/subscription-allowance.js", "./subscription-allowance.types": "./dist/subscription-allowance/subscription-allowance.types.js", - "./subscription-environment": "./dist/cli-execution/subscription-environment.js", + "./harness-environment": "./dist/cli-execution/harness-environment.js", "./spent-allowance": "./dist/spent-allowance/spent-allowance.js", "./spent-allowance.const": "./dist/spent-allowance/spent-allowance.const.js" }, @@ -68,19 +68,19 @@ "./harness-error": "./src/cli-execution/harness-error.ts", "./harness-error.const": "./src/cli-execution/harness-error.const.ts", "./harness-event-parser.types": "./src/agent-harness/harness-event-parser.types.ts", - "./harness-run-artifacts.const": "./src/subscription-cli-harness/harness-run-artifacts.const.ts", - "./harness-run-outcome": "./src/subscription-cli-harness/harness-run-outcome.ts", - "./harness-run-outcome.types": "./src/subscription-cli-harness/harness-run-outcome.types.ts", + "./harness-run-artifacts.const": "./src/cli-agent-harness/harness-run-artifacts.const.ts", + "./harness-run-outcome": "./src/cli-agent-harness/harness-run-outcome.ts", + "./harness-run-outcome.types": "./src/cli-agent-harness/harness-run-outcome.types.ts", "./harness-event.const": "./src/agent-harness/harness-event.const.ts", "./harness-event.types": "./src/agent-harness/harness-event.types.ts", "./muse-cli.const": "./src/muse-cli/muse-cli.const.ts", "./muse-harness": "./src/muse-cli/muse-harness.ts", - "./response-schema": "./src/subscription-cli-harness/response-schema.ts", - "./subscription-cli-harness": "./src/subscription-cli-harness/subscription-cli-harness.ts", - "./subscription-cli-harness.types": "./src/subscription-cli-harness/subscription-cli-harness.types.ts", + "./response-schema": "./src/cli-agent-harness/response-schema.ts", + "./cli-agent-harness": "./src/cli-agent-harness/cli-agent-harness.ts", + "./cli-agent-harness.types": "./src/cli-agent-harness/cli-agent-harness.types.ts", "./subscription-allowance": "./src/subscription-allowance/subscription-allowance.ts", "./subscription-allowance.types": "./src/subscription-allowance/subscription-allowance.types.ts", - "./subscription-environment": "./src/cli-execution/subscription-environment.ts", + "./harness-environment": "./src/cli-execution/harness-environment.ts", "./spent-allowance": "./src/spent-allowance/spent-allowance.ts", "./spent-allowance.const": "./src/spent-allowance/spent-allowance.const.ts" }, diff --git a/packages/harness/src/claude-cli/claude-harness.test.ts b/packages/harness/src/claude-cli/claude-harness.test.ts index 6beb402a..bd5e3177 100644 --- a/packages/harness/src/claude-cli/claude-harness.test.ts +++ b/packages/harness/src/claude-cli/claude-harness.test.ts @@ -9,20 +9,20 @@ import { import { HarnessEventTypes, HarnessToolPhases } from "#src/agent-harness/harness-event.const"; import { ClaudePermissionModes, ClaudeSessionDefaults } from "#src/claude-cli/claude-cli.const"; import { ClaudeHarness } from "#src/claude-cli/claude-harness"; -import { - captureSuccess, - FakeHarnessProcessRunner, - streamSuccess -} from "#src/cli-execution/cli-process-runner.fixture"; -import { HarnessCapabilityError } from "#src/cli-execution/harness-error"; -import { testEnvironment } from "#src/cli-execution/subscription-environment.fixture"; import { AnswerJsonSchema, answerSchema, harnessRequest, lastCompleted, removeHarnessRunDirectories -} from "#src/subscription-cli-harness/harness-run.fixture"; +} from "#src/cli-agent-harness/harness-run.fixture"; +import { + captureSuccess, + FakeHarnessProcessRunner, + streamSuccess +} from "#src/cli-execution/cli-process-runner.fixture"; +import { testEnvironment } from "#src/cli-execution/harness-environment.fixture"; +import { HarnessCapabilityError } from "#src/cli-execution/harness-error"; const ClaudeTestNativeEventTypes = { SYSTEM: "system", diff --git a/packages/harness/src/claude-cli/claude-harness.ts b/packages/harness/src/claude-cli/claude-harness.ts index 7b0dc960..83f553eb 100644 --- a/packages/harness/src/claude-cli/claude-harness.ts +++ b/packages/harness/src/claude-cli/claude-harness.ts @@ -16,15 +16,15 @@ import { claudeAuthenticationCommand, claudeRunArguments } from "#src/claude-cli/claude-run-arguments"; +import { CliAgentHarness } from "#src/cli-agent-harness/cli-agent-harness"; +import type { + CliHarnessOptions, + HarnessCommand, + HarnessRunPaths +} from "#src/cli-agent-harness/cli-agent-harness.types"; import type { HarnessCaptureResult } from "#src/cli-execution/cli-process-runner.types"; import { HarnessCapabilityError } from "#src/cli-execution/harness-error"; import { HarnessCapabilityGaps } from "#src/cli-execution/harness-error.const"; -import { SubscriptionCliHarness } from "#src/subscription-cli-harness/subscription-cli-harness"; -import type { - HarnessCommand, - HarnessRunPaths, - SubscriptionHarnessOptions -} from "#src/subscription-cli-harness/subscription-cli-harness.types"; const ClaudeAuthStatusSchema = z.looseObject({ loggedIn: z.literal(true), @@ -33,10 +33,10 @@ const ClaudeAuthStatusSchema = z.looseObject({ subscriptionType: z.string().trim().min(1) }); -export class ClaudeHarness extends SubscriptionCliHarness { +export class ClaudeHarness extends CliAgentHarness { readonly kind = HarnessKinds.CLAUDE; - constructor(options: SubscriptionHarnessOptions = {}) { + constructor(options: CliHarnessOptions = {}) { super(CLAUDE_BINARY, options); } @@ -58,7 +58,7 @@ export class ClaudeHarness extends SubscriptionCliHarness { } catch (error) { throw new HarnessCapabilityError( this.kind, - HarnessCapabilityGaps.SUBSCRIPTION, + HarnessCapabilityGaps.CREDENTIAL, "Claude CLI is not authenticated through a claude.ai subscription", { need: "Claude CLI logged in through an active claude.ai subscription", diff --git a/packages/harness/src/claude-cli/claude-run-arguments.ts b/packages/harness/src/claude-cli/claude-run-arguments.ts index 9042995d..e0992833 100644 --- a/packages/harness/src/claude-cli/claude-run-arguments.ts +++ b/packages/harness/src/claude-cli/claude-run-arguments.ts @@ -1,7 +1,7 @@ import { HarnessExecutionProfiles } from "#src/agent-harness/agent-harness.const"; import type { HarnessRunRequest, HarnessSession } from "#src/agent-harness/agent-harness.types"; import { ClaudeOutputFormats, ClaudePermissionModes } from "#src/claude-cli/claude-cli.const"; -import { responseJsonSchema } from "#src/subscription-cli-harness/response-schema"; +import { responseJsonSchema } from "#src/cli-agent-harness/response-schema"; /** * The headless command line for one Claude CLI run. Every harness that drives this binary shares it, diff --git a/packages/harness/src/subscription-cli-harness/subscription-cli-harness.test.ts b/packages/harness/src/cli-agent-harness/cli-agent-harness.test.ts similarity index 97% rename from packages/harness/src/subscription-cli-harness/subscription-cli-harness.test.ts rename to packages/harness/src/cli-agent-harness/cli-agent-harness.test.ts index e005e2ec..9a1ce5ca 100644 --- a/packages/harness/src/subscription-cli-harness/subscription-cli-harness.test.ts +++ b/packages/harness/src/cli-agent-harness/cli-agent-harness.test.ts @@ -4,6 +4,12 @@ import { afterEach, describe, expect, it } from "vitest"; import { HarnessRunStatuses } from "#src/agent-harness/agent-harness.const"; import { HarnessEventTypes } from "#src/agent-harness/harness-event.const"; import type { HarnessEvent } from "#src/agent-harness/harness-event.types"; +import { + harnessRequest, + lastCompleted, + removeHarnessRunDirectories, + TestTimeoutMilliseconds +} from "#src/cli-agent-harness/harness-run.fixture"; import { captureCancellationOnAbort, captureSuccess, @@ -13,25 +19,19 @@ import { TestProcessSignals } from "#src/cli-execution/cli-process-runner.fixture"; import type { HarnessProcessExit } from "#src/cli-execution/cli-process-runner.types"; +import { testEnvironment } from "#src/cli-execution/harness-environment.fixture"; import { HarnessAbortedError } from "#src/cli-execution/harness-error"; import { HarnessErrorCodes, HarnessTimeoutPhases } from "#src/cli-execution/harness-error.const"; -import { testEnvironment } from "#src/cli-execution/subscription-environment.fixture"; import { CodexTestCliValues, CodexTestLoginMarkers, CodexTestNativeEventTypes } from "#src/codex-cli/codex-cli.fixture"; import { CodexHarness } from "#src/codex-cli/codex-harness"; -import { - harnessRequest, - lastCompleted, - removeHarnessRunDirectories, - TestTimeoutMilliseconds -} from "#src/subscription-cli-harness/harness-run.fixture"; afterEach(removeHarnessRunDirectories); -describe("subscription CLI harness run lifecycle", () => { +describe("CLI agent harness run lifecycle", () => { it("honors an already-aborted signal before invoking a CLI", async () => { const runner = new FakeHarnessProcessRunner([]); const harness = new CodexHarness({ runner, environment: testEnvironment() }); diff --git a/packages/harness/src/subscription-cli-harness/subscription-cli-harness.ts b/packages/harness/src/cli-agent-harness/cli-agent-harness.ts similarity index 92% rename from packages/harness/src/subscription-cli-harness/subscription-cli-harness.ts rename to packages/harness/src/cli-agent-harness/cli-agent-harness.ts index 11a7ec16..3889a8ac 100644 --- a/packages/harness/src/subscription-cli-harness/subscription-cli-harness.ts +++ b/packages/harness/src/cli-agent-harness/cli-agent-harness.ts @@ -18,62 +18,62 @@ import type { import { HarnessDiagnosticLevels, HarnessEventTypes } from "#src/agent-harness/harness-event.const"; import type { HarnessCompletedEvent, HarnessEvent } from "#src/agent-harness/harness-event.types"; import type { HarnessEventParser } from "#src/agent-harness/harness-event-parser.types"; -import { ExecaHarnessProcessRunner } from "#src/cli-execution/cli-process-runner"; import type { - HarnessCaptureResult, - HarnessProcessExit, - HarnessProcessRunner -} from "#src/cli-execution/cli-process-runner.types"; -import { - HarnessCapabilityError, - HarnessProtocolError, - HarnessTimeoutError -} from "#src/cli-execution/harness-error"; -import { HarnessTimeoutPhases } from "#src/cli-execution/harness-error.const"; -import { - removedHarnessEnvironmentVariables, - sanitizeHarnessEnvironment -} from "#src/cli-execution/subscription-environment"; -import { spentAllowanceError, spentAllowanceMessage } from "#src/spent-allowance/spent-allowance"; + CliHarnessOptions, + HarnessCommand, + HarnessRunPaths +} from "#src/cli-agent-harness/cli-agent-harness.types"; +import { runHarnessPreflight } from "#src/cli-agent-harness/harness-preflight"; import { closeRunFiles, collectArtifacts, createRunFiles, writeStderrArtifact -} from "#src/subscription-cli-harness/harness-run-artifacts"; +} from "#src/cli-agent-harness/harness-run-artifacts"; import { appendEvent, appendNativeEventLine, parseNativeEvent, stampEvent -} from "#src/subscription-cli-harness/harness-run-events"; +} from "#src/cli-agent-harness/harness-run-events"; import { writeFinishedRunManifest, writeStartedRunManifest -} from "#src/subscription-cli-harness/harness-run-manifest"; -import type { FinishedRunManifest } from "#src/subscription-cli-harness/harness-run-manifest.types"; -import { validateHarnessRunRequest } from "#src/subscription-cli-harness/harness-run-request-validation"; -import { determineRunStatus } from "#src/subscription-cli-harness/harness-run-status"; +} from "#src/cli-agent-harness/harness-run-manifest"; +import type { FinishedRunManifest } from "#src/cli-agent-harness/harness-run-manifest.types"; +import { validateHarnessRunRequest } from "#src/cli-agent-harness/harness-run-request-validation"; +import { determineRunStatus } from "#src/cli-agent-harness/harness-run-status"; import { createWatchdogSignal, validateTimeoutMilliseconds -} from "#src/subscription-cli-harness/harness-run-watchdog"; -import { parseStructuredOutput } from "#src/subscription-cli-harness/response-schema"; +} from "#src/cli-agent-harness/harness-run-watchdog"; +import { parseStructuredOutput } from "#src/cli-agent-harness/response-schema"; +import { ExecaHarnessProcessRunner } from "#src/cli-execution/cli-process-runner"; import type { - HarnessCommand, - HarnessRunPaths, - SubscriptionHarnessOptions -} from "#src/subscription-cli-harness/subscription-cli-harness.types"; -import { runSubscriptionPreflight } from "#src/subscription-cli-harness/subscription-preflight"; + HarnessCaptureResult, + HarnessProcessExit, + HarnessProcessRunner +} from "#src/cli-execution/cli-process-runner.types"; +import { + removedHarnessEnvironmentVariables, + sanitizeHarnessEnvironment +} from "#src/cli-execution/harness-environment"; +import { + HarnessCapabilityError, + HarnessProtocolError, + HarnessTimeoutError +} from "#src/cli-execution/harness-error"; +import { HarnessTimeoutPhases } from "#src/cli-execution/harness-error.const"; +import { spentAllowanceError, spentAllowanceMessage } from "#src/spent-allowance/spent-allowance"; -export abstract class SubscriptionCliHarness implements AgentHarness { +export abstract class CliAgentHarness implements AgentHarness { abstract readonly kind: HarnessKind; readonly #binary: string; readonly #runner: HarnessProcessRunner; readonly #sourceEnvironment: Readonly; readonly #preflightTimeoutMs: number; - protected constructor(defaultBinary: string, options: SubscriptionHarnessOptions) { + protected constructor(defaultBinary: string, options: CliHarnessOptions) { this.#binary = options.binary ?? defaultBinary; this.#runner = options.runner ?? new ExecaHarnessProcessRunner(); this.#sourceEnvironment = options.environment ?? process.env; @@ -114,7 +114,7 @@ export abstract class SubscriptionCliHarness implements AgentHarness { /** * The environment the CLI runs with, once every inherited provider credential has been stripped. - * A harness whose subscription credential lives outside the CLI's own login adds it back here, + * A harness whose credential lives outside the CLI's own login adds it back here, * and nowhere else, so the run can never inherit a stray endpoint from the operator's shell. */ protected extendEnvironment( @@ -143,7 +143,7 @@ export abstract class SubscriptionCliHarness implements AgentHarness { cwd: string, signal?: AbortSignal ): Promise { - return runSubscriptionPreflight( + return runHarnessPreflight( { kind: this.kind, binary: this.#binary, diff --git a/packages/harness/src/subscription-cli-harness/subscription-cli-harness.types.ts b/packages/harness/src/cli-agent-harness/cli-agent-harness.types.ts similarity index 94% rename from packages/harness/src/subscription-cli-harness/subscription-cli-harness.types.ts rename to packages/harness/src/cli-agent-harness/cli-agent-harness.types.ts index 4a2b39cc..46a91464 100644 --- a/packages/harness/src/subscription-cli-harness/subscription-cli-harness.types.ts +++ b/packages/harness/src/cli-agent-harness/cli-agent-harness.types.ts @@ -1,6 +1,6 @@ import type { HarnessProcessRunner } from "#src/cli-execution/cli-process-runner.types"; -export interface SubscriptionHarnessOptions { +export interface CliHarnessOptions { readonly binary?: string; readonly runner?: HarnessProcessRunner; readonly environment?: Readonly; diff --git a/packages/harness/src/subscription-cli-harness/subscription-preflight.test.ts b/packages/harness/src/cli-agent-harness/harness-preflight.test.ts similarity index 95% rename from packages/harness/src/subscription-cli-harness/subscription-preflight.test.ts rename to packages/harness/src/cli-agent-harness/harness-preflight.test.ts index 6eaf0472..b3347e2b 100644 --- a/packages/harness/src/subscription-cli-harness/subscription-preflight.test.ts +++ b/packages/harness/src/cli-agent-harness/harness-preflight.test.ts @@ -3,9 +3,9 @@ import { captureSuccess, FakeHarnessProcessRunner } from "#src/cli-execution/cli-process-runner.fixture"; +import { testEnvironment } from "#src/cli-execution/harness-environment.fixture"; import { HarnessCapabilityError } from "#src/cli-execution/harness-error"; import { HarnessCapabilityGaps } from "#src/cli-execution/harness-error.const"; -import { testEnvironment } from "#src/cli-execution/subscription-environment.fixture"; import { CodexTestCliValues, CodexTestLoginMarkers } from "#src/codex-cli/codex-cli.fixture"; import { CodexHarness } from "#src/codex-cli/codex-harness"; import { DeepseekHarness } from "#src/deepseek-cli/deepseek-harness"; @@ -26,7 +26,7 @@ function preflightRefusal(...captures: readonly ReturnType { +describe("runHarnessPreflight", () => { it("reports a CLI that is not there as a missing installation", async () => { const refusal = await preflightRefusal({ ...captureSuccess(""), @@ -46,7 +46,7 @@ describe("runSubscriptionPreflight", () => { }); expect(refusal).toBeInstanceOf(HarnessCapabilityError); - expect(refusal).toMatchObject({ gap: HarnessCapabilityGaps.SUBSCRIPTION }); + expect(refusal).toMatchObject({ gap: HarnessCapabilityGaps.CREDENTIAL }); }); /** diff --git a/packages/harness/src/subscription-cli-harness/subscription-preflight.ts b/packages/harness/src/cli-agent-harness/harness-preflight.ts similarity index 90% rename from packages/harness/src/subscription-cli-harness/subscription-preflight.ts rename to packages/harness/src/cli-agent-harness/harness-preflight.ts index bb8c7c82..ac117f23 100644 --- a/packages/harness/src/subscription-cli-harness/subscription-preflight.ts +++ b/packages/harness/src/cli-agent-harness/harness-preflight.ts @@ -1,5 +1,8 @@ import type { HarnessKind } from "#src/agent-harness/agent-harness.const"; import type { HarnessPreflight } from "#src/agent-harness/agent-harness.types"; +import type { HarnessPreflightRequest } from "#src/cli-agent-harness/harness-preflight.types"; +import { createWatchdogSignal } from "#src/cli-agent-harness/harness-run-watchdog"; +import type { WatchdogSignal } from "#src/cli-agent-harness/harness-run-watchdog.types"; import type { HarnessCaptureResult } from "#src/cli-execution/cli-process-runner.types"; import { HarnessAbortedError, @@ -10,12 +13,9 @@ import { HarnessCapabilityGaps, HarnessTimeoutPhases } from "#src/cli-execution/harness-error.const"; -import { createWatchdogSignal } from "#src/subscription-cli-harness/harness-run-watchdog"; -import type { WatchdogSignal } from "#src/subscription-cli-harness/harness-run-watchdog.types"; -import type { SubscriptionPreflightRequest } from "#src/subscription-cli-harness/subscription-preflight.types"; -export async function runSubscriptionPreflight( - request: SubscriptionPreflightRequest, +export async function runHarnessPreflight( + request: HarnessPreflightRequest, signal?: AbortSignal ): Promise { throwIfAborted(request.kind, signal); @@ -83,7 +83,7 @@ function throwIfAborted( } function throwIfPreflightStopped( - request: SubscriptionPreflightRequest, + request: HarnessPreflightRequest, watchdog: WatchdogSignal, signal: AbortSignal | undefined, result: HarnessCaptureResult diff --git a/packages/harness/src/subscription-cli-harness/subscription-preflight.types.ts b/packages/harness/src/cli-agent-harness/harness-preflight.types.ts similarity index 93% rename from packages/harness/src/subscription-cli-harness/subscription-preflight.types.ts rename to packages/harness/src/cli-agent-harness/harness-preflight.types.ts index 03b65718..86d8b0ef 100644 --- a/packages/harness/src/subscription-cli-harness/subscription-preflight.types.ts +++ b/packages/harness/src/cli-agent-harness/harness-preflight.types.ts @@ -5,7 +5,7 @@ import type { HarnessProcessRunner } from "#src/cli-execution/cli-process-runner.types"; -export interface SubscriptionPreflightRequest { +export interface HarnessPreflightRequest { readonly kind: HarnessKind; readonly binary: string; readonly runner: HarnessProcessRunner; diff --git a/packages/harness/src/subscription-cli-harness/harness-run-artifacts.const.ts b/packages/harness/src/cli-agent-harness/harness-run-artifacts.const.ts similarity index 100% rename from packages/harness/src/subscription-cli-harness/harness-run-artifacts.const.ts rename to packages/harness/src/cli-agent-harness/harness-run-artifacts.const.ts diff --git a/packages/harness/src/subscription-cli-harness/harness-run-artifacts.ts b/packages/harness/src/cli-agent-harness/harness-run-artifacts.ts similarity index 95% rename from packages/harness/src/subscription-cli-harness/harness-run-artifacts.ts rename to packages/harness/src/cli-agent-harness/harness-run-artifacts.ts index 8be7a345..c61e01d4 100644 --- a/packages/harness/src/subscription-cli-harness/harness-run-artifacts.ts +++ b/packages/harness/src/cli-agent-harness/harness-run-artifacts.ts @@ -4,16 +4,16 @@ import { mkdir, open, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import type { HarnessKind } from "#src/agent-harness/agent-harness.const"; import type { HarnessArtifact, HarnessRunRequest } from "#src/agent-harness/agent-harness.types"; -import { HarnessRequestError } from "#src/cli-execution/harness-error"; import { HARNESS_ARTIFACT_FILE_MODE, HarnessArtifactFiles -} from "#src/subscription-cli-harness/harness-run-artifacts.const"; +} from "#src/cli-agent-harness/harness-run-artifacts.const"; import type { HarnessRunFiles, NonManifestArtifacts -} from "#src/subscription-cli-harness/harness-run-artifacts.types"; -import { responseJsonSchema } from "#src/subscription-cli-harness/response-schema"; +} from "#src/cli-agent-harness/harness-run-artifacts.types"; +import { responseJsonSchema } from "#src/cli-agent-harness/response-schema"; +import { HarnessRequestError } from "#src/cli-execution/harness-error"; /** * `promptText` is what the CLI will actually be given, which is not always what the caller wrote: a diff --git a/packages/harness/src/subscription-cli-harness/harness-run-artifacts.types.ts b/packages/harness/src/cli-agent-harness/harness-run-artifacts.types.ts similarity index 100% rename from packages/harness/src/subscription-cli-harness/harness-run-artifacts.types.ts rename to packages/harness/src/cli-agent-harness/harness-run-artifacts.types.ts diff --git a/packages/harness/src/subscription-cli-harness/harness-run-events.ts b/packages/harness/src/cli-agent-harness/harness-run-events.ts similarity index 100% rename from packages/harness/src/subscription-cli-harness/harness-run-events.ts rename to packages/harness/src/cli-agent-harness/harness-run-events.ts diff --git a/packages/harness/src/subscription-cli-harness/harness-run-manifest.const.ts b/packages/harness/src/cli-agent-harness/harness-run-manifest.const.ts similarity index 100% rename from packages/harness/src/subscription-cli-harness/harness-run-manifest.const.ts rename to packages/harness/src/cli-agent-harness/harness-run-manifest.const.ts diff --git a/packages/harness/src/subscription-cli-harness/harness-run-manifest.ts b/packages/harness/src/cli-agent-harness/harness-run-manifest.ts similarity index 76% rename from packages/harness/src/subscription-cli-harness/harness-run-manifest.ts rename to packages/harness/src/cli-agent-harness/harness-run-manifest.ts index f93fe14a..d2a4091f 100644 --- a/packages/harness/src/subscription-cli-harness/harness-run-manifest.ts +++ b/packages/harness/src/cli-agent-harness/harness-run-manifest.ts @@ -1,13 +1,13 @@ import writeFileAtomic from "write-file-atomic"; import { HarnessRunStatuses } from "#src/agent-harness/agent-harness.const"; import type { HarnessArtifact } from "#src/agent-harness/agent-harness.types"; -import { hashArtifact } from "#src/subscription-cli-harness/harness-run-artifacts"; -import { HARNESS_ARTIFACT_FILE_MODE } from "#src/subscription-cli-harness/harness-run-artifacts.const"; -import { HARNESS_MANIFEST_SCHEMA_VERSION } from "#src/subscription-cli-harness/harness-run-manifest.const"; +import { hashArtifact } from "#src/cli-agent-harness/harness-run-artifacts"; +import { HARNESS_ARTIFACT_FILE_MODE } from "#src/cli-agent-harness/harness-run-artifacts.const"; +import { HARNESS_MANIFEST_SCHEMA_VERSION } from "#src/cli-agent-harness/harness-run-manifest.const"; import type { FinishedRunManifest, StartedRunManifest -} from "#src/subscription-cli-harness/harness-run-manifest.types"; +} from "#src/cli-agent-harness/harness-run-manifest.types"; export async function writeStartedRunManifest( manifestPath: string, diff --git a/packages/harness/src/subscription-cli-harness/harness-run-manifest.types.ts b/packages/harness/src/cli-agent-harness/harness-run-manifest.types.ts similarity index 92% rename from packages/harness/src/subscription-cli-harness/harness-run-manifest.types.ts rename to packages/harness/src/cli-agent-harness/harness-run-manifest.types.ts index 94d2b2b6..83c9bcf6 100644 --- a/packages/harness/src/subscription-cli-harness/harness-run-manifest.types.ts +++ b/packages/harness/src/cli-agent-harness/harness-run-manifest.types.ts @@ -5,7 +5,7 @@ import type { HarnessCommandRecord, HarnessSession } from "#src/agent-harness/agent-harness.types"; -import type { NonManifestArtifacts } from "#src/subscription-cli-harness/harness-run-artifacts.types"; +import type { NonManifestArtifacts } from "#src/cli-agent-harness/harness-run-artifacts.types"; export interface StartedRunManifest { readonly kind: HarnessKind; diff --git a/packages/harness/src/subscription-cli-harness/harness-run-outcome.test.ts b/packages/harness/src/cli-agent-harness/harness-run-outcome.test.ts similarity index 87% rename from packages/harness/src/subscription-cli-harness/harness-run-outcome.test.ts rename to packages/harness/src/cli-agent-harness/harness-run-outcome.test.ts index 433558f8..a4900987 100644 --- a/packages/harness/src/subscription-cli-harness/harness-run-outcome.test.ts +++ b/packages/harness/src/cli-agent-harness/harness-run-outcome.test.ts @@ -8,27 +8,27 @@ import { HarnessKinds, HarnessTimeoutMilliseconds } from "#src/agent-harness/agent-harness.const"; +import { + harnessRequest, + lastCompleted, + removeHarnessRunDirectories, + TestTimeoutMilliseconds +} from "#src/cli-agent-harness/harness-run.fixture"; +import { HarnessArtifactFiles } from "#src/cli-agent-harness/harness-run-artifacts.const"; +import { writeStartedRunManifest } from "#src/cli-agent-harness/harness-run-manifest"; +import { readFinishedRunOutcome } from "#src/cli-agent-harness/harness-run-outcome"; import { captureSuccess, FakeHarnessProcessRunner, streamUntilAbort } from "#src/cli-execution/cli-process-runner.fixture"; -import { testEnvironment } from "#src/cli-execution/subscription-environment.fixture"; +import { testEnvironment } from "#src/cli-execution/harness-environment.fixture"; import { CodexTestCliValues, CodexTestLoginMarkers, CodexTestNativeEventTypes } from "#src/codex-cli/codex-cli.fixture"; import { CodexHarness } from "#src/codex-cli/codex-harness"; -import { - harnessRequest, - lastCompleted, - removeHarnessRunDirectories, - TestTimeoutMilliseconds -} from "#src/subscription-cli-harness/harness-run.fixture"; -import { HarnessArtifactFiles } from "#src/subscription-cli-harness/harness-run-artifacts.const"; -import { writeStartedRunManifest } from "#src/subscription-cli-harness/harness-run-manifest"; -import { readFinishedRunOutcome } from "#src/subscription-cli-harness/harness-run-outcome"; afterEach(removeHarnessRunDirectories); diff --git a/packages/harness/src/subscription-cli-harness/harness-run-outcome.ts b/packages/harness/src/cli-agent-harness/harness-run-outcome.ts similarity index 90% rename from packages/harness/src/subscription-cli-harness/harness-run-outcome.ts rename to packages/harness/src/cli-agent-harness/harness-run-outcome.ts index 3f5db739..5c3ab966 100644 --- a/packages/harness/src/subscription-cli-harness/harness-run-outcome.ts +++ b/packages/harness/src/cli-agent-harness/harness-run-outcome.ts @@ -2,8 +2,8 @@ import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { z } from "zod"; import { HarnessRunStatuses } from "#src/agent-harness/agent-harness.const"; -import { HarnessArtifactFiles } from "#src/subscription-cli-harness/harness-run-artifacts.const"; -import type { FinishedRunOutcome } from "#src/subscription-cli-harness/harness-run-outcome.types"; +import { HarnessArtifactFiles } from "#src/cli-agent-harness/harness-run-artifacts.const"; +import type { FinishedRunOutcome } from "#src/cli-agent-harness/harness-run-outcome.types"; /** The fields both the started and the finished manifest share, plus the two only an end has. */ const RunManifestOutcomeSchema = z.object({ diff --git a/packages/harness/src/subscription-cli-harness/harness-run-outcome.types.ts b/packages/harness/src/cli-agent-harness/harness-run-outcome.types.ts similarity index 100% rename from packages/harness/src/subscription-cli-harness/harness-run-outcome.types.ts rename to packages/harness/src/cli-agent-harness/harness-run-outcome.types.ts diff --git a/packages/harness/src/subscription-cli-harness/harness-run-request-validation.ts b/packages/harness/src/cli-agent-harness/harness-run-request-validation.ts similarity index 92% rename from packages/harness/src/subscription-cli-harness/harness-run-request-validation.ts rename to packages/harness/src/cli-agent-harness/harness-run-request-validation.ts index 7cd1ad92..3aaa48d5 100644 --- a/packages/harness/src/subscription-cli-harness/harness-run-request-validation.ts +++ b/packages/harness/src/cli-agent-harness/harness-run-request-validation.ts @@ -3,8 +3,8 @@ import { HarnessTimeoutMilliseconds } from "#src/agent-harness/agent-harness.const"; import type { HarnessRunRequest } from "#src/agent-harness/agent-harness.types"; +import { validateTimeoutMilliseconds } from "#src/cli-agent-harness/harness-run-watchdog"; import { HarnessRequestError } from "#src/cli-execution/harness-error"; -import { validateTimeoutMilliseconds } from "#src/subscription-cli-harness/harness-run-watchdog"; export function validateHarnessRunRequest(kind: HarnessKind, request: HarnessRunRequest): void { if (!request.prompt.trim()) { diff --git a/packages/harness/src/subscription-cli-harness/harness-run-status.ts b/packages/harness/src/cli-agent-harness/harness-run-status.ts similarity index 100% rename from packages/harness/src/subscription-cli-harness/harness-run-status.ts rename to packages/harness/src/cli-agent-harness/harness-run-status.ts diff --git a/packages/harness/src/subscription-cli-harness/harness-run-watchdog.const.ts b/packages/harness/src/cli-agent-harness/harness-run-watchdog.const.ts similarity index 100% rename from packages/harness/src/subscription-cli-harness/harness-run-watchdog.const.ts rename to packages/harness/src/cli-agent-harness/harness-run-watchdog.const.ts diff --git a/packages/harness/src/subscription-cli-harness/harness-run-watchdog.ts b/packages/harness/src/cli-agent-harness/harness-run-watchdog.ts similarity index 81% rename from packages/harness/src/subscription-cli-harness/harness-run-watchdog.ts rename to packages/harness/src/cli-agent-harness/harness-run-watchdog.ts index 8de85828..847e101d 100644 --- a/packages/harness/src/subscription-cli-harness/harness-run-watchdog.ts +++ b/packages/harness/src/cli-agent-harness/harness-run-watchdog.ts @@ -1,5 +1,5 @@ -import { MAXIMUM_TIMEOUT_MILLISECONDS } from "#src/subscription-cli-harness/harness-run-watchdog.const"; -import type { WatchdogSignal } from "#src/subscription-cli-harness/harness-run-watchdog.types"; +import { MAXIMUM_TIMEOUT_MILLISECONDS } from "#src/cli-agent-harness/harness-run-watchdog.const"; +import type { WatchdogSignal } from "#src/cli-agent-harness/harness-run-watchdog.types"; export function createWatchdogSignal( timeoutMs: number, diff --git a/packages/harness/src/subscription-cli-harness/harness-run-watchdog.types.ts b/packages/harness/src/cli-agent-harness/harness-run-watchdog.types.ts similarity index 100% rename from packages/harness/src/subscription-cli-harness/harness-run-watchdog.types.ts rename to packages/harness/src/cli-agent-harness/harness-run-watchdog.types.ts diff --git a/packages/harness/src/subscription-cli-harness/harness-run.fixture.ts b/packages/harness/src/cli-agent-harness/harness-run.fixture.ts similarity index 100% rename from packages/harness/src/subscription-cli-harness/harness-run.fixture.ts rename to packages/harness/src/cli-agent-harness/harness-run.fixture.ts diff --git a/packages/harness/src/subscription-cli-harness/response-schema.test.ts b/packages/harness/src/cli-agent-harness/response-schema.test.ts similarity index 93% rename from packages/harness/src/subscription-cli-harness/response-schema.test.ts rename to packages/harness/src/cli-agent-harness/response-schema.test.ts index 073b4a10..7631b86e 100644 --- a/packages/harness/src/subscription-cli-harness/response-schema.test.ts +++ b/packages/harness/src/cli-agent-harness/response-schema.test.ts @@ -1,11 +1,8 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; import { HarnessKinds } from "#src/agent-harness/agent-harness.const"; +import { parseStructuredOutput, responseJsonSchema } from "#src/cli-agent-harness/response-schema"; import { HarnessProtocolError } from "#src/cli-execution/harness-error"; -import { - parseStructuredOutput, - responseJsonSchema -} from "#src/subscription-cli-harness/response-schema"; const SourcesSchema = z.object({ sources: z.array(z.object({ url: z.url() })) diff --git a/packages/harness/src/subscription-cli-harness/response-schema.ts b/packages/harness/src/cli-agent-harness/response-schema.ts similarity index 100% rename from packages/harness/src/subscription-cli-harness/response-schema.ts rename to packages/harness/src/cli-agent-harness/response-schema.ts diff --git a/packages/harness/src/cli-execution/subscription-environment.const.ts b/packages/harness/src/cli-execution/harness-environment.const.ts similarity index 100% rename from packages/harness/src/cli-execution/subscription-environment.const.ts rename to packages/harness/src/cli-execution/harness-environment.const.ts diff --git a/packages/harness/src/cli-execution/subscription-environment.fixture.ts b/packages/harness/src/cli-execution/harness-environment.fixture.ts similarity index 100% rename from packages/harness/src/cli-execution/subscription-environment.fixture.ts rename to packages/harness/src/cli-execution/harness-environment.fixture.ts diff --git a/packages/harness/src/cli-execution/subscription-environment.test.ts b/packages/harness/src/cli-execution/harness-environment.test.ts similarity index 85% rename from packages/harness/src/cli-execution/subscription-environment.test.ts rename to packages/harness/src/cli-execution/harness-environment.test.ts index 3140e8ac..d82b5dce 100644 --- a/packages/harness/src/cli-execution/subscription-environment.test.ts +++ b/packages/harness/src/cli-execution/harness-environment.test.ts @@ -3,10 +3,10 @@ import { isForbiddenHarnessEnvironmentVariable, removedHarnessEnvironmentVariables, sanitizeHarnessEnvironment -} from "#src/cli-execution/subscription-environment"; -import { testEnvironment } from "#src/cli-execution/subscription-environment.fixture"; +} from "#src/cli-execution/harness-environment"; +import { testEnvironment } from "#src/cli-execution/harness-environment.fixture"; -describe("subscription environment isolation", () => { +describe("harness environment isolation", () => { it("strips model API credentials and billing routes while retaining local CLI auth state", () => { const environment = testEnvironment(); const sanitized = sanitizeHarnessEnvironment(environment); diff --git a/packages/harness/src/cli-execution/subscription-environment.ts b/packages/harness/src/cli-execution/harness-environment.ts similarity index 94% rename from packages/harness/src/cli-execution/subscription-environment.ts rename to packages/harness/src/cli-execution/harness-environment.ts index 81afb06e..ba90c1ed 100644 --- a/packages/harness/src/cli-execution/subscription-environment.ts +++ b/packages/harness/src/cli-execution/harness-environment.ts @@ -1,7 +1,7 @@ import { ForbiddenEnvironmentPrefix, ForbiddenEnvironmentVariable -} from "#src/cli-execution/subscription-environment.const"; +} from "#src/cli-execution/harness-environment.const"; const forbiddenEnvironmentVariables: ReadonlySet = new Set( Object.values(ForbiddenEnvironmentVariable) diff --git a/packages/harness/src/cli-execution/harness-error.const.ts b/packages/harness/src/cli-execution/harness-error.const.ts index 3eb628d7..9cc1a785 100644 --- a/packages/harness/src/cli-execution/harness-error.const.ts +++ b/packages/harness/src/cli-execution/harness-error.const.ts @@ -1,5 +1,5 @@ export const HarnessErrorCodes = { - SUBSCRIPTION_AUTH_REQUIRED: "subscription_auth_required", + CAPABILITY_REQUIRED: "capability_required", INVALID_REQUEST: "invalid_request", INVALID_EVENT_STREAM: "invalid_event_stream", ABORTED: "aborted", @@ -10,12 +10,18 @@ export type HarnessErrorCode = (typeof HarnessErrorCodes)[keyof typeof HarnessEr /** * What a harness turned out not to have. The three are different jobs for whoever has to fix it — - * install the CLI, sign it in, or wait for the allowance — and only the code that found the gap can - * tell them apart, so it says so here rather than leaving a reader to guess from a message. + * install the CLI, give it a credential it can spend, or wait for the allowance — and only the code + * that found the gap can tell them apart, so it says so here rather than leaving a reader to guess + * from a message. + * + * `CREDENTIAL` covers all five harnesses rather than the three that hold a subscription: DeepSeek is + * handed an API key and Muse Code logs in to a Meta account, and both raise this same gap. Naming it + * after a subscription would make the readiness state it maps to, `NOT_SIGNED_IN`, disagree with the + * reason it was set. */ export const HarnessCapabilityGaps = { INSTALLATION: "installation", - SUBSCRIPTION: "subscription", + CREDENTIAL: "credential", ALLOWANCE: "allowance" } as const; diff --git a/packages/harness/src/cli-execution/harness-error.ts b/packages/harness/src/cli-execution/harness-error.ts index 4e8a81be..4603cd3f 100644 --- a/packages/harness/src/cli-execution/harness-error.ts +++ b/packages/harness/src/cli-execution/harness-error.ts @@ -41,7 +41,7 @@ export class HarnessCapabilityError extends HarnessError { capabilityRequest: HarnessCapabilityRequest, options?: ErrorOptions ) { - super(harness, HarnessErrorCodes.SUBSCRIPTION_AUTH_REQUIRED, message, options); + super(harness, HarnessErrorCodes.CAPABILITY_REQUIRED, message, options); this.name = "HarnessCapabilityError"; this.gap = gap; this.capabilityRequest = capabilityRequest; diff --git a/packages/harness/src/codex-cli/codex-harness.test.ts b/packages/harness/src/codex-cli/codex-harness.test.ts index b3ba44a9..16c4a30d 100644 --- a/packages/harness/src/codex-cli/codex-harness.test.ts +++ b/packages/harness/src/codex-cli/codex-harness.test.ts @@ -10,14 +10,20 @@ import { } from "#src/agent-harness/agent-harness.const"; import { HarnessEventTypes } from "#src/agent-harness/harness-event.const"; import type { HarnessEvent } from "#src/agent-harness/harness-event.types"; +import { + answerSchema, + harnessRequest, + lastCompleted, + removeHarnessRunDirectories +} from "#src/cli-agent-harness/harness-run.fixture"; import { captureSuccess, FakeHarnessProcessRunner, streamFailure, streamSuccess } from "#src/cli-execution/cli-process-runner.fixture"; +import { testEnvironment } from "#src/cli-execution/harness-environment.fixture"; import { HarnessErrorCodes } from "#src/cli-execution/harness-error.const"; -import { testEnvironment } from "#src/cli-execution/subscription-environment.fixture"; import { CodexColorModes, CodexPermissionModes, @@ -31,12 +37,6 @@ import { CodexTestUsageLimitMessage } from "#src/codex-cli/codex-cli.fixture"; import { CodexHarness } from "#src/codex-cli/codex-harness"; -import { - answerSchema, - harnessRequest, - lastCompleted, - removeHarnessRunDirectories -} from "#src/subscription-cli-harness/harness-run.fixture"; afterEach(removeHarnessRunDirectories); @@ -229,7 +229,7 @@ describe("CodexHarness", () => { const harness = new CodexHarness({ runner, environment: testEnvironment() }); await expect(harness.preflight()).rejects.toMatchObject({ - code: HarnessErrorCodes.SUBSCRIPTION_AUTH_REQUIRED, + code: HarnessErrorCodes.CAPABILITY_REQUIRED, harness: HarnessKinds.CODEX, capabilityRequest: { need: expect.stringContaining("ChatGPT") @@ -266,7 +266,7 @@ describe("CodexHarness", () => { }; await expect(drain()).rejects.toMatchObject({ - code: HarnessErrorCodes.SUBSCRIPTION_AUTH_REQUIRED, + code: HarnessErrorCodes.CAPABILITY_REQUIRED, harness: HarnessKinds.CODEX, capabilityRequest: { reason: CodexTestUsageLimitMessage } }); diff --git a/packages/harness/src/codex-cli/codex-harness.ts b/packages/harness/src/codex-cli/codex-harness.ts index a422581e..8415a21b 100644 --- a/packages/harness/src/codex-cli/codex-harness.ts +++ b/packages/harness/src/codex-cli/codex-harness.ts @@ -5,6 +5,12 @@ import type { HarnessSession } from "#src/agent-harness/agent-harness.types"; import type { HarnessEventParser } from "#src/agent-harness/harness-event-parser.types"; +import { CliAgentHarness } from "#src/cli-agent-harness/cli-agent-harness"; +import type { + CliHarnessOptions, + HarnessCommand, + HarnessRunPaths +} from "#src/cli-agent-harness/cli-agent-harness.types"; import type { HarnessCaptureResult } from "#src/cli-execution/cli-process-runner.types"; import { HarnessCapabilityError } from "#src/cli-execution/harness-error"; import { HarnessCapabilityGaps } from "#src/cli-execution/harness-error.const"; @@ -18,17 +24,11 @@ import { codexReasoningEffortOverride, codexRunArguments } from "#src/codex-cli/codex-run-arguments"; -import { SubscriptionCliHarness } from "#src/subscription-cli-harness/subscription-cli-harness"; -import type { - HarnessCommand, - HarnessRunPaths, - SubscriptionHarnessOptions -} from "#src/subscription-cli-harness/subscription-cli-harness.types"; -export class CodexHarness extends SubscriptionCliHarness { +export class CodexHarness extends CliAgentHarness { readonly kind = HarnessKinds.CODEX; - constructor(options: SubscriptionHarnessOptions = {}) { + constructor(options: CliHarnessOptions = {}) { super(CODEX_BINARY, options); } @@ -41,7 +41,7 @@ export class CodexHarness extends SubscriptionCliHarness { if (result.failed || !statusOutput.includes(CodexLoginMarkers.CHATGPT)) { throw new HarnessCapabilityError( this.kind, - HarnessCapabilityGaps.SUBSCRIPTION, + HarnessCapabilityGaps.CREDENTIAL, "Codex CLI is not authenticated through ChatGPT", { need: "Codex CLI logged in through an active ChatGPT subscription", diff --git a/packages/harness/src/deepseek-cli/deepseek-harness.test.ts b/packages/harness/src/deepseek-cli/deepseek-harness.test.ts index e698fe19..b6c4ee22 100644 --- a/packages/harness/src/deepseek-cli/deepseek-harness.test.ts +++ b/packages/harness/src/deepseek-cli/deepseek-harness.test.ts @@ -5,14 +5,19 @@ import { HarnessKinds, HarnessRunStatuses } from "#src/agent-harness/agent-harness.const"; +import { + harnessRequest, + lastCompleted, + removeHarnessRunDirectories +} from "#src/cli-agent-harness/harness-run.fixture"; import { captureSuccess, type FakeCaptureResult, FakeHarnessProcessRunner, streamSuccess } from "#src/cli-execution/cli-process-runner.fixture"; +import { testEnvironment } from "#src/cli-execution/harness-environment.fixture"; import { HarnessCapabilityError } from "#src/cli-execution/harness-error"; -import { testEnvironment } from "#src/cli-execution/subscription-environment.fixture"; import { CodexConfigKeys, CodexNativeEventTypes, @@ -27,11 +32,6 @@ import { } from "#src/deepseek-cli/deepseek-cli.const"; import type { DeepseekWallet } from "#src/deepseek-cli/deepseek-credential.types"; import { DeepseekHarness } from "#src/deepseek-cli/deepseek-harness"; -import { - harnessRequest, - lastCompleted, - removeHarnessRunDirectories -} from "#src/subscription-cli-harness/harness-run.fixture"; const DeepseekTestCliValues = { VERSION: "codex-cli 0.146.0", diff --git a/packages/harness/src/deepseek-cli/deepseek-harness.ts b/packages/harness/src/deepseek-cli/deepseek-harness.ts index 9b6f4fd6..43f1daed 100644 --- a/packages/harness/src/deepseek-cli/deepseek-harness.ts +++ b/packages/harness/src/deepseek-cli/deepseek-harness.ts @@ -7,6 +7,11 @@ import type { } from "#src/agent-harness/agent-harness.types"; import type { HarnessEvent } from "#src/agent-harness/harness-event.types"; import type { HarnessEventParser } from "#src/agent-harness/harness-event-parser.types"; +import { CliAgentHarness } from "#src/cli-agent-harness/cli-agent-harness"; +import type { + HarnessCommand, + HarnessRunPaths +} from "#src/cli-agent-harness/cli-agent-harness.types"; import { HarnessCapabilityError } from "#src/cli-execution/harness-error"; import { HarnessCapabilityGaps } from "#src/cli-execution/harness-error.const"; import { CodexEventParser } from "#src/codex-cli/codex-event-parser"; @@ -29,11 +34,6 @@ import type { ResolveDeepseekWallet } from "#src/deepseek-cli/deepseek-credential.types"; import type { DeepseekHarnessOptions } from "#src/deepseek-cli/deepseek-harness.types"; -import { SubscriptionCliHarness } from "#src/subscription-cli-harness/subscription-cli-harness"; -import type { - HarnessCommand, - HarnessRunPaths -} from "#src/subscription-cli-harness/subscription-cli-harness.types"; /** * DeepSeek driven through the Codex CLI, which speaks the Responses API that DeepSeek serves. It is @@ -41,7 +41,7 @@ import type { * spent by the token, so an investigation left running costs money for as long as it runs. Nothing * here caps that, and the setup page says so where the operator hands the key over. */ -export class DeepseekHarness extends SubscriptionCliHarness { +export class DeepseekHarness extends CliAgentHarness { readonly kind = HarnessKinds.DEEPSEEK; readonly #resolveWallet: ResolveDeepseekWallet; #held: Promise | undefined; @@ -157,7 +157,7 @@ export class DeepseekHarness extends SubscriptionCliHarness { #unusableWallet(cause: unknown): HarnessCapabilityError { return new HarnessCapabilityError( this.kind, - HarnessCapabilityGaps.SUBSCRIPTION, + HarnessCapabilityGaps.CREDENTIAL, "The lab holds no DeepSeek key it can spend", { need: "A DeepSeek API key with a wallet DeepSeek will still serve", diff --git a/packages/harness/src/deepseek-cli/deepseek-harness.types.ts b/packages/harness/src/deepseek-cli/deepseek-harness.types.ts index b74a9ef0..55206ca9 100644 --- a/packages/harness/src/deepseek-cli/deepseek-harness.types.ts +++ b/packages/harness/src/deepseek-cli/deepseek-harness.types.ts @@ -1,6 +1,6 @@ +import type { CliHarnessOptions } from "#src/cli-agent-harness/cli-agent-harness.types"; import type { ResolveDeepseekWallet } from "#src/deepseek-cli/deepseek-credential.types"; -import type { SubscriptionHarnessOptions } from "#src/subscription-cli-harness/subscription-cli-harness.types"; -export interface DeepseekHarnessOptions extends SubscriptionHarnessOptions { +export interface DeepseekHarnessOptions extends CliHarnessOptions { readonly resolveWallet?: ResolveDeepseekWallet; } diff --git a/packages/harness/src/glm-cli/glm-harness.test.ts b/packages/harness/src/glm-cli/glm-harness.test.ts index 96d21261..83d80dc3 100644 --- a/packages/harness/src/glm-cli/glm-harness.test.ts +++ b/packages/harness/src/glm-cli/glm-harness.test.ts @@ -5,14 +5,19 @@ import { HarnessRunStatuses } from "#src/agent-harness/agent-harness.const"; import { ClaudeApiProviders } from "#src/claude-cli/claude-cli.const"; +import { + harnessRequest, + lastCompleted, + removeHarnessRunDirectories +} from "#src/cli-agent-harness/harness-run.fixture"; import { captureSuccess, FakeHarnessProcessRunner, streamSuccess } from "#src/cli-execution/cli-process-runner.fixture"; +import { ForbiddenEnvironmentVariable } from "#src/cli-execution/harness-environment.const"; +import { testEnvironment } from "#src/cli-execution/harness-environment.fixture"; import { HarnessCapabilityError } from "#src/cli-execution/harness-error"; -import { ForbiddenEnvironmentVariable } from "#src/cli-execution/subscription-environment.const"; -import { testEnvironment } from "#src/cli-execution/subscription-environment.fixture"; import { ClaudeReportedAuthMethods, GlmSessionDefaults, @@ -20,11 +25,6 @@ import { } from "#src/glm-cli/glm-cli.const"; import { GlmHarness } from "#src/glm-cli/glm-harness"; import type { ZaiCodingPlan } from "#src/glm-cli/zai-coding-plan.types"; -import { - harnessRequest, - lastCompleted, - removeHarnessRunDirectories -} from "#src/subscription-cli-harness/harness-run.fixture"; const GlmTestNativeEventTypes = { SYSTEM: "system", diff --git a/packages/harness/src/glm-cli/glm-harness.ts b/packages/harness/src/glm-cli/glm-harness.ts index 9ba09b21..98b83f4d 100644 --- a/packages/harness/src/glm-cli/glm-harness.ts +++ b/packages/harness/src/glm-cli/glm-harness.ts @@ -12,10 +12,15 @@ import { claudeAuthenticationCommand, claudeRunArguments } from "#src/claude-cli/claude-run-arguments"; +import { CliAgentHarness } from "#src/cli-agent-harness/cli-agent-harness"; +import type { + HarnessCommand, + HarnessRunPaths +} from "#src/cli-agent-harness/cli-agent-harness.types"; import type { HarnessCaptureResult } from "#src/cli-execution/cli-process-runner.types"; +import { ForbiddenEnvironmentVariable } from "#src/cli-execution/harness-environment.const"; import { HarnessCapabilityError } from "#src/cli-execution/harness-error"; import { HarnessCapabilityGaps } from "#src/cli-execution/harness-error.const"; -import { ForbiddenEnvironmentVariable } from "#src/cli-execution/subscription-environment.const"; import { ClaudeReportedAuthMethods, GLM_BINARY, @@ -25,11 +30,6 @@ import { import type { GlmHarnessOptions } from "#src/glm-cli/glm-harness.types"; import { resolveZaiCodingPlan } from "#src/glm-cli/zai-coding-plan"; import type { ResolveZaiCodingPlan, ZaiCodingPlan } from "#src/glm-cli/zai-coding-plan.types"; -import { SubscriptionCliHarness } from "#src/subscription-cli-harness/subscription-cli-harness"; -import type { - HarnessCommand, - HarnessRunPaths -} from "#src/subscription-cli-harness/subscription-cli-harness.types"; /** * A CLI that reported anything else ignored the injected coding-plan token and fell back to its own @@ -41,7 +41,7 @@ const GlmAuthStatusSchema = z.looseObject({ apiProvider: z.literal(ClaudeApiProviders.FIRST_PARTY) }); -export class GlmHarness extends SubscriptionCliHarness { +export class GlmHarness extends CliAgentHarness { readonly kind = HarnessKinds.GLM; readonly #resolveCodingPlan: ResolveZaiCodingPlan; @@ -112,7 +112,7 @@ export class GlmHarness extends SubscriptionCliHarness { #missingCodingPlan(cause: unknown): HarnessCapabilityError { return new HarnessCapabilityError( this.kind, - HarnessCapabilityGaps.SUBSCRIPTION, + HarnessCapabilityGaps.CREDENTIAL, "Claude CLI is not running on a Z.ai GLM Coding Plan subscription", { need: "A local ZCode sign-in carrying an active GLM Coding Plan", diff --git a/packages/harness/src/glm-cli/glm-harness.types.ts b/packages/harness/src/glm-cli/glm-harness.types.ts index 559b62be..2c78adf4 100644 --- a/packages/harness/src/glm-cli/glm-harness.types.ts +++ b/packages/harness/src/glm-cli/glm-harness.types.ts @@ -1,6 +1,6 @@ +import type { CliHarnessOptions } from "#src/cli-agent-harness/cli-agent-harness.types"; import type { ResolveZaiCodingPlan } from "#src/glm-cli/zai-coding-plan.types"; -import type { SubscriptionHarnessOptions } from "#src/subscription-cli-harness/subscription-cli-harness.types"; -export interface GlmHarnessOptions extends SubscriptionHarnessOptions { +export interface GlmHarnessOptions extends CliHarnessOptions { readonly resolveCodingPlan?: ResolveZaiCodingPlan; } diff --git a/packages/harness/src/muse-cli/muse-harness.test.ts b/packages/harness/src/muse-cli/muse-harness.test.ts index 5768714d..af878815 100644 --- a/packages/harness/src/muse-cli/muse-harness.test.ts +++ b/packages/harness/src/muse-cli/muse-harness.test.ts @@ -8,13 +8,19 @@ import { HarnessRunStatuses } from "#src/agent-harness/agent-harness.const"; import { HarnessDiagnosticLevels, HarnessEventTypes } from "#src/agent-harness/harness-event.const"; +import { + answerSchema, + harnessRequest, + lastCompleted, + removeHarnessRunDirectories +} from "#src/cli-agent-harness/harness-run.fixture"; import { captureSuccess, FakeHarnessProcessRunner, streamSuccess } from "#src/cli-execution/cli-process-runner.fixture"; +import { testEnvironment } from "#src/cli-execution/harness-environment.fixture"; import { HarnessCapabilityError } from "#src/cli-execution/harness-error"; -import { testEnvironment } from "#src/cli-execution/subscription-environment.fixture"; import { museCredentialPath } from "#src/muse-cli/muse-account"; import type { MuseAccount } from "#src/muse-cli/muse-account.types"; import { @@ -28,12 +34,6 @@ import { } from "#src/muse-cli/muse-cli.const"; import { MuseTestValues, museRecord, museRunStream } from "#src/muse-cli/muse-cli.fixture"; import { MuseHarness } from "#src/muse-cli/muse-harness"; -import { - answerSchema, - harnessRequest, - lastCompleted, - removeHarnessRunDirectories -} from "#src/subscription-cli-harness/harness-run.fixture"; const MuseTestAccount: MuseAccount = { email: MuseTestValues.EMAIL, diff --git a/packages/harness/src/muse-cli/muse-harness.ts b/packages/harness/src/muse-cli/muse-harness.ts index ed2d9feb..d7bd4c02 100644 --- a/packages/harness/src/muse-cli/muse-harness.ts +++ b/packages/harness/src/muse-cli/muse-harness.ts @@ -12,6 +12,11 @@ import type { } from "#src/agent-harness/agent-harness.types"; import type { HarnessEvent } from "#src/agent-harness/harness-event.types"; import type { HarnessEventParser } from "#src/agent-harness/harness-event-parser.types"; +import { CliAgentHarness } from "#src/cli-agent-harness/cli-agent-harness"; +import type { + HarnessCommand, + HarnessRunPaths +} from "#src/cli-agent-harness/cli-agent-harness.types"; import { HarnessCapabilityError } from "#src/cli-execution/harness-error"; import { HarnessCapabilityGaps } from "#src/cli-execution/harness-error.const"; import { resolveMuseAccount } from "#src/muse-cli/muse-account"; @@ -28,11 +33,6 @@ import { MuseEventParser } from "#src/muse-cli/muse-event-parser"; import type { MuseHarnessOptions } from "#src/muse-cli/muse-harness.types"; import { museRunArguments } from "#src/muse-cli/muse-run-arguments"; import { museStructuredPrompt } from "#src/muse-cli/muse-structured-response"; -import { SubscriptionCliHarness } from "#src/subscription-cli-harness/subscription-cli-harness"; -import type { - HarnessCommand, - HarnessRunPaths -} from "#src/subscription-cli-harness/subscription-cli-harness.types"; /** * Meta's Muse Code, driven headless through `muse exec`. @@ -44,7 +44,7 @@ import type { * preflight says the run is metered, the harness setup card says it before the operator picks the * harness, and no spend cap can be set against it because there is no window to take a fraction of. */ -export class MuseHarness extends SubscriptionCliHarness { +export class MuseHarness extends CliAgentHarness { readonly kind = HarnessKinds.MUSE; readonly #resolveAccount: ResolveMuseAccount; readonly #environment: Readonly; @@ -173,7 +173,7 @@ export class MuseHarness extends SubscriptionCliHarness { #unusableCredential(cause: unknown): HarnessCapabilityError { return new HarnessCapabilityError( this.kind, - HarnessCapabilityGaps.SUBSCRIPTION, + HarnessCapabilityGaps.CREDENTIAL, "Muse Code is not signed in to a Meta account the lab can name", { need: "Muse Code signed in with `muse login` against Meta's own host", diff --git a/packages/harness/src/muse-cli/muse-harness.types.ts b/packages/harness/src/muse-cli/muse-harness.types.ts index 3c905d5c..b1576904 100644 --- a/packages/harness/src/muse-cli/muse-harness.types.ts +++ b/packages/harness/src/muse-cli/muse-harness.types.ts @@ -1,6 +1,6 @@ +import type { CliHarnessOptions } from "#src/cli-agent-harness/cli-agent-harness.types"; import type { ResolveMuseAccount } from "#src/muse-cli/muse-account.types"; -import type { SubscriptionHarnessOptions } from "#src/subscription-cli-harness/subscription-cli-harness.types"; -export interface MuseHarnessOptions extends SubscriptionHarnessOptions { +export interface MuseHarnessOptions extends CliHarnessOptions { readonly resolveAccount?: ResolveMuseAccount; } diff --git a/packages/harness/src/muse-cli/muse-structured-response.ts b/packages/harness/src/muse-cli/muse-structured-response.ts index 602af201..1e96acf2 100644 --- a/packages/harness/src/muse-cli/muse-structured-response.ts +++ b/packages/harness/src/muse-cli/muse-structured-response.ts @@ -1,5 +1,5 @@ import type { z } from "zod"; -import { responseJsonSchema } from "#src/subscription-cli-harness/response-schema"; +import { responseJsonSchema } from "#src/cli-agent-harness/response-schema"; const JSON_FENCE = /^```(?:json)?\s*\n([\s\S]*?)\n?```$/; diff --git a/packages/harness/src/spent-allowance/spent-allowance.test.ts b/packages/harness/src/spent-allowance/spent-allowance.test.ts index 8b878e92..f1b4f81c 100644 --- a/packages/harness/src/spent-allowance/spent-allowance.test.ts +++ b/packages/harness/src/spent-allowance/spent-allowance.test.ts @@ -21,7 +21,7 @@ describe("spent allowance", () => { it("tells a subscription operator to wait, keeping the reset time the vendor named", () => { const error = spentAllowanceError(HarnessKinds.CODEX, CodexTestUsageLimitMessage); - expect(error.code).toBe(HarnessErrorCodes.SUBSCRIPTION_AUTH_REQUIRED); + expect(error.code).toBe(HarnessErrorCodes.CAPABILITY_REQUIRED); expect(error.gap).toBe(HarnessCapabilityGaps.ALLOWANCE); expect(error.capabilityRequest.reason).toContain("try again at Aug 9th, 2026 6:50 PM"); expect(error.capabilityRequest.provisioningHint).toContain("reset"); From 5f2ff4e0bebeb1951dcffda357f1293b04145a72 Mon Sep 17 00:00:00 2001 From: Nikita Snetkov Date: Fri, 7 Aug 2026 19:37:28 +0500 Subject: [PATCH 5/9] fix(allowance): report a wallet as a balance rather than as a plan tier DeepSeek's remaining money and Muse Code's "Meta publishes no balance" were both reported in `plan`, the field that carries the tier a vendor sells. The page then ran them through the helper that title-cases a plan name and rendered them in the badge a bought subscription gets, so a dollar amount appeared as though it were a plan the operator holds. An allowance now carries `plan` and `balance` separately, the same split the preflight has always reported: a vendor that sells tiers names one and states no balance, a vendor that sells tokens states a balance and has no tier. Co-Authored-By: Claude Opus 5 (1M context) --- .../status-server.test.ts | 3 +- .../src/research-cycle/agent-dispatch.test.ts | 2 ++ .../subscription-allowance-readings.test.ts | 6 ++-- .../subscription-allowance.ts | 2 ++ .../subscription-block.test.ts | 1 + .../allowance-reading-time.test.ts | 1 + .../subscription-allowance-list.test.tsx | 23 +++++++++++++ .../subscription-allowance-list.tsx | 7 ++++ .../subscription-allowance-section.test.tsx | 1 + .../claude-allowance.ts | 1 + .../subscription-allowance/codex-allowance.ts | 1 + .../deepseek-allowance.ts | 16 +++++---- .../subscription-allowance/glm-allowance.ts | 1 + .../metered-allowance.test.ts | 33 +++++++++++++++++++ .../subscription-allowance/muse-allowance.ts | 16 +++++---- .../subscription-allowance.const.ts | 4 +-- .../subscription-allowance.types.ts | 12 +++++-- .../protocol/src/spend-caps/spend-cap.test.ts | 2 ++ .../subscription-allowance.schema.ts | 12 ++++--- 19 files changed, 119 insertions(+), 25 deletions(-) create mode 100644 packages/harness/src/subscription-allowance/metered-allowance.test.ts diff --git a/apps/daemon/src/investigation-status/status-server.test.ts b/apps/daemon/src/investigation-status/status-server.test.ts index 051c3944..9857b23f 100644 --- a/apps/daemon/src/investigation-status/status-server.test.ts +++ b/apps/daemon/src/investigation-status/status-server.test.ts @@ -346,6 +346,7 @@ describe("status server", () => { read: async (kind) => ({ kind, plan: "max", + balance: null, windows: [{ durationMinutes: 300, usedPercent: 41, resetsAt: null }] }) }) @@ -366,7 +367,7 @@ describe("status server", () => { subscriptions: new SubscriptionAllowanceReadings({ read: async (kind) => { asked += 1; - return { kind, plan: "max", windows: [] }; + return { kind, plan: "max", balance: null, windows: [] }; } }) }); diff --git a/apps/daemon/src/research-cycle/agent-dispatch.test.ts b/apps/daemon/src/research-cycle/agent-dispatch.test.ts index 5cb925d5..de2f37d9 100644 --- a/apps/daemon/src/research-cycle/agent-dispatch.test.ts +++ b/apps/daemon/src/research-cycle/agent-dispatch.test.ts @@ -99,6 +99,7 @@ function readings(spent: readonly HarnessKind[]): SubscriptionAllowanceReadings read: async (kind): Promise => ({ kind, plan: "max", + balance: null, windows: [ { durationMinutes: 10_080, @@ -116,6 +117,7 @@ function partlySpentReadings(usedPercent: number): SubscriptionAllowanceReadings read: async (kind): Promise => ({ kind, plan: "max", + balance: null, windows: [ { durationMinutes: WEEKLY_WINDOW_MINUTES, diff --git a/apps/daemon/src/subscription-allowance/subscription-allowance-readings.test.ts b/apps/daemon/src/subscription-allowance/subscription-allowance-readings.test.ts index f5780531..f40375ad 100644 --- a/apps/daemon/src/subscription-allowance/subscription-allowance-readings.test.ts +++ b/apps/daemon/src/subscription-allowance/subscription-allowance-readings.test.ts @@ -8,7 +8,7 @@ import { SubscriptionAllowanceReadings } from "#src/subscription-allowance/subsc const TTL_MILLISECONDS = 60_000; function reading(windows: HarnessAllowance["windows"]): HarnessAllowance { - return { kind: HarnessKinds.CLAUDE, plan: "max", windows }; + return { kind: HarnessKinds.CLAUDE, plan: "max", balance: null, windows }; } describe("SubscriptionAllowanceReadings", () => { @@ -153,7 +153,7 @@ describe("SubscriptionAllowanceReadings", () => { const readings = new SubscriptionAllowanceReadings({ read: async (kind) => { asked += 1; - return { kind, plan: "max", windows: [] }; + return { kind, plan: "max", balance: null, windows: [] }; }, ttlMs: TTL_MILLISECONDS, now: () => 0 @@ -171,7 +171,7 @@ describe("SubscriptionAllowanceReadings", () => { if (kind === HarnessKinds.CODEX) { throw new Error("codex CLI is not installed"); } - return { kind, plan: "pro", windows: [] }; + return { kind, plan: "pro", balance: null, windows: [] }; } }); diff --git a/apps/daemon/src/subscription-allowance/subscription-allowance.ts b/apps/daemon/src/subscription-allowance/subscription-allowance.ts index ce9f8775..c7bbe977 100644 --- a/apps/daemon/src/subscription-allowance/subscription-allowance.ts +++ b/apps/daemon/src/subscription-allowance/subscription-allowance.ts @@ -32,6 +32,7 @@ export function allowanceFromReading( ? SubscriptionAllowanceState.EXHAUSTED : SubscriptionAllowanceState.AVAILABLE, plan: reading.plan, + balance: reading.balance, windows, error: null, read_at: readAt @@ -51,6 +52,7 @@ export function unreadableAllowance( harness: kind, state: SubscriptionAllowanceState.UNREADABLE, plan: null, + balance: null, windows: [], error: cause instanceof Error ? cause.message : String(cause), read_at: readAt diff --git a/apps/daemon/src/subscription-allowance/subscription-block.test.ts b/apps/daemon/src/subscription-allowance/subscription-block.test.ts index a8e0240f..b9621154 100644 --- a/apps/daemon/src/subscription-allowance/subscription-block.test.ts +++ b/apps/daemon/src/subscription-allowance/subscription-block.test.ts @@ -19,6 +19,7 @@ function readings(usedPercent: number, resetsAt: string | null = RESETS_AT) { read: async (kind): Promise => ({ kind, plan: "max", + balance: null, windows: [{ durationMinutes: WEEKLY_MINUTES, usedPercent, resetsAt }] }) }); diff --git a/apps/dashboard/src/subscription-allowance/allowance-reading-time.test.ts b/apps/dashboard/src/subscription-allowance/allowance-reading-time.test.ts index 73a30f25..ea45a788 100644 --- a/apps/dashboard/src/subscription-allowance/allowance-reading-time.test.ts +++ b/apps/dashboard/src/subscription-allowance/allowance-reading-time.test.ts @@ -9,6 +9,7 @@ function readAt(harness: AgentHarnessKind, read_at: string): SubscriptionAllowan harness, state: SubscriptionAllowanceState.AVAILABLE, plan: "max", + balance: null, windows: [], error: null, read_at diff --git a/apps/dashboard/src/subscription-allowance/subscription-allowance-list.test.tsx b/apps/dashboard/src/subscription-allowance/subscription-allowance-list.test.tsx index bcd9905c..a72c8374 100644 --- a/apps/dashboard/src/subscription-allowance/subscription-allowance-list.test.tsx +++ b/apps/dashboard/src/subscription-allowance/subscription-allowance-list.test.tsx @@ -11,6 +11,7 @@ const ROSTER: SubscriptionAllowanceRoster = [ harness: AgentHarnessKind.CLAUDE, state: SubscriptionAllowanceState.AVAILABLE, plan: "max", + balance: null, windows: [ { duration_minutes: 300, used_percent: 41, resets_at: "2026-08-03T17:40:00.000Z" }, { duration_minutes: 10_080, used_percent: 28, resets_at: "2026-08-08T07:00:00.000Z" } @@ -22,6 +23,7 @@ const ROSTER: SubscriptionAllowanceRoster = [ harness: AgentHarnessKind.CODEX, state: SubscriptionAllowanceState.EXHAUSTED, plan: "plus", + balance: null, windows: [ { duration_minutes: 10_080, used_percent: 100, resets_at: "2026-08-09T13:50:53.000Z" } ], @@ -32,9 +34,19 @@ const ROSTER: SubscriptionAllowanceRoster = [ harness: AgentHarnessKind.GLM, state: SubscriptionAllowanceState.UNREADABLE, plan: null, + balance: null, windows: [], error: "No ZCode login store at /Users/operator/.zcode/v2/config.json", read_at: "2026-08-03T12:00:00.000Z" + }, + { + harness: AgentHarnessKind.DEEPSEEK, + state: SubscriptionAllowanceState.AVAILABLE, + plan: null, + balance: "4.21 USD", + windows: [], + error: null, + read_at: "2026-08-03T12:00:00.000Z" } ]; @@ -106,6 +118,17 @@ describe("SubscriptionAllowanceList", () => { expect(screen.queryByText("Held at your cap")).not.toBeInTheDocument(); }); + /** + * A wallet reading carries no windows, so without its balance a token-billed harness would sit + * on the page as a name and nothing else — which is how it used to reach here before money and + * plan tiers became separate readings. + */ + it("states what a token-billed harness has left, which is all it has to report", () => { + renderList(); + + expect(screen.getByText("4.21 USD")).toBeInTheDocument(); + }); + it("meters without a limiter where the caps are not the page's to move", () => { render(); diff --git a/apps/dashboard/src/subscription-allowance/subscription-allowance-list.tsx b/apps/dashboard/src/subscription-allowance/subscription-allowance-list.tsx index 91500d32..50749890 100644 --- a/apps/dashboard/src/subscription-allowance/subscription-allowance-list.tsx +++ b/apps/dashboard/src/subscription-allowance/subscription-allowance-list.tsx @@ -122,6 +122,13 @@ function SubscriptionHeader({ {subscriptionPlanName(allowance.plan)} )} + {/** + * A balance is money and stays in the vendor's own spelling: capitalizing it the way a + * plan tier is capitalized would present a wallet as a plan the operator had bought. + */} + {allowance.balance === null ? null : ( + {allowance.balance} + )} {allowance.state === SubscriptionAllowanceState.EXHAUSTED ? ( {t("exhausted")} ) : null} diff --git a/apps/dashboard/src/subscription-allowance/subscription-allowance-section.test.tsx b/apps/dashboard/src/subscription-allowance/subscription-allowance-section.test.tsx index d591dae3..0f6e3e14 100644 --- a/apps/dashboard/src/subscription-allowance/subscription-allowance-section.test.tsx +++ b/apps/dashboard/src/subscription-allowance/subscription-allowance-section.test.tsx @@ -21,6 +21,7 @@ function roster(readAt: string, usedPercent: number): SubscriptionAllowanceRoste harness: AgentHarnessKind.GLM, state: SubscriptionAllowanceState.AVAILABLE, plan: "pro", + balance: null, windows: [{ duration_minutes: 300, used_percent: usedPercent, resets_at: null }], error: null, read_at: readAt diff --git a/packages/harness/src/subscription-allowance/claude-allowance.ts b/packages/harness/src/subscription-allowance/claude-allowance.ts index 0a5daa2c..abb7c51d 100644 --- a/packages/harness/src/subscription-allowance/claude-allowance.ts +++ b/packages/harness/src/subscription-allowance/claude-allowance.ts @@ -53,6 +53,7 @@ export function claudeAllowanceFromUsage( return { kind: HarnessKinds.CLAUDE, plan, + balance: null, windows: windows.filter((window): window is AllowanceWindow => window !== undefined) }; } diff --git a/packages/harness/src/subscription-allowance/codex-allowance.ts b/packages/harness/src/subscription-allowance/codex-allowance.ts index 59430aa4..04bb5588 100644 --- a/packages/harness/src/subscription-allowance/codex-allowance.ts +++ b/packages/harness/src/subscription-allowance/codex-allowance.ts @@ -28,6 +28,7 @@ export function codexAllowanceFromRateLimits(payload: unknown): SubscriptionAllo return { kind: HarnessKinds.CODEX, plan: limits.planType ?? null, + balance: null, windows: [limits.primary, limits.secondary] .filter((window) => window !== null && window !== undefined) .map(codexWindow) diff --git a/packages/harness/src/subscription-allowance/deepseek-allowance.ts b/packages/harness/src/subscription-allowance/deepseek-allowance.ts index afd53814..b4105b4b 100644 --- a/packages/harness/src/subscription-allowance/deepseek-allowance.ts +++ b/packages/harness/src/subscription-allowance/deepseek-allowance.ts @@ -1,23 +1,27 @@ import { HarnessKinds } from "#src/agent-harness/agent-harness.const"; import { resolveDeepseekWallet } from "#src/deepseek-cli/deepseek-credential"; +import type { ResolveDeepseekWallet } from "#src/deepseek-cli/deepseek-credential.types"; import { allowanceDeadline } from "#src/subscription-allowance/allowance-deadline"; import type { SubscriptionAllowance } from "#src/subscription-allowance/subscription-allowance.types"; /** * DeepSeek meters nothing. There is no window that fills and resets, so this reading carries none: - * a wallet is spent until it is empty, whenever that happens. The balance is reported as the plan, - * because it is the one thing about the account that decides whether the lab can keep working, and - * the harness setup card says outright that it is money rather than a subscription. + * a wallet is spent until it is empty, whenever that happens. What it has left is reported as the + * balance it is, and DeepSeek sells no tier, so it names no plan. * * Carrying no windows also means no spend cap can be set against DeepSeek, which is the truth rather * than an omission: a cap is a fraction of a window, and there is no window here to take a fraction * of. */ -export async function readDeepseekAllowance(signal?: AbortSignal): Promise { - const wallet = await resolveDeepseekWallet(allowanceDeadline(signal)); +export async function readDeepseekAllowance( + signal?: AbortSignal, + resolveWallet: ResolveDeepseekWallet = resolveDeepseekWallet +): Promise { + const wallet = await resolveWallet(allowanceDeadline(signal)); return { kind: HarnessKinds.DEEPSEEK, - plan: wallet.balance, + plan: null, + balance: wallet.balance, windows: [] }; } diff --git a/packages/harness/src/subscription-allowance/glm-allowance.ts b/packages/harness/src/subscription-allowance/glm-allowance.ts index 0865bb7a..e0a67ce3 100644 --- a/packages/harness/src/subscription-allowance/glm-allowance.ts +++ b/packages/harness/src/subscription-allowance/glm-allowance.ts @@ -36,6 +36,7 @@ export function glmAllowanceFromQuota(payload: unknown): SubscriptionAllowance { return { kind: HarnessKinds.GLM, plan: quota.level ?? null, + balance: null, windows: quota.limits .filter((limit) => SPENDING_LIMIT_TYPES.includes(limit.type)) .map(glmWindow) diff --git a/packages/harness/src/subscription-allowance/metered-allowance.test.ts b/packages/harness/src/subscription-allowance/metered-allowance.test.ts new file mode 100644 index 00000000..97261d95 --- /dev/null +++ b/packages/harness/src/subscription-allowance/metered-allowance.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { HarnessKinds } from "#src/agent-harness/agent-harness.const"; +import { readDeepseekAllowance } from "#src/subscription-allowance/deepseek-allowance"; +import { readMuseAllowance } from "#src/subscription-allowance/muse-allowance"; + +/** + * The two token-billed harnesses answer in money, and money used to be reported in the field a plan + * tier goes in — so the page rendered a wallet balance as though it named a plan the operator had + * bought, styled and capitalised like one. A tier and an amount are separate readings now. + */ +describe("allowance readings for a token-billed harness", () => { + it("reports a DeepSeek wallet as a balance and names no plan", async () => { + const allowance = await readDeepseekAllowance(undefined, () => + Promise.resolve({ apiKey: "sk-test", available: true, balance: "4.21 USD" }) + ); + + expect(allowance).toEqual({ + kind: HarnessKinds.DEEPSEEK, + plan: null, + balance: "4.21 USD", + windows: [] + }); + }); + + /** Meta states nothing, so the balance says that in words and no window can be capped. */ + it("reports Muse Code as a balance Meta does not publish", async () => { + const allowance = await readMuseAllowance(); + + expect(allowance.plan).toBeNull(); + expect(allowance.balance).toMatch(/no balance/iu); + expect(allowance.windows).toEqual([]); + }); +}); diff --git a/packages/harness/src/subscription-allowance/muse-allowance.ts b/packages/harness/src/subscription-allowance/muse-allowance.ts index 0a0a1f05..233b415b 100644 --- a/packages/harness/src/subscription-allowance/muse-allowance.ts +++ b/packages/harness/src/subscription-allowance/muse-allowance.ts @@ -1,18 +1,22 @@ import { HarnessKinds } from "#src/agent-harness/agent-harness.const"; -import { MUSE_METERED_PLAN } from "#src/subscription-allowance/subscription-allowance.const"; +import { MUSE_UNPUBLISHED_BALANCE } from "#src/subscription-allowance/subscription-allowance.const"; import type { SubscriptionAllowance } from "#src/subscription-allowance/subscription-allowance.types"; /** * Muse Code meters nothing an operator can read. There is no window that fills and resets, and unlike - * a DeepSeek wallet there is not even a balance to report: Meta bills the account after the fact and - * publishes nothing the CLI can be asked for mid-run. So this reading states the one true thing — - * that the account is spent by the token — and carries no windows, which is also why no spend cap can - * be set against Muse. A cap is a fraction of a window, and there is no window here. + * a DeepSeek wallet there is not even a number to report: Meta bills the account after the fact and + * publishes nothing the CLI can be asked for mid-run. So the balance says that in words rather than + * being left blank, which would read as a reading that failed instead of one that succeeded and found + * nothing to state. Muse sells no tier either, so it names no plan. + * + * Carrying no windows is also why no spend cap can be set against Muse. A cap is a fraction of a + * window, and there is no window here. */ export function readMuseAllowance(): Promise { return Promise.resolve({ kind: HarnessKinds.MUSE, - plan: MUSE_METERED_PLAN, + plan: null, + balance: MUSE_UNPUBLISHED_BALANCE, windows: [] }); } diff --git a/packages/harness/src/subscription-allowance/subscription-allowance.const.ts b/packages/harness/src/subscription-allowance/subscription-allowance.const.ts index 53b13e9a..10b9762b 100644 --- a/packages/harness/src/subscription-allowance/subscription-allowance.const.ts +++ b/packages/harness/src/subscription-allowance/subscription-allowance.const.ts @@ -55,12 +55,12 @@ export const ZaiSpendingLimitType = { export type ZaiSpendingLimitType = (typeof ZaiSpendingLimitType)[keyof typeof ZaiSpendingLimitType]; /** - * What stands where a plan tier goes for Muse Code, which sells no tier. It is a sentence rather than + * What stands where a balance goes for Muse Code, which publishes none. It is a sentence rather than * a blank because a blank reads as a reading that failed, and this one succeeded: there is nothing to * report, and an operator deciding whether to leave an investigation running overnight has to be told * that rather than left to infer it from an empty field. */ -export const MUSE_METERED_PLAN = "Metered — Meta publishes no balance"; +export const MUSE_UNPUBLISHED_BALANCE = "Meta publishes no balance"; /** A reading is monitoring, not work: it may never outlast the preflight it rides alongside. */ export const ALLOWANCE_TIMEOUT_MILLISECONDS = 15_000; diff --git a/packages/harness/src/subscription-allowance/subscription-allowance.types.ts b/packages/harness/src/subscription-allowance/subscription-allowance.types.ts index f945f54b..49191422 100644 --- a/packages/harness/src/subscription-allowance/subscription-allowance.types.ts +++ b/packages/harness/src/subscription-allowance/subscription-allowance.types.ts @@ -12,11 +12,19 @@ export interface AllowanceWindow { readonly resetsAt: string | null; } -/** What one vendor answered when asked what is left of the operator's subscription. */ +/** + * What one vendor answered when asked what is left of the operator's account. A tier and an amount + * are separate fields because they are separate facts, and the same split the preflight already + * reports: a vendor that sells tiers names one and states no balance, a vendor that sells tokens + * states a balance and has no tier. Putting money in `plan` would have the page render a wallet as + * though it were a subscription the operator had bought. + */ export interface SubscriptionAllowance { readonly kind: HarnessKind; - /** The plan tier the vendor named, which is what identifies the account that answered. */ + /** The plan tier the vendor named. Null for a vendor that sells no tier. */ readonly plan: string | null; + /** What a token-billed account has left to spend. Null for a subscription. */ + readonly balance: string | null; readonly windows: readonly AllowanceWindow[]; } diff --git a/packages/protocol/src/spend-caps/spend-cap.test.ts b/packages/protocol/src/spend-caps/spend-cap.test.ts index c7a2dde7..9515005a 100644 --- a/packages/protocol/src/spend-caps/spend-cap.test.ts +++ b/packages/protocol/src/spend-caps/spend-cap.test.ts @@ -21,6 +21,7 @@ function claudeAllowance(windows: readonly AllowanceWindow[]): SubscriptionAllow harness: AgentHarnessKind.CLAUDE, state: SubscriptionAllowanceState.AVAILABLE, plan: "max", + balance: null, windows: [...windows], error: null, read_at: "2026-08-05T09:00:00.000Z" @@ -72,6 +73,7 @@ describe("withheldWindows", () => { harness: AgentHarnessKind.CLAUDE, state: SubscriptionAllowanceState.UNREADABLE, plan: null, + balance: null, windows: [], error: "No Keychain entry", read_at: "2026-08-05T09:00:00.000Z" diff --git a/packages/protocol/src/subscription-allowance/subscription-allowance.schema.ts b/packages/protocol/src/subscription-allowance/subscription-allowance.schema.ts index 5f91ff39..5b6bab26 100644 --- a/packages/protocol/src/subscription-allowance/subscription-allowance.schema.ts +++ b/packages/protocol/src/subscription-allowance/subscription-allowance.schema.ts @@ -15,18 +15,20 @@ export const AllowanceWindowSchema = z.object({ }); /** - * What one subscription had left when it was last read. `windows` is empty whenever the state is + * What one harness account had left when it was last read. `windows` is empty whenever the state is * `unreadable`, so a viewer never renders a meter built from a failed reading. */ export const SubscriptionAllowanceSchema = z.object({ harness: z.enum(AgentHarnessKind), state: z.enum(SubscriptionAllowanceState), + /** The plan tier the vendor named. Null for a vendor that sells no tier. */ + plan: z.string().min(1).nullable(), /** - * What the vendor said the account is worth: a plan tier from a vendor that sells tiers, and the - * balance left from one that sells tokens. Either way it is the sentence that tells the operator - * which account answered and how much of it is left. + * What a token-billed account has left to spend, in the vendor's own words. Null for a + * subscription: a plan the operator already bought has no balance to run down, and showing money + * beside one would invent a number the vendor never stated. */ - plan: z.string().min(1).nullable(), + balance: z.string().min(1).nullable(), windows: z.array(AllowanceWindowSchema), error: z.string().min(1).nullable(), read_at: z.iso.datetime() From ea47ca2c3016014f0b5b98a59c5e802e3e947e4d Mon Sep 17 00:00:00 2001 From: Nikita Snetkov Date: Fri, 7 Aug 2026 19:44:47 +0500 Subject: [PATCH 6/9] refactor(allowance)!: name the allowance domain after harnesses, not subscriptions The domain that reads what each harness has left was called subscription-allowance across four packages, served at /api/subscriptions, and shown to the operator under a heading that said Subscriptions. It carries DeepSeek's wallet and Muse Code's metered account, neither of which is a subscription, so the page named two of its five cards wrong and the settings tab named itself wrong above them. The domain is harness-allowance, the route is /api/allowances, the section is Allowances, and the daemon plumbing that passed these readings around as `subscriptions` passes them as `allowances`. What a vendor answers is a HarnessAllowanceReading and what the lab publishes is a HarnessAllowance, which are two different shapes that were both called the same thing. The dashboard directory is allowance-panel rather than harness-allowance so its `#src//` paths cannot collide with the protocol's, which the dashboard tsconfig resolves against its own src. BREAKING CHANGE: GET /api/subscriptions is now GET /api/allowances. The daemon and dashboard ship together and no third party pins it. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/daemon-runtime/daemon-startup.ts | 8 +-- .../research-loop-controller.const.ts | 2 +- .../research-loop-controller.test.ts | 2 +- .../research-loop-controller.ts | 18 +++--- .../harness-allowance-readings.test.ts} | 38 ++++++------- .../harness-allowance-readings.ts} | 30 +++++----- .../harness-allowance-readings.types.ts} | 4 +- .../harness-allowance.const.ts} | 2 +- .../harness-allowance.ts} | 24 ++++---- .../harness-block.const.ts} | 7 +-- .../harness-block.test.ts} | 36 ++++++------ .../harness-block.ts} | 55 +++++++++--------- .../harness-block.types.ts} | 16 +++--- .../investigation-registry.ts | 16 +++--- .../investigation-registry.types.ts | 4 +- .../status-server.test.ts | 26 ++++----- .../src/investigation-status/status-server.ts | 24 ++++---- .../status-server.types.ts | 6 +- .../investigation-workspace.test.ts | 2 +- .../src/research-cycle/agent-dispatch.test.ts | 54 +++++++++--------- .../src/research-cycle/agent-dispatch.ts | 29 +++++----- .../src/research-cycle/lead-research.ts | 4 +- .../src/research-cycle/research-loop.ts | 28 +++++----- .../src/research-cycle/research-loop.types.ts | 10 ++-- .../src/allowance-panel/allowance-client.ts | 35 ++++++++++++ .../allowance-list.const.ts} | 4 +- .../allowance-list.test.tsx} | 28 +++++----- .../allowance-list.tsx} | 50 ++++++++--------- .../allowance-panel.i18n.ts} | 24 ++++---- .../allowance-reading-header.const.ts | 2 +- .../allowance-reading-header.test.tsx | 8 +-- .../allowance-reading-header.tsx | 10 ++-- .../allowance-reading-time.test.ts | 12 ++-- .../allowance-reading-time.ts | 6 +- .../allowance-section.const.ts} | 0 .../allowance-section.test.tsx} | 48 ++++++---------- .../allowance-section.tsx} | 56 +++++++++---------- .../allowance-window-label.ts | 0 .../spend-cap-draft.test.ts | 2 +- .../spend-cap-draft.ts | 0 .../spend-cap-meter.const.ts | 2 +- .../spend-cap-meter.tsx | 26 ++++----- .../dashboard-routes/settings-view.const.ts | 8 +-- .../src/dashboard-routes/settings-view.tsx | 6 +- .../interface-language/translation-catalog.ts | 14 ++--- .../investigation-dispatch/dispatch-panel.tsx | 2 +- .../investigation-dispatch.i18n.ts | 6 +- .../subscription-allowance-client.ts | 39 ------------- packages/harness/package.json | 8 +-- .../allowance-deadline.ts | 2 +- .../allowance-instant.ts | 0 .../claude-allowance.test.ts | 4 +- .../claude-allowance.ts | 14 ++--- .../codex-allowance.test.ts | 2 +- .../codex-allowance.ts | 14 ++--- .../codex-app-server.ts | 4 +- .../deepseek-allowance.ts | 6 +- .../glm-allowance.test.ts | 4 +- .../glm-allowance.ts | 14 ++--- .../harness-allowance.const.ts} | 0 .../harness-allowance/harness-allowance.ts | 30 ++++++++++ .../harness-allowance.types.ts} | 4 +- .../metered-allowance.test.ts | 4 +- .../muse-allowance.ts | 6 +- .../subscription-allowance.ts | 30 ---------- packages/protocol/package.json | 16 +++--- .../harness-allowance.const.ts} | 6 +- .../harness-allowance.schema.ts} | 8 +-- .../harness-allowance.types.ts | 10 ++++ .../window-reset.test.ts | 4 +- .../window-reset.ts | 2 +- .../protocol/src/spend-caps/spend-cap.test.ts | 18 +++--- packages/protocol/src/spend-caps/spend-cap.ts | 13 ++--- .../subscription-allowance.types.ts | 10 ---- 74 files changed, 518 insertions(+), 548 deletions(-) rename apps/daemon/src/{subscription-allowance/subscription-allowance-readings.test.ts => harness-allowance/harness-allowance-readings.test.ts} (79%) rename apps/daemon/src/{subscription-allowance/subscription-allowance-readings.ts => harness-allowance/harness-allowance-readings.ts} (73%) rename apps/daemon/src/{subscription-allowance/subscription-allowance-readings.types.ts => harness-allowance/harness-allowance-readings.types.ts} (73%) rename apps/daemon/src/{subscription-allowance/subscription-allowance.const.ts => harness-allowance/harness-allowance.const.ts} (90%) rename apps/daemon/src/{subscription-allowance/subscription-allowance.ts => harness-allowance/harness-allowance.ts} (70%) rename apps/daemon/src/{subscription-allowance/subscription-block.const.ts => harness-allowance/harness-block.const.ts} (59%) rename apps/daemon/src/{subscription-allowance/subscription-block.test.ts => harness-allowance/harness-block.test.ts} (63%) rename apps/daemon/src/{subscription-allowance/subscription-block.ts => harness-allowance/harness-block.ts} (69%) rename apps/daemon/src/{subscription-allowance/subscription-block.types.ts => harness-allowance/harness-block.types.ts} (62%) create mode 100644 apps/dashboard/src/allowance-panel/allowance-client.ts rename apps/dashboard/src/{subscription-allowance/subscription-allowance-list.const.ts => allowance-panel/allowance-list.const.ts} (91%) rename apps/dashboard/src/{subscription-allowance/subscription-allowance-list.test.tsx => allowance-panel/allowance-list.test.tsx} (80%) rename apps/dashboard/src/{subscription-allowance/subscription-allowance-list.tsx => allowance-panel/allowance-list.tsx} (77%) rename apps/dashboard/src/{subscription-allowance/subscription-allowance.i18n.ts => allowance-panel/allowance-panel.i18n.ts} (68%) rename apps/dashboard/src/{subscription-allowance => allowance-panel}/allowance-reading-header.const.ts (92%) rename apps/dashboard/src/{subscription-allowance => allowance-panel}/allowance-reading-header.test.tsx (73%) rename apps/dashboard/src/{subscription-allowance => allowance-panel}/allowance-reading-header.tsx (90%) rename apps/dashboard/src/{subscription-allowance => allowance-panel}/allowance-reading-time.test.ts (58%) rename apps/dashboard/src/{subscription-allowance => allowance-panel}/allowance-reading-time.ts (65%) rename apps/dashboard/src/{subscription-allowance/subscription-allowance-section.const.ts => allowance-panel/allowance-section.const.ts} (100%) rename apps/dashboard/src/{subscription-allowance/subscription-allowance-section.test.tsx => allowance-panel/allowance-section.test.tsx} (78%) rename apps/dashboard/src/{subscription-allowance/subscription-allowance-section.tsx => allowance-panel/allowance-section.tsx} (81%) rename apps/dashboard/src/{subscription-allowance => allowance-panel}/allowance-window-label.ts (100%) rename apps/dashboard/src/{subscription-allowance => allowance-panel}/spend-cap-draft.test.ts (95%) rename apps/dashboard/src/{subscription-allowance => allowance-panel}/spend-cap-draft.ts (100%) rename apps/dashboard/src/{subscription-allowance => allowance-panel}/spend-cap-meter.const.ts (97%) rename apps/dashboard/src/{subscription-allowance => allowance-panel}/spend-cap-meter.tsx (87%) delete mode 100644 apps/dashboard/src/subscription-allowance/subscription-allowance-client.ts rename packages/harness/src/{subscription-allowance => harness-allowance}/allowance-deadline.ts (80%) rename packages/harness/src/{subscription-allowance => harness-allowance}/allowance-instant.ts (100%) rename packages/harness/src/{subscription-allowance => harness-allowance}/claude-allowance.test.ts (90%) rename packages/harness/src/{subscription-allowance => harness-allowance}/claude-allowance.ts (91%) rename packages/harness/src/{subscription-allowance => harness-allowance}/codex-allowance.test.ts (95%) rename packages/harness/src/{subscription-allowance => harness-allowance}/codex-allowance.ts (80%) rename packages/harness/src/{subscription-allowance => harness-allowance}/codex-app-server.ts (94%) rename packages/harness/src/{subscription-allowance => harness-allowance}/deepseek-allowance.ts (82%) rename packages/harness/src/{subscription-allowance => harness-allowance}/glm-allowance.test.ts (94%) rename packages/harness/src/{subscription-allowance => harness-allowance}/glm-allowance.ts (85%) rename packages/harness/src/{subscription-allowance/subscription-allowance.const.ts => harness-allowance/harness-allowance.const.ts} (100%) create mode 100644 packages/harness/src/harness-allowance/harness-allowance.ts rename packages/harness/src/{subscription-allowance/subscription-allowance.types.ts => harness-allowance/harness-allowance.types.ts} (90%) rename packages/harness/src/{subscription-allowance => harness-allowance}/metered-allowance.test.ts (88%) rename packages/harness/src/{subscription-allowance => harness-allowance}/muse-allowance.ts (75%) delete mode 100644 packages/harness/src/subscription-allowance/subscription-allowance.ts rename packages/protocol/src/{subscription-allowance/subscription-allowance.const.ts => harness-allowance/harness-allowance.const.ts} (74%) rename packages/protocol/src/{subscription-allowance/subscription-allowance.schema.ts => harness-allowance/harness-allowance.schema.ts} (83%) create mode 100644 packages/protocol/src/harness-allowance/harness-allowance.types.ts rename packages/protocol/src/{subscription-allowance => harness-allowance}/window-reset.test.ts (84%) rename packages/protocol/src/{subscription-allowance => harness-allowance}/window-reset.ts (87%) delete mode 100644 packages/protocol/src/subscription-allowance/subscription-allowance.types.ts diff --git a/apps/daemon/src/daemon-runtime/daemon-startup.ts b/apps/daemon/src/daemon-runtime/daemon-startup.ts index b10a3197..4751da14 100644 --- a/apps/daemon/src/daemon-runtime/daemon-startup.ts +++ b/apps/daemon/src/daemon-runtime/daemon-startup.ts @@ -10,6 +10,7 @@ import { PromiseSettlementStatus } from "#src/daemon-runtime/daemon-startup.cons import type { DaemonDependencies, RunningDaemon } from "#src/daemon-runtime/daemon-startup.types"; import { DaemonStartupStep } from "#src/daemon-runtime/daemon-startup-progress.const"; import type { DaemonStartupProgress } from "#src/daemon-runtime/daemon-startup-progress.types"; +import { HarnessAllowanceReadings } from "#src/harness-allowance/harness-allowance-readings"; import { HarnessReadinessChecks } from "#src/harness-readiness/harness-readiness-checks"; import { InvestigationRegistry } from "#src/investigation-registry/investigation-registry"; import { createStatusServer } from "#src/investigation-status/status-server"; @@ -18,7 +19,6 @@ import { investigationsAnswering } from "#src/operator-answers/answering-investi import { OperatorAnswers } from "#src/operator-answers/operator-answers"; import { NotificationDispatch } from "#src/operator-notifications/notification-dispatch"; import { NotificationSettingsStore } from "#src/operator-notifications/notification-settings-store"; -import { SubscriptionAllowanceReadings } from "#src/subscription-allowance/subscription-allowance-readings"; /** * Brings up the lab: the database it keeps its investigations in, the registry that holds them, @@ -46,7 +46,7 @@ export async function startDaemon( ? at(DaemonStartupStep.DASHBOARD_MISSING, config.dashboardRoot) : at(DaemonStartupStep.DASHBOARD, dashboardRoot) ); - const subscriptions = new SubscriptionAllowanceReadings(); + const allowances = new HarnessAllowanceReadings(); const harnesses = new HarnessReadinessChecks(); const settings = new LabSettingsStore(database.settings); const notificationSettings = new NotificationSettingsStore(database.notifications); @@ -76,14 +76,14 @@ export async function startDaemon( workspaceRoot: config.workspaceRoot, persistence: database.persistence, investigations: database.investigations, - subscriptions, + allowances, settings, ...(dependencies.researchLoop === undefined ? {} : { researchLoop: dependencies.researchLoop }) }); app = createStatusServer(registry, { - subscriptions, + allowances, harnesses, settings, notifications: { settings: notificationSettings, dispatch }, diff --git a/apps/daemon/src/daemon-runtime/research-loop-controller.const.ts b/apps/daemon/src/daemon-runtime/research-loop-controller.const.ts index 04ed04b5..7bd97547 100644 --- a/apps/daemon/src/daemon-runtime/research-loop-controller.const.ts +++ b/apps/daemon/src/daemon-runtime/research-loop-controller.const.ts @@ -6,4 +6,4 @@ export const RESUME_STEP_MS = 3_600_000; /** Why the investigation is awake, written where an operator reads its history rather than its logs. */ -export const RESUME_REASON = "The subscriptions this investigation was waiting on are back"; +export const RESUME_REASON = "The allowances this investigation was waiting on are back"; diff --git a/apps/daemon/src/daemon-runtime/research-loop-controller.test.ts b/apps/daemon/src/daemon-runtime/research-loop-controller.test.ts index 2032bdc0..23c0e615 100644 --- a/apps/daemon/src/daemon-runtime/research-loop-controller.test.ts +++ b/apps/daemon/src/daemon-runtime/research-loop-controller.test.ts @@ -28,7 +28,7 @@ function countingLoop(started: string[]) { } describe("ResearchLoopController", () => { - it("takes the investigation back up once the subscriptions it slept on are due", async () => { + it("takes the investigation back up once the allowances it slept on are due", async () => { const workspace = await createWorkspace(); const started: string[] = []; const controller = new ResearchLoopController( diff --git a/apps/daemon/src/daemon-runtime/research-loop-controller.ts b/apps/daemon/src/daemon-runtime/research-loop-controller.ts index cf39da77..fdf0a3b8 100644 --- a/apps/daemon/src/daemon-runtime/research-loop-controller.ts +++ b/apps/daemon/src/daemon-runtime/research-loop-controller.ts @@ -5,13 +5,13 @@ import { InvestigationState } from "@openlab/protocol/investigation-lifecycle/in import type { StatusSnapshot } from "@openlab/protocol/investigation-status/status-snapshot.types"; import type { AgentActivityHub } from "#src/agent-activity/agent-activity-hub"; import { RESUME_REASON, RESUME_STEP_MS } from "#src/daemon-runtime/research-loop-controller.const"; +import type { HarnessAllowanceReadings } from "#src/harness-allowance/harness-allowance-readings"; import type { InvestigationWorkspace } from "#src/investigation-workspace/investigation-workspace"; import type { LabSettingsReader } from "#src/lab-settings/lab-settings.types"; import type { ResearchLoopOptions, ResearchLoopOutcome } from "#src/research-cycle/research-loop.types"; -import type { SubscriptionAllowanceReadings } from "#src/subscription-allowance/subscription-allowance-readings"; export class ResearchLoopController { readonly #workspace: InvestigationWorkspace; @@ -23,7 +23,7 @@ export class ResearchLoopController { /** Built afresh for every loop, because the roster an investigation runs on can be moved. */ readonly #harnesses: (() => readonly AgentHarness[]) | undefined; readonly #settings: LabSettingsReader | undefined; - readonly #subscriptions: SubscriptionAllowanceReadings | undefined; + readonly #allowances: HarnessAllowanceReadings | undefined; #abortController: AbortController | undefined; #running: Promise | undefined; #restartRequested = false; @@ -39,14 +39,14 @@ export class ResearchLoopController { ) => Promise, harnesses?: () => readonly AgentHarness[], settings?: LabSettingsReader, - subscriptions?: SubscriptionAllowanceReadings + allowances?: HarnessAllowanceReadings ) { this.#workspace = workspace; this.#activity = activity; this.#run = run; this.#harnesses = harnesses; this.#settings = settings; - this.#subscriptions = subscriptions; + this.#allowances = allowances; this.#unsubscribe = workspace.subscribe((event, snapshot) => { if (event.type !== EventType.INVESTIGATION_STATE_CHANGED) { return; @@ -78,7 +78,7 @@ export class ResearchLoopController { signal: abortController.signal, ...(this.#harnesses === undefined ? {} : { harnesses: this.#harnesses() }), ...(this.#settings === undefined ? {} : { settings: this.#settings }), - ...(this.#subscriptions === undefined ? {} : { subscriptions: this.#subscriptions }) + ...(this.#allowances === undefined ? {} : { allowances: this.#allowances }) }); this.#running = running; const clear = () => { @@ -113,14 +113,14 @@ export class ResearchLoopController { * once, when it starts, so the one in flight is given up rather than finished — the same cost as * a pause, and the only way a new harness reaches the next agent. * - * An investigation the lab put to sleep with a date on it is sleeping on its subscriptions, and + * An investigation the lab put to sleep with a date on it is sleeping on its allowances, and * pointing it somewhere else is the operator answering exactly that wait, so it goes back to * work now instead of at the reset it was holding out for. One that was paused, or that ran out * of directions, stays where the operator left it. */ async redispatch(reason: Error): Promise { if (this.#workspace.getSnapshot().investigation.state === InvestigationState.HIBERNATING) { - await this.wakeIfWaitingOnSubscriptions(reason); + await this.wakeIfWaitingOnAllowances(reason); return; } await this.cancel(reason); @@ -128,11 +128,11 @@ export class ResearchLoopController { } /** - * Gives an investigation the lab parked on its subscriptions another go at them, now rather than + * Gives an investigation the lab parked on its allowances another go at them, now rather than * at the reset it was holding out for. Only a sleep the lab dated is one it took on itself: an * investigation the operator paused, and one that ran out of directions, are left where they are. */ - async wakeIfWaitingOnSubscriptions(reason: Error): Promise { + async wakeIfWaitingOnAllowances(reason: Error): Promise { const investigation = this.#workspace.getSnapshot().investigation; if ( investigation.state !== InvestigationState.HIBERNATING || diff --git a/apps/daemon/src/subscription-allowance/subscription-allowance-readings.test.ts b/apps/daemon/src/harness-allowance/harness-allowance-readings.test.ts similarity index 79% rename from apps/daemon/src/subscription-allowance/subscription-allowance-readings.test.ts rename to apps/daemon/src/harness-allowance/harness-allowance-readings.test.ts index f40375ad..61687d01 100644 --- a/apps/daemon/src/subscription-allowance/subscription-allowance-readings.test.ts +++ b/apps/daemon/src/harness-allowance/harness-allowance-readings.test.ts @@ -1,19 +1,19 @@ import { HarnessKinds } from "@openlab/harness/agent-harness.const"; -import type { SubscriptionAllowance as HarnessAllowance } from "@openlab/harness/subscription-allowance.types"; -import { SubscriptionAllowanceState } from "@openlab/protocol/subscription-allowance/subscription-allowance.const"; +import type { HarnessAllowanceReading } from "@openlab/harness/harness-allowance.types"; +import { HarnessAllowanceState } from "@openlab/protocol/harness-allowance/harness-allowance.const"; import { describe, expect, it } from "vitest"; import { EVERY_HARNESS_KIND } from "#src/agent-harness/harness-factory"; -import { SubscriptionAllowanceReadings } from "#src/subscription-allowance/subscription-allowance-readings"; +import { HarnessAllowanceReadings } from "#src/harness-allowance/harness-allowance-readings"; const TTL_MILLISECONDS = 60_000; -function reading(windows: HarnessAllowance["windows"]): HarnessAllowance { +function reading(windows: HarnessAllowanceReading["windows"]): HarnessAllowanceReading { return { kind: HarnessKinds.CLAUDE, plan: "max", balance: null, windows }; } -describe("SubscriptionAllowanceReadings", () => { +describe("HarnessAllowanceReadings", () => { it("calls a plan spent on the window that ran out, however much the others have left", async () => { - const readings = new SubscriptionAllowanceReadings({ + const readings = new HarnessAllowanceReadings({ read: async () => reading([ { durationMinutes: 300, usedPercent: 4, resetsAt: null }, @@ -23,12 +23,12 @@ describe("SubscriptionAllowanceReadings", () => { const allowance = await readings.read(HarnessKinds.CLAUDE); - expect(allowance.state).toBe(SubscriptionAllowanceState.EXHAUSTED); + expect(allowance.state).toBe(HarnessAllowanceState.EXHAUSTED); expect(allowance.plan).toBe("max"); }); it("reports a vendor that could not be asked as unread rather than spent", async () => { - const readings = new SubscriptionAllowanceReadings({ + const readings = new HarnessAllowanceReadings({ read: async () => { throw new Error("No Keychain entry"); } @@ -36,7 +36,7 @@ describe("SubscriptionAllowanceReadings", () => { const allowance = await readings.read(HarnessKinds.CLAUDE); - expect(allowance.state).toBe(SubscriptionAllowanceState.UNREADABLE); + expect(allowance.state).toBe(HarnessAllowanceState.UNREADABLE); expect(allowance.error).toBe("No Keychain entry"); expect(allowance.windows).toHaveLength(0); }); @@ -44,7 +44,7 @@ describe("SubscriptionAllowanceReadings", () => { it("asks a vendor once per interval however many callers want to know", async () => { let asked = 0; let clock = 0; - const readings = new SubscriptionAllowanceReadings({ + const readings = new HarnessAllowanceReadings({ read: async () => { asked += 1; return reading([{ durationMinutes: 300, usedPercent: asked, resetsAt: null }]); @@ -64,7 +64,7 @@ describe("SubscriptionAllowanceReadings", () => { it("asks again once the reading it held has expired", async () => { let asked = 0; let clock = 0; - const readings = new SubscriptionAllowanceReadings({ + const readings = new HarnessAllowanceReadings({ read: async () => { asked += 1; return reading([{ durationMinutes: 300, usedPercent: asked, resetsAt: null }]); @@ -83,7 +83,7 @@ describe("SubscriptionAllowanceReadings", () => { it("shares one request between callers that arrive together", async () => { let asked = 0; - const readings = new SubscriptionAllowanceReadings({ + const readings = new HarnessAllowanceReadings({ read: async () => { asked += 1; await Promise.resolve(); @@ -103,7 +103,7 @@ describe("SubscriptionAllowanceReadings", () => { it("stands by the last answer when a vendor throttles the next reading", async () => { let asked = 0; let clock = 0; - const readings = new SubscriptionAllowanceReadings({ + const readings = new HarnessAllowanceReadings({ read: async () => { asked += 1; if (asked > 1) { @@ -120,14 +120,14 @@ describe("SubscriptionAllowanceReadings", () => { const throttled = await readings.read(HarnessKinds.CLAUDE); expect(asked).toBe(2); - expect(throttled.state).toBe(SubscriptionAllowanceState.EXHAUSTED); + expect(throttled.state).toBe(HarnessAllowanceState.EXHAUSTED); expect(throttled.read_at).toBe(first.read_at); }); it("waits out the interval again after a refused reading rather than retrying at once", async () => { let asked = 0; let clock = 0; - const readings = new SubscriptionAllowanceReadings({ + const readings = new HarnessAllowanceReadings({ read: async () => { asked += 1; if (asked > 1) { @@ -150,7 +150,7 @@ describe("SubscriptionAllowanceReadings", () => { it("asks the vendors again on a refresh however much of the interval is left", async () => { let asked = 0; - const readings = new SubscriptionAllowanceReadings({ + const readings = new HarnessAllowanceReadings({ read: async (kind) => { asked += 1; return { kind, plan: "max", balance: null, windows: [] }; @@ -165,8 +165,8 @@ describe("SubscriptionAllowanceReadings", () => { expect(asked).toBe(EVERY_HARNESS_KIND.length * 2); }); - it("reads every subscription the lab can run on, not only the ones that answered", async () => { - const readings = new SubscriptionAllowanceReadings({ + it("reads every harness the lab can run on, not only the ones that answered", async () => { + const readings = new HarnessAllowanceReadings({ read: async (kind) => { if (kind === HarnessKinds.CODEX) { throw new Error("codex CLI is not installed"); @@ -178,6 +178,6 @@ describe("SubscriptionAllowanceReadings", () => { const roster = await readings.readAll(); expect(roster.map((allowance) => allowance.harness)).toEqual([...EVERY_HARNESS_KIND]); - expect(roster[0]?.state).toBe(SubscriptionAllowanceState.UNREADABLE); + expect(roster[0]?.state).toBe(HarnessAllowanceState.UNREADABLE); }); }); diff --git a/apps/daemon/src/subscription-allowance/subscription-allowance-readings.ts b/apps/daemon/src/harness-allowance/harness-allowance-readings.ts similarity index 73% rename from apps/daemon/src/subscription-allowance/subscription-allowance-readings.ts rename to apps/daemon/src/harness-allowance/harness-allowance-readings.ts index 79217f98..2a98efb5 100644 --- a/apps/daemon/src/subscription-allowance/subscription-allowance-readings.ts +++ b/apps/daemon/src/harness-allowance/harness-allowance-readings.ts @@ -1,20 +1,20 @@ import { type HarnessKind, HarnessKinds } from "@openlab/harness/agent-harness.const"; -import { readSubscriptionAllowance } from "@openlab/harness/subscription-allowance"; -import type { SubscriptionAllowance } from "@openlab/protocol/subscription-allowance/subscription-allowance.types"; +import { readHarnessAllowance } from "@openlab/harness/harness-allowance"; +import type { HarnessAllowance } from "@openlab/protocol/harness-allowance/harness-allowance.types"; import { allowanceFromReading, unreadableAllowance -} from "#src/subscription-allowance/subscription-allowance"; -import { ALLOWANCE_READING_TTL_MILLISECONDS } from "#src/subscription-allowance/subscription-allowance.const"; +} from "#src/harness-allowance/harness-allowance"; +import { ALLOWANCE_READING_TTL_MILLISECONDS } from "#src/harness-allowance/harness-allowance.const"; import type { AllowanceReadingsOptions, ReadHarnessAllowance -} from "#src/subscription-allowance/subscription-allowance-readings.types"; +} from "#src/harness-allowance/harness-allowance-readings.types"; const ALL_HARNESS_KINDS: readonly HarnessKind[] = Object.values(HarnessKinds); interface CachedReading { - readonly allowance: SubscriptionAllowance; + readonly allowance: HarnessAllowance; readonly at: number; } @@ -23,20 +23,20 @@ interface CachedReading { * through here, so a vendor is asked once per interval however many callers want to know, and two * callers arriving together share one request rather than launching two. */ -export class SubscriptionAllowanceReadings { +export class HarnessAllowanceReadings { readonly #read: ReadHarnessAllowance; readonly #ttlMs: number; readonly #now: () => number; readonly #cached = new Map(); - readonly #pending = new Map>(); + readonly #pending = new Map>(); constructor(options: AllowanceReadingsOptions = {}) { - this.#read = options.read ?? readSubscriptionAllowance; + this.#read = options.read ?? readHarnessAllowance; this.#ttlMs = options.ttlMs ?? ALLOWANCE_READING_TTL_MILLISECONDS; this.#now = options.now ?? Date.now; } - read(kind: HarnessKind, signal?: AbortSignal): Promise { + read(kind: HarnessKind, signal?: AbortSignal): Promise { const cached = this.#cached.get(kind); if (cached !== undefined && this.#now() - cached.at < this.#ttlMs) { return Promise.resolve(cached.allowance); @@ -45,7 +45,7 @@ export class SubscriptionAllowanceReadings { return this.#ask(kind, signal); } - readAll(signal?: AbortSignal): Promise { + readAll(signal?: AbortSignal): Promise { return Promise.all(ALL_HARNESS_KINDS.map((kind) => this.read(kind, signal))); } @@ -54,12 +54,12 @@ export class SubscriptionAllowanceReadings { * who presses refresh is saying the numbers on the page are not the ones they want, and serving * them the answer they are already looking at would make the button do nothing for a minute. */ - refreshAll(signal?: AbortSignal): Promise { + refreshAll(signal?: AbortSignal): Promise { return Promise.all(ALL_HARNESS_KINDS.map((kind) => this.#ask(kind, signal))); } /** One request per vendor at a time, so callers arriving together join the one in flight. */ - #ask(kind: HarnessKind, signal?: AbortSignal): Promise { + #ask(kind: HarnessKind, signal?: AbortSignal): Promise { const pending = this.#pending.get(kind); if (pending !== undefined) { return pending; @@ -75,9 +75,9 @@ export class SubscriptionAllowanceReadings { * usage endpoint itself, and forgetting a spent plan on a throttled poll would send the next run * straight into it, so the last answer stands and only its interval restarts. */ - async #readVendor(kind: HarnessKind, signal?: AbortSignal): Promise { + async #readVendor(kind: HarnessKind, signal?: AbortSignal): Promise { const at = this.#now(); - let allowance: SubscriptionAllowance; + let allowance: HarnessAllowance; try { allowance = allowanceFromReading( await this.#read(kind, signal), diff --git a/apps/daemon/src/subscription-allowance/subscription-allowance-readings.types.ts b/apps/daemon/src/harness-allowance/harness-allowance-readings.types.ts similarity index 73% rename from apps/daemon/src/subscription-allowance/subscription-allowance-readings.types.ts rename to apps/daemon/src/harness-allowance/harness-allowance-readings.types.ts index 554beefd..a88e36ca 100644 --- a/apps/daemon/src/subscription-allowance/subscription-allowance-readings.types.ts +++ b/apps/daemon/src/harness-allowance/harness-allowance-readings.types.ts @@ -1,10 +1,10 @@ import type { HarnessKind } from "@openlab/harness/agent-harness.const"; -import type { SubscriptionAllowance as HarnessAllowance } from "@openlab/harness/subscription-allowance.types"; +import type { HarnessAllowanceReading } from "@openlab/harness/harness-allowance.types"; export type ReadHarnessAllowance = ( kind: HarnessKind, signal?: AbortSignal -) => Promise; +) => Promise; export interface AllowanceReadingsOptions { readonly read?: ReadHarnessAllowance; diff --git a/apps/daemon/src/subscription-allowance/subscription-allowance.const.ts b/apps/daemon/src/harness-allowance/harness-allowance.const.ts similarity index 90% rename from apps/daemon/src/subscription-allowance/subscription-allowance.const.ts rename to apps/daemon/src/harness-allowance/harness-allowance.const.ts index 84132c95..ac0421f5 100644 --- a/apps/daemon/src/subscription-allowance/subscription-allowance.const.ts +++ b/apps/daemon/src/harness-allowance/harness-allowance.const.ts @@ -1,4 +1,4 @@ -export const SUBSCRIPTION_ALLOWANCE_ROUTE = "/api/subscriptions"; +export const HARNESS_ALLOWANCE_ROUTE = "/api/allowances"; /** * How long a reading stands before the vendor is asked again. Every dashboard poll and every diff --git a/apps/daemon/src/subscription-allowance/subscription-allowance.ts b/apps/daemon/src/harness-allowance/harness-allowance.ts similarity index 70% rename from apps/daemon/src/subscription-allowance/subscription-allowance.ts rename to apps/daemon/src/harness-allowance/harness-allowance.ts index c7bbe977..2e4c64f1 100644 --- a/apps/daemon/src/subscription-allowance/subscription-allowance.ts +++ b/apps/daemon/src/harness-allowance/harness-allowance.ts @@ -1,23 +1,23 @@ import type { HarnessKind } from "@openlab/harness/agent-harness.const"; -import type { SubscriptionAllowance as HarnessAllowance } from "@openlab/harness/subscription-allowance.types"; +import type { HarnessAllowanceReading } from "@openlab/harness/harness-allowance.types"; import { ALLOWANCE_EXHAUSTED_PERCENT, - SubscriptionAllowanceState -} from "@openlab/protocol/subscription-allowance/subscription-allowance.const"; + HarnessAllowanceState +} from "@openlab/protocol/harness-allowance/harness-allowance.const"; import type { AllowanceWindow, - SubscriptionAllowance -} from "@openlab/protocol/subscription-allowance/subscription-allowance.types"; + HarnessAllowance +} from "@openlab/protocol/harness-allowance/harness-allowance.types"; /** - * Classifies what a vendor answered into what the lab may do with that subscription. A window at + * Classifies what a vendor answered into what the lab may do with that harness. A window at * its ceiling exhausts the plan on its own: the vendor stops serving on whichever window ran out * first, however much the others have left. */ export function allowanceFromReading( - reading: HarnessAllowance, + reading: HarnessAllowanceReading, readAt: string -): SubscriptionAllowance { +): HarnessAllowance { const windows = reading.windows.map( (window): AllowanceWindow => ({ duration_minutes: window.durationMinutes, @@ -29,8 +29,8 @@ export function allowanceFromReading( return { harness: reading.kind, state: windows.some((window) => window.used_percent >= ALLOWANCE_EXHAUSTED_PERCENT) - ? SubscriptionAllowanceState.EXHAUSTED - : SubscriptionAllowanceState.AVAILABLE, + ? HarnessAllowanceState.EXHAUSTED + : HarnessAllowanceState.AVAILABLE, plan: reading.plan, balance: reading.balance, windows, @@ -47,10 +47,10 @@ export function unreadableAllowance( kind: HarnessKind, cause: unknown, readAt: string -): SubscriptionAllowance { +): HarnessAllowance { return { harness: kind, - state: SubscriptionAllowanceState.UNREADABLE, + state: HarnessAllowanceState.UNREADABLE, plan: null, balance: null, windows: [], diff --git a/apps/daemon/src/subscription-allowance/subscription-block.const.ts b/apps/daemon/src/harness-allowance/harness-block.const.ts similarity index 59% rename from apps/daemon/src/subscription-allowance/subscription-block.const.ts rename to apps/daemon/src/harness-allowance/harness-block.const.ts index a0478f62..e35ef33a 100644 --- a/apps/daemon/src/subscription-allowance/subscription-block.const.ts +++ b/apps/daemon/src/harness-allowance/harness-block.const.ts @@ -1,13 +1,12 @@ /** - * Why the lab will not dispatch to a subscription. The two are kept apart because they are acted on + * Why the lab will not dispatch to a harness. The two are kept apart because they are acted on * differently: a vendor that has stopped serving is a capability the operator has to restore, while * a cap is the operator's own instruction and needs nothing from them at all. */ -export const SubscriptionBlockKind = { +export const HarnessBlockKind = { EXHAUSTED: "exhausted", WITHHELD: "withheld", /** The harness lost something it needs mid-run: a session, a CLI, a login. */ UNAVAILABLE: "unavailable" } as const; -export type SubscriptionBlockKind = - (typeof SubscriptionBlockKind)[keyof typeof SubscriptionBlockKind]; +export type HarnessBlockKind = (typeof HarnessBlockKind)[keyof typeof HarnessBlockKind]; diff --git a/apps/daemon/src/subscription-allowance/subscription-block.test.ts b/apps/daemon/src/harness-allowance/harness-block.test.ts similarity index 63% rename from apps/daemon/src/subscription-allowance/subscription-block.test.ts rename to apps/daemon/src/harness-allowance/harness-block.test.ts index b9621154..ce08d006 100644 --- a/apps/daemon/src/subscription-allowance/subscription-block.test.ts +++ b/apps/daemon/src/harness-allowance/harness-block.test.ts @@ -1,11 +1,11 @@ import { HarnessKinds } from "@openlab/harness/agent-harness.const"; -import type { SubscriptionAllowance as HarnessAllowance } from "@openlab/harness/subscription-allowance.types"; +import type { HarnessAllowanceReading } from "@openlab/harness/harness-allowance.types"; import { AgentHarnessKind } from "@openlab/protocol/agents/agent-execution.const"; import { SpendCapsSchema } from "@openlab/protocol/spend-caps/spend-cap.schema"; import { describe, expect, it } from "vitest"; -import { SubscriptionAllowanceReadings } from "#src/subscription-allowance/subscription-allowance-readings"; -import { subscriptionBlock } from "#src/subscription-allowance/subscription-block"; -import { SubscriptionBlockKind } from "#src/subscription-allowance/subscription-block.const"; +import { HarnessAllowanceReadings } from "#src/harness-allowance/harness-allowance-readings"; +import { harnessBlock } from "#src/harness-allowance/harness-block"; +import { HarnessBlockKind } from "#src/harness-allowance/harness-block.const"; const WEEKLY_MINUTES = 10_080; const RESETS_AT = "2026-08-09T13:50:53.000Z"; @@ -15,8 +15,8 @@ const CAPS = SpendCapsSchema.parse([ ]); function readings(usedPercent: number, resetsAt: string | null = RESETS_AT) { - return new SubscriptionAllowanceReadings({ - read: async (kind): Promise => ({ + return new HarnessAllowanceReadings({ + read: async (kind): Promise => ({ kind, plan: "max", balance: null, @@ -25,9 +25,9 @@ function readings(usedPercent: number, resetsAt: string | null = RESETS_AT) { }); } -describe("subscriptionBlock", () => { - it("says how far past the cap the subscription is and when it is worth asking again", async () => { - const block = await subscriptionBlock({ +describe("harnessBlock", () => { + it("says how far past the cap the harness is and when it is worth asking again", async () => { + const block = await harnessBlock({ readings: readings(74), caps: CAPS, kind: HarnessKinds.CLAUDE, @@ -35,15 +35,15 @@ describe("subscriptionBlock", () => { }); expect(block).toMatchObject({ - kind: SubscriptionBlockKind.WITHHELD, + kind: HarnessBlockKind.WITHHELD, reason: `The max plan is 74% into a window capped at 60%, and is held until ${RESETS_AT}`, returnsAt: RESETS_AT }); expect(block?.capabilityError).toBeUndefined(); }); - it("holds a subscription the vendor states no reset for, without promising it back", async () => { - const block = await subscriptionBlock({ + it("holds a harness the vendor states no reset for, without promising it back", async () => { + const block = await harnessBlock({ readings: readings(74, null), caps: CAPS, kind: HarnessKinds.CLAUDE, @@ -54,8 +54,8 @@ describe("subscriptionBlock", () => { expect(block?.returnsAt).toBeUndefined(); }); - it("asks the operator about a subscription its vendor has stopped serving", async () => { - const block = await subscriptionBlock({ + it("asks the operator about a harness its vendor has stopped serving", async () => { + const block = await harnessBlock({ readings: readings(100), caps: CAPS, kind: HarnessKinds.CLAUDE, @@ -63,15 +63,15 @@ describe("subscriptionBlock", () => { }); expect(block).toMatchObject({ - kind: SubscriptionBlockKind.EXHAUSTED, + kind: HarnessBlockKind.EXHAUSTED, reason: `The max plan has no allowance left until ${RESETS_AT}` }); expect(block?.capabilityError?.capabilityRequest.reason).toContain("no allowance left"); }); - it("lets a subscription under its cap through", async () => { + it("lets a harness under its cap through", async () => { await expect( - subscriptionBlock({ + harnessBlock({ readings: readings(59), caps: CAPS, kind: HarnessKinds.CLAUDE, @@ -82,7 +82,7 @@ describe("subscriptionBlock", () => { it("dispatches blind rather than guessing when no readings were wired in", async () => { await expect( - subscriptionBlock({ caps: CAPS, kind: HarnessKinds.CLAUDE, spendPastCaps: false }) + harnessBlock({ caps: CAPS, kind: HarnessKinds.CLAUDE, spendPastCaps: false }) ).resolves.toBeUndefined(); }); }); diff --git a/apps/daemon/src/subscription-allowance/subscription-block.ts b/apps/daemon/src/harness-allowance/harness-block.ts similarity index 69% rename from apps/daemon/src/subscription-allowance/subscription-block.ts rename to apps/daemon/src/harness-allowance/harness-block.ts index 833a283e..de9319bb 100644 --- a/apps/daemon/src/subscription-allowance/subscription-block.ts +++ b/apps/daemon/src/harness-allowance/harness-block.ts @@ -1,40 +1,35 @@ import type { HarnessKind } from "@openlab/harness/agent-harness.const"; import { spentAllowanceError } from "@openlab/harness/spent-allowance"; -import { windowSpendCap, withheldWindows } from "@openlab/protocol/spend-caps/spend-cap"; -import type { SpendCaps } from "@openlab/protocol/spend-caps/spend-cap.types"; import { ALLOWANCE_EXHAUSTED_PERCENT, - SubscriptionAllowanceState -} from "@openlab/protocol/subscription-allowance/subscription-allowance.const"; + HarnessAllowanceState +} from "@openlab/protocol/harness-allowance/harness-allowance.const"; import type { AllowanceWindow, - SubscriptionAllowance -} from "@openlab/protocol/subscription-allowance/subscription-allowance.types"; -import { latestWindowReset } from "@openlab/protocol/subscription-allowance/window-reset"; -import { SubscriptionBlockKind } from "#src/subscription-allowance/subscription-block.const"; -import type { - SubscriptionBlock, - SubscriptionBlockInput -} from "#src/subscription-allowance/subscription-block.types"; + HarnessAllowance +} from "@openlab/protocol/harness-allowance/harness-allowance.types"; +import { latestWindowReset } from "@openlab/protocol/harness-allowance/window-reset"; +import { windowSpendCap, withheldWindows } from "@openlab/protocol/spend-caps/spend-cap"; +import type { SpendCaps } from "@openlab/protocol/spend-caps/spend-cap.types"; +import { HarnessBlockKind } from "#src/harness-allowance/harness-block.const"; +import type { HarnessBlock, HarnessBlockInput } from "#src/harness-allowance/harness-block.types"; /** - * What is stopping the lab from spending one subscription, decided before a run is prepared rather + * What is stopping the lab from spending one harness, decided before a run is prepared rather * than after one died on it. * - * The vendor is asked first, because a subscription it has stopped serving is a fact about the + * The vendor is asked first, because an account it has stopped serving is a fact about the * account rather than about the operator's wishes: it is reported as the capability it is, and * spending past the caps cannot buy past it. A cap is checked only afterwards, and only where the * operator set one — a spent window and a reading that failed both leave every cap out of it. */ -export async function subscriptionBlock( - input: SubscriptionBlockInput -): Promise { +export async function harnessBlock(input: HarnessBlockInput): Promise { if (input.readings === undefined) { return undefined; } const allowance = await input.readings.read(input.kind, input.signal); - if (allowance.state === SubscriptionAllowanceState.EXHAUSTED) { + if (allowance.state === HarnessAllowanceState.EXHAUSTED) { return spentBlock(input.kind, allowance); } if (input.spendPastCaps) { @@ -48,8 +43,8 @@ export async function subscriptionBlock( } /** A harness that lost what it needs mid-run, carried in the same terms as the ones read ahead. */ -export function unavailableBlock(kind: HarnessKind, reason: string): SubscriptionBlock { - return { harness: kind, kind: SubscriptionBlockKind.UNAVAILABLE, reason }; +export function unavailableBlock(kind: HarnessKind, reason: string): HarnessBlock { + return { harness: kind, kind: HarnessBlockKind.UNAVAILABLE, reason }; } /** @@ -57,25 +52,25 @@ export function unavailableBlock(kind: HarnessKind, reason: string): Subscriptio * that ran out is not named: the vendor stops serving whatever else has headroom, so the wait is * the whole answer. */ -export function spentAllowanceMessage(allowance: SubscriptionAllowance): string { +export function spentAllowanceMessage(allowance: HarnessAllowance): string { const returns = latestWindowReset(spentWindows(allowance)); return returns === undefined ? `The ${planName(allowance)} plan has no allowance left` : `The ${planName(allowance)} plan has no allowance left until ${returns}`; } -function spentBlock(kind: HarnessKind, allowance: SubscriptionAllowance): SubscriptionBlock { +function spentBlock(kind: HarnessKind, allowance: HarnessAllowance): HarnessBlock { const reason = spentAllowanceMessage(allowance); const returnsAt = latestWindowReset(spentWindows(allowance)); return { harness: kind, - kind: SubscriptionBlockKind.EXHAUSTED, + kind: HarnessBlockKind.EXHAUSTED, reason, ...(returnsAt === undefined ? {} : { returnsAt }), /** * The refusal the vendor would have answered with, raised before a run is dispatched. It is * the same capability error a spent allowance produces mid-run, so the investigation falls - * through to the next subscription, asks the operator about this one, and hibernates on the + * through to the next harness, asks the operator about this one, and hibernates on the * real reason exactly as it already did. */ capabilityError: spentAllowanceError(kind, reason) @@ -84,14 +79,14 @@ function spentBlock(kind: HarnessKind, allowance: SubscriptionAllowance): Subscr function withheldBlock( kind: HarnessKind, - allowance: SubscriptionAllowance, + allowance: HarnessAllowance, withheld: readonly AllowanceWindow[], caps: SpendCaps -): SubscriptionBlock { +): HarnessBlock { const returnsAt = latestWindowReset(withheld); return { harness: kind, - kind: SubscriptionBlockKind.WITHHELD, + kind: HarnessBlockKind.WITHHELD, reason: withheldMessage(allowance, withheld, caps), ...(returnsAt === undefined ? {} : { returnsAt }) }; @@ -102,7 +97,7 @@ function withheldBlock( * subscription that still has allowance left only makes sense against the number it was held at. */ function withheldMessage( - allowance: SubscriptionAllowance, + allowance: HarnessAllowance, withheld: readonly AllowanceWindow[], caps: SpendCaps ): string { @@ -117,10 +112,10 @@ function withheldMessage( } /** The windows the vendor has stopped serving on, which are the ones the wait is measured by. */ -function spentWindows(allowance: SubscriptionAllowance): AllowanceWindow[] { +function spentWindows(allowance: HarnessAllowance): AllowanceWindow[] { return allowance.windows.filter((window) => window.used_percent >= ALLOWANCE_EXHAUSTED_PERCENT); } -function planName(allowance: SubscriptionAllowance): string { +function planName(allowance: HarnessAllowance): string { return allowance.plan ?? allowance.harness; } diff --git a/apps/daemon/src/subscription-allowance/subscription-block.types.ts b/apps/daemon/src/harness-allowance/harness-block.types.ts similarity index 62% rename from apps/daemon/src/subscription-allowance/subscription-block.types.ts rename to apps/daemon/src/harness-allowance/harness-block.types.ts index ab1daa22..bf095727 100644 --- a/apps/daemon/src/subscription-allowance/subscription-block.types.ts +++ b/apps/daemon/src/harness-allowance/harness-block.types.ts @@ -1,14 +1,14 @@ import type { HarnessKind } from "@openlab/harness/agent-harness.const"; import type { HarnessCapabilityError } from "@openlab/harness/harness-error"; import type { SpendCaps } from "@openlab/protocol/spend-caps/spend-cap.types"; -import type { SubscriptionAllowanceReadings } from "#src/subscription-allowance/subscription-allowance-readings"; -import type { SubscriptionBlockKind } from "#src/subscription-allowance/subscription-block.const"; +import type { HarnessAllowanceReadings } from "#src/harness-allowance/harness-allowance-readings"; +import type { HarnessBlockKind } from "#src/harness-allowance/harness-block.const"; -/** One subscription the lab may not spend right now, and everything an operator can act on. */ -export interface SubscriptionBlock { +/** One harness the lab may not spend right now, and everything an operator can act on. */ +export interface HarnessBlock { readonly harness: HarnessKind; - readonly kind: SubscriptionBlockKind; - /** Prose that says what stopped this subscription, in the vendor's or the operator's terms. */ + readonly kind: HarnessBlockKind; + /** Prose that says what stopped this harness, in the vendor's or the operator's terms. */ readonly reason: string; /** When it may be dispatched to again, where the vendor stated the reset. */ readonly returnsAt?: string; @@ -19,9 +19,9 @@ export interface SubscriptionBlock { readonly capabilityError?: HarnessCapabilityError; } -export interface SubscriptionBlockInput { +export interface HarnessBlockInput { /** Absent leaves the lab dispatching blind, learning a spent allowance from the vendor. */ - readonly readings?: SubscriptionAllowanceReadings; + readonly readings?: HarnessAllowanceReadings; readonly caps: SpendCaps; readonly kind: HarnessKind; /** This investigation was told to spend past the caps, so only the vendor can stop it. */ diff --git a/apps/daemon/src/investigation-registry/investigation-registry.ts b/apps/daemon/src/investigation-registry/investigation-registry.ts index 350deeb4..8de30e9e 100644 --- a/apps/daemon/src/investigation-registry/investigation-registry.ts +++ b/apps/daemon/src/investigation-registry/investigation-registry.ts @@ -5,6 +5,7 @@ import type { InvestigationSummary } from "@openlab/protocol/investigation-statu import { AgentActivityHub } from "#src/agent-activity/agent-activity-hub"; import { createHarnesses } from "#src/agent-harness/harness-factory"; import { ResearchLoopController } from "#src/daemon-runtime/research-loop-controller"; +import type { HarnessAllowanceReadings } from "#src/harness-allowance/harness-allowance-readings"; import { RESTORED_INVESTIGATION_LIMIT, RegistryCloseReason @@ -23,10 +24,9 @@ import type { StatusListener } from "#src/investigation-workspace/investigation- import type { LabSettingsReader } from "#src/lab-settings/lab-settings.types"; import { SHIPPED_LAB_SETTINGS } from "#src/lab-settings/lab-settings-store"; import { runResearchLoop } from "#src/research-cycle/research-loop"; -import type { SubscriptionAllowanceReadings } from "#src/subscription-allowance/subscription-allowance-readings"; /** - * Every investigation the lab is holding. One lab, one database, one set of subscriptions — and as + * Every investigation the lab is holding. One lab, one database, one set of allowances — and as * many investigations as the operator has started, each with its own run directory, its own agents * and its own research loop running alongside the others. */ @@ -38,7 +38,7 @@ export class InvestigationRegistry { readonly #workspaceRoot: string; readonly #persistence: RegistryPersistence; readonly #investigations: InvestigationRecords; - readonly #subscriptions: SubscriptionAllowanceReadings | undefined; + readonly #allowances: HarnessAllowanceReadings | undefined; readonly #settings: LabSettingsReader; readonly #researchLoop: ResearchLoopRunner; @@ -46,7 +46,7 @@ export class InvestigationRegistry { this.#workspaceRoot = options.workspaceRoot; this.#persistence = options.persistence; this.#investigations = options.investigations; - this.#subscriptions = options.subscriptions; + this.#allowances = options.allowances; this.#settings = options.settings ?? SHIPPED_LAB_SETTINGS; this.#researchLoop = options.researchLoop ?? runResearchLoop; } @@ -124,14 +124,14 @@ export class InvestigationRegistry { } /** - * Gives every investigation the lab put to sleep on its subscriptions another go at them. The + * Gives every investigation the lab put to sleep on its allowances another go at them. The * caps are the lab's, so raising one answers the wait of every run held by it at once, and each * investigation decides for itself whether it was that wait it was sleeping on. */ - async wakeInvestigationsWaitingOnSubscriptions(reason: string): Promise { + async wakeInvestigationsWaitingOnAllowances(reason: string): Promise { await Promise.all( [...this.#held.values()].map((held) => - held.controller.wakeIfWaitingOnSubscriptions(new Error(reason)) + held.controller.wakeIfWaitingOnAllowances(new Error(reason)) ) ); } @@ -171,7 +171,7 @@ export class InvestigationRegistry { this.#researchLoop, () => createHarnesses(workspace.input.harness_kinds), this.#settings, - this.#subscriptions + this.#allowances ); const held: HeldInvestigation = { workspace, activity, controller }; this.#held.set(workspace.investigationId, held); diff --git a/apps/daemon/src/investigation-registry/investigation-registry.types.ts b/apps/daemon/src/investigation-registry/investigation-registry.types.ts index 4d707342..6373da63 100644 --- a/apps/daemon/src/investigation-registry/investigation-registry.types.ts +++ b/apps/daemon/src/investigation-registry/investigation-registry.types.ts @@ -3,13 +3,13 @@ import type { RuntimePersistence } from "@openlab/db/runtime/runtime-persistence import type { InvestigationSummary } from "@openlab/protocol/investigation-status/investigation-summary.types"; import type { AgentActivityHub } from "#src/agent-activity/agent-activity-hub"; import type { ResearchLoopController } from "#src/daemon-runtime/research-loop-controller"; +import type { HarnessAllowanceReadings } from "#src/harness-allowance/harness-allowance-readings"; import type { InvestigationWorkspace } from "#src/investigation-workspace/investigation-workspace"; import type { LabSettingsReader } from "#src/lab-settings/lab-settings.types"; import type { ResearchLoopOptions, ResearchLoopOutcome } from "#src/research-cycle/research-loop.types"; -import type { SubscriptionAllowanceReadings } from "#src/subscription-allowance/subscription-allowance-readings"; export type RegistryPersistence = Pick< RuntimePersistence, @@ -36,7 +36,7 @@ export interface InvestigationRegistryOptions { readonly workspaceRoot: string; readonly persistence: RegistryPersistence; readonly investigations: InvestigationRecords; - readonly subscriptions?: SubscriptionAllowanceReadings; + readonly allowances?: HarnessAllowanceReadings; /** Absent starts every investigation on the shipped roster and every role on its default. */ readonly settings?: LabSettingsReader; readonly researchLoop?: ResearchLoopRunner; diff --git a/apps/daemon/src/investigation-status/status-server.test.ts b/apps/daemon/src/investigation-status/status-server.test.ts index 9857b23f..8fa0ce79 100644 --- a/apps/daemon/src/investigation-status/status-server.test.ts +++ b/apps/daemon/src/investigation-status/status-server.test.ts @@ -4,16 +4,17 @@ import path from "node:path"; import { AgentEffortLevel, AgentHarnessKind } from "@openlab/protocol/agents/agent-execution.const"; import { AgentRole } from "@openlab/protocol/agents/agent-role.const"; import { CapabilityStatus } from "@openlab/protocol/capabilities/capability-request.const"; +import { HarnessAllowanceRosterSchema } from "@openlab/protocol/harness-allowance/harness-allowance.schema"; import { HarnessReadinessRosterSchema } from "@openlab/protocol/harness-readiness/harness-readiness.schema"; import { EventType } from "@openlab/protocol/investigation-events/event-type.const"; import { InvestigationInputSchema } from "@openlab/protocol/investigation-input/investigation-input.schema"; import { InvestigationState } from "@openlab/protocol/investigation-lifecycle/investigation-state.const"; import { LabSettingsSchema } from "@openlab/protocol/lab-settings/lab-settings.schema"; import { LabStorageSchema } from "@openlab/protocol/lab-storage/lab-storage.schema"; -import { SubscriptionAllowanceRosterSchema } from "@openlab/protocol/subscription-allowance/subscription-allowance.schema"; import { describe, expect, it } from "vitest"; import { harnessNotInstalled } from "#src/agent-harness/agent-harness.fixture"; import { EVERY_HARNESS_KIND } from "#src/agent-harness/harness-factory"; +import { HarnessAllowanceReadings } from "#src/harness-allowance/harness-allowance-readings"; import { HarnessReadinessChecks } from "#src/harness-readiness/harness-readiness-checks"; import { InvestigationRegistry } from "#src/investigation-registry/investigation-registry"; import type { HeldInvestigation } from "#src/investigation-registry/investigation-registry.types"; @@ -24,7 +25,6 @@ import { InMemoryLabSettings } from "#src/lab-settings/lab-settings.fixture"; import { LabSettingsStore } from "#src/lab-settings/lab-settings-store"; import { ResearchLoopOutcomeStatus } from "#src/research-cycle/research-loop.const"; import type { ResearchLoopOutcome } from "#src/research-cycle/research-loop.types"; -import { SubscriptionAllowanceReadings } from "#src/subscription-allowance/subscription-allowance-readings"; async function createTestLab(options: StatusServerOptions = {}) { const workspaceRoot = await mkdtemp(path.join(tmpdir(), "lab-server-test-")); @@ -195,7 +195,7 @@ describe("status server", () => { await lab.server.close(); }); - it("puts a run sleeping on its subscriptions back to work when it is pointed elsewhere", async () => { + it("puts a run sleeping on its allowances back to work when it is pointed elsewhere", async () => { const lab = await createTestLab(); const held = await lab.open("Waiting on the allowance"); await held.workspace.hibernate( @@ -342,7 +342,7 @@ describe("status server", () => { it("serves every subscription against the schema the dashboard validates with", async () => { const lab = await createTestLab({ - subscriptions: new SubscriptionAllowanceReadings({ + allowances: new HarnessAllowanceReadings({ read: async (kind) => ({ kind, plan: "max", @@ -352,10 +352,10 @@ describe("status server", () => { }) }); - const response = await lab.server.inject({ method: "GET", url: "/api/subscriptions" }); + const response = await lab.server.inject({ method: "GET", url: "/api/allowances" }); expect(response.statusCode).toBe(200); - expect(SubscriptionAllowanceRosterSchema.parse(response.json())).toHaveLength( + expect(HarnessAllowanceRosterSchema.parse(response.json())).toHaveLength( EVERY_HARNESS_KIND.length ); await lab.server.close(); @@ -364,7 +364,7 @@ describe("status server", () => { it("re-asks the vendors for a refresh and serves the held reading to every other poll", async () => { let asked = 0; const lab = await createTestLab({ - subscriptions: new SubscriptionAllowanceReadings({ + allowances: new HarnessAllowanceReadings({ read: async (kind) => { asked += 1; return { kind, plan: "max", balance: null, windows: [] }; @@ -372,12 +372,12 @@ describe("status server", () => { }) }); - await lab.server.inject({ method: "GET", url: "/api/subscriptions" }); - await lab.server.inject({ method: "GET", url: "/api/subscriptions" }); + await lab.server.inject({ method: "GET", url: "/api/allowances" }); + await lab.server.inject({ method: "GET", url: "/api/allowances" }); const polled = asked; const refreshed = await lab.server.inject({ method: "GET", - url: "/api/subscriptions?fresh=1" + url: "/api/allowances?fresh=1" }); expect(polled).toBe(EVERY_HARNESS_KIND.length); @@ -400,10 +400,10 @@ describe("status server", () => { await lab.server.close(); }); - it("leaves the subscriptions route unserved when no readings were wired in", async () => { + it("leaves the allowances route unserved when no readings were wired in", async () => { const lab = await createTestLab(); - const response = await lab.server.inject({ method: "GET", url: "/api/subscriptions" }); + const response = await lab.server.inject({ method: "GET", url: "/api/allowances" }); expect(response.statusCode).toBe(404); await lab.server.close(); @@ -491,7 +491,7 @@ describe("status server", () => { await lab.server.close(); }); - it("puts a run sleeping on its subscriptions back to work when the caps are raised", async () => { + it("puts a run sleeping on its allowances back to work when the caps are raised", async () => { const store = new LabSettingsStore(new InMemoryLabSettings()); await store.write( LabSettingsSchema.parse({ diff --git a/apps/daemon/src/investigation-status/status-server.ts b/apps/daemon/src/investigation-status/status-server.ts index 8b4c6269..7e576588 100644 --- a/apps/daemon/src/investigation-status/status-server.ts +++ b/apps/daemon/src/investigation-status/status-server.ts @@ -16,6 +16,11 @@ import { isSpendCapLoosened } from "@openlab/protocol/spend-caps/spend-cap"; import Fastify, { type FastifyInstance, type FastifyReply } from "fastify"; import { DaemonLogLevel } from "#src/daemon-runtime/daemon-config.const"; import { registerDeepseekKeyRoute } from "#src/deepseek-key/deepseek-key-route"; +import { + FRESH_READING_PARAM, + FRESH_READING_VALUE, + HARNESS_ALLOWANCE_ROUTE +} from "#src/harness-allowance/harness-allowance.const"; import { HARNESS_READINESS_ROUTE } from "#src/harness-readiness/harness-readiness.const"; import type { InvestigationRegistry } from "#src/investigation-registry/investigation-registry"; import type { HeldInvestigation } from "#src/investigation-registry/investigation-registry.types"; @@ -41,11 +46,6 @@ import { NotificationRoute } from "#src/operator-notifications/operator-notifications.const"; import { RELEASE_NOTICE_ROUTE } from "#src/release-notice/release-notice.const"; -import { - FRESH_READING_PARAM, - FRESH_READING_VALUE, - SUBSCRIPTION_ALLOWANCE_ROUTE -} from "#src/subscription-allowance/subscription-allowance.const"; interface InvestigationParams { id: string; @@ -102,7 +102,7 @@ export function createStatusServer( * out for. Every other settings change is read by the next dispatch anyway. */ if (isSpendCapLoosened(held, stored.spend_caps)) { - await registry.wakeInvestigationsWaitingOnSubscriptions(SPEND_CAPS_RAISED_REASON); + await registry.wakeInvestigationsWaitingOnAllowances(SPEND_CAPS_RAISED_REASON); } return stored; }); @@ -164,14 +164,14 @@ export function createStatusServer( registerDeepseekKeyRoute(app); } - if (options.subscriptions !== undefined) { - const subscriptions = options.subscriptions; + if (options.allowances !== undefined) { + const allowances = options.allowances; app.get<{ Querystring: Record }>( - SUBSCRIPTION_ALLOWANCE_ROUTE, + HARNESS_ALLOWANCE_ROUTE, async (request) => request.query[FRESH_READING_PARAM] === FRESH_READING_VALUE - ? subscriptions.refreshAll() - : subscriptions.readAll() + ? allowances.refreshAll() + : allowances.readAll() ); } @@ -253,7 +253,7 @@ export function createStatusServer( ); /** - * What one investigation dispatches to. Reading it is how the operator sees which subscriptions + * What one investigation dispatches to. Reading it is how the operator sees which allowances * a stopped run may still reach; writing it is one of the two ways past a lab held at its caps, * the other being to lift the caps off this investigation, which is written here as well. */ diff --git a/apps/daemon/src/investigation-status/status-server.types.ts b/apps/daemon/src/investigation-status/status-server.types.ts index a766a67b..a9443c25 100644 --- a/apps/daemon/src/investigation-status/status-server.types.ts +++ b/apps/daemon/src/investigation-status/status-server.types.ts @@ -1,10 +1,10 @@ import type { ReleaseNotice } from "@openlab/protocol/release-notice/release-notice.types"; import type { DaemonLogLevel } from "#src/daemon-runtime/daemon-config.const"; +import type { HarnessAllowanceReadings } from "#src/harness-allowance/harness-allowance-readings"; import type { HarnessReadinessChecks } from "#src/harness-readiness/harness-readiness-checks"; import type { LabSettingsStore } from "#src/lab-settings/lab-settings-store"; import type { NotificationDispatch } from "#src/operator-notifications/notification-dispatch"; import type { NotificationSettingsStore } from "#src/operator-notifications/notification-settings-store"; -import type { SubscriptionAllowanceReadings } from "#src/subscription-allowance/subscription-allowance-readings"; /** * Who the lab reports to, and the thing that does the reporting. They arrive together because the @@ -16,8 +16,8 @@ export interface NotificationServerOptions { } export interface StatusServerOptions { - /** The subscriptions are the lab's, not one investigation's, so the readings are shared. */ - subscriptions?: SubscriptionAllowanceReadings; + /** The allowances are the lab's, not one investigation's, so the readings are shared. */ + allowances?: HarnessAllowanceReadings; /** Whether the CLIs on this machine can run. Without it the lab cannot be set up from a page. */ harnesses?: HarnessReadinessChecks; /** What the operator set for the lab. Without it the lab answers on its shipped defaults. */ diff --git a/apps/daemon/src/investigation-workspace/investigation-workspace.test.ts b/apps/daemon/src/investigation-workspace/investigation-workspace.test.ts index ab6080b9..4a772650 100644 --- a/apps/daemon/src/investigation-workspace/investigation-workspace.test.ts +++ b/apps/daemon/src/investigation-workspace/investigation-workspace.test.ts @@ -200,7 +200,7 @@ describe("InvestigationWorkspace", () => { await workspace.hibernate("Every subscription is at its cap", resumeAt); expect(workspace.getSnapshot().investigation.resume_at).toBe(resumeAt); - await workspace.wakeIfHibernating("The subscriptions are back", WakeTrigger.ALLOWANCE); + await workspace.wakeIfHibernating("The allowances are back", WakeTrigger.ALLOWANCE); expect(workspace.getSnapshot().investigation.state).toBe(InvestigationState.RUNNING); expect(workspace.getSnapshot().investigation.resume_at).toBeUndefined(); diff --git a/apps/daemon/src/research-cycle/agent-dispatch.test.ts b/apps/daemon/src/research-cycle/agent-dispatch.test.ts index de2f37d9..6dbf8a00 100644 --- a/apps/daemon/src/research-cycle/agent-dispatch.test.ts +++ b/apps/daemon/src/research-cycle/agent-dispatch.test.ts @@ -11,8 +11,8 @@ import type { HarnessPreflight, HarnessRunRequest } from "@openlab/harness/agent-harness.types"; +import type { HarnessAllowanceReading } from "@openlab/harness/harness-allowance.types"; import { HarnessEventTypes } from "@openlab/harness/harness-event.const"; -import type { SubscriptionAllowance as HarnessAllowance } from "@openlab/harness/subscription-allowance.types"; import { AgentEffortLevel, AgentHarnessKind } from "@openlab/protocol/agents/agent-execution.const"; import { AgentRole } from "@openlab/protocol/agents/agent-role.const"; import { CapabilityStatus } from "@openlab/protocol/capabilities/capability-request.const"; @@ -22,6 +22,8 @@ import { LabSettingsSchema } from "@openlab/protocol/lab-settings/lab-settings.s import { describe, expect, it } from "vitest"; import { harnessEvents, harnessRunResult } from "#src/agent-activity/agent-activity.fixture"; import { AgentActivityHub } from "#src/agent-activity/agent-activity-hub"; +import { HarnessAllowanceReadings } from "#src/harness-allowance/harness-allowance-readings"; +import { HarnessBlockKind } from "#src/harness-allowance/harness-block.const"; import { InvestigationWorkspace } from "#src/investigation-workspace/investigation-workspace"; import type { LabSettingsReader } from "#src/lab-settings/lab-settings.types"; import { SHIPPED_LAB_SETTINGS } from "#src/lab-settings/lab-settings-store"; @@ -29,10 +31,8 @@ import { DirectorPlanSchema } from "#src/research-contract/research-contract"; import { DispatchBlockedError, runAgentWithFallback } from "#src/research-cycle/agent-dispatch"; import type { AgentWorkspace } from "#src/research-cycle/agent-workspace.types"; import type { AvailableHarness } from "#src/research-cycle/research-loop.types"; -import { SubscriptionAllowanceReadings } from "#src/subscription-allowance/subscription-allowance-readings"; -import { SubscriptionBlockKind } from "#src/subscription-allowance/subscription-block.const"; -/** The window every subscription in these tests is metered on, and the cap set against it. */ +/** The window every harness in these tests is metered on, and the cap set against it. */ const WEEKLY_WINDOW_MINUTES = 10_080; const WEEKLY_CAP_PERCENT = 60; @@ -94,9 +94,9 @@ async function agentWorkspace(role: AgentRole): Promise { }; } -function readings(spent: readonly HarnessKind[]): SubscriptionAllowanceReadings { - return new SubscriptionAllowanceReadings({ - read: async (kind): Promise => ({ +function readings(spent: readonly HarnessKind[]): HarnessAllowanceReadings { + return new HarnessAllowanceReadings({ + read: async (kind): Promise => ({ kind, plan: "max", balance: null, @@ -111,10 +111,10 @@ function readings(spent: readonly HarnessKind[]): SubscriptionAllowanceReadings }); } -/** Every subscription part-spent, so only a cap the operator set can stop the lab reaching one. */ -function partlySpentReadings(usedPercent: number): SubscriptionAllowanceReadings { - return new SubscriptionAllowanceReadings({ - read: async (kind): Promise => ({ +/** Every harness part-spent, so only a cap the operator set can stop the lab reaching one. */ +function partlySpentReadings(usedPercent: number): HarnessAllowanceReadings { + return new HarnessAllowanceReadings({ + read: async (kind): Promise => ({ kind, plan: "max", balance: null, @@ -152,7 +152,7 @@ async function testWorkspace( } describe("runAgentWithFallback", () => { - it("asks the operator instead of dispatching when no subscription has allowance left", async () => { + it("asks the operator instead of dispatching when no harness has allowance left", async () => { const workspace = await testWorkspace("dispatch-spent"); let workspacesCreated = 0; @@ -160,14 +160,14 @@ describe("runAgentWithFallback", () => { workspace, activity: new AgentActivityHub(), available: [unusedHarness(HarnessKinds.CODEX), unusedHarness(HarnessKinds.CLAUDE)], - subscriptions: readings([HarnessKinds.CODEX, HarnessKinds.CLAUDE]), + allowances: readings([HarnessKinds.CODEX, HarnessKinds.CLAUDE]), settings: SHIPPED_LAB_SETTINGS, preferredIndex: 0, role: AgentRole.DIRECTOR, objective: "Find where this goal might be reachable", createAgentWorkspace: async () => { workspacesCreated += 1; - throw new Error("a run was prepared for a spent subscription"); + throw new Error("a run was prepared for a spent harness"); }, prompt: "Plan the cycle", schema: DirectorPlanSchema @@ -182,7 +182,7 @@ describe("runAgentWithFallback", () => { ).not.toHaveLength(0); }); - it("moves on to the subscription that still has allowance", async () => { + it("moves on to the harness that still has allowance", async () => { const workspace = await testWorkspace("dispatch-fallthrough"); const dispatched: string[] = []; const reached = new Error("reached dispatch"); @@ -191,7 +191,7 @@ describe("runAgentWithFallback", () => { workspace, activity: new AgentActivityHub(), available: [unusedHarness(HarnessKinds.CODEX), unusedHarness(HarnessKinds.CLAUDE)], - subscriptions: readings([HarnessKinds.CODEX]), + allowances: readings([HarnessKinds.CODEX]), settings: SHIPPED_LAB_SETTINGS, preferredIndex: 0, role: AgentRole.DIRECTOR, @@ -230,7 +230,7 @@ describe("runAgentWithFallback", () => { await expect(dispatch).rejects.toBe(reached); }); - it("passes over the subscription that reached its cap and reaches for one that has not", async () => { + it("passes over the harness that reached its cap and reaches for one that has not", async () => { const workspace = await testWorkspace("dispatch-capped"); const dispatched: HarnessKind[] = []; const requests: HarnessRunRequest[] = []; @@ -242,7 +242,7 @@ describe("runAgentWithFallback", () => { unusedHarness(HarnessKinds.CODEX), answeringHarness(HarnessKinds.CLAUDE, requests) ], - subscriptions: partlySpentReadings(WEEKLY_CAP_PERCENT + 4), + allowances: partlySpentReadings(WEEKLY_CAP_PERCENT + 4), settings: cappedSettings(AgentHarnessKind.CODEX), preferredIndex: 0, role: AgentRole.DIRECTOR, @@ -264,20 +264,20 @@ describe("runAgentWithFallback", () => { ).toEqual([HarnessKinds.CODEX]); }); - it("asks the operator for nothing when its own caps are what held every subscription", async () => { + it("asks the operator for nothing when its own caps are what held every harness", async () => { const workspace = await testWorkspace("dispatch-all-capped"); const dispatch = runAgentWithFallback({ workspace, activity: new AgentActivityHub(), available: [unusedHarness(HarnessKinds.CODEX), unusedHarness(HarnessKinds.CLAUDE)], - subscriptions: partlySpentReadings(WEEKLY_CAP_PERCENT), + allowances: partlySpentReadings(WEEKLY_CAP_PERCENT), settings: cappedSettings(AgentHarnessKind.CODEX, AgentHarnessKind.CLAUDE), preferredIndex: 0, role: AgentRole.DIRECTOR, objective: "Find where this goal might be reachable", createAgentWorkspace: async () => { - throw new Error("a run was prepared for a subscription at its cap"); + throw new Error("a run was prepared for a harness at its cap"); }, prompt: "Plan the cycle", schema: DirectorPlanSchema @@ -285,8 +285,8 @@ describe("runAgentWithFallback", () => { await expect(dispatch).rejects.toMatchObject({ blocks: [ - { harness: HarnessKinds.CODEX, kind: SubscriptionBlockKind.WITHHELD }, - { harness: HarnessKinds.CLAUDE, kind: SubscriptionBlockKind.WITHHELD } + { harness: HarnessKinds.CODEX, kind: HarnessBlockKind.WITHHELD }, + { harness: HarnessKinds.CLAUDE, kind: HarnessBlockKind.WITHHELD } ] }); expect(workspace.getSnapshot().capability_requests).toHaveLength(0); @@ -300,7 +300,7 @@ describe("runAgentWithFallback", () => { workspace, activity: new AgentActivityHub(), available: [answeringHarness(HarnessKinds.CODEX, requests)], - subscriptions: partlySpentReadings(WEEKLY_CAP_PERCENT + 30), + allowances: partlySpentReadings(WEEKLY_CAP_PERCENT + 30), settings: cappedSettings(AgentHarnessKind.CODEX), preferredIndex: 0, role: AgentRole.DIRECTOR, @@ -320,20 +320,20 @@ describe("runAgentWithFallback", () => { workspace, activity: new AgentActivityHub(), available: [unusedHarness(HarnessKinds.CODEX)], - subscriptions: readings([HarnessKinds.CODEX]), + allowances: readings([HarnessKinds.CODEX]), settings: cappedSettings(AgentHarnessKind.CODEX), preferredIndex: 0, role: AgentRole.DIRECTOR, objective: "Find where this goal might be reachable", createAgentWorkspace: async () => { - throw new Error("a run was prepared for a spent subscription"); + throw new Error("a run was prepared for a spent harness"); }, prompt: "Plan the cycle", schema: DirectorPlanSchema }); await expect(dispatch).rejects.toMatchObject({ - blocks: [{ harness: HarnessKinds.CODEX, kind: SubscriptionBlockKind.EXHAUSTED }] + blocks: [{ harness: HarnessKinds.CODEX, kind: HarnessBlockKind.EXHAUSTED }] }); expect(workspace.getSnapshot().capability_requests).not.toHaveLength(0); }); diff --git a/apps/daemon/src/research-cycle/agent-dispatch.ts b/apps/daemon/src/research-cycle/agent-dispatch.ts index 2267c8d5..9289c5f6 100644 --- a/apps/daemon/src/research-cycle/agent-dispatch.ts +++ b/apps/daemon/src/research-cycle/agent-dispatch.ts @@ -4,6 +4,8 @@ import { HarnessAbortedError, HarnessCapabilityError } from "@openlab/harness/ha import { AgentRunStatus } from "@openlab/protocol/agent-runs/agent-run-status.const"; import type { CapabilityRequest } from "@openlab/protocol/capabilities/capability-request.types"; import { EventType } from "@openlab/protocol/investigation-events/event-type.const"; +import { harnessBlock, unavailableBlock } from "#src/harness-allowance/harness-block"; +import type { HarnessBlock } from "#src/harness-allowance/harness-block.types"; import type { InvestigationWorkspace } from "#src/investigation-workspace/investigation-workspace"; import { resolveRoleExecution } from "#src/lab-settings/role-execution"; import type { CapabilityRequestCandidate } from "#src/research-contract/research-contract"; @@ -25,22 +27,17 @@ import { runStructuredAgent, StructuredAgentRunError } from "#src/research-cycle/structured-agent-run"; -import { - subscriptionBlock, - unavailableBlock -} from "#src/subscription-allowance/subscription-block"; -import type { SubscriptionBlock } from "#src/subscription-allowance/subscription-block.types"; /** - * Nothing this investigation may dispatch to would take the work. What stopped each subscription is + * Nothing this investigation may dispatch to would take the work. What stopped each harness is * carried through rather than summarised away, because the investigation hibernates on it and the * operator reads it: a lab held by the caps it was given and one that lost its logins are different * problems, and only one of them is waiting on a person. */ export class DispatchBlockedError extends Error { - readonly blocks: readonly SubscriptionBlock[]; + readonly blocks: readonly HarnessBlock[]; - constructor(role: string, blocks: readonly SubscriptionBlock[]) { + constructor(role: string, blocks: readonly HarnessBlock[]) { super(`No agent CLI harness could take the ${role} work`); this.name = "DispatchBlockedError"; this.blocks = blocks; @@ -52,7 +49,7 @@ export class DispatchBlockedError extends Error { * attempt is journalled as its own run: a second harness taking over is a second agent doing the * work, and an operator reading the history should see both. * - * A subscription that has nothing left, or that has reached the cap the operator set on it, is + * A harness that has nothing left, or that has reached the cap the operator set on it, is * passed over before any run is prepared: the same outcome, reached without spending a run to find * out. */ @@ -60,13 +57,13 @@ export async function runAgentWithFallback input: AgentDispatchInput ): Promise> { let lastError: unknown; - const blocks: SubscriptionBlock[] = []; + const blocks: HarnessBlock[] = []; for (let offset = 0; offset < input.available.length; offset += 1) { throwIfAborted(input.signal); const harness = selectHarness(input.available, input.preferredIndex + offset).harness; const settings = input.settings.read(); - const block = await subscriptionBlock({ - ...(input.subscriptions === undefined ? {} : { readings: input.subscriptions }), + const block = await harnessBlock({ + ...(input.allowances === undefined ? {} : { readings: input.allowances }), caps: settings.spend_caps, kind: harness.kind, spendPastCaps: input.workspace.input.spend_past_caps, @@ -75,7 +72,7 @@ export async function runAgentWithFallback if (block !== undefined) { lastError = block.capabilityError ?? new Error(block.reason); blocks.push(block); - await recordSubscriptionBlock(input.workspace, block); + await recordHarnessBlock(input.workspace, block); continue; } @@ -142,13 +139,13 @@ export async function runAgentWithFallback } /** - * Writes down that a subscription was passed over. A vendor that has stopped serving is asked + * Writes down that a harness was passed over. A vendor that has stopped serving is asked * about, because only the operator can bring it back; a cap is the operator's own instruction, so * it is journalled and nothing is asked of them. */ -async function recordSubscriptionBlock( +async function recordHarnessBlock( workspace: InvestigationWorkspace, - block: SubscriptionBlock + block: HarnessBlock ): Promise { if (block.capabilityError !== undefined) { await requestHarnessCapability(workspace, block.capabilityError.capabilityRequest); diff --git a/apps/daemon/src/research-cycle/lead-research.ts b/apps/daemon/src/research-cycle/lead-research.ts index 3bb3eac4..63cb3e50 100644 --- a/apps/daemon/src/research-cycle/lead-research.ts +++ b/apps/daemon/src/research-cycle/lead-research.ts @@ -31,7 +31,7 @@ export async function researchLead(input: LeadResearchInput): Promise { await workspace.update((draft) => { for (const run of draft.runs) { @@ -163,15 +161,15 @@ async function hibernateOnBlockedDispatch( } /** Nothing but the operator's own caps stopped the work, so there is nothing to ask them for. */ -function heldBySpendCaps(blocks: readonly SubscriptionBlock[]): boolean { - return blocks.length > 0 && blocks.every(({ kind }) => kind === SubscriptionBlockKind.WITHHELD); +function heldBySpendCaps(blocks: readonly HarnessBlock[]): boolean { + return blocks.length > 0 && blocks.every(({ kind }) => kind === HarnessBlockKind.WITHHELD); } /** - * What every subscription answered, under the one sentence that says what it amounts to. A preflight + * What every harness answered, under the one sentence that says what it amounts to. A preflight * that found nothing carries no answers at all, which is the older and blunter way to be stopped. */ -function blockedDispatchReason(blocks: readonly SubscriptionBlock[]): string { +function blockedDispatchReason(blocks: readonly HarnessBlock[]): string { if (blocks.length === 0) { return HibernationReason.NO_HARNESS; } @@ -183,11 +181,11 @@ function blockedDispatchReason(blocks: readonly SubscriptionBlock[]): string { } /** - * When the first of the blocked subscriptions is worth asking again. One of them coming back is + * When the first of the blocked allowances is worth asking again. One of them coming back is * enough to research on, so the earliest wins, and the margin keeps the investigation from waking * onto a reading taken before the reset it is waiting for. */ -function dispatchResumesAt(blocks: readonly SubscriptionBlock[]): string | undefined { +function dispatchResumesAt(blocks: readonly HarnessBlock[]): string | undefined { const returns = blocks .flatMap(({ returnsAt }) => (returnsAt === undefined ? [] : [Date.parse(returnsAt)])) .filter((instant) => !Number.isNaN(instant)); @@ -203,7 +201,7 @@ async function runResearchCycle(input: ResearchCycleInput): Promise { readonly workspace: InvestigationWorkspace; readonly activity: AgentActivityHub; readonly available: readonly AvailableHarness[]; - readonly subscriptions?: SubscriptionAllowanceReadings; + readonly allowances?: HarnessAllowanceReadings; /** The settings in force, already resolved by the loop for every stage of a cycle. */ readonly settings: LabSettingsReader; readonly preferredIndex: number; diff --git a/apps/dashboard/src/allowance-panel/allowance-client.ts b/apps/dashboard/src/allowance-panel/allowance-client.ts new file mode 100644 index 00000000..9f1243af --- /dev/null +++ b/apps/dashboard/src/allowance-panel/allowance-client.ts @@ -0,0 +1,35 @@ +import { HarnessAllowanceRosterSchema } from "@openlab/protocol/harness-allowance/harness-allowance.schema"; +import type { HarnessAllowanceRoster } from "@openlab/protocol/harness-allowance/harness-allowance.types"; + +const HARNESS_ALLOWANCE_ENDPOINT = "/api/allowances"; + +/** Tells the daemon to put the reading it is holding aside and ask the vendors again. */ +const FRESH_READING_QUERY = "?fresh=1"; + +export const harnessAllowanceQueryKey = ["lab", "allowances"] as const; + +/** Whatever the daemon has. Asking faster than it reads the vendors returns the same answer. */ +export function fetchHarnessAllowance(signal?: AbortSignal): Promise { + return requestAllowance(HARNESS_ALLOWANCE_ENDPOINT, signal); +} + +/** What the vendors say right now, which is the only thing a refresh can honestly mean. */ +export function refreshHarnessAllowance(signal?: AbortSignal): Promise { + return requestAllowance(`${HARNESS_ALLOWANCE_ENDPOINT}${FRESH_READING_QUERY}`, signal); +} + +async function requestAllowance( + url: string, + signal?: AbortSignal +): Promise { + const response = await fetch(url, { + headers: { Accept: "application/json" }, + ...(signal ? { signal } : {}) + }); + + if (!response.ok) { + throw new Error(`Allowances endpoint returned ${response.status}.`); + } + + return HarnessAllowanceRosterSchema.parse(await response.json()); +} diff --git a/apps/dashboard/src/subscription-allowance/subscription-allowance-list.const.ts b/apps/dashboard/src/allowance-panel/allowance-list.const.ts similarity index 91% rename from apps/dashboard/src/subscription-allowance/subscription-allowance-list.const.ts rename to apps/dashboard/src/allowance-panel/allowance-list.const.ts index 0b1e151f..ef3e820d 100644 --- a/apps/dashboard/src/subscription-allowance/subscription-allowance-list.const.ts +++ b/apps/dashboard/src/allowance-panel/allowance-list.const.ts @@ -2,7 +2,7 @@ export const ALLOWANCE_LIST = "grid list-none gap-3" as const; export const ALLOWANCE_CARD = "flex min-w-0 flex-col gap-3 rounded-2xl border p-4" as const; -/** A subscription the lab will pass over is worth spotting from across the page. */ +/** A harness the lab will pass over is worth spotting from across the page. */ export const ALLOWANCE_CARD_SPENT = "border-destructive/40" as const; /** The share of a window a vendor stops serving at, which is also where a meter fills up. */ @@ -10,7 +10,7 @@ export const SPENT_PERCENT = 100; export const ALLOWANCE_CARD_HEADER = "flex flex-wrap items-center gap-x-3 gap-y-2" as const; -export const ALLOWANCE_SUBSCRIPTION_NAME = "text-base font-medium" as const; +export const ALLOWANCE_HARNESS_NAME = "text-base font-medium" as const; /** * A vendor names its own tiers and spells them however it likes, so the page cases them on the way diff --git a/apps/dashboard/src/subscription-allowance/subscription-allowance-list.test.tsx b/apps/dashboard/src/allowance-panel/allowance-list.test.tsx similarity index 80% rename from apps/dashboard/src/subscription-allowance/subscription-allowance-list.test.tsx rename to apps/dashboard/src/allowance-panel/allowance-list.test.tsx index a72c8374..82b41e73 100644 --- a/apps/dashboard/src/subscription-allowance/subscription-allowance-list.test.tsx +++ b/apps/dashboard/src/allowance-panel/allowance-list.test.tsx @@ -1,15 +1,15 @@ import { AgentHarnessKind } from "@openlab/protocol/agents/agent-execution.const"; +import { HarnessAllowanceState } from "@openlab/protocol/harness-allowance/harness-allowance.const"; +import type { HarnessAllowanceRoster } from "@openlab/protocol/harness-allowance/harness-allowance.types"; import type { SpendCaps } from "@openlab/protocol/spend-caps/spend-cap.types"; -import { SubscriptionAllowanceState } from "@openlab/protocol/subscription-allowance/subscription-allowance.const"; -import type { SubscriptionAllowanceRoster } from "@openlab/protocol/subscription-allowance/subscription-allowance.types"; import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; -import { SubscriptionAllowanceList } from "#src/subscription-allowance/subscription-allowance-list"; +import { HarnessAllowanceList } from "#src/allowance-panel/allowance-list"; -const ROSTER: SubscriptionAllowanceRoster = [ +const ROSTER: HarnessAllowanceRoster = [ { harness: AgentHarnessKind.CLAUDE, - state: SubscriptionAllowanceState.AVAILABLE, + state: HarnessAllowanceState.AVAILABLE, plan: "max", balance: null, windows: [ @@ -21,7 +21,7 @@ const ROSTER: SubscriptionAllowanceRoster = [ }, { harness: AgentHarnessKind.CODEX, - state: SubscriptionAllowanceState.EXHAUSTED, + state: HarnessAllowanceState.EXHAUSTED, plan: "plus", balance: null, windows: [ @@ -32,7 +32,7 @@ const ROSTER: SubscriptionAllowanceRoster = [ }, { harness: AgentHarnessKind.GLM, - state: SubscriptionAllowanceState.UNREADABLE, + state: HarnessAllowanceState.UNREADABLE, plan: null, balance: null, windows: [], @@ -41,7 +41,7 @@ const ROSTER: SubscriptionAllowanceRoster = [ }, { harness: AgentHarnessKind.DEEPSEEK, - state: SubscriptionAllowanceState.AVAILABLE, + state: HarnessAllowanceState.AVAILABLE, plan: null, balance: "4.21 USD", windows: [], @@ -56,7 +56,7 @@ const CLAUDE_SESSION_CAP = [ function renderList(caps: SpendCaps = [], heldBy: SpendCaps = caps) { return render( - { +describe("HarnessAllowanceList", () => { it("meters each window against what the vendor said is spent", () => { renderList(); @@ -76,14 +76,14 @@ describe("SubscriptionAllowanceList", () => { expect(week.map((meter) => meter.value)).toEqual([28, 100]); }); - it("marks the subscription the lab will pass over", () => { + it("marks the harness the lab will pass over", () => { renderList(); expect(screen.getByText("No allowance left")).toBeInTheDocument(); expect(screen.getByText("Plus")).toBeInTheDocument(); }); - it("gives the vendor's reason in full when a subscription could not be read", () => { + it("gives the vendor's reason in full when a harness could not be read", () => { renderList(); expect( @@ -105,7 +105,7 @@ describe("SubscriptionAllowanceList", () => { ).toBe("100"); }); - it("says which subscription the lab is holding back at the operator's own cap", () => { + it("says which harness the lab is holding back at the operator's own cap", () => { renderList(CLAUDE_SESSION_CAP); expect(screen.getByText("Held at your cap")).toBeInTheDocument(); @@ -130,7 +130,7 @@ describe("SubscriptionAllowanceList", () => { }); it("meters without a limiter where the caps are not the page's to move", () => { - render(); + render(); expect(screen.queryByRole("slider")).not.toBeInTheDocument(); expect(screen.getByLabelText("5 hours window")).toBeInTheDocument(); diff --git a/apps/dashboard/src/subscription-allowance/subscription-allowance-list.tsx b/apps/dashboard/src/allowance-panel/allowance-list.tsx similarity index 77% rename from apps/dashboard/src/subscription-allowance/subscription-allowance-list.tsx rename to apps/dashboard/src/allowance-panel/allowance-list.tsx index 50749890..a534c12c 100644 --- a/apps/dashboard/src/subscription-allowance/subscription-allowance-list.tsx +++ b/apps/dashboard/src/allowance-panel/allowance-list.tsx @@ -1,33 +1,33 @@ import type { AgentHarnessKind } from "@openlab/protocol/agents/agent-execution.const"; +import { HarnessAllowanceState } from "@openlab/protocol/harness-allowance/harness-allowance.const"; +import type { + HarnessAllowance, + HarnessAllowanceRoster +} from "@openlab/protocol/harness-allowance/harness-allowance.types"; import { windowSpendCap, withheldWindows } from "@openlab/protocol/spend-caps/spend-cap"; import type { SpendCaps } from "@openlab/protocol/spend-caps/spend-cap.types"; -import { SubscriptionAllowanceState } from "@openlab/protocol/subscription-allowance/subscription-allowance.const"; -import type { - SubscriptionAllowance, - SubscriptionAllowanceRoster -} from "@openlab/protocol/subscription-allowance/subscription-allowance.types"; import { useTranslation } from "react-i18next"; import { HARNESS_NAME } from "#src/agent-harness/harness-name.const"; import { subscriptionPlanName } from "#src/agent-harness/subscription-plan-name"; -import { Badge } from "#src/design-system/badge"; -import { cn } from "#src/design-system/class-names"; -import { allowanceWindowName } from "#src/subscription-allowance/allowance-window-label"; -import { SpendCapMeter } from "#src/subscription-allowance/spend-cap-meter"; -import { SUBSCRIPTION_ALLOWANCE_NAMESPACE } from "#src/subscription-allowance/subscription-allowance.i18n"; import { ALLOWANCE_CARD, ALLOWANCE_CARD_HEADER, ALLOWANCE_CARD_SPENT, ALLOWANCE_ERROR, + ALLOWANCE_HARNESS_NAME, ALLOWANCE_LIST, ALLOWANCE_PLAN_NAME, - ALLOWANCE_SUBSCRIPTION_NAME, ALLOWANCE_WINDOW, ALLOWANCE_WINDOW_LIST -} from "#src/subscription-allowance/subscription-allowance-list.const"; +} from "#src/allowance-panel/allowance-list.const"; +import { HARNESS_ALLOWANCE_NAMESPACE } from "#src/allowance-panel/allowance-panel.i18n"; +import { allowanceWindowName } from "#src/allowance-panel/allowance-window-label"; +import { SpendCapMeter } from "#src/allowance-panel/spend-cap-meter"; +import { Badge } from "#src/design-system/badge"; +import { cn } from "#src/design-system/class-names"; -interface SubscriptionAllowanceListProps { - allowances: SubscriptionAllowanceRoster; +interface HarnessAllowanceListProps { + allowances: HarnessAllowanceRoster; /** Where the limiters stand on the page, which is where the operator has dragged them. */ caps: SpendCaps; /** The caps the lab has actually been given, which decide what it is doing right now. */ @@ -41,13 +41,13 @@ interface SubscriptionAllowanceListProps { * spending it. The readings are drawn as a plain list under the block's heading rather than boxed * in a card that would repeat it. */ -export function SubscriptionAllowanceList({ +export function HarnessAllowanceList({ allowances, caps, heldBy, setCap -}: SubscriptionAllowanceListProps) { - const { t } = useTranslation(SUBSCRIPTION_ALLOWANCE_NAMESPACE); +}: HarnessAllowanceListProps) { + const { t } = useTranslation(HARNESS_ALLOWANCE_NAMESPACE); const windowLabel = (durationMinutes: number): string => { const name = allowanceWindowName(durationMinutes); return "count" in name ? t(name.key, { count: name.count }) : t(name.key); @@ -62,13 +62,13 @@ export function SubscriptionAllowanceList({ key={allowance.harness} className={cn( ALLOWANCE_CARD, - (allowance.state === SubscriptionAllowanceState.EXHAUSTED || + (allowance.state === HarnessAllowanceState.EXHAUSTED || held.length > 0) && ALLOWANCE_CARD_SPENT )} > - 0} /> - {allowance.state === SubscriptionAllowanceState.UNREADABLE ? ( + 0} /> + {allowance.state === HarnessAllowanceState.UNREADABLE ? (

{allowance.error}

) : (
    @@ -105,18 +105,18 @@ export function SubscriptionAllowanceList({ ); } -function SubscriptionHeader({ +function AllowanceHeader({ allowance, withheld }: { - allowance: SubscriptionAllowance; + allowance: HarnessAllowance; withheld: boolean; }) { - const { t } = useTranslation(SUBSCRIPTION_ALLOWANCE_NAMESPACE); + const { t } = useTranslation(HARNESS_ALLOWANCE_NAMESPACE); return (
    - {HARNESS_NAME[allowance.harness]} + {HARNESS_NAME[allowance.harness]} {allowance.plan === null ? null : ( {subscriptionPlanName(allowance.plan)} @@ -129,7 +129,7 @@ function SubscriptionHeader({ {allowance.balance === null ? null : ( {allowance.balance} )} - {allowance.state === SubscriptionAllowanceState.EXHAUSTED ? ( + {allowance.state === HarnessAllowanceState.EXHAUSTED ? ( {t("exhausted")} ) : null} {withheld ? {t("withheld")} : null} diff --git a/apps/dashboard/src/subscription-allowance/subscription-allowance.i18n.ts b/apps/dashboard/src/allowance-panel/allowance-panel.i18n.ts similarity index 68% rename from apps/dashboard/src/subscription-allowance/subscription-allowance.i18n.ts rename to apps/dashboard/src/allowance-panel/allowance-panel.i18n.ts index 1457162d..823b1bf3 100644 --- a/apps/dashboard/src/subscription-allowance/subscription-allowance.i18n.ts +++ b/apps/dashboard/src/allowance-panel/allowance-panel.i18n.ts @@ -1,15 +1,15 @@ import type { Translated } from "#src/interface-language/translation-catalog.types"; -export const SUBSCRIPTION_ALLOWANCE_NAMESPACE = "subscription-allowance" as const; +export const HARNESS_ALLOWANCE_NAMESPACE = "harness-allowance" as const; -export const SUBSCRIPTION_ALLOWANCE_EN = { - title: "Subscriptions", +export const HARNESS_ALLOWANCE_EN = { + title: "Allowances", description: - "What each subscription has left, and how far into it the lab may spend. Drag a limiter to stop the lab dispatching before a window runs out; a run already under way still finishes, and an investigation can be told to spend past the caps.", + "What each harness has left, and how far into it the lab may spend. Drag a limiter to stop the lab dispatching before a window runs out; a run already under way still finishes, and an investigation can be told to spend past the caps. A harness billed by the token carries no window, so it states a balance and takes no limiter.", pending: "Asking the vendors what is left", - emptyTitle: "No subscription readings", + emptyTitle: "No allowance readings", emptyDescription: - "The local runtime is not reporting what the subscriptions have left, so the lab is dispatching without seeing their allowance.", + "The local runtime is not reporting what the harnesses have left, so the lab is dispatching without seeing their allowance.", readAt: "Read at", autoRefresh_one: "Refreshes on its own every minute", @@ -44,14 +44,14 @@ export const SUBSCRIPTION_ALLOWANCE_EN = { windowMinutes_other: "{{count}} minutes" }; -export const SUBSCRIPTION_ALLOWANCE_RU = { - title: "Подписки", +export const HARNESS_ALLOWANCE_RU = { + title: "Лимиты", description: - "Сколько осталось у каждой подписки и до какой отметки лаборатория может её тратить. Перетащите ограничитель, чтобы она перестала отправлять работу раньше, чем окно закончится: уже запущенный агент доработает, а отдельному исследованию можно разрешить тратить дальше ограничителя.", + "Сколько осталось у каждой оболочки и до какой отметки лаборатория может это тратить. Перетащите ограничитель, чтобы она перестала отправлять работу раньше, чем окно закончится: уже запущенный агент доработает, а отдельному исследованию можно разрешить тратить дальше ограничителя. У оболочки с оплатой по токенам окна нет — она показывает баланс и ограничителя не принимает.", pending: "Спрашиваем у вендоров, сколько осталось", - emptyTitle: "Нет данных по подпискам", + emptyTitle: "Нет данных по лимитам", emptyDescription: - "Локальная среда не сообщает, сколько осталось у подписок, поэтому лаборатория отправляет работу, не видя их лимитов.", + "Локальная среда не сообщает, сколько осталось у оболочек, поэтому лаборатория отправляет работу, не видя их лимитов.", readAt: "Считано", autoRefresh_one: "Обновляется само каждую минуту", @@ -88,4 +88,4 @@ export const SUBSCRIPTION_ALLOWANCE_RU = { windowMinutes_few: "{{count}} минуты", windowMinutes_many: "{{count}} минут", windowMinutes_other: "{{count}} минут" -} satisfies Translated; +} satisfies Translated; diff --git a/apps/dashboard/src/subscription-allowance/allowance-reading-header.const.ts b/apps/dashboard/src/allowance-panel/allowance-reading-header.const.ts similarity index 92% rename from apps/dashboard/src/subscription-allowance/allowance-reading-header.const.ts rename to apps/dashboard/src/allowance-panel/allowance-reading-header.const.ts index a8f91ef4..0624072a 100644 --- a/apps/dashboard/src/subscription-allowance/allowance-reading-header.const.ts +++ b/apps/dashboard/src/allowance-panel/allowance-reading-header.const.ts @@ -1,4 +1,4 @@ -import { ALLOWANCE_REFETCH_MILLISECONDS } from "#src/subscription-allowance/subscription-allowance-section.const"; +import { ALLOWANCE_REFETCH_MILLISECONDS } from "#src/allowance-panel/allowance-section.const"; const MILLISECONDS_PER_MINUTE = 60_000; diff --git a/apps/dashboard/src/subscription-allowance/allowance-reading-header.test.tsx b/apps/dashboard/src/allowance-panel/allowance-reading-header.test.tsx similarity index 73% rename from apps/dashboard/src/subscription-allowance/allowance-reading-header.test.tsx rename to apps/dashboard/src/allowance-panel/allowance-reading-header.test.tsx index e109ddce..a0710de4 100644 --- a/apps/dashboard/src/subscription-allowance/allowance-reading-header.test.tsx +++ b/apps/dashboard/src/allowance-panel/allowance-reading-header.test.tsx @@ -1,8 +1,8 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; -import { AllowanceReadingHeader } from "#src/subscription-allowance/allowance-reading-header"; -import { SUBSCRIPTION_ALLOWANCE_EN } from "#src/subscription-allowance/subscription-allowance.i18n"; +import { HARNESS_ALLOWANCE_EN } from "#src/allowance-panel/allowance-panel.i18n"; +import { AllowanceReadingHeader } from "#src/allowance-panel/allowance-reading-header"; const READ_AT = "2026-08-04T12:48:00.000Z"; @@ -19,7 +19,7 @@ describe("AllowanceReadingHeader", () => { /> ); - await user.click(screen.getByRole("button", { name: SUBSCRIPTION_ALLOWANCE_EN.refresh })); + await user.click(screen.getByRole("button", { name: HARNESS_ALLOWANCE_EN.refresh })); expect(refresh).toHaveBeenCalledOnce(); }); @@ -36,7 +36,7 @@ describe("AllowanceReadingHeader", () => { /> ); - await user.click(screen.getByRole("button", { name: SUBSCRIPTION_ALLOWANCE_EN.refresh })); + await user.click(screen.getByRole("button", { name: HARNESS_ALLOWANCE_EN.refresh })); expect(refresh).not.toHaveBeenCalled(); }); diff --git a/apps/dashboard/src/subscription-allowance/allowance-reading-header.tsx b/apps/dashboard/src/allowance-panel/allowance-reading-header.tsx similarity index 90% rename from apps/dashboard/src/subscription-allowance/allowance-reading-header.tsx rename to apps/dashboard/src/allowance-panel/allowance-reading-header.tsx index 2ee7e9bf..074e99be 100644 --- a/apps/dashboard/src/subscription-allowance/allowance-reading-header.tsx +++ b/apps/dashboard/src/allowance-panel/allowance-reading-header.tsx @@ -1,7 +1,6 @@ import { RefreshCwIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { Button } from "#src/design-system/button"; -import { cn } from "#src/design-system/class-names"; +import { HARNESS_ALLOWANCE_NAMESPACE } from "#src/allowance-panel/allowance-panel.i18n"; import { AUTO_REFRESH_MINUTES, READING_ACTIONS, @@ -11,8 +10,9 @@ import { READING_REFRESH_TURNING, READING_STATE, READING_TIME -} from "#src/subscription-allowance/allowance-reading-header.const"; -import { SUBSCRIPTION_ALLOWANCE_NAMESPACE } from "#src/subscription-allowance/subscription-allowance.i18n"; +} from "#src/allowance-panel/allowance-reading-header.const"; +import { Button } from "#src/design-system/button"; +import { cn } from "#src/design-system/class-names"; import { formatDate } from "#src/value-display/timestamp-display"; interface AllowanceReadingHeaderProps { @@ -39,7 +39,7 @@ export function AllowanceReadingHeader({ reading, failed }: AllowanceReadingHeaderProps) { - const { t } = useTranslation(SUBSCRIPTION_ALLOWANCE_NAMESPACE); + const { t } = useTranslation(HARNESS_ALLOWANCE_NAMESPACE); return (
    diff --git a/apps/dashboard/src/subscription-allowance/allowance-reading-time.test.ts b/apps/dashboard/src/allowance-panel/allowance-reading-time.test.ts similarity index 58% rename from apps/dashboard/src/subscription-allowance/allowance-reading-time.test.ts rename to apps/dashboard/src/allowance-panel/allowance-reading-time.test.ts index ea45a788..c9c4d434 100644 --- a/apps/dashboard/src/subscription-allowance/allowance-reading-time.test.ts +++ b/apps/dashboard/src/allowance-panel/allowance-reading-time.test.ts @@ -1,13 +1,13 @@ import { AgentHarnessKind } from "@openlab/protocol/agents/agent-execution.const"; -import { SubscriptionAllowanceState } from "@openlab/protocol/subscription-allowance/subscription-allowance.const"; -import type { SubscriptionAllowanceRoster } from "@openlab/protocol/subscription-allowance/subscription-allowance.types"; +import { HarnessAllowanceState } from "@openlab/protocol/harness-allowance/harness-allowance.const"; +import type { HarnessAllowanceRoster } from "@openlab/protocol/harness-allowance/harness-allowance.types"; import { describe, expect, it } from "vitest"; -import { allowanceReadingTime } from "#src/subscription-allowance/allowance-reading-time"; +import { allowanceReadingTime } from "#src/allowance-panel/allowance-reading-time"; -function readAt(harness: AgentHarnessKind, read_at: string): SubscriptionAllowanceRoster[number] { +function readAt(harness: AgentHarnessKind, read_at: string): HarnessAllowanceRoster[number] { return { harness, - state: SubscriptionAllowanceState.AVAILABLE, + state: HarnessAllowanceState.AVAILABLE, plan: "max", balance: null, windows: [], @@ -27,7 +27,7 @@ describe("allowanceReadingTime", () => { expect(allowanceReadingTime(roster)).toBe("2026-08-03T11:31:00.000Z"); }); - it("has no reading time to give when no subscription was read", () => { + it("has no reading time to give when no harness was read", () => { expect(allowanceReadingTime([])).toBeUndefined(); }); }); diff --git a/apps/dashboard/src/subscription-allowance/allowance-reading-time.ts b/apps/dashboard/src/allowance-panel/allowance-reading-time.ts similarity index 65% rename from apps/dashboard/src/subscription-allowance/allowance-reading-time.ts rename to apps/dashboard/src/allowance-panel/allowance-reading-time.ts index 3bdc3ee8..02d0ca30 100644 --- a/apps/dashboard/src/subscription-allowance/allowance-reading-time.ts +++ b/apps/dashboard/src/allowance-panel/allowance-reading-time.ts @@ -1,12 +1,12 @@ -import type { SubscriptionAllowanceRoster } from "@openlab/protocol/subscription-allowance/subscription-allowance.types"; +import type { HarnessAllowanceRoster } from "@openlab/protocol/harness-allowance/harness-allowance.types"; /** * When the numbers on the page were read. The daemon holds each vendor separately and stands by the - * last answer from one that throttles the next reading, so the subscriptions can be read at + * last answer from one that throttles the next reading, so the allowances can be read at * different moments. The oldest of them is the only one that cannot claim the page is fresher than * it is. The readings are UTC timestamps, so the earliest is also the first in text order. */ -export function allowanceReadingTime(allowances: SubscriptionAllowanceRoster): string | undefined { +export function allowanceReadingTime(allowances: HarnessAllowanceRoster): string | undefined { return allowances .map((allowance) => allowance.read_at) .sort() diff --git a/apps/dashboard/src/subscription-allowance/subscription-allowance-section.const.ts b/apps/dashboard/src/allowance-panel/allowance-section.const.ts similarity index 100% rename from apps/dashboard/src/subscription-allowance/subscription-allowance-section.const.ts rename to apps/dashboard/src/allowance-panel/allowance-section.const.ts diff --git a/apps/dashboard/src/subscription-allowance/subscription-allowance-section.test.tsx b/apps/dashboard/src/allowance-panel/allowance-section.test.tsx similarity index 78% rename from apps/dashboard/src/subscription-allowance/subscription-allowance-section.test.tsx rename to apps/dashboard/src/allowance-panel/allowance-section.test.tsx index 0f6e3e14..5cdc8101 100644 --- a/apps/dashboard/src/subscription-allowance/subscription-allowance-section.test.tsx +++ b/apps/dashboard/src/allowance-panel/allowance-section.test.tsx @@ -1,25 +1,25 @@ import { AgentHarnessKind } from "@openlab/protocol/agents/agent-execution.const"; +import { HarnessAllowanceState } from "@openlab/protocol/harness-allowance/harness-allowance.const"; +import type { HarnessAllowanceRoster } from "@openlab/protocol/harness-allowance/harness-allowance.types"; import { LabSettingsSchema } from "@openlab/protocol/lab-settings/lab-settings.schema"; -import { SubscriptionAllowanceState } from "@openlab/protocol/subscription-allowance/subscription-allowance.const"; -import type { SubscriptionAllowanceRoster } from "@openlab/protocol/subscription-allowance/subscription-allowance.types"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; -import { SUBSCRIPTION_ALLOWANCE_EN } from "#src/subscription-allowance/subscription-allowance.i18n"; -import { SubscriptionAllowanceSection } from "#src/subscription-allowance/subscription-allowance-section"; +import { HARNESS_ALLOWANCE_EN } from "#src/allowance-panel/allowance-panel.i18n"; +import { HarnessAllowanceSection } from "#src/allowance-panel/allowance-section"; import { formatDate } from "#src/value-display/timestamp-display"; const HELD_READING = "2026-08-03T12:00:00.000Z"; const FRESH_READING = "2026-08-03T12:41:00.000Z"; -function roster(readAt: string, usedPercent: number): SubscriptionAllowanceRoster { +function roster(readAt: string, usedPercent: number): HarnessAllowanceRoster { return [ { harness: AgentHarnessKind.GLM, - state: SubscriptionAllowanceState.AVAILABLE, + state: HarnessAllowanceState.AVAILABLE, plan: "pro", balance: null, windows: [{ duration_minutes: 300, used_percent: usedPercent, resets_at: null }], @@ -90,14 +90,14 @@ function savedCaps(request: ReturnType): unknown { function renderView() { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return render(, { + return render(, { wrapper: ({ children }: { children: ReactNode }) => ( {children} ) }); } -describe("SubscriptionAllowanceSection", () => { +describe("HarnessAllowanceSection", () => { it("reads the allowance off the runtime and meters it", async () => { respondWith(ROSTER); renderView(); @@ -113,9 +113,7 @@ describe("SubscriptionAllowanceSection", () => { renderView(); expect( - await screen.findByText( - `${SUBSCRIPTION_ALLOWANCE_EN.readAt} ${formatDate(HELD_READING)}` - ) + await screen.findByText(`${HARNESS_ALLOWANCE_EN.readAt} ${formatDate(HELD_READING)}`) ).toBeInTheDocument(); }); @@ -124,14 +122,10 @@ describe("SubscriptionAllowanceSection", () => { renderView(); await screen.findByLabelText("5 hours window"); - await userEvent.click( - screen.getByRole("button", { name: SUBSCRIPTION_ALLOWANCE_EN.refresh }) - ); + await userEvent.click(screen.getByRole("button", { name: HARNESS_ALLOWANCE_EN.refresh })); expect( - await screen.findByText( - `${SUBSCRIPTION_ALLOWANCE_EN.readAt} ${formatDate(FRESH_READING)}` - ) + await screen.findByText(`${HARNESS_ALLOWANCE_EN.readAt} ${formatDate(FRESH_READING)}`) ).toBeInTheDocument(); expect(request.mock.calls.at(-1)?.[0]).toContain("fresh=1"); expect(screen.getByLabelText("5 hours window").value).toBe(12); @@ -143,13 +137,11 @@ describe("SubscriptionAllowanceSection", () => { await screen.findByLabelText("5 hours window"); respondWith({ error: "Codex is rate-limiting the usage endpoint" }, 503); - await userEvent.click( - screen.getByRole("button", { name: SUBSCRIPTION_ALLOWANCE_EN.refresh }) - ); + await userEvent.click(screen.getByRole("button", { name: HARNESS_ALLOWANCE_EN.refresh })); expect(await screen.findByRole("alert")).toBeInTheDocument(); expect( - screen.getByText(`${SUBSCRIPTION_ALLOWANCE_EN.readAt} ${formatDate(HELD_READING)}`) + screen.getByText(`${HARNESS_ALLOWANCE_EN.readAt} ${formatDate(HELD_READING)}`) ).toBeInTheDocument(); }); @@ -157,7 +149,7 @@ describe("SubscriptionAllowanceSection", () => { respondWith({ error: "Not found" }, 404); renderView(); - expect(await screen.findByText(SUBSCRIPTION_ALLOWANCE_EN.emptyTitle)).toBeInTheDocument(); + expect(await screen.findByText(HARNESS_ALLOWANCE_EN.emptyTitle)).toBeInTheDocument(); expect(screen.queryByLabelText("5 hours window")).not.toBeInTheDocument(); }); @@ -168,9 +160,7 @@ describe("SubscriptionAllowanceSection", () => { limiter.focus(); await userEvent.keyboard("{ArrowLeft}{ArrowLeft}"); - await userEvent.click( - screen.getByRole("button", { name: SUBSCRIPTION_ALLOWANCE_EN.capSave }) - ); + await userEvent.click(screen.getByRole("button", { name: HARNESS_ALLOWANCE_EN.capSave })); expect(savedCaps(request)).toEqual([ { harness: AgentHarnessKind.GLM, window_minutes: 300, max_used_percent: 98 } @@ -191,11 +181,9 @@ describe("SubscriptionAllowanceSection", () => { limiter.focus(); await userEvent.keyboard("{ArrowLeft}"); - await userEvent.click( - screen.getByRole("button", { name: SUBSCRIPTION_ALLOWANCE_EN.capSave }) - ); + await userEvent.click(screen.getByRole("button", { name: HARNESS_ALLOWANCE_EN.capSave })); - expect(await screen.findByText(SUBSCRIPTION_ALLOWANCE_EN.capSaved)).toBeInTheDocument(); + expect(await screen.findByText(HARNESS_ALLOWANCE_EN.capSaved)).toBeInTheDocument(); expect( screen.getByRole("slider", { name: "5 hours spend cap" }).getAttribute("aria-valuenow") ).toBe("45"); @@ -208,7 +196,7 @@ describe("SubscriptionAllowanceSection", () => { await screen.findByRole("slider", { name: "5 hours spend cap" }); expect( - screen.queryByRole("button", { name: SUBSCRIPTION_ALLOWANCE_EN.capSave }) + screen.queryByRole("button", { name: HARNESS_ALLOWANCE_EN.capSave }) ).not.toBeInTheDocument(); }); diff --git a/apps/dashboard/src/subscription-allowance/subscription-allowance-section.tsx b/apps/dashboard/src/allowance-panel/allowance-section.tsx similarity index 81% rename from apps/dashboard/src/subscription-allowance/subscription-allowance-section.tsx rename to apps/dashboard/src/allowance-panel/allowance-section.tsx index c3d0897b..c615cd7e 100644 --- a/apps/dashboard/src/subscription-allowance/subscription-allowance-section.tsx +++ b/apps/dashboard/src/allowance-panel/allowance-section.tsx @@ -1,11 +1,28 @@ import type { AgentHarnessKind } from "@openlab/protocol/agents/agent-execution.const"; +import type { HarnessAllowanceRoster } from "@openlab/protocol/harness-allowance/harness-allowance.types"; import type { LabSettings } from "@openlab/protocol/lab-settings/lab-settings.types"; import type { SpendCaps } from "@openlab/protocol/spend-caps/spend-cap.types"; -import type { SubscriptionAllowanceRoster } from "@openlab/protocol/subscription-allowance/subscription-allowance.types"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { CheckIcon } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { + fetchHarnessAllowance, + harnessAllowanceQueryKey, + refreshHarnessAllowance +} from "#src/allowance-panel/allowance-client"; +import { HarnessAllowanceList } from "#src/allowance-panel/allowance-list"; +import { HARNESS_ALLOWANCE_NAMESPACE } from "#src/allowance-panel/allowance-panel.i18n"; +import { AllowanceReadingHeader } from "#src/allowance-panel/allowance-reading-header"; +import { allowanceReadingTime } from "#src/allowance-panel/allowance-reading-time"; +import { + ALLOWANCE_READING_PENDING, + ALLOWANCE_REFETCH_MILLISECONDS, + CAPS_ACTIONS, + CAPS_FAILURE, + CAPS_SAVED +} from "#src/allowance-panel/allowance-section.const"; +import { hasCapEdits, withWindowCap } from "#src/allowance-panel/spend-cap-draft"; import { Button } from "#src/design-system/button"; import { Spinner } from "#src/design-system/spinner"; import { @@ -15,23 +32,6 @@ import { } from "#src/lab-configuration/lab-settings-client"; import { Panel } from "#src/panel/panel"; import { PanelEmptyState } from "#src/panel/panel-empty-state"; -import { AllowanceReadingHeader } from "#src/subscription-allowance/allowance-reading-header"; -import { allowanceReadingTime } from "#src/subscription-allowance/allowance-reading-time"; -import { hasCapEdits, withWindowCap } from "#src/subscription-allowance/spend-cap-draft"; -import { SUBSCRIPTION_ALLOWANCE_NAMESPACE } from "#src/subscription-allowance/subscription-allowance.i18n"; -import { - fetchSubscriptionAllowance, - refreshSubscriptionAllowance, - subscriptionAllowanceQueryKey -} from "#src/subscription-allowance/subscription-allowance-client"; -import { SubscriptionAllowanceList } from "#src/subscription-allowance/subscription-allowance-list"; -import { - ALLOWANCE_READING_PENDING, - ALLOWANCE_REFETCH_MILLISECONDS, - CAPS_ACTIONS, - CAPS_FAILURE, - CAPS_SAVED -} from "#src/subscription-allowance/subscription-allowance-section.const"; /** What the lab last stored, beside the caps the operator has since dragged the limiters to. */ interface SpendCapEdit { @@ -40,16 +40,16 @@ interface SpendCapEdit { } /** - * What every subscription the lab can run on has left, and how far into each one the lab may spend. + * What every harness the lab can run on has left, and how far into each one the lab may spend. * The two belong on one block because neither is worth much alone: a cap is a mark on a reading, * and a reading with no cap on it does not say what the lab will actually do with the allowance. */ -export function SubscriptionAllowanceSection() { - const { t } = useTranslation(SUBSCRIPTION_ALLOWANCE_NAMESPACE); +export function HarnessAllowanceSection() { + const { t } = useTranslation(HARNESS_ALLOWANCE_NAMESPACE); const queryClient = useQueryClient(); const allowances = useQuery({ - queryKey: subscriptionAllowanceQueryKey, - queryFn: ({ signal }) => fetchSubscriptionAllowance(signal), + queryKey: harnessAllowanceQueryKey, + queryFn: ({ signal }) => fetchHarnessAllowance(signal), retry: false, staleTime: ALLOWANCE_REFETCH_MILLISECONDS, refetchInterval: ALLOWANCE_REFETCH_MILLISECONDS, @@ -62,8 +62,8 @@ export function SubscriptionAllowanceSection() { * the interval carries on from there. */ const refresh = useMutation({ - mutationFn: () => refreshSubscriptionAllowance(), - onSuccess: (roster) => queryClient.setQueryData(subscriptionAllowanceQueryKey, roster) + mutationFn: () => refreshHarnessAllowance(), + onSuccess: (roster) => queryClient.setQueryData(harnessAllowanceQueryKey, roster) }); const settings = useQuery({ @@ -152,7 +152,7 @@ export function SubscriptionAllowanceSection() { } interface AllowanceReadingsProps { - allowances: SubscriptionAllowanceRoster | undefined; + allowances: HarnessAllowanceRoster | undefined; /** The first reading is still on its way, so there is nothing to show yet rather than nothing. */ pending: boolean; refresh: () => void; @@ -177,7 +177,7 @@ function AllowanceReadings({ heldBy, setCap }: AllowanceReadingsProps) { - const { t } = useTranslation(SUBSCRIPTION_ALLOWANCE_NAMESPACE); + const { t } = useTranslation(HARNESS_ALLOWANCE_NAMESPACE); if (allowances === undefined) { return pending ? ( @@ -198,7 +198,7 @@ function AllowanceReadings({ reading={reading} failed={failed} /> - void; @@ -41,7 +41,7 @@ interface SpendCapMeterProps { * consumption already reaches. */ export function SpendCapMeter({ window, label, cap, withheld, setCap }: SpendCapMeterProps) { - const { t } = useTranslation(SUBSCRIPTION_ALLOWANCE_NAMESPACE); + const { t } = useTranslation(HARNESS_ALLOWANCE_NAMESPACE); const spent = window.used_percent >= SPENT_PERCENT; return ( diff --git a/apps/dashboard/src/dashboard-routes/settings-view.const.ts b/apps/dashboard/src/dashboard-routes/settings-view.const.ts index 9f8674fc..a4f67190 100644 --- a/apps/dashboard/src/dashboard-routes/settings-view.const.ts +++ b/apps/dashboard/src/dashboard-routes/settings-view.const.ts @@ -1,7 +1,7 @@ +import { HARNESS_ALLOWANCE_NAMESPACE } from "#src/allowance-panel/allowance-panel.i18n"; import { HARNESS_SETTINGS_NAMESPACE } from "#src/harness-settings/harness-settings.i18n"; import { LAB_MAINTENANCE_NAMESPACE } from "#src/lab-maintenance/lab-maintenance.i18n"; import { OPERATOR_NOTIFICATIONS_NAMESPACE } from "#src/operator-notifications/operator-notifications.i18n"; -import { SUBSCRIPTION_ALLOWANCE_NAMESPACE } from "#src/subscription-allowance/subscription-allowance.i18n"; /** * The blocks the lab itself is read and set through. They are panels of one page rather than @@ -10,7 +10,7 @@ import { SUBSCRIPTION_ALLOWANCE_NAMESPACE } from "#src/subscription-allowance/su */ export const SettingsSection = { HARNESSES: "harnesses", - SUBSCRIPTIONS: "subscriptions", + ALLOWANCES: "allowances", NOTIFICATIONS: "notifications", STORAGE: "storage" } as const; @@ -22,7 +22,7 @@ export type SettingsSection = (typeof SettingsSection)[keyof typeof SettingsSect */ export const SETTINGS_SECTIONS = [ { section: SettingsSection.HARNESSES, title: `${HARNESS_SETTINGS_NAMESPACE}:title` }, - { section: SettingsSection.SUBSCRIPTIONS, title: `${SUBSCRIPTION_ALLOWANCE_NAMESPACE}:title` }, + { section: SettingsSection.ALLOWANCES, title: `${HARNESS_ALLOWANCE_NAMESPACE}:title` }, { section: SettingsSection.NOTIFICATIONS, title: `${OPERATOR_NOTIFICATIONS_NAMESPACE}:title` @@ -33,7 +33,7 @@ export const SETTINGS_SECTIONS = [ /** The vocabularies the tab strip reads from, which are the ones its blocks are written in. */ export const SETTINGS_NAMESPACES = [ HARNESS_SETTINGS_NAMESPACE, - SUBSCRIPTION_ALLOWANCE_NAMESPACE, + HARNESS_ALLOWANCE_NAMESPACE, OPERATOR_NOTIFICATIONS_NAMESPACE, LAB_MAINTENANCE_NAMESPACE ] as const; diff --git a/apps/dashboard/src/dashboard-routes/settings-view.tsx b/apps/dashboard/src/dashboard-routes/settings-view.tsx index 94e80546..df1c71cd 100644 --- a/apps/dashboard/src/dashboard-routes/settings-view.tsx +++ b/apps/dashboard/src/dashboard-routes/settings-view.tsx @@ -1,4 +1,5 @@ import { useTranslation } from "react-i18next"; +import { HarnessAllowanceSection } from "#src/allowance-panel/allowance-section"; import { SETTINGS_NAMESPACES, SETTINGS_PAGE, @@ -9,7 +10,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "#src/design-system/tab import { HarnessSettingsSection } from "#src/harness-settings/harness-settings-section"; import { LabStorageSection } from "#src/lab-maintenance/lab-storage-section"; import { NotificationSettingsSection } from "#src/operator-notifications/notification-settings-section"; -import { SubscriptionAllowanceSection } from "#src/subscription-allowance/subscription-allowance-section"; /** * What the operator reads and sets for the lab itself rather than for one investigation. The @@ -32,8 +32,8 @@ export function SettingsView() { - - + + diff --git a/apps/dashboard/src/interface-language/translation-catalog.ts b/apps/dashboard/src/interface-language/translation-catalog.ts index f676b38b..c115c7cf 100644 --- a/apps/dashboard/src/interface-language/translation-catalog.ts +++ b/apps/dashboard/src/interface-language/translation-catalog.ts @@ -1,3 +1,8 @@ +import { + HARNESS_ALLOWANCE_EN, + HARNESS_ALLOWANCE_NAMESPACE, + HARNESS_ALLOWANCE_RU +} from "#src/allowance-panel/allowance-panel.i18n"; import { BREAKTHROUGH_EN, BREAKTHROUGH_NAMESPACE, @@ -111,11 +116,6 @@ import { STATUS_TAG_NAMESPACE, STATUS_TAG_RU } from "#src/status-tag/status-tag.i18n"; -import { - SUBSCRIPTION_ALLOWANCE_EN, - SUBSCRIPTION_ALLOWANCE_NAMESPACE, - SUBSCRIPTION_ALLOWANCE_RU -} from "#src/subscription-allowance/subscription-allowance.i18n"; import { TEAM_EN, TEAM_NAMESPACE, TEAM_RU } from "#src/team/team.i18n"; import { THEME_EN, THEME_NAMESPACE, THEME_RU } from "#src/theme/theme.i18n"; import { @@ -156,7 +156,7 @@ export const EN_TRANSLATIONS = { [OPERATOR_NOTIFICATIONS_NAMESPACE]: OPERATOR_NOTIFICATIONS_EN, [RESEARCH_CYCLE_NAMESPACE]: RESEARCH_CYCLE_EN, [STATUS_TAG_NAMESPACE]: STATUS_TAG_EN, - [SUBSCRIPTION_ALLOWANCE_NAMESPACE]: SUBSCRIPTION_ALLOWANCE_EN, + [HARNESS_ALLOWANCE_NAMESPACE]: HARNESS_ALLOWANCE_EN, [TEAM_NAMESPACE]: TEAM_EN, [THEME_NAMESPACE]: THEME_EN, [VALUE_DISPLAY_NAMESPACE]: VALUE_DISPLAY_EN, @@ -190,7 +190,7 @@ export const RU_TRANSLATIONS = { [OPERATOR_NOTIFICATIONS_NAMESPACE]: OPERATOR_NOTIFICATIONS_RU, [RESEARCH_CYCLE_NAMESPACE]: RESEARCH_CYCLE_RU, [STATUS_TAG_NAMESPACE]: STATUS_TAG_RU, - [SUBSCRIPTION_ALLOWANCE_NAMESPACE]: SUBSCRIPTION_ALLOWANCE_RU, + [HARNESS_ALLOWANCE_NAMESPACE]: HARNESS_ALLOWANCE_RU, [TEAM_NAMESPACE]: TEAM_RU, [THEME_NAMESPACE]: THEME_RU, [VALUE_DISPLAY_NAMESPACE]: VALUE_DISPLAY_RU, diff --git a/apps/dashboard/src/investigation-dispatch/dispatch-panel.tsx b/apps/dashboard/src/investigation-dispatch/dispatch-panel.tsx index 63a3f1a7..b638c38b 100644 --- a/apps/dashboard/src/investigation-dispatch/dispatch-panel.tsx +++ b/apps/dashboard/src/investigation-dispatch/dispatch-panel.tsx @@ -41,7 +41,7 @@ interface DispatchEdit { } /** - * The two moves an operator has over one investigation's spending: point it at other subscriptions, + * The two moves an operator has over one investigation's spending: point it at other harnesses, * or take the lab's caps off it. They belong together because they are the same decision seen from * either side — this run matters more than the allowance, or it can wait on a harness that has some. */ diff --git a/apps/dashboard/src/investigation-dispatch/investigation-dispatch.i18n.ts b/apps/dashboard/src/investigation-dispatch/investigation-dispatch.i18n.ts index 111f3bd7..042e6f16 100644 --- a/apps/dashboard/src/investigation-dispatch/investigation-dispatch.i18n.ts +++ b/apps/dashboard/src/investigation-dispatch/investigation-dispatch.i18n.ts @@ -5,7 +5,7 @@ export const INVESTIGATION_DISPATCH_NAMESPACE = "investigation-dispatch" as cons export const INVESTIGATION_DISPATCH_EN = { title: "Dispatch", description: - "Which subscriptions this investigation may put agents on, and whether the lab's spend caps hold it. Changing either gives up the cycle in flight and starts the next one on the answer.", + "Which harnesses this investigation may put agents on, and whether the lab's spend caps hold it. Changing either gives up the cycle in flight and starts the next one on the answer.", harnesses: "Harnesses", harnessesHint: "It rotates through them in this order. At least one is needed to dispatch to.", @@ -13,7 +13,7 @@ export const INVESTIGATION_DISPATCH_EN = { pastCaps: "Spend past the caps", pastCapsHint: - "Runs this investigation on whatever the vendors will still serve, ignoring the caps set on the subscriptions. A vendor that has stopped serving still stops it.", + "Runs this investigation on whatever the vendors will still serve, ignoring the caps set on them. A vendor that has stopped serving still stops it.", save: "Save the dispatch", saving: "Saving", @@ -28,7 +28,7 @@ export const INVESTIGATION_DISPATCH_EN = { export const INVESTIGATION_DISPATCH_RU = { title: "Отправка работы", description: - "На какие подписки это исследование может ставить агентов и держат ли его ограничители лаборатории. Смена любого из двух прерывает текущий цикл, а следующий стартует уже с ответом лаборатории.", + "На какие оболочки это исследование может ставить агентов и держат ли его ограничители лаборатории. Смена любого из двух прерывает текущий цикл, а следующий стартует уже с ответом лаборатории.", harnesses: "Оболочки", harnessesHint: diff --git a/apps/dashboard/src/subscription-allowance/subscription-allowance-client.ts b/apps/dashboard/src/subscription-allowance/subscription-allowance-client.ts deleted file mode 100644 index b4d83286..00000000 --- a/apps/dashboard/src/subscription-allowance/subscription-allowance-client.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { SubscriptionAllowanceRosterSchema } from "@openlab/protocol/subscription-allowance/subscription-allowance.schema"; -import type { SubscriptionAllowanceRoster } from "@openlab/protocol/subscription-allowance/subscription-allowance.types"; - -const SUBSCRIPTION_ALLOWANCE_ENDPOINT = "/api/subscriptions"; - -/** Tells the daemon to put the reading it is holding aside and ask the vendors again. */ -const FRESH_READING_QUERY = "?fresh=1"; - -export const subscriptionAllowanceQueryKey = ["lab", "subscriptions"] as const; - -/** Whatever the daemon has. Asking faster than it reads the vendors returns the same answer. */ -export function fetchSubscriptionAllowance( - signal?: AbortSignal -): Promise { - return requestAllowance(SUBSCRIPTION_ALLOWANCE_ENDPOINT, signal); -} - -/** What the vendors say right now, which is the only thing a refresh can honestly mean. */ -export function refreshSubscriptionAllowance( - signal?: AbortSignal -): Promise { - return requestAllowance(`${SUBSCRIPTION_ALLOWANCE_ENDPOINT}${FRESH_READING_QUERY}`, signal); -} - -async function requestAllowance( - url: string, - signal?: AbortSignal -): Promise { - const response = await fetch(url, { - headers: { Accept: "application/json" }, - ...(signal ? { signal } : {}) - }); - - if (!response.ok) { - throw new Error(`Subscriptions endpoint returned ${response.status}.`); - } - - return SubscriptionAllowanceRosterSchema.parse(await response.json()); -} diff --git a/packages/harness/package.json b/packages/harness/package.json index 1b72e3d4..49a87526 100644 --- a/packages/harness/package.json +++ b/packages/harness/package.json @@ -38,8 +38,8 @@ "./response-schema": "./dist/cli-agent-harness/response-schema.js", "./cli-agent-harness": "./dist/cli-agent-harness/cli-agent-harness.js", "./cli-agent-harness.types": "./dist/cli-agent-harness/cli-agent-harness.types.js", - "./subscription-allowance": "./dist/subscription-allowance/subscription-allowance.js", - "./subscription-allowance.types": "./dist/subscription-allowance/subscription-allowance.types.js", + "./harness-allowance": "./dist/harness-allowance/harness-allowance.js", + "./harness-allowance.types": "./dist/harness-allowance/harness-allowance.types.js", "./harness-environment": "./dist/cli-execution/harness-environment.js", "./spent-allowance": "./dist/spent-allowance/spent-allowance.js", "./spent-allowance.const": "./dist/spent-allowance/spent-allowance.const.js" @@ -78,8 +78,8 @@ "./response-schema": "./src/cli-agent-harness/response-schema.ts", "./cli-agent-harness": "./src/cli-agent-harness/cli-agent-harness.ts", "./cli-agent-harness.types": "./src/cli-agent-harness/cli-agent-harness.types.ts", - "./subscription-allowance": "./src/subscription-allowance/subscription-allowance.ts", - "./subscription-allowance.types": "./src/subscription-allowance/subscription-allowance.types.ts", + "./harness-allowance": "./src/harness-allowance/harness-allowance.ts", + "./harness-allowance.types": "./src/harness-allowance/harness-allowance.types.ts", "./harness-environment": "./src/cli-execution/harness-environment.ts", "./spent-allowance": "./src/spent-allowance/spent-allowance.ts", "./spent-allowance.const": "./src/spent-allowance/spent-allowance.const.ts" diff --git a/packages/harness/src/subscription-allowance/allowance-deadline.ts b/packages/harness/src/harness-allowance/allowance-deadline.ts similarity index 80% rename from packages/harness/src/subscription-allowance/allowance-deadline.ts rename to packages/harness/src/harness-allowance/allowance-deadline.ts index 34636340..744e582c 100644 --- a/packages/harness/src/subscription-allowance/allowance-deadline.ts +++ b/packages/harness/src/harness-allowance/allowance-deadline.ts @@ -1,4 +1,4 @@ -import { ALLOWANCE_TIMEOUT_MILLISECONDS } from "#src/subscription-allowance/subscription-allowance.const"; +import { ALLOWANCE_TIMEOUT_MILLISECONDS } from "#src/harness-allowance/harness-allowance.const"; /** * Bounds one reading. A vendor that stopped answering must not hold up the work the reading was diff --git a/packages/harness/src/subscription-allowance/allowance-instant.ts b/packages/harness/src/harness-allowance/allowance-instant.ts similarity index 100% rename from packages/harness/src/subscription-allowance/allowance-instant.ts rename to packages/harness/src/harness-allowance/allowance-instant.ts diff --git a/packages/harness/src/subscription-allowance/claude-allowance.test.ts b/packages/harness/src/harness-allowance/claude-allowance.test.ts similarity index 90% rename from packages/harness/src/subscription-allowance/claude-allowance.test.ts rename to packages/harness/src/harness-allowance/claude-allowance.test.ts index 7544df18..b2624b8c 100644 --- a/packages/harness/src/subscription-allowance/claude-allowance.test.ts +++ b/packages/harness/src/harness-allowance/claude-allowance.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { HarnessKinds } from "#src/agent-harness/agent-harness.const"; -import { claudeAllowanceFromUsage } from "#src/subscription-allowance/claude-allowance"; -import { ClaudeWindowMinutes } from "#src/subscription-allowance/subscription-allowance.const"; +import { claudeAllowanceFromUsage } from "#src/harness-allowance/claude-allowance"; +import { ClaudeWindowMinutes } from "#src/harness-allowance/harness-allowance.const"; const ClaudeUsagePayload = { five_hour: { diff --git a/packages/harness/src/subscription-allowance/claude-allowance.ts b/packages/harness/src/harness-allowance/claude-allowance.ts similarity index 91% rename from packages/harness/src/subscription-allowance/claude-allowance.ts rename to packages/harness/src/harness-allowance/claude-allowance.ts index abb7c51d..76105551 100644 --- a/packages/harness/src/subscription-allowance/claude-allowance.ts +++ b/packages/harness/src/harness-allowance/claude-allowance.ts @@ -1,18 +1,18 @@ import { execa } from "execa"; import { z } from "zod"; import { HarnessKinds } from "#src/agent-harness/agent-harness.const"; -import { allowanceDeadline } from "#src/subscription-allowance/allowance-deadline"; -import { allowanceInstant } from "#src/subscription-allowance/allowance-instant"; +import { allowanceDeadline } from "#src/harness-allowance/allowance-deadline"; +import { allowanceInstant } from "#src/harness-allowance/allowance-instant"; import { CLAUDE_OAUTH_BETA, CLAUDE_OAUTH_USAGE_URL, ClaudeCredentialStore, ClaudeWindowMinutes -} from "#src/subscription-allowance/subscription-allowance.const"; +} from "#src/harness-allowance/harness-allowance.const"; import type { AllowanceWindow, - SubscriptionAllowance -} from "#src/subscription-allowance/subscription-allowance.types"; + HarnessAllowanceReading +} from "#src/harness-allowance/harness-allowance.types"; const KeychainCredentialsSchema = z.looseObject({ claudeAiOauth: z.looseObject({ @@ -44,7 +44,7 @@ interface ClaudeCredentials { export function claudeAllowanceFromUsage( payload: unknown, plan: string | null -): SubscriptionAllowance { +): HarnessAllowanceReading { const usage = OAuthUsageSchema.parse(payload); const windows = [ claudeWindow(usage.five_hour, ClaudeWindowMinutes.FIVE_HOUR), @@ -62,7 +62,7 @@ export function claudeAllowanceFromUsage( * Asks Anthropic what is left of the login the Claude CLI provisioned. The token is read from the * same Keychain entry the CLI writes, so the lab never holds a credential of its own. */ -export async function readClaudeAllowance(signal?: AbortSignal): Promise { +export async function readClaudeAllowance(signal?: AbortSignal): Promise { const credentials = await readClaudeCredentials(signal); const response = await fetch(CLAUDE_OAUTH_USAGE_URL, { headers: { diff --git a/packages/harness/src/subscription-allowance/codex-allowance.test.ts b/packages/harness/src/harness-allowance/codex-allowance.test.ts similarity index 95% rename from packages/harness/src/subscription-allowance/codex-allowance.test.ts rename to packages/harness/src/harness-allowance/codex-allowance.test.ts index e7f411ef..7a05a6aa 100644 --- a/packages/harness/src/subscription-allowance/codex-allowance.test.ts +++ b/packages/harness/src/harness-allowance/codex-allowance.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { HarnessKinds } from "#src/agent-harness/agent-harness.const"; -import { codexAllowanceFromRateLimits } from "#src/subscription-allowance/codex-allowance"; +import { codexAllowanceFromRateLimits } from "#src/harness-allowance/codex-allowance"; const CodexRateLimitsPayload = { rateLimits: { diff --git a/packages/harness/src/subscription-allowance/codex-allowance.ts b/packages/harness/src/harness-allowance/codex-allowance.ts similarity index 80% rename from packages/harness/src/subscription-allowance/codex-allowance.ts rename to packages/harness/src/harness-allowance/codex-allowance.ts index 04bb5588..a5d48640 100644 --- a/packages/harness/src/subscription-allowance/codex-allowance.ts +++ b/packages/harness/src/harness-allowance/codex-allowance.ts @@ -1,12 +1,12 @@ import { z } from "zod"; import { HarnessKinds } from "#src/agent-harness/agent-harness.const"; -import { allowanceInstant } from "#src/subscription-allowance/allowance-instant"; -import { requestCodexAppServer } from "#src/subscription-allowance/codex-app-server"; -import { CodexAppServer } from "#src/subscription-allowance/subscription-allowance.const"; +import { allowanceInstant } from "#src/harness-allowance/allowance-instant"; +import { requestCodexAppServer } from "#src/harness-allowance/codex-app-server"; +import { CodexAppServer } from "#src/harness-allowance/harness-allowance.const"; import type { AllowanceWindow, - SubscriptionAllowance -} from "#src/subscription-allowance/subscription-allowance.types"; + HarnessAllowanceReading +} from "#src/harness-allowance/harness-allowance.types"; /** Codex is the one vendor that states how long a window runs, so nothing has to be assumed. */ const CodexWindowSchema = z.looseObject({ @@ -23,7 +23,7 @@ const CodexRateLimitsSchema = z.looseObject({ }) }); -export function codexAllowanceFromRateLimits(payload: unknown): SubscriptionAllowance { +export function codexAllowanceFromRateLimits(payload: unknown): HarnessAllowanceReading { const limits = CodexRateLimitsSchema.parse(payload).rateLimits; return { kind: HarnessKinds.CODEX, @@ -35,7 +35,7 @@ export function codexAllowanceFromRateLimits(payload: unknown): SubscriptionAllo }; } -export async function readCodexAllowance(signal?: AbortSignal): Promise { +export async function readCodexAllowance(signal?: AbortSignal): Promise { return codexAllowanceFromRateLimits( await requestCodexAppServer(CodexAppServer.RATE_LIMITS, signal) ); diff --git a/packages/harness/src/subscription-allowance/codex-app-server.ts b/packages/harness/src/harness-allowance/codex-app-server.ts similarity index 94% rename from packages/harness/src/subscription-allowance/codex-app-server.ts rename to packages/harness/src/harness-allowance/codex-app-server.ts index a3f7eb84..fdb893bf 100644 --- a/packages/harness/src/subscription-allowance/codex-app-server.ts +++ b/packages/harness/src/harness-allowance/codex-app-server.ts @@ -2,8 +2,8 @@ import { createInterface } from "node:readline"; import { execa } from "execa"; import { z } from "zod"; import { CODEX_BINARY } from "#src/codex-cli/codex-cli.const"; -import { allowanceDeadline } from "#src/subscription-allowance/allowance-deadline"; -import { CodexAppServer } from "#src/subscription-allowance/subscription-allowance.const"; +import { allowanceDeadline } from "#src/harness-allowance/allowance-deadline"; +import { CodexAppServer } from "#src/harness-allowance/harness-allowance.const"; const INITIALIZE_ID = 1; const REQUEST_ID = 2; diff --git a/packages/harness/src/subscription-allowance/deepseek-allowance.ts b/packages/harness/src/harness-allowance/deepseek-allowance.ts similarity index 82% rename from packages/harness/src/subscription-allowance/deepseek-allowance.ts rename to packages/harness/src/harness-allowance/deepseek-allowance.ts index b4105b4b..66904075 100644 --- a/packages/harness/src/subscription-allowance/deepseek-allowance.ts +++ b/packages/harness/src/harness-allowance/deepseek-allowance.ts @@ -1,8 +1,8 @@ import { HarnessKinds } from "#src/agent-harness/agent-harness.const"; import { resolveDeepseekWallet } from "#src/deepseek-cli/deepseek-credential"; import type { ResolveDeepseekWallet } from "#src/deepseek-cli/deepseek-credential.types"; -import { allowanceDeadline } from "#src/subscription-allowance/allowance-deadline"; -import type { SubscriptionAllowance } from "#src/subscription-allowance/subscription-allowance.types"; +import { allowanceDeadline } from "#src/harness-allowance/allowance-deadline"; +import type { HarnessAllowanceReading } from "#src/harness-allowance/harness-allowance.types"; /** * DeepSeek meters nothing. There is no window that fills and resets, so this reading carries none: @@ -16,7 +16,7 @@ import type { SubscriptionAllowance } from "#src/subscription-allowance/subscrip export async function readDeepseekAllowance( signal?: AbortSignal, resolveWallet: ResolveDeepseekWallet = resolveDeepseekWallet -): Promise { +): Promise { const wallet = await resolveWallet(allowanceDeadline(signal)); return { kind: HarnessKinds.DEEPSEEK, diff --git a/packages/harness/src/subscription-allowance/glm-allowance.test.ts b/packages/harness/src/harness-allowance/glm-allowance.test.ts similarity index 94% rename from packages/harness/src/subscription-allowance/glm-allowance.test.ts rename to packages/harness/src/harness-allowance/glm-allowance.test.ts index f61b2feb..bb230475 100644 --- a/packages/harness/src/subscription-allowance/glm-allowance.test.ts +++ b/packages/harness/src/harness-allowance/glm-allowance.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { HarnessKinds } from "#src/agent-harness/agent-harness.const"; -import { glmAllowanceFromQuota } from "#src/subscription-allowance/glm-allowance"; -import { ZaiSpendingLimitType } from "#src/subscription-allowance/subscription-allowance.const"; +import { glmAllowanceFromQuota } from "#src/harness-allowance/glm-allowance"; +import { ZaiSpendingLimitType } from "#src/harness-allowance/harness-allowance.const"; const ZaiQuotaPayload = { code: 200, diff --git a/packages/harness/src/subscription-allowance/glm-allowance.ts b/packages/harness/src/harness-allowance/glm-allowance.ts similarity index 85% rename from packages/harness/src/subscription-allowance/glm-allowance.ts rename to packages/harness/src/harness-allowance/glm-allowance.ts index e0a67ce3..9df2f51e 100644 --- a/packages/harness/src/subscription-allowance/glm-allowance.ts +++ b/packages/harness/src/harness-allowance/glm-allowance.ts @@ -2,16 +2,16 @@ import { z } from "zod"; import { HarnessKinds } from "#src/agent-harness/agent-harness.const"; import { ZAI_CODING_PLAN_QUOTA_URL } from "#src/glm-cli/glm-cli.const"; import { readCodingPlanKey } from "#src/glm-cli/zai-coding-plan"; -import { allowanceDeadline } from "#src/subscription-allowance/allowance-deadline"; -import { allowanceInstant } from "#src/subscription-allowance/allowance-instant"; +import { allowanceDeadline } from "#src/harness-allowance/allowance-deadline"; +import { allowanceInstant } from "#src/harness-allowance/allowance-instant"; import { ZaiPeriodMinutes, ZaiSpendingLimitType -} from "#src/subscription-allowance/subscription-allowance.const"; +} from "#src/harness-allowance/harness-allowance.const"; import type { AllowanceWindow, - SubscriptionAllowance -} from "#src/subscription-allowance/subscription-allowance.types"; + HarnessAllowanceReading +} from "#src/harness-allowance/harness-allowance.types"; const ZaiLimitSchema = z.looseObject({ type: z.string().trim().min(1), @@ -31,7 +31,7 @@ const ZaiQuotaSchema = z.looseObject({ const SPENDING_LIMIT_TYPES: readonly string[] = Object.values(ZaiSpendingLimitType); -export function glmAllowanceFromQuota(payload: unknown): SubscriptionAllowance { +export function glmAllowanceFromQuota(payload: unknown): HarnessAllowanceReading { const quota = ZaiQuotaSchema.parse(payload).data; return { kind: HarnessKinds.GLM, @@ -49,7 +49,7 @@ export function glmAllowanceFromQuota(payload: unknown): SubscriptionAllowance { * The same endpoint already proves a run is subscription-billed; this reads the quota it states * alongside the tier. */ -export async function readGlmAllowance(signal?: AbortSignal): Promise { +export async function readGlmAllowance(signal?: AbortSignal): Promise { const apiKey = await readCodingPlanKey(); const response = await fetch(ZAI_CODING_PLAN_QUOTA_URL, { headers: { Authorization: apiKey }, diff --git a/packages/harness/src/subscription-allowance/subscription-allowance.const.ts b/packages/harness/src/harness-allowance/harness-allowance.const.ts similarity index 100% rename from packages/harness/src/subscription-allowance/subscription-allowance.const.ts rename to packages/harness/src/harness-allowance/harness-allowance.const.ts diff --git a/packages/harness/src/harness-allowance/harness-allowance.ts b/packages/harness/src/harness-allowance/harness-allowance.ts new file mode 100644 index 00000000..bd347c66 --- /dev/null +++ b/packages/harness/src/harness-allowance/harness-allowance.ts @@ -0,0 +1,30 @@ +import { type HarnessKind, HarnessKinds } from "#src/agent-harness/agent-harness.const"; +import { readClaudeAllowance } from "#src/harness-allowance/claude-allowance"; +import { readCodexAllowance } from "#src/harness-allowance/codex-allowance"; +import { readDeepseekAllowance } from "#src/harness-allowance/deepseek-allowance"; +import { readGlmAllowance } from "#src/harness-allowance/glm-allowance"; +import type { + HarnessAllowanceReading, + ReadHarnessAllowance +} from "#src/harness-allowance/harness-allowance.types"; +import { readMuseAllowance } from "#src/harness-allowance/muse-allowance"; + +const ALLOWANCE_READER: Record = { + [HarnessKinds.CLAUDE]: readClaudeAllowance, + [HarnessKinds.CODEX]: readCodexAllowance, + [HarnessKinds.GLM]: readGlmAllowance, + [HarnessKinds.DEEPSEEK]: readDeepseekAllowance, + [HarnessKinds.MUSE]: readMuseAllowance +}; + +/** + * Asks one vendor what is left of the subscription that harness runs on. Every vendor answers from + * the login the operator already made, so a reading needs no credential of the lab's own and never + * spends the allowance it is reporting on. + */ +export function readHarnessAllowance( + kind: HarnessKind, + signal?: AbortSignal +): Promise { + return ALLOWANCE_READER[kind](signal); +} diff --git a/packages/harness/src/subscription-allowance/subscription-allowance.types.ts b/packages/harness/src/harness-allowance/harness-allowance.types.ts similarity index 90% rename from packages/harness/src/subscription-allowance/subscription-allowance.types.ts rename to packages/harness/src/harness-allowance/harness-allowance.types.ts index 49191422..335d985b 100644 --- a/packages/harness/src/subscription-allowance/subscription-allowance.types.ts +++ b/packages/harness/src/harness-allowance/harness-allowance.types.ts @@ -19,7 +19,7 @@ export interface AllowanceWindow { * states a balance and has no tier. Putting money in `plan` would have the page render a wallet as * though it were a subscription the operator had bought. */ -export interface SubscriptionAllowance { +export interface HarnessAllowanceReading { readonly kind: HarnessKind; /** The plan tier the vendor named. Null for a vendor that sells no tier. */ readonly plan: string | null; @@ -28,4 +28,4 @@ export interface SubscriptionAllowance { readonly windows: readonly AllowanceWindow[]; } -export type ReadSubscriptionAllowance = (signal?: AbortSignal) => Promise; +export type ReadHarnessAllowance = (signal?: AbortSignal) => Promise; diff --git a/packages/harness/src/subscription-allowance/metered-allowance.test.ts b/packages/harness/src/harness-allowance/metered-allowance.test.ts similarity index 88% rename from packages/harness/src/subscription-allowance/metered-allowance.test.ts rename to packages/harness/src/harness-allowance/metered-allowance.test.ts index 97261d95..93e25790 100644 --- a/packages/harness/src/subscription-allowance/metered-allowance.test.ts +++ b/packages/harness/src/harness-allowance/metered-allowance.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { HarnessKinds } from "#src/agent-harness/agent-harness.const"; -import { readDeepseekAllowance } from "#src/subscription-allowance/deepseek-allowance"; -import { readMuseAllowance } from "#src/subscription-allowance/muse-allowance"; +import { readDeepseekAllowance } from "#src/harness-allowance/deepseek-allowance"; +import { readMuseAllowance } from "#src/harness-allowance/muse-allowance"; /** * The two token-billed harnesses answer in money, and money used to be reported in the field a plan diff --git a/packages/harness/src/subscription-allowance/muse-allowance.ts b/packages/harness/src/harness-allowance/muse-allowance.ts similarity index 75% rename from packages/harness/src/subscription-allowance/muse-allowance.ts rename to packages/harness/src/harness-allowance/muse-allowance.ts index 233b415b..cb637199 100644 --- a/packages/harness/src/subscription-allowance/muse-allowance.ts +++ b/packages/harness/src/harness-allowance/muse-allowance.ts @@ -1,6 +1,6 @@ import { HarnessKinds } from "#src/agent-harness/agent-harness.const"; -import { MUSE_UNPUBLISHED_BALANCE } from "#src/subscription-allowance/subscription-allowance.const"; -import type { SubscriptionAllowance } from "#src/subscription-allowance/subscription-allowance.types"; +import { MUSE_UNPUBLISHED_BALANCE } from "#src/harness-allowance/harness-allowance.const"; +import type { HarnessAllowanceReading } from "#src/harness-allowance/harness-allowance.types"; /** * Muse Code meters nothing an operator can read. There is no window that fills and resets, and unlike @@ -12,7 +12,7 @@ import type { SubscriptionAllowance } from "#src/subscription-allowance/subscrip * Carrying no windows is also why no spend cap can be set against Muse. A cap is a fraction of a * window, and there is no window here. */ -export function readMuseAllowance(): Promise { +export function readMuseAllowance(): Promise { return Promise.resolve({ kind: HarnessKinds.MUSE, plan: null, diff --git a/packages/harness/src/subscription-allowance/subscription-allowance.ts b/packages/harness/src/subscription-allowance/subscription-allowance.ts deleted file mode 100644 index 610be09d..00000000 --- a/packages/harness/src/subscription-allowance/subscription-allowance.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { type HarnessKind, HarnessKinds } from "#src/agent-harness/agent-harness.const"; -import { readClaudeAllowance } from "#src/subscription-allowance/claude-allowance"; -import { readCodexAllowance } from "#src/subscription-allowance/codex-allowance"; -import { readDeepseekAllowance } from "#src/subscription-allowance/deepseek-allowance"; -import { readGlmAllowance } from "#src/subscription-allowance/glm-allowance"; -import { readMuseAllowance } from "#src/subscription-allowance/muse-allowance"; -import type { - ReadSubscriptionAllowance, - SubscriptionAllowance -} from "#src/subscription-allowance/subscription-allowance.types"; - -const ALLOWANCE_READER: Record = { - [HarnessKinds.CLAUDE]: readClaudeAllowance, - [HarnessKinds.CODEX]: readCodexAllowance, - [HarnessKinds.GLM]: readGlmAllowance, - [HarnessKinds.DEEPSEEK]: readDeepseekAllowance, - [HarnessKinds.MUSE]: readMuseAllowance -}; - -/** - * Asks one vendor what is left of the subscription that harness runs on. Every vendor answers from - * the login the operator already made, so a reading needs no credential of the lab's own and never - * spends the allowance it is reporting on. - */ -export function readSubscriptionAllowance( - kind: HarnessKind, - signal?: AbortSignal -): Promise { - return ALLOWANCE_READER[kind](signal); -} diff --git a/packages/protocol/package.json b/packages/protocol/package.json index b6637a3a..53a56c61 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -78,10 +78,10 @@ "./spend-caps/spend-cap.const": "./dist/spend-caps/spend-cap.const.js", "./spend-caps/spend-cap.schema": "./dist/spend-caps/spend-cap.schema.js", "./spend-caps/spend-cap.types": "./dist/spend-caps/spend-cap.types.js", - "./subscription-allowance/subscription-allowance.const": "./dist/subscription-allowance/subscription-allowance.const.js", - "./subscription-allowance/subscription-allowance.schema": "./dist/subscription-allowance/subscription-allowance.schema.js", - "./subscription-allowance/subscription-allowance.types": "./dist/subscription-allowance/subscription-allowance.types.js", - "./subscription-allowance/window-reset": "./dist/subscription-allowance/window-reset.js", + "./harness-allowance/harness-allowance.const": "./dist/harness-allowance/harness-allowance.const.js", + "./harness-allowance/harness-allowance.schema": "./dist/harness-allowance/harness-allowance.schema.js", + "./harness-allowance/harness-allowance.types": "./dist/harness-allowance/harness-allowance.types.js", + "./harness-allowance/window-reset": "./dist/harness-allowance/window-reset.js", "./verdicts/verdict.schema": "./dist/verdicts/verdict.schema.js", "./verdicts/verdict.types": "./dist/verdicts/verdict.types.js" }, @@ -159,10 +159,10 @@ "./spend-caps/spend-cap.const": "./src/spend-caps/spend-cap.const.ts", "./spend-caps/spend-cap.schema": "./src/spend-caps/spend-cap.schema.ts", "./spend-caps/spend-cap.types": "./src/spend-caps/spend-cap.types.ts", - "./subscription-allowance/subscription-allowance.const": "./src/subscription-allowance/subscription-allowance.const.ts", - "./subscription-allowance/subscription-allowance.schema": "./src/subscription-allowance/subscription-allowance.schema.ts", - "./subscription-allowance/subscription-allowance.types": "./src/subscription-allowance/subscription-allowance.types.ts", - "./subscription-allowance/window-reset": "./src/subscription-allowance/window-reset.ts", + "./harness-allowance/harness-allowance.const": "./src/harness-allowance/harness-allowance.const.ts", + "./harness-allowance/harness-allowance.schema": "./src/harness-allowance/harness-allowance.schema.ts", + "./harness-allowance/harness-allowance.types": "./src/harness-allowance/harness-allowance.types.ts", + "./harness-allowance/window-reset": "./src/harness-allowance/window-reset.ts", "./verdicts/verdict.schema": "./src/verdicts/verdict.schema.ts", "./verdicts/verdict.types": "./src/verdicts/verdict.types.ts" }, diff --git a/packages/protocol/src/subscription-allowance/subscription-allowance.const.ts b/packages/protocol/src/harness-allowance/harness-allowance.const.ts similarity index 74% rename from packages/protocol/src/subscription-allowance/subscription-allowance.const.ts rename to packages/protocol/src/harness-allowance/harness-allowance.const.ts index 91cd6014..c39a830e 100644 --- a/packages/protocol/src/subscription-allowance/subscription-allowance.const.ts +++ b/packages/protocol/src/harness-allowance/harness-allowance.const.ts @@ -3,13 +3,13 @@ * `UNREADABLE` is deliberately not `EXHAUSTED`: a monitoring call that failed says nothing about the * allowance, and stopping the lab on it would turn a broken reading into a stopped lab. */ -export const SubscriptionAllowanceState = { +export const HarnessAllowanceState = { AVAILABLE: "available", EXHAUSTED: "exhausted", UNREADABLE: "unreadable" } as const; -export type SubscriptionAllowanceState = - (typeof SubscriptionAllowanceState)[keyof typeof SubscriptionAllowanceState]; +export type HarnessAllowanceState = + (typeof HarnessAllowanceState)[keyof typeof HarnessAllowanceState]; /** The share of a window a vendor stops serving at. */ export const ALLOWANCE_EXHAUSTED_PERCENT = 100; diff --git a/packages/protocol/src/subscription-allowance/subscription-allowance.schema.ts b/packages/protocol/src/harness-allowance/harness-allowance.schema.ts similarity index 83% rename from packages/protocol/src/subscription-allowance/subscription-allowance.schema.ts rename to packages/protocol/src/harness-allowance/harness-allowance.schema.ts index 5b6bab26..1fb58f3f 100644 --- a/packages/protocol/src/subscription-allowance/subscription-allowance.schema.ts +++ b/packages/protocol/src/harness-allowance/harness-allowance.schema.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { AgentHarnessKind } from "#src/agents/agent-execution.const"; -import { SubscriptionAllowanceState } from "#src/subscription-allowance/subscription-allowance.const"; +import { HarnessAllowanceState } from "#src/harness-allowance/harness-allowance.const"; /** * One rolling window a vendor meters. The three vendors disagree on how many windows they keep and @@ -18,9 +18,9 @@ export const AllowanceWindowSchema = z.object({ * What one harness account had left when it was last read. `windows` is empty whenever the state is * `unreadable`, so a viewer never renders a meter built from a failed reading. */ -export const SubscriptionAllowanceSchema = z.object({ +export const HarnessAllowanceSchema = z.object({ harness: z.enum(AgentHarnessKind), - state: z.enum(SubscriptionAllowanceState), + state: z.enum(HarnessAllowanceState), /** The plan tier the vendor named. Null for a vendor that sells no tier. */ plan: z.string().min(1).nullable(), /** @@ -34,4 +34,4 @@ export const SubscriptionAllowanceSchema = z.object({ read_at: z.iso.datetime() }); -export const SubscriptionAllowanceRosterSchema = z.array(SubscriptionAllowanceSchema); +export const HarnessAllowanceRosterSchema = z.array(HarnessAllowanceSchema); diff --git a/packages/protocol/src/harness-allowance/harness-allowance.types.ts b/packages/protocol/src/harness-allowance/harness-allowance.types.ts new file mode 100644 index 00000000..a5758bfc --- /dev/null +++ b/packages/protocol/src/harness-allowance/harness-allowance.types.ts @@ -0,0 +1,10 @@ +import type { z } from "zod"; +import type { + AllowanceWindowSchema, + HarnessAllowanceRosterSchema, + HarnessAllowanceSchema +} from "#src/harness-allowance/harness-allowance.schema"; + +export type AllowanceWindow = z.infer; +export type HarnessAllowance = z.infer; +export type HarnessAllowanceRoster = z.infer; diff --git a/packages/protocol/src/subscription-allowance/window-reset.test.ts b/packages/protocol/src/harness-allowance/window-reset.test.ts similarity index 84% rename from packages/protocol/src/subscription-allowance/window-reset.test.ts rename to packages/protocol/src/harness-allowance/window-reset.test.ts index 5e2d9ec4..82f0c12b 100644 --- a/packages/protocol/src/subscription-allowance/window-reset.test.ts +++ b/packages/protocol/src/harness-allowance/window-reset.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { AllowanceWindow } from "#src/subscription-allowance/subscription-allowance.types"; -import { latestWindowReset } from "#src/subscription-allowance/window-reset"; +import type { AllowanceWindow } from "#src/harness-allowance/harness-allowance.types"; +import { latestWindowReset } from "#src/harness-allowance/window-reset"; function window(resetsAt: string | null): AllowanceWindow { return { duration_minutes: 300, used_percent: 100, resets_at: resetsAt }; diff --git a/packages/protocol/src/subscription-allowance/window-reset.ts b/packages/protocol/src/harness-allowance/window-reset.ts similarity index 87% rename from packages/protocol/src/subscription-allowance/window-reset.ts rename to packages/protocol/src/harness-allowance/window-reset.ts index 8c784595..982dfaa4 100644 --- a/packages/protocol/src/subscription-allowance/window-reset.ts +++ b/packages/protocol/src/harness-allowance/window-reset.ts @@ -1,4 +1,4 @@ -import type { AllowanceWindow } from "#src/subscription-allowance/subscription-allowance.types"; +import type { AllowanceWindow } from "#src/harness-allowance/harness-allowance.types"; /** * The last of these windows to come back. Whatever stopped a subscription — the vendor's own diff --git a/packages/protocol/src/spend-caps/spend-cap.test.ts b/packages/protocol/src/spend-caps/spend-cap.test.ts index 9515005a..bf36362a 100644 --- a/packages/protocol/src/spend-caps/spend-cap.test.ts +++ b/packages/protocol/src/spend-caps/spend-cap.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; import { AgentHarnessKind } from "#src/agents/agent-execution.const"; -import { isSpendCapLoosened, windowSpendCap, withheldWindows } from "#src/spend-caps/spend-cap"; -import { SpendCapsSchema } from "#src/spend-caps/spend-cap.schema"; -import { SubscriptionAllowanceState } from "#src/subscription-allowance/subscription-allowance.const"; +import { HarnessAllowanceState } from "#src/harness-allowance/harness-allowance.const"; import type { AllowanceWindow, - SubscriptionAllowance -} from "#src/subscription-allowance/subscription-allowance.types"; + HarnessAllowance +} from "#src/harness-allowance/harness-allowance.types"; +import { isSpendCapLoosened, windowSpendCap, withheldWindows } from "#src/spend-caps/spend-cap"; +import { SpendCapsSchema } from "#src/spend-caps/spend-cap.schema"; const FIVE_HOURS = 300; const SEVEN_DAYS = 10_080; @@ -16,10 +16,10 @@ const CAPS = SpendCapsSchema.parse([ { harness: AgentHarnessKind.CLAUDE, window_minutes: SEVEN_DAYS, max_used_percent: 60 } ]); -function claudeAllowance(windows: readonly AllowanceWindow[]): SubscriptionAllowance { +function claudeAllowance(windows: readonly AllowanceWindow[]): HarnessAllowance { return { harness: AgentHarnessKind.CLAUDE, - state: SubscriptionAllowanceState.AVAILABLE, + state: HarnessAllowanceState.AVAILABLE, plan: "max", balance: null, windows: [...windows], @@ -69,9 +69,9 @@ describe("withheldWindows", () => { }); it("withholds nothing when the vendor could not be read, so broken monitoring never parks the lab", () => { - const unreadable: SubscriptionAllowance = { + const unreadable: HarnessAllowance = { harness: AgentHarnessKind.CLAUDE, - state: SubscriptionAllowanceState.UNREADABLE, + state: HarnessAllowanceState.UNREADABLE, plan: null, balance: null, windows: [], diff --git a/packages/protocol/src/spend-caps/spend-cap.ts b/packages/protocol/src/spend-caps/spend-cap.ts index 48b85d66..136e2ce1 100644 --- a/packages/protocol/src/spend-caps/spend-cap.ts +++ b/packages/protocol/src/spend-caps/spend-cap.ts @@ -1,10 +1,10 @@ import type { AgentHarnessKind } from "#src/agents/agent-execution.const"; -import { NO_SPEND_CAP_PERCENT } from "#src/spend-caps/spend-cap.const"; -import type { SpendCaps } from "#src/spend-caps/spend-cap.types"; import type { AllowanceWindow, - SubscriptionAllowance -} from "#src/subscription-allowance/subscription-allowance.types"; + HarnessAllowance +} from "#src/harness-allowance/harness-allowance.types"; +import { NO_SPEND_CAP_PERCENT } from "#src/spend-caps/spend-cap.const"; +import type { SpendCaps } from "#src/spend-caps/spend-cap.types"; /** How far into one window the lab may spend: the whole of it until an operator asks for less. */ export function windowSpendCap( @@ -47,10 +47,7 @@ export function isSpendCapLoosened(before: SpendCaps, after: SpendCaps): boolean * subscription: the lab keeps dispatching and learns the limit from the vendor, as it did before * caps existed. */ -export function withheldWindows( - allowance: SubscriptionAllowance, - caps: SpendCaps -): AllowanceWindow[] { +export function withheldWindows(allowance: HarnessAllowance, caps: SpendCaps): AllowanceWindow[] { return allowance.windows.filter((window) => { const cap = windowSpendCap(caps, allowance.harness, window.duration_minutes); return isCapped(cap) && window.used_percent >= cap; diff --git a/packages/protocol/src/subscription-allowance/subscription-allowance.types.ts b/packages/protocol/src/subscription-allowance/subscription-allowance.types.ts deleted file mode 100644 index afd9ce9d..00000000 --- a/packages/protocol/src/subscription-allowance/subscription-allowance.types.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { z } from "zod"; -import type { - AllowanceWindowSchema, - SubscriptionAllowanceRosterSchema, - SubscriptionAllowanceSchema -} from "#src/subscription-allowance/subscription-allowance.schema"; - -export type AllowanceWindow = z.infer; -export type SubscriptionAllowance = z.infer; -export type SubscriptionAllowanceRoster = z.infer; From e38be9809cb58951b9882c51c038eacc3826daaf Mon Sep 17 00:00:00 2001 From: Nikita Snetkov Date: Fri, 7 Aug 2026 19:47:50 +0500 Subject: [PATCH 7/9] refactor(landing): read harness billing from the lab rather than restating it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The site kept its own copy of which harness costs money — a second HARNESS_BILLING table declared beside the roster, with the answer hand-written into each entry. Two tables for one fact, and this is the page an operator decides what to install from, so a drift between them would land in the worst possible place. The roster entries now name the harness they stand for and the page reads the billing out of @openlab/protocol, the same table the dashboard card and the spent-allowance refusal read. Co-Authored-By: Claude Opus 5 (1M context) --- apps/landing/package.json | 1 + .../src/harness-roster/harness-roster.astro | 16 ++++--- .../harness-roster/harness-roster.const.ts | 45 ++++++++++--------- pnpm-lock.yaml | 3 ++ 4 files changed, 37 insertions(+), 28 deletions(-) diff --git a/apps/landing/package.json b/apps/landing/package.json index c0aad831..e2d39e34 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -17,6 +17,7 @@ "format": "biome format --write ." }, "dependencies": { + "@openlab/protocol": "workspace:*", "astro": "7.1.6" }, "devDependencies": { diff --git a/apps/landing/src/harness-roster/harness-roster.astro b/apps/landing/src/harness-roster/harness-roster.astro index 2a9c7d89..658eafb7 100644 --- a/apps/landing/src/harness-roster/harness-roster.astro +++ b/apps/landing/src/harness-roster/harness-roster.astro @@ -1,8 +1,11 @@ --- +import { + AgentHarnessBilling, + HARNESS_BILLING +} from "@openlab/protocol/agents/agent-billing.const"; import SectionMarker from "#src/brand/section-marker.astro"; import { HARNESS, - HARNESS_BILLING, HARNESS_BILLING_LABEL, HARNESS_STRIP_REPEATS } from "#src/harness-roster/harness-roster.const"; @@ -10,10 +13,11 @@ import { /** * What the lab thinks with. A page selling autonomous research has to answer the money question * before anything else, so the roster leads with who pays for each harness rather than burying it: - * three run on a plan already bought, and the fourth says outright that it bills a wallet. + * three run on a plan already bought, and two say outright that they bill a wallet. Which is which + * is read from the runtime's own billing table, so the site cannot drift from what the lab does. * - * The moving strip above the roster is the same four names and carries no information the cells - * below do not, which is why it is hidden from assistive technology rather than read out twice. + * The moving strip above the roster is the same names and carries no information the cells below do + * not, which is why it is hidden from assistive technology rather than read out twice. */ const stripRoster = Array.from({ length: HARNESS_STRIP_REPEATS }, () => HARNESS).flat(); --- @@ -59,12 +63,12 @@ const stripRoster = Array.from({ length: HARNESS_STRIP_REPEATS }, () => HARNESS)

    - {HARNESS_BILLING_LABEL[harness.billing]} + {HARNESS_BILLING_LABEL[HARNESS_BILLING[harness.kind]]}

    {harness.note}

    diff --git a/apps/landing/src/harness-roster/harness-roster.const.ts b/apps/landing/src/harness-roster/harness-roster.const.ts index 3f1fe242..4e532e24 100644 --- a/apps/landing/src/harness-roster/harness-roster.const.ts +++ b/apps/landing/src/harness-roster/harness-roster.const.ts @@ -1,63 +1,64 @@ -/** - * How a harness is paid for. It is a named set rather than a boolean because the two are not - * opposites an operator can infer one from the other: a plan already bought costs the same whether - * the lab works for ten minutes or all night, and a wallet billed by the token does not. - */ -export const HARNESS_BILLING = { - SUBSCRIPTION: "subscription", - USAGE: "usage" -} as const; - -export type HarnessBilling = (typeof HARNESS_BILLING)[keyof typeof HARNESS_BILLING]; +import { AgentHarnessBilling } from "@openlab/protocol/agents/agent-billing.const"; +import { AgentHarnessKind } from "@openlab/protocol/agents/agent-execution.const"; /** * What each kind of billing is called on the page. The usage line is blunt on purpose: an operator - * hands over a key for exactly one of these four, and they should read that before they do, not - * after the first invoice. + * hands over a key for exactly one of these, and they should read that before they do, not after the + * first invoice. */ export const HARNESS_BILLING_LABEL = { - [HARNESS_BILLING.SUBSCRIPTION]: "Plan you already hold", - [HARNESS_BILLING.USAGE]: "Your wallet, by the token" -} as const satisfies Record; + [AgentHarnessBilling.SUBSCRIPTION]: "Plan you already hold", + [AgentHarnessBilling.USAGE]: "Your wallet, by the token" +} as const satisfies Record; /** * Every harness the lab dispatches agents to, each named the way its vendor names it. Two of them * are driven through another vendor's CLI, and saying which keeps an operator from reading them as * a second subscription to that vendor. The `command` is the executable `openlab doctor` looks for, * so it is the literal name on `PATH` and never a prettier spelling of it. + * + * An entry names the harness it stands for rather than restating how that harness is paid for. The + * lab already holds one billing table, and this is the page an operator decides what to install + * from: a site that disagreed with the runtime about which harness spends money would be the worst + * place in the product to be wrong. */ export const HARNESS = [ { name: "Codex", command: "codex", - billing: HARNESS_BILLING.SUBSCRIPTION, + kind: AgentHarnessKind.CODEX, note: "The Codex CLI, on the subscription it is already signed in to." }, { name: "Claude", command: "claude", - billing: HARNESS_BILLING.SUBSCRIPTION, + kind: AgentHarnessKind.CLAUDE, note: "The Claude Code CLI, on the subscription it is already signed in to." }, { name: "GLM", command: "glm", - billing: HARNESS_BILLING.SUBSCRIPTION, + kind: AgentHarnessKind.GLM, note: "A Z.ai coding plan, served over an Anthropic-compatible API and driven through the Claude CLI." }, { name: "DeepSeek", command: "deepseek", - billing: HARNESS_BILLING.USAGE, + kind: AgentHarnessKind.DEEPSEEK, note: "A key you hand over, driven through the Codex CLI. Nothing caps it, and its setup card says so first." }, { name: "Muse Code", command: "muse", - billing: HARNESS_BILLING.USAGE, + kind: AgentHarnessKind.MUSE, note: "A Meta account, metered the same way and publishing no balance at all, so the lab cannot say what a run cost until Meta does." } -] as const; +] as const satisfies readonly { + readonly name: string; + readonly command: string; + readonly kind: AgentHarnessKind; + readonly note: string; +}[]; /** * How many times the roster is written into one half of the moving strip. Four keeps the line full diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 418e6295..574dacd7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -224,6 +224,9 @@ importers: apps/landing: dependencies: + '@openlab/protocol': + specifier: workspace:* + version: link:../../packages/protocol astro: specifier: 7.1.6 version: 7.1.6(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) From a9d8a53508b7e5f5f4c87158e7b330b858d6a468 Mon Sep 17 00:00:00 2001 From: Nikita Snetkov Date: Fri, 7 Aug 2026 19:49:23 +0500 Subject: [PATCH 8/9] docs: correct the two places still describing the allowances panel as subscriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README listed the settings panel under its old heading, and the readiness schema said a ready harness names "the subscription it is signed in to" — which two of the five never do, and which the schema itself contradicts by carrying a balance beside the plan. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 5 +++-- .../src/harness-readiness/harness-readiness.schema.ts | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 852c732e..bc180ba1 100644 --- a/README.md +++ b/README.md @@ -189,9 +189,10 @@ asks the daemon for anything: - **Harnesses** — the roster a new investigation starts on, and the model and reasoning effort behind each of the director, researcher and verifier roles; -- **Subscriptions** — what each authenticated subscription has left and when the reading was taken, +- **Allowances** — what each authenticated harness has left and when the reading was taken, refreshed on its own and on demand, with a limiter on every window that says how far into it the - lab may spend; + lab may spend. A harness billed by the token carries no window, so it states a balance and takes + no limiter; - **Notifications** — the channels the lab reaches the operator through when nobody is watching this page, which moments each one reports and what language it writes in; - **Storage** — what each run directory takes up under `OPENLAB_HOME`, including directories left behind diff --git a/packages/protocol/src/harness-readiness/harness-readiness.schema.ts b/packages/protocol/src/harness-readiness/harness-readiness.schema.ts index a48e50db..506ec5df 100644 --- a/packages/protocol/src/harness-readiness/harness-readiness.schema.ts +++ b/packages/protocol/src/harness-readiness/harness-readiness.schema.ts @@ -3,9 +3,9 @@ import { AgentHarnessKind } from "#src/agents/agent-execution.const"; import { HarnessReadinessState } from "#src/harness-readiness/harness-readiness.const"; /** - * Whether one harness can run, as of the moment the lab last ran its CLI. `cli_version` and `plan` - * are what the two checks got back, so a ready harness proves itself by naming the version that - * answered and the subscription it is signed in to rather than by asserting it is fine. + * Whether one harness can run, as of the moment the lab last ran its CLI. `cli_version`, `plan` and + * `balance` are what the two checks got back, so a ready harness proves itself by naming the version + * that answered and the account it is signed in to rather than by asserting it is fine. */ export const HarnessReadinessSchema = z.object({ harness: z.enum(AgentHarnessKind), From f25dada68f96bd1ebc91927005fd54f18a8f0bf4 Mon Sep 17 00:00:00 2001 From: Nikita Snetkov Date: Fri, 7 Aug 2026 20:54:22 +0500 Subject: [PATCH 9/9] fix(allowance): call an empty wallet spent rather than available MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exhaustion was read off the windows alone, and a wallet meters none — so a DeepSeek wallet DeepSeek had stopped serving came back as an account with everything still to spend. The panel showed it available beside a balance of nought while the preflight was already refusing every run on the same verdict, leaving an operator nothing on the page to explain why nothing dispatched. The vendor's own verdict is carried on the reading and taken at its word, so the panel and the dispatch decision agree and the harness is stopped a step earlier. A spent account is only called a plan where the vendor sold a tier: a wallet comes back when the operator pays for it, never on a renewal they could wait for. --- .../harness-allowance-readings.test.ts | 27 +++++++++++++-- .../harness-allowance/harness-allowance.ts | 11 +++++-- .../harness-allowance/harness-block.test.ts | 33 +++++++++++++++++++ .../src/harness-allowance/harness-block.ts | 22 +++++++++---- .../status-server.test.ts | 3 +- .../src/research-cycle/agent-dispatch.test.ts | 2 ++ .../src/harness-allowance/claude-allowance.ts | 1 + .../src/harness-allowance/codex-allowance.ts | 1 + .../harness-allowance/deepseek-allowance.ts | 5 +++ .../src/harness-allowance/glm-allowance.ts | 1 + .../harness-allowance.types.ts | 7 ++++ .../metered-allowance.test.ts | 14 ++++++++ .../src/harness-allowance/muse-allowance.ts | 5 +++ 13 files changed, 119 insertions(+), 13 deletions(-) diff --git a/apps/daemon/src/harness-allowance/harness-allowance-readings.test.ts b/apps/daemon/src/harness-allowance/harness-allowance-readings.test.ts index 61687d01..63bd4ae3 100644 --- a/apps/daemon/src/harness-allowance/harness-allowance-readings.test.ts +++ b/apps/daemon/src/harness-allowance/harness-allowance-readings.test.ts @@ -8,7 +8,7 @@ import { HarnessAllowanceReadings } from "#src/harness-allowance/harness-allowan const TTL_MILLISECONDS = 60_000; function reading(windows: HarnessAllowanceReading["windows"]): HarnessAllowanceReading { - return { kind: HarnessKinds.CLAUDE, plan: "max", balance: null, windows }; + return { kind: HarnessKinds.CLAUDE, plan: "max", balance: null, spent: false, windows }; } describe("HarnessAllowanceReadings", () => { @@ -27,6 +27,27 @@ describe("HarnessAllowanceReadings", () => { expect(allowance.plan).toBe("max"); }); + /** + * A wallet meters no window, so there is no ceiling for it to have reached and the vendor says + * it outright instead. Read off the windows alone, an empty wallet came back as an account with + * everything still to spend — on the same page that names the balance as nought. + */ + it("calls an empty wallet spent, having no window to have read it off", async () => { + const readings = new HarnessAllowanceReadings({ + read: async (kind): Promise => ({ + kind, + plan: null, + balance: "0.00 USD", + spent: true, + windows: [] + }) + }); + + const allowance = await readings.read(HarnessKinds.DEEPSEEK); + + expect(allowance.state).toBe(HarnessAllowanceState.EXHAUSTED); + }); + it("reports a vendor that could not be asked as unread rather than spent", async () => { const readings = new HarnessAllowanceReadings({ read: async () => { @@ -153,7 +174,7 @@ describe("HarnessAllowanceReadings", () => { const readings = new HarnessAllowanceReadings({ read: async (kind) => { asked += 1; - return { kind, plan: "max", balance: null, windows: [] }; + return { kind, plan: "max", balance: null, spent: false, windows: [] }; }, ttlMs: TTL_MILLISECONDS, now: () => 0 @@ -171,7 +192,7 @@ describe("HarnessAllowanceReadings", () => { if (kind === HarnessKinds.CODEX) { throw new Error("codex CLI is not installed"); } - return { kind, plan: "pro", balance: null, windows: [] }; + return { kind, plan: "pro", balance: null, spent: false, windows: [] }; } }); diff --git a/apps/daemon/src/harness-allowance/harness-allowance.ts b/apps/daemon/src/harness-allowance/harness-allowance.ts index 2e4c64f1..58ff4524 100644 --- a/apps/daemon/src/harness-allowance/harness-allowance.ts +++ b/apps/daemon/src/harness-allowance/harness-allowance.ts @@ -13,6 +13,9 @@ import type { * Classifies what a vendor answered into what the lab may do with that harness. A window at * its ceiling exhausts the plan on its own: the vendor stops serving on whichever window ran out * first, however much the others have left. + * + * A vendor that says outright it has stopped serving is taken at its word, which is the only thing + * an empty wallet can say — it meters no window, so there is no ceiling for it to have reached. */ export function allowanceFromReading( reading: HarnessAllowanceReading, @@ -28,9 +31,11 @@ export function allowanceFromReading( return { harness: reading.kind, - state: windows.some((window) => window.used_percent >= ALLOWANCE_EXHAUSTED_PERCENT) - ? HarnessAllowanceState.EXHAUSTED - : HarnessAllowanceState.AVAILABLE, + state: + reading.spent || + windows.some((window) => window.used_percent >= ALLOWANCE_EXHAUSTED_PERCENT) + ? HarnessAllowanceState.EXHAUSTED + : HarnessAllowanceState.AVAILABLE, plan: reading.plan, balance: reading.balance, windows, diff --git a/apps/daemon/src/harness-allowance/harness-block.test.ts b/apps/daemon/src/harness-allowance/harness-block.test.ts index ce08d006..19fa438f 100644 --- a/apps/daemon/src/harness-allowance/harness-block.test.ts +++ b/apps/daemon/src/harness-allowance/harness-block.test.ts @@ -20,11 +20,24 @@ function readings(usedPercent: number, resetsAt: string | null = RESETS_AT) { kind, plan: "max", balance: null, + spent: false, windows: [{ durationMinutes: WEEKLY_MINUTES, usedPercent, resetsAt }] }) }); } +function spentWalletReadings() { + return new HarnessAllowanceReadings({ + read: async (kind): Promise => ({ + kind, + plan: null, + balance: "0.00 USD", + spent: true, + windows: [] + }) + }); +} + describe("harnessBlock", () => { it("says how far past the cap the harness is and when it is worth asking again", async () => { const block = await harnessBlock({ @@ -69,6 +82,26 @@ describe("harnessBlock", () => { expect(block?.capabilityError?.capabilityRequest.reason).toContain("no allowance left"); }); + /** + * The wallet the preflight would refuse the run on anyway, stopped a step earlier and for the + * reason the vendor gave. Told to wait for a plan it never bought, an operator would sit out a + * renewal that only their own payment brings back. + */ + it("stops a spent wallet without calling it a plan that will renew", async () => { + const block = await harnessBlock({ + readings: spentWalletReadings(), + caps: CAPS, + kind: HarnessKinds.DEEPSEEK, + spendPastCaps: false + }); + + expect(block).toMatchObject({ + kind: HarnessBlockKind.EXHAUSTED, + reason: "The deepseek account has no allowance left" + }); + expect(block?.returnsAt).toBeUndefined(); + }); + it("lets a harness under its cap through", async () => { await expect( harnessBlock({ diff --git a/apps/daemon/src/harness-allowance/harness-block.ts b/apps/daemon/src/harness-allowance/harness-block.ts index de9319bb..8d313534 100644 --- a/apps/daemon/src/harness-allowance/harness-block.ts +++ b/apps/daemon/src/harness-allowance/harness-block.ts @@ -51,12 +51,16 @@ export function unavailableBlock(kind: HarnessKind, reason: string): HarnessBloc * States when the allowance comes back, which is the only thing an operator can act on. The window * that ran out is not named: the vendor stops serving whatever else has headroom, so the wait is * the whole answer. + * + * Only an account the vendor sold a tier for is called a plan. A wallet names no tier, and calling + * it a plan tells an operator whose account comes back when they pay for it that they are waiting + * for a renewal that is never coming. */ export function spentAllowanceMessage(allowance: HarnessAllowance): string { const returns = latestWindowReset(spentWindows(allowance)); return returns === undefined - ? `The ${planName(allowance)} plan has no allowance left` - : `The ${planName(allowance)} plan has no allowance left until ${returns}`; + ? `${accountName(allowance)} has no allowance left` + : `${accountName(allowance)} has no allowance left until ${returns}`; } function spentBlock(kind: HarnessKind, allowance: HarnessAllowance): HarnessBlock { @@ -103,10 +107,10 @@ function withheldMessage( ): string { const window = withheld[0]; if (window === undefined) { - return `The ${planName(allowance)} plan has reached its spend cap`; + return `${accountName(allowance)} has reached its spend cap`; } const cap = windowSpendCap(caps, allowance.harness, window.duration_minutes); - const spent = `The ${planName(allowance)} plan is ${Math.round(window.used_percent)}% into a window capped at ${cap}%`; + const spent = `${accountName(allowance)} is ${Math.round(window.used_percent)}% into a window capped at ${cap}%`; const returns = latestWindowReset(withheld); return returns === undefined ? spent : `${spent}, and is held until ${returns}`; } @@ -116,6 +120,12 @@ function spentWindows(allowance: HarnessAllowance): AllowanceWindow[] { return allowance.windows.filter((window) => window.used_percent >= ALLOWANCE_EXHAUSTED_PERCENT); } -function planName(allowance: HarnessAllowance): string { - return allowance.plan ?? allowance.harness; +/** + * The account as the operator bought it: the tier where the vendor sold one, and the harness itself + * where it sold none and there is nothing else to call it by. + */ +function accountName(allowance: HarnessAllowance): string { + return allowance.plan === null + ? `The ${allowance.harness} account` + : `The ${allowance.plan} plan`; } diff --git a/apps/daemon/src/investigation-status/status-server.test.ts b/apps/daemon/src/investigation-status/status-server.test.ts index 8fa0ce79..dfb0b0f0 100644 --- a/apps/daemon/src/investigation-status/status-server.test.ts +++ b/apps/daemon/src/investigation-status/status-server.test.ts @@ -347,6 +347,7 @@ describe("status server", () => { kind, plan: "max", balance: null, + spent: false, windows: [{ durationMinutes: 300, usedPercent: 41, resetsAt: null }] }) }) @@ -367,7 +368,7 @@ describe("status server", () => { allowances: new HarnessAllowanceReadings({ read: async (kind) => { asked += 1; - return { kind, plan: "max", balance: null, windows: [] }; + return { kind, plan: "max", balance: null, spent: false, windows: [] }; } }) }); diff --git a/apps/daemon/src/research-cycle/agent-dispatch.test.ts b/apps/daemon/src/research-cycle/agent-dispatch.test.ts index 6dbf8a00..206ea630 100644 --- a/apps/daemon/src/research-cycle/agent-dispatch.test.ts +++ b/apps/daemon/src/research-cycle/agent-dispatch.test.ts @@ -100,6 +100,7 @@ function readings(spent: readonly HarnessKind[]): HarnessAllowanceReadings { kind, plan: "max", balance: null, + spent: false, windows: [ { durationMinutes: 10_080, @@ -118,6 +119,7 @@ function partlySpentReadings(usedPercent: number): HarnessAllowanceReadings { kind, plan: "max", balance: null, + spent: false, windows: [ { durationMinutes: WEEKLY_WINDOW_MINUTES, diff --git a/packages/harness/src/harness-allowance/claude-allowance.ts b/packages/harness/src/harness-allowance/claude-allowance.ts index 76105551..f5929d67 100644 --- a/packages/harness/src/harness-allowance/claude-allowance.ts +++ b/packages/harness/src/harness-allowance/claude-allowance.ts @@ -54,6 +54,7 @@ export function claudeAllowanceFromUsage( kind: HarnessKinds.CLAUDE, plan, balance: null, + spent: false, windows: windows.filter((window): window is AllowanceWindow => window !== undefined) }; } diff --git a/packages/harness/src/harness-allowance/codex-allowance.ts b/packages/harness/src/harness-allowance/codex-allowance.ts index a5d48640..5e6092f9 100644 --- a/packages/harness/src/harness-allowance/codex-allowance.ts +++ b/packages/harness/src/harness-allowance/codex-allowance.ts @@ -29,6 +29,7 @@ export function codexAllowanceFromRateLimits(payload: unknown): HarnessAllowance kind: HarnessKinds.CODEX, plan: limits.planType ?? null, balance: null, + spent: false, windows: [limits.primary, limits.secondary] .filter((window) => window !== null && window !== undefined) .map(codexWindow) diff --git a/packages/harness/src/harness-allowance/deepseek-allowance.ts b/packages/harness/src/harness-allowance/deepseek-allowance.ts index 66904075..1d865489 100644 --- a/packages/harness/src/harness-allowance/deepseek-allowance.ts +++ b/packages/harness/src/harness-allowance/deepseek-allowance.ts @@ -9,6 +9,10 @@ import type { HarnessAllowanceReading } from "#src/harness-allowance/harness-all * a wallet is spent until it is empty, whenever that happens. What it has left is reported as the * balance it is, and DeepSeek sells no tier, so it names no plan. * + * Whether the wallet may still be spent is DeepSeek's own verdict and is carried as it was given. + * It is the same verdict the preflight refuses a run on, and a reading that dropped it would leave + * the panel calling a wallet available while the lab was already refusing to dispatch to it. + * * Carrying no windows also means no spend cap can be set against DeepSeek, which is the truth rather * than an omission: a cap is a fraction of a window, and there is no window here to take a fraction * of. @@ -22,6 +26,7 @@ export async function readDeepseekAllowance( kind: HarnessKinds.DEEPSEEK, plan: null, balance: wallet.balance, + spent: !wallet.available, windows: [] }; } diff --git a/packages/harness/src/harness-allowance/glm-allowance.ts b/packages/harness/src/harness-allowance/glm-allowance.ts index 9df2f51e..f7584f96 100644 --- a/packages/harness/src/harness-allowance/glm-allowance.ts +++ b/packages/harness/src/harness-allowance/glm-allowance.ts @@ -37,6 +37,7 @@ export function glmAllowanceFromQuota(payload: unknown): HarnessAllowanceReading kind: HarnessKinds.GLM, plan: quota.level ?? null, balance: null, + spent: false, windows: quota.limits .filter((limit) => SPENDING_LIMIT_TYPES.includes(limit.type)) .map(glmWindow) diff --git a/packages/harness/src/harness-allowance/harness-allowance.types.ts b/packages/harness/src/harness-allowance/harness-allowance.types.ts index 335d985b..0fbc72c2 100644 --- a/packages/harness/src/harness-allowance/harness-allowance.types.ts +++ b/packages/harness/src/harness-allowance/harness-allowance.types.ts @@ -25,6 +25,13 @@ export interface HarnessAllowanceReading { readonly plan: string | null; /** What a token-billed account has left to spend. Null for a subscription. */ readonly balance: string | null; + /** + * The vendor has stopped serving this account, said outright rather than read off a meter. A + * subscription says it through a window at its ceiling and leaves this false; a wallet has no + * window to say it with, so an empty one would otherwise read as an account with everything + * still to spend. + */ + readonly spent: boolean; readonly windows: readonly AllowanceWindow[]; } diff --git a/packages/harness/src/harness-allowance/metered-allowance.test.ts b/packages/harness/src/harness-allowance/metered-allowance.test.ts index 93e25790..3fcd9212 100644 --- a/packages/harness/src/harness-allowance/metered-allowance.test.ts +++ b/packages/harness/src/harness-allowance/metered-allowance.test.ts @@ -18,10 +18,24 @@ describe("allowance readings for a token-billed harness", () => { kind: HarnessKinds.DEEPSEEK, plan: null, balance: "4.21 USD", + spent: false, windows: [] }); }); + /** + * The same verdict the preflight refuses a run on. Dropping it left the panel calling a wallet + * available while the lab was already refusing to dispatch to it, and an operator reading that + * page had no way to tell why nothing was running. + */ + it("carries DeepSeek's own verdict that a wallet can no longer be spent", async () => { + const allowance = await readDeepseekAllowance(undefined, () => + Promise.resolve({ apiKey: "sk-test", available: false, balance: "0.00 USD" }) + ); + + expect(allowance.spent).toBe(true); + }); + /** Meta states nothing, so the balance says that in words and no window can be capped. */ it("reports Muse Code as a balance Meta does not publish", async () => { const allowance = await readMuseAllowance(); diff --git a/packages/harness/src/harness-allowance/muse-allowance.ts b/packages/harness/src/harness-allowance/muse-allowance.ts index cb637199..0b316871 100644 --- a/packages/harness/src/harness-allowance/muse-allowance.ts +++ b/packages/harness/src/harness-allowance/muse-allowance.ts @@ -11,12 +11,17 @@ import type { HarnessAllowanceReading } from "#src/harness-allowance/harness-all * * Carrying no windows is also why no spend cap can be set against Muse. A cap is a fraction of a * window, and there is no window here. + * + * Nothing published also means no verdict on whether the account may still be spent, so the reading + * claims none. The lab goes on dispatching to Muse and learns what Meta thinks from the run itself, + * which is what it did before there was a reading at all. */ export function readMuseAllowance(): Promise { return Promise.resolve({ kind: HarnessKinds.MUSE, plan: null, balance: MUSE_UNPUBLISHED_BALANCE, + spent: false, windows: [] }); }