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
2 changes: 1 addition & 1 deletion .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,5 @@ Tick what this change touches and was checked on. Leave the rest empty.
- [ ] Both transports: the real core and `lib/fake-client.ts`
- [ ] Reverse actions: archive, uninstall, unsubscribe, shutdown
- [ ] Windows and Linux
- [ ] Contract first: `packages/contracts/src/index.ts`
- [ ] Contract first: the domain file in `packages/contracts/src/`
- [ ] Documentation
9 changes: 6 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,12 @@ prerequisites, adjacent runtime files and shell E2E coverage.
- Native process code belongs behind `ProcessPlatform` in `packages/core/src/platform/`.
Drivers and RPC handlers must not import an OS backend. The shell's OS
integration belongs in `apps/shell/src-tauri/src/platform/`.
- Change `packages/contracts/src/index.ts` before implementing an RPC method,
event or shared type. Update the real core and the in-memory client together.
Follow [Adding an RPC method](docs/development.md#adding-an-rpc-method).
- Change the contract before implementing an RPC method, event or shared type:
the domain file in `packages/contracts/src/` (`threads.ts`, `providers.ts`),
which `index.ts` re-exports. Update the real core and the in-memory client
together. Follow [Adding an RPC method](docs/development.md#adding-an-rpc-method);
a branch still editing the single `index.ts` runs
`bun scripts/migrate/split-contracts.ts` after merging main.
- Tests and benches use a fresh temporary `BOITE_DATA_DIR`. Real data directories
and CLI logins are only for explicitly enabled live tests.
- Kill only a process whose PID the test captured at spawn, or use
Expand Down
10 changes: 8 additions & 2 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -547,8 +547,14 @@ live in `fake-client.parity.test.ts`. A fake path below a directory named

### Adding an RPC method

1. Add the method's `params` and `result` to `RpcMethods` in
`packages/contracts/src/index.ts`, beside its domain's entries.
1. Add the method's `params` and `result` to its domain's `<Domain>RpcMethods`
in `packages/contracts/src/<domain>.ts` (`threads.*` in `ThreadsRpcMethods`
of `threads.ts`), and its types beside them. `index.ts` re-exports every
domain file and composes `RpcMethods` and `RpcEvents` from them; a new
domain is a file there, an `export *` and one line in each `extends`.
A branch cut before the split, still editing the single `index.ts`, runs
`bun scripts/migrate/split-contracts.ts` once `git merge origin/main`
stops: it replays its edits onto the domain files.
2. Register the core handler in the domain's `register*Methods` function.
For `threads.*`, use `registerThreadMethods` in
`packages/core/src/threads/rpc.ts`, called by `packages/core/src/modules.ts`.
Expand Down
2 changes: 1 addition & 1 deletion docs/panel.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ Captures: [changes](images/panel-changes-desktop.png) · [files](images/panel-fi

Every surface reads the thread's working directory through the core, never the
browser's file system: `git.status`, `git.diff`, `files.list`, `files.read`,
`files.write`, `todos.*`, `threads.tasks.*` in `packages/contracts/src/index.ts`.
`files.write`, `todos.*`, `threads.tasks.*` in `packages/contracts/src/` (`git.ts`, `files.ts`, `todos.ts`, `threads.ts`).
Paths are relative to the thread's cwd, or absolute inside it, and a path that
resolves outside (a symlink out, a `..`) is refused by name. `files.write` also
refuses a link to nothing, since the write would create its target.
Expand Down
210 changes: 210 additions & 0 deletions packages/contracts/src/accounts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import type { AccountId, ProviderId, Timestamp } from './ids';
import type { RpcEvents } from './index';
import type { SubscriptionProxy } from './subscription-proxy';
import type { TerminalState } from './terminals';

// ---------------------------------------------------------------------------
// Accounts: a descriptor plus an isolation directory. One account per thread.
// ---------------------------------------------------------------------------

export type AccountStatus = 'unknown' | 'ok' | 'unauthenticated' | 'error';

export interface QuotaWindow {
id: string;
label: string;
usedPercent: number;
resetsAt: Timestamp | null;
/** Douane's choice of the window shown first and largest. Missing means the weekly window leads. */
primary?: true;
}

/** No redeemable identifiers reach the client. The core selects the next credit. */
export interface QuotaResetCredits {
availableCount: number;
nextExpiresAt: Timestamp | null;
}

export interface QuotaCredits {
/** A prepaid balance or a monthly spending budget, never interchangeable. */
kind: 'balance' | 'budget';
/** Null means the provider did not confirm automatic paid usage. */
enabled: boolean | null;
/** Provider units. Budgets are displayed as a percentage, not a wallet. */
remaining: number | null;
limit: number | null;
unlimited: boolean;
}

export interface QuotaReading {
windows: QuotaWindow[];
resetCredits?: QuotaResetCredits;
credits?: QuotaCredits;
}

/** A credit line a subscription gateway reports, in its own unit. Unknown numbers are null. */
export interface GatewayQuotaCredit {
id: string;
label: string;
unit: string | null;
remaining: number | null;
limit: number | null;
used: number | null;
}

/**
* One account of the gateway, or one average over a provider's accounts, as
* Douane's `GET /v1/quotas` reports it once the core bounded and checked it.
*/
export interface GatewayQuotaEntry {
id: string;
label: string;
plan: string | null;
/** 1 for one account, N for an average over N accounts. */
accounts: number;
status: 'ready' | 'cooldown' | 'error' | 'disabled';
/** Short and sanitized by the gateway, bounded again by the core. */
error: string | null;
updatedAt: Timestamp | null;
windows: QuotaWindow[];
credits: GatewayQuotaCredit[];
}

export interface GatewayQuotaProvider {
/** The gateway's provider id: `claude`, `codex`, `antigravity` or another of its own. */
providerId: string;
name: string;
display: 'accounts' | 'average';
entries: GatewayQuotaEntry[];
}

/** What the subscription gateway said about its own accounts' limits. */
export interface SubscriptionProxyQuotas {
/**
* `ready`: the gateway answered. `unsupported`: it has no quotas route (CLIProxyAPI,
* or a Douane older than `GET /v1/quotas`), so clients embed its dashboard.
* `unavailable`: the read failed; `providers` keeps the last good answer. `off`:
* no proxy is enabled.
*/
status: 'ready' | 'unsupported' | 'unavailable' | 'off';
providers: GatewayQuotaProvider[];
/** The gateway's own `updated_at`. */
updatedAt: Timestamp | null;
/** When the core last had an answer. */
checkedAt: Timestamp | null;
error: string | null;
}

/** Provider-reported limits, never inferred from Boite's token ledger. */
export interface AccountQuota extends QuotaReading {
/**
* Account id, `quota:antigravity-cli` for the agy login's quota source (on by
* default once agy is found), or
* `proxy:<provider>:<entry>` for an entry of the subscription gateway.
*/
accountId: AccountId;
providerId: ProviderId;
providerName: string;
label: string;
enabled: boolean;
status: 'ready' | 'unavailable' | 'unsupported' | 'disabled';
/** A host's last observation, rather than a fresh account snapshot. */
source?: 'observation';
checkedAt: Timestamp | null;
error: string | null;
/** Set on a subscription gateway's entry: it is not a Boite account and has no switch. */
gateway?: AccountQuotaGateway;
}

export interface AccountQuotaGateway {
kind: SubscriptionProxy['kind'];
entryId: string;
display: GatewayQuotaProvider['display'];
plan: string | null;
accounts: number;
status: GatewayQuotaEntry['status'];
credits: GatewayQuotaCredit[];
}

export type QuotaResetOutcome = 'reset' | 'nothingToReset' | 'noCredit' | 'alreadyRedeemed';

export interface QuotaResetResult {
outcome: QuotaResetOutcome;
/** The account reading after the provider answered, possibly stale if refresh failed. */
quota: AccountQuota;
}

export interface Account {
id: AccountId;
providerId: ProviderId;
label: string;
/** Null means the provider's own default location (the user's real login). */
isolationDir: string | null;
status: AccountStatus;
identity: string | null;
createdAt: Timestamp;
}

export interface AccountsRpcMethods {
'quotas.list': { params: { refresh?: boolean; requestId?: string }; result: AccountQuota[] };
'quotas.configure': { params: { accountId: AccountId; enabled: boolean }; result: AccountQuota[] };
/** Owner-only: consumes a banked reset after an explicit client confirmation. */
'quotas.reset': { params: { accountId: AccountId; confirmed: true }; result: QuotaResetResult };

'accounts.list': { params: Record<string, never>; result: Account[] };
'accounts.add': {
params: { providerId: ProviderId; label: string; useDefaultLocation?: boolean };
result: Account;
};
/** How many top-level conversations still name the account, archived ones included: what a removal warns about. */
'accounts.threads': { params: { accountId: AccountId }; result: { count: number } };
/**
* Removing a default CLI account prevents automatic adoption; an explicit add
* can restore it. Refused while a turn runs on the account. Conversations that
* name it stay, and refuse to send until another account is chosen in them.
*/
'accounts.remove': { params: { accountId: AccountId }; result: { ok: true } };
'accounts.rename': { params: { accountId: AccountId; label: string }; result: Account };
/** Refresh the provider's login when requested; never uses a model catalogue as authentication. */
'accounts.check': { params: { accountId: AccountId; refresh?: boolean }; result: Account };
/**
* Start the provider's login command for this account. Refused when the
* provider has no `login` block, when the account uses the provider's own
* location, or when a login is already running for it. Progress arrives as
* `account.login`.
*/
'accounts.login': { params: { accountId: AccountId }; result: { ok: true } };
/** Current login state, used to rebuild cards after reconnecting. */
'accounts.logins': { params: Record<string, never>; result: RpcEvents['account.login'][] };
/** Stop the login process tree and wait for its exit. */
'accounts.loginCancel': { params: { accountId: AccountId }; result: { ok: true } };
/** One line into the running login's stdin, for a CLI that asks for a code. */
'accounts.loginInput': { params: { accountId: AccountId; text: string }; result: { ok: true } };
/**
* Open a shell with this account's environment and type its login command
* into it, or attach to the one already open. Only for a login whose
* descriptor says `terminal`. The default account is allowed: the user drives
* the shell, as in their own terminal. Closing the terminal rechecks the account.
*/
'accounts.loginTerminal': { params: { accountId: AccountId; cols: number; rows: number }; result: TerminalState };
}

export interface AccountsRpcEvents {
'quotas.updated': AccountQuota[];
/** Owner-only partial result, correlated with the caller's quotas.list request. */
'quotas.progress': { requestId: string; quota: AccountQuota };
'accounts.updated': Account;
/** An account `accounts.remove` deleted. */
'accounts.removed': { accountId: AccountId };
/**
* The login process starting, one line of its output, or its exit. `url`
* carries the first `https://` link seen in the output, once there is one.
* On exit the core rechecks the account and follows with `accounts.updated`.
*/
'account.login': {
accountId: AccountId;
state: 'running' | 'done' | 'failed';
output: string;
url: string | null;
exitCode: number | null;
};
}
Loading
Loading