Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -187,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
Expand Down
3 changes: 2 additions & 1 deletion apps/cli/src/lab-installation/render-installation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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."
);
}

Expand Down
20 changes: 10 additions & 10 deletions apps/daemon/src/agent-harness/agent-harness.fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
}
)
)
Expand Down
8 changes: 4 additions & 4 deletions apps/daemon/src/daemon-runtime/daemon-startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
18 changes: 9 additions & 9 deletions apps/daemon/src/daemon-runtime/research-loop-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<ResearchLoopOutcome> | undefined;
#restartRequested = false;
Expand All @@ -39,14 +39,14 @@ export class ResearchLoopController {
) => Promise<ResearchLoopOutcome>,
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;
Expand Down Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -113,26 +113,26 @@ 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<void> {
if (this.#workspace.getSnapshot().investigation.state === InvestigationState.HIBERNATING) {
await this.wakeIfWaitingOnSubscriptions(reason);
await this.wakeIfWaitingOnAllowances(reason);
return;
}
await this.cancel(reason);
this.start();
}

/**
* 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<void> {
async wakeIfWaitingOnAllowances(reason: Error): Promise<void> {
const investigation = this.#workspace.getSnapshot().investigation;
if (
investigation.state !== InvestigationState.HIBERNATING ||
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
return { kind: HarnessKinds.CLAUDE, plan: "max", windows };
function reading(windows: HarnessAllowanceReading["windows"]): HarnessAllowanceReading {
return { kind: HarnessKinds.CLAUDE, plan: "max", balance: null, spent: false, 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 },
Expand All @@ -23,28 +23,49 @@ 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");
});

/**
* 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<HarnessAllowanceReading> => ({
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 SubscriptionAllowanceReadings({
const readings = new HarnessAllowanceReadings({
read: async () => {
throw new Error("No Keychain entry");
}
});

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);
});

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 }]);
Expand All @@ -64,7 +85,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 }]);
Expand All @@ -83,7 +104,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();
Expand All @@ -103,7 +124,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) {
Expand All @@ -120,14 +141,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) {
Expand All @@ -150,10 +171,10 @@ 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", windows: [] };
return { kind, plan: "max", balance: null, spent: false, windows: [] };
},
ttlMs: TTL_MILLISECONDS,
now: () => 0
Expand All @@ -165,19 +186,19 @@ 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");
}
return { kind, plan: "pro", windows: [] };
return { kind, plan: "pro", balance: null, spent: false, windows: [] };
}
});

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);
});
});
Loading