feat(usage): usage sources as built-in plugins, following the session's account (#5465)

* feat(server): run internal plugins in-process through the plugin runtime

* fix(server): harden internal plugin startup and boundary checks

* feat(usage): add plugin usage sources and Codex discovery

* fix(usage): share SDK helpers and bound source cache

* feat(usage): migrate usage fetchers to plugin sources

Serve the legacy usage RPC from discovered source reports so older apps retain the same host usage list while new clients use the source registry. Keep credential reads and normalization inside each source.

* refactor(usage): shape sources as fetch functions

Keep the plugin usage API limited to stable report and presentation helpers. Source-local parsers and timeout handling preserve credential and response parity while the cases move alongside each source.

* feat(usage): resolve reports from active session accounts

Resolve source inputs at fetch time so model and credential changes select the account each session spends. Keep the legacy discovered-account RPC for existing clients. Sync OpenCode model events outside foreground turns so usage follows model switches.

* fix usage source discovery and session report parity

* test adapter usage references through daemon RPC

* name usage reference contract across server

* refactor(usage): own usage RPCs and validate session auth

* feat(omp): resolve session usage from active OAuth credential

* fix(omp): match usage source input and valid credential fallback

* test(omp): use upstream credential database schema

* test(omp): verify live session usage references

* fix(usage): declare YAML parser and consolidate credential validation

* feat(app): show usage sources in the composer and a Usage screen

Replace the provider-matched usage in the context ring tooltip with a
composer pill driven by agent.get_usage_report, add a sidebar Usage
screen over usage.list_reports grouped by host, and move the host
settings Usage page to usage.list_reports. Hosts without
features.usageSources show an update message. Source icons come from
the report's catalog SVG. The app no longer calls provider.usage.list.

* fix(usage): declare the Paseo requirement on internal usage-source plugins

A manifest without requirements.paseo targets Paseo before 0.8, so the daemon
refused to start eight of the nine usage sources.

* fix(app): keep host connection statuses live under the React Compiler

The hook read a store version only as a memo trigger (void version); the
compiler dropped it, so pages that mounted before a host came online kept
showing it as connecting.

* fix(app): place the context ring slot before the usage pill

The ring reserves its slot before it has data. With the pill first, the empty
slot sat between the pill and the mic; now it falls in the flexible space.

* refactor(app): extract the settings page layout into PageLayout

Desktop puts the title in the page above a centered column; compact moves it
into a back header. Settings renders the same pixels through it.

* feat(app): lay out the Usage screen like a settings page

The title moves from the header bar into the page on desktop; compact gets the
settings back header, which returns home when the screen was opened by URL.

* refactor(app): drop the unused actions slot from PageLayout

* test(plugin): classify the usage source SDK entry

* fix(usage): preserve platform test contracts

* fix(settings): preserve page title test contract

* feat(usage): identify and cache reports by account ID

Keep report identity stable across input routes and token rotation, and make selective refresh operate on known account reports.

* feat(app): read usage reports through report IDs

Share report data between agent pills and host views while preserving the existing popover interaction for the next UI phase.

* fix(usage): keep account identity aligned with credentials

Read labels from local token claims and use one credential lookup for usage fetches and account identification. Bound cached profile identities across token rotation.

* fix(usage): isolate failure IDs and prune expired reports

Failure reports live outside the account-key namespace, and cache writes discard entries past the five-minute TTL.

* refactor(app): share the compact time-ago prose formatter

Move the tab tooltip's "3m" -> "3m ago" wording into utils/time so the
usage card can reuse it.

* feat(usage): show when each report was fetched

The usage card reads fetchedAt as "Updated 3m ago": a tooltip on the
card header where hover exists (web, desktop), printed in the card
footer on native and compact. Shared by the pill popover and the Usage
screen.

* feat(usage): open the pill popover from cache and refresh on demand

Opening the popover no longer forces a vendor fetch; it shows the cached
report. A Refresh control force-refreshes only this report
(usage.list_reports with its single ID and forceRefresh), shows
Refreshing... while it runs, keeps the previous report visible, and says
"Unable to refresh usage" if the request fails.

* feat(app): let ToolbarButton show a tooltip other than its label

An icon action can now say a status in its tooltip ("Updated 3m ago")
while keeping its label for accessibility.

* feat(usage): refresh a report from its card header

Refresh is an icon button at the right edge of every usage card header,
in the pill popover and on the Usage screen. It force-refreshes only that
report, writes the result to the report's cache and its host list, shows
a spinner and disables itself while pending, and prints "Unable to
refresh usage" in the card on failure. Its tooltip carries the
freshness; without hover the freshness stays in the card footer. The
status indicator sits just left of the button, and the popover's
separate refresh row is gone.

* fix(server): include internal plugin assets in built output

* feat(server): built-in plugins in /plugins (#5544)

* feat(server): load compiled built-in Codex plugin

* test(server): guard built-in plugin loading and installs

* test(server): verify built-in plugins from dist in CI

* refactor(server): migrate remaining usage sources to built-in plugins

* test(server): guard all packaged usage source registrations

* docs: describe usage validation across plugin hosts

* test(ci): include built-in plugins in routing contract

* test(server): import moved Codex usage source

* fix(server): retain ACP updates received with session creation
This commit is contained in:
Mohamed Boudra
2026-09-28 16:36:34 +01:00
committed by GitHub
parent 827178df90
commit 792715e762
189 changed files with 9602 additions and 4695 deletions
+1
View File
@@ -34,6 +34,7 @@ hub:
- "packages/server/src/server/hub/**" - "packages/server/src/server/hub/**"
server: server:
- "plugins/**"
- "packages/server/**" - "packages/server/**"
- "packages/app/e2e/support/fixtures/recording.*" - "packages/app/e2e/support/fixtures/recording.*"
+7
View File
@@ -117,6 +117,9 @@ jobs:
- name: Build server stack - name: Build server stack
run: npm run build:server run: npm run build:server
- name: Verify built-in plugins from built output
run: node --test scripts/builtin-plugins-dist.test.mjs
- name: Typecheck all packages - name: Typecheck all packages
run: npm run typecheck run: npm run typecheck
@@ -172,6 +175,10 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
- name: Run built-in plugin tests
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
run: npm run test --workspace=@getpaseo/builtin-plugins
- name: Run Hub CLI/daemon contract - name: Run Hub CLI/daemon contract
if: ${{ needs.changes.outputs.full == 'false' && needs.changes.outputs.server == 'false' && needs.changes.outputs.hub != 'false' }} if: ${{ needs.changes.outputs.full == 'false' && needs.changes.outputs.server == 'false' && needs.changes.outputs.hub != 'false' }}
run: npm run test:hub-cli-contract --workspace=@getpaseo/server run: npm run test:hub-cli-contract --workspace=@getpaseo/server
+3
View File
@@ -31,6 +31,9 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Agent profile** — A named launch bundle: provider, model, mode, thinking option, feature values, and free-text notes. Applying one writes those values into the **Agent controls** and is then forgotten — there is no selected profile, no default profile, and no drift tracking. The notes are written for orchestrating agents and are what the `list_profiles` MCP tool surfaces. Stored host-wide in daemon config as `agentProfiles`. UI: "Agent profiles". Code: `AgentProfile` (`packages/protocol/src/messages.ts:161`). Carries no system prompt, because a per-agent system prompt can only be set at creation. Forbidden: "Favourite", which this replaced. - **Agent profile** — A named launch bundle: provider, model, mode, thinking option, feature values, and free-text notes. Applying one writes those values into the **Agent controls** and is then forgotten — there is no selected profile, no default profile, and no drift tracking. The notes are written for orchestrating agents and are what the `list_profiles` MCP tool surfaces. Stored host-wide in daemon config as `agentProfiles`. UI: "Agent profiles". Code: `AgentProfile` (`packages/protocol/src/messages.ts:161`). Carries no system prompt, because a per-agent system prompt can only be set at creation. Forbidden: "Favourite", which this replaced.
- **Terminal profile** — A named shell command a terminal can launch. Stored host-wide in daemon config as `terminalProfiles`. UI: "Terminal profiles". Code: `TerminalProfile` (`packages/protocol/src/messages.ts:135`), `resolveTerminalProfiles` (`packages/protocol/src/terminal-profiles.ts`). Also selectable as a New Workspace launch target, where the alternative is the chat agent. - **Terminal profile** — A named shell command a terminal can launch. Stored host-wide in daemon config as `terminalProfiles`. UI: "Terminal profiles". Code: `TerminalProfile` (`packages/protocol/src/messages.ts:135`), `resolveTerminalProfiles` (`packages/protocol/src/terminal-profiles.ts`). Also selectable as a New Workspace launch target, where the alternative is the chat agent.
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`). - **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`).
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`). - **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`). - **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`). - **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
+17 -1
View File
@@ -36,7 +36,8 @@ The daemon stores directory sources under the root `plugins` object:
} }
``` ```
The plugin system is disabled unless `pluginsEnabled` is `true`. Changing that root field is Installed plugins are disabled unless `pluginsEnabled` is `true`. Built-in plugins remain active.
Changing that root field is
runtime-safe: run `paseo reload` after editing `config.json`. Enabling starts every configured, runtime-safe: run `paseo reload` after editing `config.json`. Enabling starts every configured,
enabled plugin; disabling tears them all down without restarting the daemon. Plugin source entries enabled plugin; disabling tears them all down without restarting the daemon. Plugin source entries
remain lifecycle-owned and do not reload from manual config edits. remain lifecycle-owned and do not reload from manual config edits.
@@ -85,6 +86,15 @@ plugin before compiling and starting from disk. A failed reload stays failed; Pa
the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Removing a directory source the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Removing a directory source
never deletes it. The global `pluginsEnabled` switch remains available. never deletes it. The global `pluginsEnabled` switch remains available.
## Built-in plugins
Built-in plugins live in `plugins/<id>/` and ship with the daemon. Add a directory and one ID to
`builtinPlugins` in `packages/server/src/server/plugins/builtin/index.ts`. The workspace, build
copy, and CI checks cover every listed directory; unlisted directories do not load. Built-ins run
in process, ignore `pluginsEnabled`, and do not appear in `config.json` or the installed plugin
list. Their client bundles appear in the plugin catalog. Editing one in development requires a
daemon restart. Directory, Git, and npm installs cannot use a built-in ID.
## Install a Git source ## Install a Git source
GitHub repositories use an `owner/repository` shorthand. Other hosts use a Git URL. An existing GitHub repositories use an `owner/repository` shorthand. Other hosts use a Git URL. An existing
@@ -376,6 +386,12 @@ external `href` or `xlink:href` references are rejected. Fragment references suc
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
SVG or URL. SVG or URL.
## Usage sources
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. `input` is a Zod schema checked inside the plugin process before `fetch(input)` runs. `discover()` returns the inputs for accounts found on the host; an empty list omits the source from host usage. `fetch()` returns a report with a stable `account.key` so the daemon can deduplicate accounts and cache by source and input. A failed input or fetch becomes an error report for that source. `icon` uses the same sanitized SVG file rules as provider icons.
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the author contract and minimum version.
## Contribute buttons ## Contribute buttons
Header buttons and composer pills share the client-only descriptor and registration lifecycle in Header buttons and composer pills share the client-only descriptor and registration lifecycle in
+6 -15
View File
@@ -195,26 +195,17 @@ promise for completion: equal results, including equal discovery timestamps, emi
--- ---
## Provider Usage Fetchers ## Usage sources
Provider plan usage is fetch-on-demand, not a daemon push subscription. The app calls `provider.usage.list.request` through React Query when the usage tooltip or Host Usage settings screen is shown, and the daemon returns the normalized `ProviderUsage` list directly. Usage is fetched on demand from plugin usage sources. Each source registers through `server.registerUsageSource()` with an input schema, `fetch(input)`, and optional `discover()`. The daemon discovers configured accounts, validates inputs in the plugin runtime, caches each source/input result for five minutes, and returns `usage.list_reports.response`. A source report has an account key, availability status, plan label, windows, balances, and details. Mark the window the app should show first with `headline: true`.
To add plan usage for a provider, add `packages/server/src/services/quota-fetcher/providers/<provider>.ts` and register it in `packages/server/src/services/quota-fetcher/manifest.ts`. The provider file exports only its fetcher class; provider auth, endpoint constants, API schemas, and normalization helpers stay private in that file. A fetcher owns provider auth/API parsing and returns the generic shape: Create a built-in source under `plugins/<name>-usage-source/` with the same manifest, entry, `server/`, `shared/`, and `icon.svg` layout as an external plugin. Add its ID to `builtinPlugins` in `packages/server/src/server/plugins/builtin/index.ts`. Keep credential discovery, API parsing, and normalization inside the source; use helpers from `@getpaseo/plugin/server/usage`. The wire shape remains source agnostic. See [plugin usage sources](plugins.md#usage-sources).
- `providerId`, `displayName`, `status`, and optional `planLabel` `provider.usage.list` remains a compatibility RPC for older apps. It maps discovered reports to `ProviderUsage`. New clients use `usage.list_reports` after checking `server_info.features.usageSources`.
- any number of `windows` such as Session, Weekly, or Biweekly
- optional `balances` for credits, USD, requests, or tokens
- optional `details` for provider-specific rows
Keep the protocol shape provider-agnostic. Do not add provider-specific renderers for new limit windows; labels and generic bars should carry the UI. API responses should be parsed and normalized with Zod inside the fetcher, while the protocol boundary stays strict so old/new client compatibility is explicit. ### Credentials are read only
Kimi Code usage follows the CLI-managed credential file at `KIMI_CODE_HOME` or `~/.kimi-code/credentials/kimi-code.json`; do not probe the legacy `~/.kimi` path as the primary source for current Kimi Code installs. A source reads provider credentials without writing them. On 401 or 403 it returns `unavailable` and leaves refresh to the provider CLI. Redeeming a refresh token here would invalidate the CLI's copy; rewriting a parsed credential file could drop fields the source does not model.
Cursor usage reads the desktop `state.vscdb` token first, then `cursor-agent`'s `~/.config/cursor/auth.json`. Headless hosts only have the CLI file.
### Usage fetchers are read-only on credentials
A fetcher reads the provider's credential file and never writes it. On a 401 or 403 it returns `unavailable` and leaves refresh to the provider's own CLI: redeeming a refresh token in the fetcher invalidates the CLI's copy (refresh tokens are single-use), and rewriting the file through the fetcher's Zod schema drops any field the schema does not model, corrupting the file for the CLI.
--- ---
+4
View File
@@ -1,6 +1,10 @@
{ {
"$schema": "./node_modules/knip/schema.json", "$schema": "./node_modules/knip/schema.json",
"workspaces": { "workspaces": {
"plugins": {
"entry": ["**/index.{server,client}.{ts,tsx}", "**/*.test.ts"],
"project": ["**/*.{ts,tsx}"]
},
".": { ".": {
"entry": ["scripts/**/*.{js,mjs,cjs,ts}"], "entry": ["scripts/**/*.{js,mjs,cjs,ts}"],
"project": ["scripts/**/*.{js,mjs,cjs,ts}"] "project": ["scripts/**/*.{js,mjs,cjs,ts}"]
+33
View File
@@ -10,6 +10,7 @@
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"workspaces": [ "workspaces": [
"plugins",
"packages/expo-two-way-audio", "packages/expo-two-way-audio",
"packages/highlight", "packages/highlight",
"packages/plugin", "packages/plugin",
@@ -6333,6 +6334,10 @@
"resolved": "packages/app", "resolved": "packages/app",
"link": true "link": true
}, },
"node_modules/@getpaseo/builtin-plugins": {
"resolved": "plugins",
"link": true
},
"node_modules/@getpaseo/cli": { "node_modules/@getpaseo/cli": {
"resolved": "packages/cli", "resolved": "packages/cli",
"link": true "link": true
@@ -39238,6 +39243,7 @@
"uuid": "^9.0.1", "uuid": "^9.0.1",
"which": "^5.0.0", "which": "^5.0.0",
"ws": "^8.14.2", "ws": "^8.14.2",
"yaml": "2.9.1",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
@@ -40096,6 +40102,21 @@
"node": "^18.17.0 || >=20.5.0" "node": "^18.17.0 || >=20.5.0"
} }
}, },
"packages/server/node_modules/yaml": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"packages/server/node_modules/yocto-queue": { "packages/server/node_modules/yocto-queue": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
@@ -41712,6 +41733,18 @@
"optional": true "optional": true
} }
} }
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.0.0",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^20.9.0",
"vitest": "^4.1.6"
}
} }
} }
} }
+2 -1
View File
@@ -21,6 +21,7 @@
"url": "https://github.com/getpaseo/paseo.git" "url": "https://github.com/getpaseo/paseo.git"
}, },
"workspaces": [ "workspaces": [
"plugins",
"packages/expo-two-way-audio", "packages/expo-two-way-audio",
"packages/highlight", "packages/highlight",
"packages/plugin", "packages/plugin",
@@ -66,7 +67,7 @@
"watch:protocol": "npm run watch --workspace=@getpaseo/protocol", "watch:protocol": "npm run watch --workspace=@getpaseo/protocol",
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput", "watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
"typecheck": "npm run typecheck --workspaces --if-present", "typecheck": "npm run typecheck --workspaces --if-present",
"typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli", "typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/builtin-plugins && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
"test": "npm run test --workspaces --if-present", "test": "npm run test --workspaces --if-present",
"format": "oxfmt .", "format": "oxfmt .",
"format:files": "oxfmt", "format:files": "oxfmt",
@@ -1,152 +0,0 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { installProviderUsageFixture } from "../support/helpers/provider-usage";
import { getServerId } from "../support/helpers/server-id";
import { openSettingsHostSection } from "../support/helpers/settings";
test.describe("provider usage settings", () => {
test("renders every provider returned by the daemon usage RPC", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const usageFixture = await installProviderUsageFixture(page, [
{
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "claude",
displayName: "Claude",
status: "available",
planLabel: "Max 20x",
windows: [{ id: "session", label: "Session", usedPct: 7 }],
},
{
providerId: "codex",
displayName: "Codex",
status: "available",
planLabel: "Pro 20x",
windows: [{ id: "weekly", label: "Weekly", usedPct: 29 }],
},
{
providerId: "glm",
displayName: "GLM coding plan",
status: "available",
planLabel: "GLM coding plan",
sourceLabel: "OpenUsage 0.6.27",
windows: [
{ id: "biweekly", label: "Biweekly", usedPct: 23 },
{ id: "daily", label: "Daily", remainingPct: 30 },
],
balances: [
{ id: "credits", label: "Credits", remaining: 1234, unit: "credits" },
{ id: "extra", label: "Extra usage", used: 5, limit: 20, unit: "usd" },
],
details: [{ id: "valid", label: "Valid until", value: "2026-12-31" }],
},
],
},
]);
await gotoAppShell(page);
await openSettings(page);
expect(usageFixture.requestCount()).toBe(0);
await openSettingsHostSection(page, serverId, "usage");
await usageFixture.waitForRequestCount(1);
const card = page.getByTestId("provider-usage-card");
await expect(card).toBeVisible({ timeout: 10_000 });
await expect(card.getByText("Claude", { exact: true })).toBeVisible();
await expect(card.getByText("Codex", { exact: true })).toBeVisible();
await expect(card.getByText("GLM coding plan", { exact: true }).first()).toBeVisible();
await expect(card.getByText("Biweekly", { exact: true })).toBeVisible();
await expect(card.getByText("Daily", { exact: true })).toBeVisible();
await expect(card.getByText("70%")).toBeVisible();
await expect(card.getByText("Credits", { exact: true })).toBeVisible();
await expect(card.getByText("1,234 left", { exact: true })).toBeVisible();
await expect(card.getByText("Extra usage", { exact: true })).toBeVisible();
await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible();
await expect(card.getByText("Valid until", { exact: true })).toBeVisible();
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
await expect(card.getByText(/OpenUsage 0\.6\.27/)).toBeVisible();
});
test("refresh invalidates and refetches usage", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const usageFixture = await installProviderUsageFixture(page, [
{
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "glm",
displayName: "GLM coding plan",
status: "available",
planLabel: "GLM coding plan",
windows: [{ id: "biweekly", label: "Biweekly", usedPct: 23 }],
},
],
},
{
fetchedAt: "2026-06-19T00:01:00.000Z",
providers: [
{
providerId: "glm",
displayName: "GLM coding plan",
status: "available",
planLabel: "GLM coding plan",
windows: [{ id: "biweekly", label: "Biweekly", usedPct: 64 }],
},
],
},
]);
await gotoAppShell(page);
await openSettings(page);
await openSettingsHostSection(page, serverId, "usage");
await usageFixture.waitForRequestCount(1);
await expect(page.getByText("23%")).toBeVisible({ timeout: 10_000 });
await page.getByRole("button", { name: "Refresh", exact: true }).click();
await usageFixture.waitForRequestCount(2);
expect(usageFixture.requestCount()).toBe(2);
await expect(page.getByText("64%")).toBeVisible();
});
test("one provider error does not collapse the usage list", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
await installProviderUsageFixture(page, [
{
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "claude",
displayName: "Claude",
status: "error",
planLabel: null,
windows: [],
error: "Claude auth expired",
},
{
providerId: "codex",
displayName: "Codex",
status: "available",
planLabel: "Pro 20x",
windows: [{ id: "weekly", label: "Weekly", usedPct: 71 }],
},
],
},
]);
await gotoAppShell(page);
await openSettings(page);
await openSettingsHostSection(page, serverId, "usage");
const card = page.getByTestId("provider-usage-card");
await expect(card).toBeVisible({ timeout: 10_000 });
await expect(card.getByText("Error", { exact: true })).toBeVisible();
await expect(card.getByText("Claude auth expired", { exact: true })).toBeVisible();
await expect(card.getByText("Codex", { exact: true })).toBeVisible();
await expect(card.getByText("71%")).toBeVisible();
});
});
@@ -1,113 +0,0 @@
import { expect, test, type Page } from "../support/fixtures";
import { expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { installProviderUsageFixture } from "../support/helpers/provider-usage";
const MOBILE_VIEWPORT = { width: 390, height: 844 };
async function openMockAgent(page: Page) {
await page.setViewportSize(MOBILE_VIEWPORT);
const session = await seedMockAgentWorkspace({
repoPrefix: "provider-usage-tooltip-",
title: "Provider usage tooltip e2e",
initialPrompt: "emit 1 coalesced agent stream update for provider usage tooltip.",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
await expect(page.getByTestId("context-window-meter")).toBeVisible({ timeout: 30_000 });
return session;
}
test.describe("provider usage tooltip", () => {
test("fetches usage when the context tooltip opens and renders the active provider", async ({
page,
}) => {
test.setTimeout(180_000);
const usageFixture = await installProviderUsageFixture(page, [
{
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "mock",
displayName: "Mock provider",
status: "available",
planLabel: "Test plan",
windows: [
{
id: "session",
label: "Session",
usedPct: 42,
remainingPct: 58,
resetsAt: "2026-06-19T05:00:00.000Z",
},
],
},
],
},
]);
const session = await openMockAgent(page);
try {
expect(usageFixture.requestCount()).toBe(0);
await page.getByTestId("context-window-meter").hover();
await usageFixture.waitForRequestCount(1);
await expect(page.getByText("Mock provider", { exact: true })).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText("Test plan")).toBeVisible();
await expect(page.getByText("Session", { exact: true })).toBeVisible();
await expect(page.getByText("42%")).toBeVisible();
} finally {
await session.cleanup();
}
});
test("refreshes usage again each time the tooltip is shown", async ({ page }) => {
test.setTimeout(180_000);
const usageFixture = await installProviderUsageFixture(page, [
{
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "mock",
displayName: "Mock provider",
status: "available",
planLabel: "Test plan",
windows: [{ id: "session", label: "Session", usedPct: 41 }],
},
],
},
{
fetchedAt: "2026-06-19T00:01:00.000Z",
providers: [
{
providerId: "mock",
displayName: "Mock provider",
status: "available",
planLabel: "Test plan",
windows: [{ id: "session", label: "Session", usedPct: 64 }],
},
],
},
]);
const session = await openMockAgent(page);
try {
const meter = page.getByTestId("context-window-meter");
await meter.hover();
await usageFixture.waitForRequestCount(1);
await expect(page.getByText("41%")).toBeVisible({ timeout: 10_000 });
await page.mouse.move(0, 0);
await expect(page.getByText("Mock provider", { exact: true })).toHaveCount(0);
await meter.hover();
await usageFixture.waitForRequestCount(2);
expect(usageFixture.requestCount()).toBe(2);
await expect(page.getByText("64%")).toBeVisible();
} finally {
await session.cleanup();
}
});
});
@@ -0,0 +1,219 @@
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
import { expect, test, type Page } from "../support/fixtures";
import { expectComposerVisible, submitMessage } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
const REPORT_ID = "fixture:fixture-account";
// Two hours reads "2h ago" for an hour, so the assertion cannot race the clock.
function twoHoursAgo(): string {
return new Date(Date.now() - 2 * 60 * 60_000).toISOString();
}
function agentEntry(usedPct: number, fetchedAt = "2026-01-01T00:00:00.000Z"): UsageReportEntry {
return {
id: REPORT_ID,
account: { label: "Fixture account" },
fetchedAt,
sourceId: "fixture",
sourceLabel: "Fixture plan",
icon: '<svg viewBox="0 0 24 24"><rect width="24" height="24" fill="currentColor"/></svg>',
report: {
status: "available",
planLabel: "Test plan",
windows: [
{ id: "session", label: "Session", usedPct: 3 },
{ id: "weekly", label: "Weekly", usedPct, headline: true },
],
},
};
}
async function openMockAgent(page: Page) {
const session = await seedMockAgentWorkspace({
repoPrefix: "usage-composer-pill-",
title: "Usage composer pill e2e",
initialPrompt: "emit 1 coalesced agent stream update for usage composer pill.",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
return session;
}
test.describe("usage composer pill", () => {
test("opens the cached report without a request and shows when it was fetched", async ({
page,
}) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, {
agentReportIds: [REPORT_ID],
lists: [[agentEntry(42, twoHoursAgo())]],
});
const session = await openMockAgent(page);
try {
const pill = page.getByTestId("usage-composer-pill");
await expect(pill).toContainText("42%", { timeout: 30_000 });
expect(usage.agentRequests()[0]).toEqual({ agentId: session.agentId });
expect(usage.listRequests()).toEqual([{ forceRefresh: false, reportIds: [REPORT_ID] }]);
await pill.click();
const popover = page.getByTestId("usage-composer-popover");
await expect(popover.getByText("Fixture plan", { exact: true })).toBeVisible();
await expect(popover.getByText("Test plan")).toBeVisible();
await expect(popover.getByText("42%")).toBeVisible();
await popover.getByTestId("usage-refresh").hover();
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
await expect(popover.getByTestId("usage-freshness")).toHaveCount(0);
expect(usage.listRequests()).toHaveLength(1);
} finally {
await session.cleanup();
}
});
test("refreshes only its report from the popover", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, {
agentReportIds: [REPORT_ID],
lists: [[agentEntry(42, twoHoursAgo())], () => [agentEntry(64, new Date().toISOString())]],
});
const session = await openMockAgent(page);
try {
const pill = page.getByTestId("usage-composer-pill");
await expect(pill).toContainText("42%", { timeout: 30_000 });
await pill.click();
const popover = page.getByTestId("usage-composer-popover");
await expect(popover.getByText("42%")).toBeVisible();
await popover.getByTestId("usage-refresh").click();
await usage.waitForListRequests(2);
expect(usage.listRequests()).toEqual([
{ forceRefresh: false, reportIds: [REPORT_ID] },
{ forceRefresh: true, reportIds: [REPORT_ID] },
]);
await expect(popover.getByText("64%")).toBeVisible();
await expect(pill).toContainText("64%");
await expect(popover.getByTestId("usage-refresh")).toBeEnabled();
// Pressing closes the tooltip; it reopens on the next hover.
await popover.getByText("Fixture plan", { exact: true }).hover();
await popover.getByTestId("usage-refresh").hover();
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated just now");
expect(usage.listRequests()).toHaveLength(2);
} finally {
await session.cleanup();
}
});
test("keeps the previous report and says so when a refresh fails", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, {
agentReportIds: [REPORT_ID],
lists: [
[agentEntry(42, twoHoursAgo())],
{ error: "Usage source timed out" },
() => [agentEntry(64, new Date().toISOString())],
],
});
const session = await openMockAgent(page);
try {
const pill = page.getByTestId("usage-composer-pill");
await expect(pill).toContainText("42%", { timeout: 30_000 });
await pill.click();
const popover = page.getByTestId("usage-composer-popover");
const refresh = popover.getByTestId("usage-refresh");
await refresh.click();
await usage.waitForListRequests(2);
await expect(popover.getByTestId("usage-refresh-error")).toHaveText(
"Unable to refresh usage",
);
await expect(popover.getByText("42%")).toBeVisible();
await expect(pill).toContainText("42%");
await refresh.click();
await usage.waitForListRequests(3);
await expect(popover.getByText("64%")).toBeVisible();
await expect(popover.getByTestId("usage-refresh-error")).toHaveCount(0);
} finally {
await session.cleanup();
}
});
test("prints the freshness on the card where there is no hover", async ({ page }) => {
test.setTimeout(180_000);
await page.setViewportSize({ width: 390, height: 844 });
const usage = await installUsageReportsFixture(page, {
agentReportIds: [REPORT_ID],
lists: [[agentEntry(42, twoHoursAgo())]],
});
const session = await openMockAgent(page);
try {
const pill = page.getByTestId("usage-composer-pill");
await expect(pill).toContainText("42%", { timeout: 30_000 });
await pill.click();
const popover = page.getByTestId("usage-composer-popover");
await expect(popover.getByTestId("usage-freshness")).toHaveText("Updated 2h ago");
expect(usage.listRequests()).toHaveLength(1);
} finally {
await session.cleanup();
}
});
test("re-resolves the report ID after a turn and reuses the cached report", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, {
agentReportIds: [REPORT_ID],
lists: [[agentEntry(42)]],
});
const session = await openMockAgent(page);
try {
const pill = page.getByTestId("usage-composer-pill");
await expect(pill).toContainText("42%", { timeout: 30_000 });
const requestsBeforeTurn = usage.agentRequests().length;
const listRequestsBeforeTurn = usage.listRequests().length;
await submitMessage(page, "emit 1 coalesced agent stream update for usage composer pill.");
await usage.waitForAgentRequests(requestsBeforeTurn + 1);
expect(usage.agentRequests().at(-1)).toEqual({
agentId: session.agentId,
});
await expect(pill).toContainText("42%");
expect(usage.listRequests()).toHaveLength(listRequestsBeforeTurn);
} finally {
await session.cleanup();
}
});
test("is hidden when the agent has no usage report", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, { agentReportIds: [null] });
const session = await openMockAgent(page);
try {
await usage.waitForAgentRequests(1);
await expect(page.getByTestId("usage-composer-pill")).toHaveCount(0);
expect(usage.listRequests()).toHaveLength(0);
} finally {
await session.cleanup();
}
});
test("is hidden on a host without usage sources", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, {
usageSources: false,
agentReportIds: [REPORT_ID],
lists: [[agentEntry(42)]],
});
const session = await openMockAgent(page);
try {
await expect(page.getByTestId("context-window-meter")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("usage-composer-pill")).toHaveCount(0);
expect(usage.agentRequests()).toHaveLength(0);
} finally {
await session.cleanup();
}
});
});
@@ -0,0 +1,156 @@
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { getServerId } from "../support/helpers/server-id";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
// Two hours reads "2h ago" for an hour, so the assertion cannot race the clock.
function twoHoursAgo(): string {
return new Date(Date.now() - 2 * 60 * 60_000).toISOString();
}
test.describe("usage screen", () => {
test("opens from the sidebar and groups reports under their host", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const usage = await installUsageReportsFixture(page, {
lists: [
[
{
id: "alpha:a",
account: {},
fetchedAt: twoHoursAgo(),
sourceId: "alpha",
sourceLabel: "Alpha plan",
report: {
status: "available",
windows: [{ id: "weekly", label: "Weekly", usedPct: 31, headline: true }],
},
},
{
id: "beta:b",
account: {},
fetchedAt: "2026-01-01T00:00:00.000Z",
sourceId: "beta",
sourceLabel: "Beta plan",
report: { status: "unavailable", windows: [] },
},
],
],
});
await gotoAppShell(page);
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
await expect(page).toHaveURL(/\/usage$/);
await usage.waitForListRequests(1);
const group = page.getByTestId(`usage-host-${serverId}`);
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
await expect(group.getByText("31%")).toBeVisible();
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
await expect(group.getByText("Unavailable", { exact: true })).toBeVisible();
await group.getByTestId("usage-refresh").first().hover();
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
await expect(group.getByTestId("usage-freshness")).toHaveCount(0);
});
test("refreshes one report from its card", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const beta: UsageReportEntry = {
id: "beta:b",
account: {},
fetchedAt: twoHoursAgo(),
sourceId: "beta",
sourceLabel: "Beta plan",
report: {
status: "available",
windows: [{ id: "weekly", label: "Weekly", usedPct: 12, headline: true }],
},
};
const alpha = (usedPct: number, fetchedAt: string): UsageReportEntry => ({
id: "alpha:a",
account: {},
fetchedAt,
sourceId: "alpha",
sourceLabel: "Alpha plan",
report: {
status: "available",
windows: [{ id: "weekly", label: "Weekly", usedPct, headline: true }],
},
});
const usage = await installUsageReportsFixture(page, {
lists: [
[alpha(31, twoHoursAgo()), beta],
() => [
alpha(58, new Date().toISOString()),
{ ...beta, fetchedAt: new Date().toISOString() },
],
],
});
await gotoAppShell(page);
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
const group = page.getByTestId(`usage-host-${serverId}`);
await expect(group.getByText("31%")).toBeVisible({ timeout: 10_000 });
const alphaRefresh = group.getByTestId("usage-refresh").first();
await alphaRefresh.click();
await usage.waitForListRequests(2);
expect(usage.listRequests()).toEqual([
{ forceRefresh: false, reportIds: undefined },
{ forceRefresh: true, reportIds: ["alpha:a"] },
]);
await expect(group.getByText("58%")).toBeVisible();
await expect(group.getByText("12%")).toBeVisible();
await group.getByTestId("usage-refresh").nth(1).hover();
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
await alphaRefresh.hover();
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated just now");
});
test("shows the host once it connects after a cold load on a phone", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const usage = await installUsageReportsFixture(page, {
lists: [
[
{
id: "alpha:a",
account: {},
fetchedAt: twoHoursAgo(),
sourceId: "alpha",
sourceLabel: "Alpha plan",
report: { status: "available", windows: [] },
},
],
],
});
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/usage");
await usage.waitForListRequests(1);
const group = page.getByTestId(`usage-host-${serverId}`);
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
await expect(group.getByTestId("usage-freshness")).toHaveText("Updated 2h ago");
});
test("tells the user to update a host without usage sources", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const usage = await installUsageReportsFixture(page, { usageSources: false });
await gotoAppShell(page);
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
await expect(
page.getByTestId(`usage-host-${serverId}`).getByText("Update the host to see usage", {
exact: true,
}),
).toBeVisible({ timeout: 10_000 });
expect(usage.listRequests()).toHaveLength(0);
});
});
@@ -0,0 +1,136 @@
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
import { expect, test } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getServerId } from "../support/helpers/server-id";
import { openSettingsHostSection } from "../support/helpers/settings";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
const ICON = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="currentColor"/></svg>';
function report(input: {
sourceId: string;
sourceLabel: string;
report: Partial<UsageReportEntry["report"]>;
}): UsageReportEntry {
return {
id: `${input.sourceId}:account`,
account: { label: input.sourceId === "alpha" ? "dev@example.com" : undefined },
fetchedAt: "2026-01-01T00:00:00.000Z",
sourceId: input.sourceId,
sourceLabel: input.sourceLabel,
icon: ICON,
report: {
status: "available",
windows: [],
...input.report,
},
};
}
test.describe("usage settings", () => {
test("renders every report returned by usage.list_reports", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const usage = await installUsageReportsFixture(page, {
lists: [
[
report({
sourceId: "alpha",
sourceLabel: "Alpha plan",
report: {
planLabel: "Max",
windows: [{ id: "session", label: "Session", usedPct: 7, headline: true }],
},
}),
report({
sourceId: "beta",
sourceLabel: "Beta plan",
report: {
planLabel: "Coding plan",
windows: [
{ id: "biweekly", label: "Biweekly", usedPct: 23 },
{ id: "daily", label: "Daily", remainingPct: 30 },
],
balances: [
{ id: "credits", label: "Credits", remaining: 1234, unit: "credits" },
{ id: "extra", label: "Extra usage", used: 5, limit: 20, unit: "usd" },
],
details: [{ id: "valid", label: "Valid until", value: "2026-12-31" }],
},
}),
report({
sourceId: "gamma",
sourceLabel: "Gamma plan",
report: { status: "error", error: "Gamma auth expired" },
}),
],
],
});
await gotoAppShell(page);
await openSettings(page);
expect(usage.listRequests()).toHaveLength(0);
await openSettingsHostSection(page, serverId, "usage");
await usage.waitForListRequests(1);
const card = page.getByTestId("usage-card");
await expect(card.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
await expect(card.getByText("dev@example.com", { exact: true })).toBeVisible();
await expect(card.getByText("Beta plan", { exact: true })).toBeVisible();
await expect(card.getByText("70%")).toBeVisible();
await expect(card.getByText("1,234 left", { exact: true })).toBeVisible();
await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible();
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
await expect(card.getByText("Gamma auth expired", { exact: true })).toBeVisible();
});
test("refresh forces a fresh report", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const windows = (usedPct: number) => [{ id: "w", label: "Weekly", usedPct }];
const usage = await installUsageReportsFixture(page, {
lists: [
[
report({
sourceId: "alpha",
sourceLabel: "Alpha plan",
report: { windows: windows(23) },
}),
],
[
report({
sourceId: "alpha",
sourceLabel: "Alpha plan",
report: { windows: windows(64) },
}),
],
],
});
await gotoAppShell(page);
await openSettings(page);
await openSettingsHostSection(page, serverId, "usage");
await expect(page.getByText("23%")).toBeVisible({ timeout: 10_000 });
await page.getByRole("button", { name: "Refresh", exact: true }).click();
await usage.waitForListRequests(2);
expect(usage.listRequests().at(-1)).toEqual({ forceRefresh: true });
await expect(page.getByText("64%")).toBeVisible();
});
test("asks to update a host without usage sources and never calls it", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const usage = await installUsageReportsFixture(page, { usageSources: false });
await gotoAppShell(page);
await openSettings(page);
await openSettingsHostSection(page, serverId, "usage");
await expect(
page.getByTestId("usage-card").getByText("Update the host to see usage", { exact: true }),
).toBeVisible({ timeout: 10_000 });
expect(usage.listRequests()).toHaveLength(0);
});
});
@@ -1,154 +0,0 @@
import type { Page } from "@playwright/test";
import type { ProviderUsage } from "@getpaseo/protocol/messages";
import { daemonWsRoutePattern } from "./daemon-port";
interface ProviderUsageFixturePayload {
fetchedAt: string;
providers: ProviderUsage[];
}
export interface ProviderUsageFixture {
requestCount(): number;
waitForRequestCount(count: number): Promise<void>;
}
type WebSocketMessage = string | Buffer;
function parseJson(message: WebSocketMessage): unknown {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(raw);
} catch {
return null;
}
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
return null;
}
if (typeof maybeEnvelope.message !== "object") {
return null;
}
return maybeEnvelope.message as Record<string, unknown>;
}
function withProviderUsageFeature(message: WebSocketMessage): string | null {
const envelope = parseJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as {
type?: unknown;
message?: {
type?: unknown;
payload?: Record<string, unknown>;
};
};
const payload = maybeEnvelope.message?.payload;
if (
maybeEnvelope.type !== "session" ||
maybeEnvelope.message?.type !== "status" ||
payload?.status !== "server_info"
) {
return null;
}
return JSON.stringify({
...maybeEnvelope,
message: {
...maybeEnvelope.message,
payload: {
...payload,
features: {
...(typeof payload.features === "object" && payload.features !== null
? payload.features
: {}),
providerUsageList: true,
},
},
},
});
}
export async function installProviderUsageFixture(
page: Page,
payloads: ProviderUsageFixturePayload[],
): Promise<ProviderUsageFixture> {
let requests = 0;
const waiters: Array<{ count: number; resolve: () => void }> = [];
function notifyWaiters() {
for (const waiter of waiters.splice(0)) {
if (requests >= waiter.count) {
waiter.resolve();
} else {
waiters.push(waiter);
}
}
}
function payloadForRequest(): ProviderUsageFixturePayload {
const index = Math.min(requests - 1, payloads.length - 1);
const payload = payloads[index];
if (!payload) {
throw new Error("Provider usage fixture requires at least one payload.");
}
return payload;
}
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "provider.usage.list.request") {
requests += 1;
const requestId = sessionMessage.requestId;
if (typeof requestId !== "string") {
throw new Error("provider.usage.list.request missing requestId");
}
const payload = payloadForRequest();
notifyWaiters();
ws.send(
JSON.stringify({
type: "session",
message: {
type: "provider.usage.list.response",
payload: {
requestId,
fetchedAt: payload.fetchedAt,
providers: payload.providers,
},
},
}),
);
return;
}
server.send(message);
});
server.onMessage((message) => {
const serverInfo = typeof message === "string" ? withProviderUsageFeature(message) : null;
ws.send(serverInfo ?? message);
});
});
return {
requestCount() {
return requests;
},
waitForRequestCount(count: number) {
if (requests >= count) {
return Promise.resolve();
}
return new Promise<void>((resolve) => {
waiters.push({ count, resolve });
});
},
};
}
+1 -1
View File
@@ -74,7 +74,7 @@ export async function openSettingsHostSection(
} }
export async function expectSettingsHeader(page: Page, title: string): Promise<void> { export async function expectSettingsHeader(page: Page, title: string): Promise<void> {
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(title); await expect(page.getByTestId("page-title")).toHaveText(title);
} }
export async function openAddHostFlow(page: Page): Promise<void> { export async function openAddHostFlow(page: Page): Promise<void> {
@@ -0,0 +1,178 @@
import type { Page } from "@playwright/test";
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
import { daemonWsRoutePattern } from "./daemon-port";
export interface UsageReportsFixture {
listRequests(): Array<{ forceRefresh: boolean; reportIds?: string[] }>;
agentRequests(): Array<{ agentId: string }>;
waitForListRequests(count: number): Promise<void>;
waitForAgentRequests(count: number): Promise<void>;
}
interface UsageReportsFixtureOptions {
/**
* Successive `usage.list_reports` responses; the last one repeats. `{ error }` fails that
* request; a function builds the response when the request arrives (e.g. a fresh `fetchedAt`).
*/
lists?: Array<UsageListResponse | (() => UsageListResponse)>;
/** Successive agent report IDs; the last one repeats. */
agentReportIds?: Array<string | null>;
/** Advertise `features.usageSources`. False simulates a host from before usage sources. */
usageSources?: boolean;
}
type UsageListResponse = UsageReportEntry[] | { error: string };
type WebSocketMessage = string | Buffer;
function parseJson(message: WebSocketMessage): unknown {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(raw);
} catch {
return null;
}
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseJson(message) as { type?: unknown; message?: unknown } | null;
if (!envelope || envelope.type !== "session" || typeof envelope.message !== "object") {
return null;
}
return envelope.message as Record<string, unknown>;
}
function withUsageSourcesFeature(message: WebSocketMessage, enabled: boolean): string | null {
const envelope = parseJson(message) as {
type?: unknown;
message?: { type?: unknown; payload?: Record<string, unknown> };
} | null;
const payload = envelope?.message?.payload;
if (
envelope?.type !== "session" ||
envelope.message?.type !== "status" ||
payload?.status !== "server_info"
) {
return null;
}
const features =
typeof payload.features === "object" && payload.features !== null ? payload.features : {};
return JSON.stringify({
...envelope,
message: {
...envelope.message,
payload: { ...payload, features: { ...features, usageSources: enabled } },
},
});
}
function pick<T>(values: T[], index: number): T {
const value = values[Math.min(index, values.length - 1)];
if (value === undefined) throw new Error("Usage fixture requires at least one response.");
return value;
}
function createCounter() {
let count = 0;
const waiters: Array<{ count: number; resolve: () => void }> = [];
return {
increment() {
count += 1;
for (const waiter of waiters.splice(0)) {
if (count >= waiter.count) waiter.resolve();
else waiters.push(waiter);
}
},
waitFor(target: number): Promise<void> {
if (count >= target) return Promise.resolve();
return new Promise((resolve) => waiters.push({ count: target, resolve }));
},
};
}
export async function installUsageReportsFixture(
page: Page,
options: UsageReportsFixtureOptions,
): Promise<UsageReportsFixture> {
const listRequests: Array<{ forceRefresh: boolean; reportIds?: string[] }> = [];
const agentRequests: Array<{ agentId: string }> = [];
const listCounter = createCounter();
const agentCounter = createCounter();
const usageSources = options.usageSources ?? true;
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const request = getSessionMessage(message);
const requestId = request?.requestId;
if (request?.type === "usage.list_reports.request" && typeof requestId === "string") {
listRequests.push({
forceRefresh: request.forceRefresh === true,
reportIds: Array.isArray(request.reportIds) ? (request.reportIds as string[]) : undefined,
});
const scripted = pick(options.lists ?? [[]], listRequests.length - 1);
const response = typeof scripted === "function" ? scripted() : scripted;
if ("error" in response) {
ws.send(
JSON.stringify({
type: "session",
message: {
type: "rpc_error",
payload: {
requestId,
requestType: "usage.list_reports.request",
error: response.error,
code: "transport",
},
},
}),
);
listCounter.increment();
return;
}
const ids = Array.isArray(request.reportIds) ? request.reportIds : null;
const reports = ids ? response.filter((entry) => ids.includes(entry.id)) : response;
ws.send(
JSON.stringify({
type: "session",
message: { type: "usage.list_reports.response", payload: { requestId, reports } },
}),
);
listCounter.increment();
return;
}
if (request?.type === "agent.resolve_usage_report.request" && typeof requestId === "string") {
agentRequests.push({
agentId: String(request.agentId),
});
const reportId = pick(options.agentReportIds ?? [null], agentRequests.length - 1);
ws.send(
JSON.stringify({
type: "session",
message: {
type: "agent.resolve_usage_report.response",
payload: { requestId, reportId },
},
}),
);
agentCounter.increment();
return;
}
server.send(message);
});
server.onMessage((message) => {
const serverInfo =
typeof message === "string" ? withUsageSourcesFeature(message, usageSources) : null;
ws.send(serverInfo ?? message);
});
});
return {
listRequests: () => [...listRequests],
agentRequests: () => [...agentRequests],
waitForListRequests: (count) => listCounter.waitFor(count),
waitForAgentRequests: (count) => agentCounter.waitFor(count),
};
}
+2
View File
@@ -860,6 +860,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
pathname === "/new" || pathname === "/new" ||
pathname === "/sessions" || pathname === "/sessions" ||
pathname === "/schedules" || pathname === "/schedules" ||
pathname === "/usage" ||
routeHasKnownHost); routeHasKnownHost);
return <AppContainer chromeEnabled={shouldShowAppChrome}>{children}</AppContainer>; return <AppContainer chromeEnabled={shouldShowAppChrome}>{children}</AppContainer>;
@@ -892,6 +893,7 @@ function RootStack() {
<Stack.Screen name="open-project" /> <Stack.Screen name="open-project" />
<Stack.Screen name="sessions" /> <Stack.Screen name="sessions" />
<Stack.Screen name="schedules" /> <Stack.Screen name="schedules" />
<Stack.Screen name="usage" />
<Stack.Screen name="pair-scan" /> <Stack.Screen name="pair-scan" />
</Stack.Protected> </Stack.Protected>
<Stack.Screen name="h/[serverId]" /> <Stack.Screen name="h/[serverId]" />
+10
View File
@@ -0,0 +1,10 @@
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { UsageScreen } from "@/usage";
export default function UsageRoute() {
return (
<HostRouteBootstrapBoundary>
<UsageScreen />
</HostRouteBootstrapBoundary>
);
}
@@ -1,11 +1,8 @@
import { useCallback, useState } from "react";
import { Pressable, Text, View } from "react-native"; import { Pressable, Text, View } from "react-native";
import Svg, { Circle } from "react-native-svg"; import Svg, { Circle } from "react-native-svg";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { ProviderUsageTooltipSection } from "@/provider-usage/tooltip-section";
import { useProviderUsage } from "@/provider-usage/use-provider-usage";
import { formatTokenCount } from "./context-window-meter.utils"; import { formatTokenCount } from "./context-window-meter.utils";
interface ContextWindowMeterProps { interface ContextWindowMeterProps {
@@ -13,9 +10,6 @@ interface ContextWindowMeterProps {
usedTokens: number | null; usedTokens: number | null;
totalCostUsd?: number | null; totalCostUsd?: number | null;
showPercentage?: boolean; showPercentage?: boolean;
serverId?: string;
/** The Paseo provider key, e.g. "claude", "gemini", "codex" */
provider?: string | null;
/** Reserve the meter footprint and show a loading ring while usage is pending. */ /** Reserve the meter footprint and show a loading ring while usage is pending. */
pending?: boolean; pending?: boolean;
/** Optional glyph envelope for icon-toolbar alignment. */ /** Optional glyph envelope for icon-toolbar alignment. */
@@ -101,30 +95,13 @@ export function ContextWindowMeter({
usedTokens, usedTokens,
totalCostUsd, totalCostUsd,
showPercentage = false, showPercentage = false,
serverId,
provider,
pending = false, pending = false,
glyphSize, glyphSize,
}: ContextWindowMeterProps) { }: ContextWindowMeterProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation(); const { t } = useTranslation();
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
const { view: providerUsageView, refresh: refreshProviderUsage } = useProviderUsage(
serverId ?? null,
{ enabled: isTooltipOpen },
);
const percentage = const percentage =
maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null; maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null;
const handleTooltipOpenChange = useCallback(
(nextOpen: boolean) => {
setIsTooltipOpen(nextOpen);
if (nextOpen) {
void refreshProviderUsage().catch(() => {});
}
},
[refreshProviderUsage],
);
const geometry = getMeterGeometry(showPercentage, glyphSize); const geometry = getMeterGeometry(showPercentage, glyphSize);
// No usage yet: reserve the footprint with a track-only ring while a session is // No usage yet: reserve the footprint with a track-only ring while a session is
@@ -167,13 +144,7 @@ export function ContextWindowMeter({
typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null; typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
return ( return (
<Tooltip <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
open={isTooltipOpen}
onOpenChange={handleTooltipOpenChange}
delayDuration={0}
enabledOnDesktop
enabledOnMobile
>
<TooltipTrigger asChild triggerRefProp="ref"> <TooltipTrigger asChild triggerRefProp="ref">
<Pressable <Pressable
style={containerStyle} style={containerStyle}
@@ -233,7 +204,6 @@ export function ContextWindowMeter({
{t("contextWindow.sessionCost", { cost: formattedSessionCost })} {t("contextWindow.sessionCost", { cost: formattedSessionCost })}
</Text> </Text>
) : null} ) : null}
<ProviderUsageTooltipSection view={providerUsageView} activeProviderId={provider} />
</View> </View>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
+26 -2
View File
@@ -1,5 +1,5 @@
import { router } from "expo-router"; import { router } from "expo-router";
import { FolderPlus, GitBranch, Import, Server, Settings, X } from "lucide-react-native"; import { FolderPlus, Gauge, GitBranch, Import, Server, Settings, X } from "lucide-react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { import {
@@ -47,7 +47,11 @@ import { usePanelStore } from "@/stores/panel-store";
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window"; import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
import { useCloseAgentListGesture } from "@/mobile-panels/gestures"; import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation"; import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes"; import {
buildSettingsAddHostRoute,
buildSettingsRoute,
buildUsageRoute,
} from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation"; import { openHostOverview } from "@/navigation/settings-navigation";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot"; import { SidebarCalloutSlot } from "./sidebar-callout-slot";
@@ -426,6 +430,25 @@ function SidebarHostPicker({
); );
} }
function SidebarUsageButton({ theme }: { theme: SidebarTheme }) {
const { t } = useTranslation();
const isCompactLayout = useIsCompactFormFactor();
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const handlePress = useCallback(() => {
if (isCompactLayout) showMobileAgent();
router.push(buildUsageRoute());
}, [isCompactLayout, showMobileAgent]);
return (
<FooterIconButton
onPress={handlePress}
testID="sidebar-usage"
label={t("settings.hostSections.usage")}
icon={Gauge}
theme={theme}
/>
);
}
function IconTooltipContent({ function IconTooltipContent({
label, label,
shortcutKeys, shortcutKeys,
@@ -489,6 +512,7 @@ function SidebarFooter({
icon={Import} icon={Import}
theme={theme} theme={theme}
/> />
<SidebarUsageButton theme={theme} />
<SidebarHelpMenu /> <SidebarHelpMenu />
<FooterIconButton <FooterIconButton
onPress={handleSettings} onPress={handleSettings}
@@ -0,0 +1,71 @@
import { useMemo, type ReactNode } from "react";
import { ScrollView, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
import { BackHeader } from "@/components/headers/back-header";
import { MenuHeader } from "@/components/headers/menu-header";
import { useIsCompactFormFactor } from "@/constants/layout";
interface PageLayoutProps {
title?: string;
/** Compact back button. Defaults to navigating back. */
onBack?: () => void;
testID?: string;
titleTestID?: string;
children: ReactNode;
}
/**
* A full-width page with a centered content column. On desktop the title is a
* document heading inside the page and the header only keeps the titlebar drag
* region and window controls; on compact the title moves into a back header.
*/
export function PageLayout({ title, onBack, testID, titleTestID, children }: PageLayoutProps) {
const isCompact = useIsCompactFormFactor();
const insets = useSafeAreaInsets();
const scrollContentStyle = useMemo(() => ({ paddingBottom: insets.bottom }), [insets.bottom]);
const showTitle = !isCompact && title !== undefined;
return (
<View style={styles.container}>
{isCompact ? <BackHeader title={title} onBack={onBack} /> : <MenuHeader borderless />}
<ScrollView style={styles.scroll} contentContainerStyle={scrollContentStyle} testID={testID}>
<View style={styles.content}>
{showTitle ? (
<View testID={titleTestID}>
<Text style={styles.title} testID="page-title">
{title}
</Text>
</View>
) : null}
{children}
</View>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
scroll: {
flex: 1,
},
content: {
padding: theme.spacing[4],
paddingTop: theme.spacing[6],
width: "100%",
maxWidth: 720,
alignSelf: "center",
},
title: {
// Line up with the section titles, which sit inset from their cards.
marginLeft: theme.spacing[1],
marginBottom: theme.spacing[6],
fontSize: theme.fontSize["4xl"],
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
}));
@@ -36,14 +36,14 @@ const CATALOG_ICON_SVGS = new Map(
const catalogIconComponents = new Map<string, ProviderIconComponent>(); const catalogIconComponents = new Map<string, ProviderIconComponent>();
const snapshotIconComponents = new Map<string, { svg: string; component: ProviderIconComponent }>(); const snapshotIconComponents = new Map<string, { svg: string; component: ProviderIconComponent }>();
/** Renders a sanitized SVG string from a host catalog, tinted through `currentColor`. */
export function SvgIcon({ svg, size, color }: ProviderIconProps & { svg: string }) {
return createElement(SvgXml, { xml: svg, width: size, height: size, color });
}
function createSvgIcon(provider: string, iconSvg: string): ProviderIconComponent { function createSvgIcon(provider: string, iconSvg: string): ProviderIconComponent {
const SvgProviderIcon: ProviderIconComponent = ({ size, color }) => const SvgProviderIcon: ProviderIconComponent = ({ size, color }) =>
createElement(SvgXml, { SvgIcon({ svg: iconSvg, size, color });
xml: iconSvg,
width: size,
height: size,
color,
});
SvgProviderIcon.displayName = `SvgProviderIcon(${provider})`; SvgProviderIcon.displayName = `SvgProviderIcon(${provider})`;
return SvgProviderIcon; return SvgProviderIcon;
} }
@@ -56,6 +56,8 @@ interface ToolbarButtonCommonProps extends Omit<
> { > {
children: ReactNode; children: ReactNode;
label: string; label: string;
/** What the tooltip says when it is not the label, e.g. a status. Defaults to the label. */
tooltip?: ReactNode;
selected?: boolean; selected?: boolean;
compact?: boolean; compact?: boolean;
shortcut?: ShortcutKey[][] | null; shortcut?: ShortcutKey[][] | null;
@@ -76,6 +78,7 @@ type ToolbarButtonProps = ToolbarButtonCommonProps &
export function ToolbarButton({ export function ToolbarButton({
children, children,
label, label,
tooltip: tooltipBody,
selected = false, selected = false,
compact = false, compact = false,
shortcut, shortcut,
@@ -105,7 +108,7 @@ export function ToolbarButton({
const tooltip = ( const tooltip = (
<TooltipContent side={tooltipSide} align="center" offset={8}> <TooltipContent side={tooltipSide} align="center" offset={8}>
<View style={styles.tooltipRow}> <View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>{label}</Text> {tooltipBody ?? <Text style={styles.tooltipText}>{label}</Text>}
{shortcut ? <Shortcut chord={shortcut} /> : null} {shortcut ? <Shortcut chord={shortcut} /> : null}
</View> </View>
</TooltipContent> </TooltipContent>
+8 -11
View File
@@ -48,6 +48,7 @@ import {
type DraftAgentControlsProps, type DraftAgentControlsProps,
} from "@/composer/agent-controls"; } from "@/composer/agent-controls";
import { ContextWindowMeter } from "@/components/context-window-meter"; import { ContextWindowMeter } from "@/components/context-window-meter";
import { UsageComposerPill } from "@/usage";
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker"; import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store"; import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store";
import { useFilePicker } from "@/hooks/use-file-picker"; import { useFilePicker } from "@/hooks/use-file-picker";
@@ -274,7 +275,6 @@ function buildAgentStateSelector(serverId: string, agentId: string) {
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null, contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
totalCostUsd: agent?.lastUsage?.totalCostUsd ?? null, totalCostUsd: agent?.lastUsage?.totalCostUsd ?? null,
model: agent?.model ?? null, model: agent?.model ?? null,
provider: agent?.provider ?? null,
}; };
}; };
} }
@@ -284,8 +284,6 @@ function renderContextWindowMeter(
contextWindowUsedTokens: number | null, contextWindowUsedTokens: number | null,
totalCostUsd: number | null, totalCostUsd: number | null,
showPercentage: boolean, showPercentage: boolean,
serverId: string,
provider: string | null,
pending: boolean, pending: boolean,
glyphSize: number, glyphSize: number,
): ReactElement | null { ): ReactElement | null {
@@ -299,8 +297,6 @@ function renderContextWindowMeter(
usedTokens={contextWindowUsedTokens} usedTokens={contextWindowUsedTokens}
totalCostUsd={totalCostUsd} totalCostUsd={totalCostUsd}
showPercentage={showPercentage} showPercentage={showPercentage}
serverId={serverId}
provider={provider}
pending={pending} pending={pending}
glyphSize={glyphSize} glyphSize={glyphSize}
/> />
@@ -2085,8 +2081,6 @@ function ComposerContentImpl({
contextWindowUsedTokens, contextWindowUsedTokens,
agentState.totalCostUsd, agentState.totalCostUsd,
false, false,
serverId,
agentState.provider,
contextWindowPending, contextWindowPending,
contextWindowMeterGlyphSize, contextWindowMeterGlyphSize,
), ),
@@ -2094,15 +2088,18 @@ function ComposerContentImpl({
contextWindowMaxTokens, contextWindowMaxTokens,
contextWindowUsedTokens, contextWindowUsedTokens,
agentState.totalCostUsd, agentState.totalCostUsd,
serverId,
agentState.provider,
contextWindowPending, contextWindowPending,
contextWindowMeterGlyphSize, contextWindowMeterGlyphSize,
], ],
); );
const beforeVoiceContent = useMemo( const beforeVoiceContent = useMemo(
() => resolveContextWindowPlacement(contextWindowMeter, hasAgent), () => (
[contextWindowMeter, hasAgent], <>
{resolveContextWindowPlacement(contextWindowMeter, hasAgent)}
{hasAgent ? <UsageComposerPill serverId={serverId} agentId={agentId} /> : null}
</>
),
[agentId, contextWindowMeter, hasAgent, serverId],
); );
const hasGithubAttachment = useMemo( const hasGithubAttachment = useMemo(
-198
View File
@@ -1,198 +0,0 @@
import { useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { getProviderIcon } from "@/components/provider-icons";
import { StatusBadge } from "@/components/ui/status-badge";
import type { Theme } from "@/styles/theme";
import { ProviderUsageBalanceBar } from "./balance-bar";
import { formatAgo } from "./format";
import type { ProviderUsage } from "./types";
import { ProviderUsageWindowBar } from "./window-bar";
interface ProviderUsageIconProps {
iconKey: string;
size: number;
color?: string;
}
function ProviderUsageIcon({ iconKey, size, color = "" }: ProviderUsageIconProps) {
const Icon = getProviderIcon(iconKey);
return <Icon size={size} color={color} />;
}
const ThemedProviderUsageIcon = withUnistyles(ProviderUsageIcon);
const mutedIconColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function statusText(usage: ProviderUsage): string | null {
if (usage.status === "available") return null;
return usage.status === "error" ? "Error" : "Unavailable";
}
function footerText(usage: ProviderUsage): string | null {
const updated = formatAgo(usage.fetchedAt);
const parts = [usage.sourceLabel, updated ? `Updated ${updated}` : null].filter(
(part): part is string => typeof part === "string" && part.length > 0,
);
return parts.length > 0 ? parts.join(" · ") : null;
}
export function ProviderUsageCard({
usage,
compact = false,
}: {
usage: ProviderUsage;
compact?: boolean;
}) {
const status = statusText(usage);
const footer = footerText(usage);
const balances = usage.balances ?? [];
const details = usage.details ?? [];
const containerStyle = useMemo(
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
[compact],
);
const dotStyle = useMemo(
() => [
styles.statusDot,
usage.status === "available" && styles.statusDotAvailable,
usage.status === "error" && styles.statusDotError,
],
[usage.status],
);
return (
<View style={containerStyle}>
<View style={styles.header}>
<ThemedProviderUsageIcon iconKey={usage.providerId} size={14} uniProps={mutedIconColor} />
<Text style={styles.name} numberOfLines={1}>
{usage.displayName}
</Text>
{usage.planLabel ? <StatusBadge label={usage.planLabel} variant="muted" /> : null}
<View style={styles.headerSpacer} />
{status ? (
<View style={styles.statusRow}>
<View style={dotStyle} />
<Text style={styles.statusLabel}>{status}</Text>
</View>
) : null}
</View>
{usage.error ? (
<Text style={styles.error} numberOfLines={3}>
{usage.error}
</Text>
) : null}
{usage.windows.length > 0 || balances.length > 0 ? (
<View style={styles.bars}>
{usage.windows.map((window) => (
<ProviderUsageWindowBar key={window.id} window={window} />
))}
{balances.map((balance) => (
<ProviderUsageBalanceBar key={balance.id} balance={balance} />
))}
</View>
) : null}
{details.length > 0 ? (
<View style={styles.details}>
{details.map((detail) => (
<View key={detail.id} style={styles.detailRow}>
<Text style={styles.detailLabel} numberOfLines={1}>
{detail.label}
</Text>
<Text style={styles.detailValue} numberOfLines={1}>
{detail.value}
</Text>
</View>
))}
</View>
) : null}
{footer ? (
<Text style={styles.footer} numberOfLines={1}>
{footer}
</Text>
) : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
gap: theme.spacing[3],
},
containerPadded: {
gap: theme.spacing[4],
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
},
containerCompact: {
gap: theme.spacing[3],
},
header: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
name: {
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
headerSpacer: {
flex: 1,
},
statusRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: theme.colors.foregroundMuted,
},
statusDotAvailable: {
backgroundColor: theme.colors.statusSuccess,
},
statusDotError: {
backgroundColor: theme.colors.statusDanger,
},
statusLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
bars: {
gap: theme.spacing[3],
},
details: {
gap: theme.spacing[1],
},
detailRow: {
flexDirection: "row",
justifyContent: "space-between",
gap: theme.spacing[2],
},
detailLabel: {
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
detailValue: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
error: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.4,
},
footer: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
}));
-18
View File
@@ -1,18 +0,0 @@
// User-facing copy for the provider-usage surfaces, centralized so localization
// is a single-file change. INTEGRATION: move these into the i18n resources
// (a `providerUsage` namespace across all locales) and swap to `t(...)` at the
// call sites once the feature is wired to data. Kept inline for now because the
// surfaces are not yet mounted and the locale files are being edited elsewhere.
export const providerUsageCopy = {
title: "Plan usage",
refresh: "Refresh",
refreshing: "Refreshing...",
loading: "Loading usage...",
empty: "No usage data",
errorTitle: "Unable to load usage",
hostUnavailable: "Connect to this host to see provider usage",
hostUpgradeRequired: "Update the host to see provider usage",
clientUnavailable: "Host connection is not ready",
retry: "Try again",
tooltipLoading: "Loading plan usage…",
} as const;
@@ -1,99 +0,0 @@
import { RefreshCw } from "lucide-react-native";
import { useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { providerUsageCopy } from "./copy";
import { ProviderUsageList } from "./list";
import type { ProviderUsageView } from "./types";
export function ProviderUsageSettingsSection({
view,
onRefresh,
}: {
view: ProviderUsageView;
onRefresh: () => void;
}) {
const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing);
const refreshButton = useMemo(
() => (
<Button
variant="ghost"
size="sm"
leftIcon={RefreshCw}
loading={busy}
onPress={onRefresh}
accessibilityLabel={providerUsageCopy.refresh}
>
{busy ? providerUsageCopy.refreshing : providerUsageCopy.refresh}
</Button>
),
[busy, onRefresh],
);
return (
<SettingsSection
title={providerUsageCopy.title}
testID="provider-usage-card"
trailing={refreshButton}
>
<ProviderUsageBody view={view} onRefresh={onRefresh} />
</SettingsSection>
);
}
function ProviderUsageBody({
view,
onRefresh,
}: {
view: ProviderUsageView;
onRefresh: () => void;
}) {
if (view.kind === "loading") {
return (
<View style={[settingsStyles.card, styles.emptyCard]}>
<Text style={styles.emptyText}>{providerUsageCopy.loading}</Text>
</View>
);
}
if (view.kind === "error") {
return (
<Alert
size="sm"
variant="error"
title={providerUsageCopy.errorTitle}
description={view.message}
>
<Button variant="outline" size="sm" onPress={onRefresh}>
{providerUsageCopy.retry}
</Button>
</Alert>
);
}
if (view.payload.providers.length === 0) {
return (
<View style={[settingsStyles.card, styles.emptyCard]}>
<Text style={styles.emptyText}>{providerUsageCopy.empty}</Text>
</View>
);
}
return <ProviderUsageList providers={view.payload.providers} />;
}
const styles = StyleSheet.create((theme) => ({
emptyCard: {
padding: theme.spacing[4],
alignItems: "center",
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
}));
@@ -1,75 +0,0 @@
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { ProviderUsageCard } from "./card";
import { providerUsageCopy } from "./copy";
import type { ProviderUsage, ProviderUsageView } from "./types";
function matchProvider(
providers: ProviderUsage[],
activeProviderId: string | null | undefined,
): ProviderUsage | null {
if (!activeProviderId) return null;
const target = activeProviderId.toLowerCase();
return providers.find((usage) => usage.providerId.toLowerCase() === target) ?? null;
}
// Renders the active agent's provider usage inside the context-meter tooltip.
// Returns nothing when the active provider has no usage entry, so the meter's
// own context section stays the whole tooltip.
export function ProviderUsageTooltipSection({
view,
activeProviderId,
}: {
view: ProviderUsageView;
activeProviderId: string | null | undefined;
}) {
if (view.kind === "loading") {
return (
<>
<View style={styles.divider} />
<Text style={styles.detail}>{providerUsageCopy.tooltipLoading}</Text>
</>
);
}
if (view.kind === "error") {
return (
<>
<View style={styles.divider} />
<Text style={styles.error}>{view.message}</Text>
</>
);
}
const usage = matchProvider(view.payload.providers, activeProviderId);
if (!usage) return null;
return (
<>
<View style={styles.divider} />
<ProviderUsageCard usage={usage} compact />
</>
);
}
const styles = StyleSheet.create((theme) => ({
divider: {
height: 1,
// Same token the popover draws its own outline with, so the rule reads as the
// popover's edge. `border` is invisible here (equals the popover background).
backgroundColor: theme.colors.borderAccent,
marginVertical: theme.spacing[2],
// Cancel the tooltip content's horizontal padding so the rule spans edge to edge.
marginHorizontal: -theme.spacing[2],
},
detail: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.4,
},
error: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.4,
},
}));
-26
View File
@@ -1,26 +0,0 @@
import type {
ProviderUsage,
ProviderUsageBalance,
ProviderUsageDetail,
ProviderUsageListResponseMessage,
ProviderUsageStatus,
ProviderUsageTone,
ProviderUsageWindow,
} from "@getpaseo/protocol/messages";
export type {
ProviderUsage,
ProviderUsageBalance,
ProviderUsageDetail,
ProviderUsageStatus,
ProviderUsageTone,
ProviderUsageWindow,
};
export type ProviderUsageBalanceUnit = ProviderUsageBalance["unit"];
export type ProviderUsageListPayload = ProviderUsageListResponseMessage["payload"];
export type ProviderUsageView =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; payload: ProviderUsageListPayload; isRefreshing: boolean };
@@ -1,103 +0,0 @@
import { useCallback, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { providerUsageCopy } from "./copy";
import type { ProviderUsageListPayload, ProviderUsageView } from "./types";
export const PROVIDER_USAGE_STALE_TIME_MS = 5 * 60 * 1000;
type ProviderUsageClient = Pick<DaemonClient, "listProviderUsage">;
export function providerUsageQueryKey(serverId: string | null | undefined) {
return ["providerUsage", serverId ?? ""] as const;
}
async function fetchProviderUsage(client: ProviderUsageClient): Promise<ProviderUsageListPayload> {
return client.listProviderUsage();
}
interface UseProviderUsageOptions {
enabled?: boolean;
}
export function useProviderUsage(
serverId: string | null | undefined,
options: UseProviderUsageOptions = {},
): {
view: ProviderUsageView;
refresh: () => Promise<void>;
canFetch: boolean;
} {
const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const supportsProviderUsage = useSessionStore(
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providerUsageList === true,
);
const queryKey = useMemo(() => providerUsageQueryKey(serverId), [serverId]);
const canFetch = Boolean(serverId && client && isConnected && supportsProviderUsage);
const enabled = Boolean((options.enabled ?? true) && canFetch);
const queryFn = useCallback(async () => {
if (!client) {
throw new Error(providerUsageCopy.clientUnavailable);
}
return fetchProviderUsage(client);
}, [client]);
const query = useQuery({
queryKey,
queryFn,
enabled,
staleTime: PROVIDER_USAGE_STALE_TIME_MS,
refetchOnMount: true,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
});
const refresh = useCallback(async () => {
if (!canFetch) return;
await queryClient.invalidateQueries({ queryKey });
await queryClient.fetchQuery({
queryKey,
queryFn,
staleTime: PROVIDER_USAGE_STALE_TIME_MS,
});
}, [canFetch, queryClient, queryFn, queryKey]);
const view = useMemo<ProviderUsageView>(() => {
if (!serverId || !client || !isConnected) {
return { kind: "error", message: providerUsageCopy.hostUnavailable };
}
if (!supportsProviderUsage) {
return { kind: "error", message: providerUsageCopy.hostUpgradeRequired };
}
if (query.data) {
return {
kind: "ready",
payload: query.data,
isRefreshing: query.isFetching,
};
}
if (query.isError) {
return {
kind: "error",
message: query.error instanceof Error ? query.error.message : String(query.error),
};
}
return { kind: "loading" };
}, [
client,
isConnected,
query.data,
query.error,
query.isError,
query.isFetching,
serverId,
supportsProviderUsage,
]);
return { view, refresh, canFetch };
}
+12 -11
View File
@@ -2653,21 +2653,22 @@ export function useHostRuntimeConnectionStatuses(
serverIds: readonly string[], serverIds: readonly string[],
): ReadonlyMap<string, HostRuntimeConnectionStatus> { ): ReadonlyMap<string, HostRuntimeConnectionStatus> {
const store = getHostRuntimeStore(); const store = getHostRuntimeStore();
const version = useSyncExternalStore( // The snapshot is the statuses themselves, joined into a string so React compares by
// value. A version counter read only for reactivity is dropped by the React Compiler.
const readStatuses = () =>
serverIds
.map((serverId) => store.getSnapshot(serverId)?.connectionStatus ?? "connecting")
.join("\n");
const statuses = useSyncExternalStore(
(onStoreChange) => store.subscribeAll(onStoreChange), (onStoreChange) => store.subscribeAll(onStoreChange),
() => store.getVersion(), readStatuses,
() => store.getVersion(), readStatuses,
); );
return useMemo(() => { return useMemo(() => {
// The aggregate version is the reactivity trigger; re-read snapshots on every host tick. const values = statuses.split("\n") as HostRuntimeConnectionStatus[];
void version; return new Map(serverIds.map((serverId, index) => [serverId, values[index]]));
const entries: Array<[string, HostRuntimeConnectionStatus]> = serverIds.map((serverId) => [ }, [serverIds, statuses]);
serverId,
store.getSnapshot(serverId)?.connectionStatus ?? "connecting",
]);
return new Map(entries);
}, [serverIds, store, version]);
} }
export function useHostRuntimeLastError(serverId: string): string | null { export function useHostRuntimeLastError(serverId: string): string | null {
+13 -58
View File
@@ -71,7 +71,7 @@ import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window"; import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window";
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
import { BackHeader } from "@/components/headers/back-header"; import { BackHeader } from "@/components/headers/back-header";
import { ScreenHeader } from "@/components/headers/screen-header"; import { PageLayout } from "@/components/page-layout";
import { AddHostMethodModal } from "@/components/add-host-method-modal"; import { AddHostMethodModal } from "@/components/add-host-method-modal";
import { AddHostModal } from "@/components/add-host-modal"; import { AddHostModal } from "@/components/add-host-modal";
import { AddRemoteSshHostModal } from "@/components/add-remote-ssh-host-modal"; import { AddRemoteSshHostModal } from "@/components/add-remote-ssh-host-modal";
@@ -1305,30 +1305,25 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
}, []); }, []);
const installedPlugins = useInstalledPlugins(); const installedPlugins = useInstalledPlugins();
const detailHeader = ((): { const detailTitle = ((): string | undefined => {
title: string;
titleAccessory?: ReactNode;
} | null => {
if (view.kind === "plugin") { if (view.kind === "plugin") {
const screen = installedPlugins const screen = installedPlugins
.find((plugin) => plugin.serverId === view.serverId && plugin.id === view.pluginId) .find((plugin) => plugin.serverId === view.serverId && plugin.id === view.pluginId)
?.settingsScreens.find((candidate) => candidate.id === view.screenId); ?.settingsScreens.find((candidate) => candidate.id === view.screenId);
return { title: `${view.pluginId} · ${screen?.title ?? t("settings.title")}` }; return `${view.pluginId} · ${screen?.title ?? t("settings.title")}`;
} }
if (view.kind === "host") { if (view.kind === "host") {
const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section); const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section);
if (!item) return null; return item ? t(item.labelKey) : undefined;
return { title: t(item.labelKey) };
} }
if (view.kind === "section") { if (view.kind === "section") {
const item = SIDEBAR_SECTION_ITEMS.find((s) => s.id === view.section); const item = SIDEBAR_SECTION_ITEMS.find((s) => s.id === view.section);
if (!item) return null; return item ? t(item.labelKey) : undefined;
return { title: t(item.labelKey) };
} }
if (view.kind === "project") { if (view.kind === "project") {
return { title: t("settings.projects") }; return t("settings.projects");
} }
return null; return undefined;
})(); })();
const content: ReactNode = (() => { const content: ReactNode = (() => {
@@ -1399,15 +1394,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
); );
} }
const desktopPageTitle = detailHeader ? (
<View style={styles.pageTitleRow}>
<Text style={styles.pageTitle} testID="settings-detail-header-title">
{detailHeader.title}
</Text>
{detailHeader.titleAccessory}
</View>
) : null;
const addHostModals = ( const addHostModals = (
<> <>
<AddHostMethodModal <AddHostMethodModal
@@ -1464,14 +1450,9 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
if (isCompactLayout) { if (isCompactLayout) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<BackHeader <PageLayout title={detailTitle} onBack={handleBackFromDetail}>
title={detailHeader?.title} {content}
titleAccessory={detailHeader?.titleAccessory} </PageLayout>
onBack={handleBackFromDetail}
/>
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
<View style={styles.content}>{content}</View>
</ScrollView>
{addHostModals} {addHostModals}
</View> </View>
); );
@@ -1497,15 +1478,9 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
</WindowChromeRegion> </WindowChromeRegion>
<WindowChromeRegion corners="top-right"> <WindowChromeRegion corners="top-right">
<View style={desktopStyles.contentPane} testID="settings-detail-pane"> <View style={desktopStyles.contentPane} testID="settings-detail-pane">
{/* Keeps the titlebar drag region and window controls; the page <PageLayout title={detailTitle} titleTestID="settings-detail-header-title">
title lives in the content, like a document heading. */} {content}
<ScreenHeader borderless /> </PageLayout>
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
<View style={styles.content}>
{desktopPageTitle}
{content}
</View>
</ScrollView>
</View> </View>
</WindowChromeRegion> </WindowChromeRegion>
</View> </View>
@@ -1536,26 +1511,6 @@ const styles = StyleSheet.create((theme) => ({
scrollView: { scrollView: {
flex: 1, flex: 1,
}, },
content: {
padding: theme.spacing[4],
paddingTop: theme.spacing[6],
width: "100%",
maxWidth: 720,
alignSelf: "center",
},
pageTitleRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
// Line up with the section titles, which sit inset from their cards.
marginLeft: theme.spacing[1],
marginBottom: theme.spacing[6],
},
pageTitle: {
fontSize: theme.fontSize["4xl"],
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
aboutValue: { aboutValue: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
@@ -50,8 +50,7 @@ import {
useHosts, useHosts,
} from "@/runtime/host-runtime"; } from "@/runtime/host-runtime";
import { ProvidersSection } from "@/screens/settings/providers-section"; import { ProvidersSection } from "@/screens/settings/providers-section";
import { ProviderUsageSettingsSection } from "@/provider-usage/settings-section"; import { HostUsageSection } from "@/usage";
import { useProviderUsage } from "@/provider-usage/use-provider-usage";
import { HostAppearanceSection } from "@/screens/settings/host-appearance-section"; import { HostAppearanceSection } from "@/screens/settings/host-appearance-section";
import { SettingsSection } from "@/components/settings/headings/settings-section"; import { SettingsSection } from "@/components/settings/headings/settings-section";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
@@ -343,10 +342,6 @@ export function HostProvidersPage({ serverId }: { serverId: string }) {
export function HostUsagePage({ serverId }: { serverId: string }) { export function HostUsagePage({ serverId }: { serverId: string }) {
const host = useHostProfile(serverId); const host = useHostProfile(serverId);
const { view: providerUsageView, refresh: refreshProviderUsage } = useProviderUsage(serverId);
const handleRefresh = useCallback(() => {
void refreshProviderUsage();
}, [refreshProviderUsage]);
if (!host) { if (!host) {
return <HostNotFound />; return <HostNotFound />;
@@ -354,7 +349,7 @@ export function HostUsagePage({ serverId }: { serverId: string }) {
return ( return (
<View> <View>
<ProviderUsageSettingsSection view={providerUsageView} onRefresh={handleRefresh} /> <HostUsageSection serverId={serverId} />
</View> </View>
); );
} }
@@ -69,6 +69,7 @@ import { RenderProfile } from "@/utils/render-profiler";
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim"; import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { useCompactTimeAgo } from "@/hooks/use-compact-time-ago"; import { useCompactTimeAgo } from "@/hooks/use-compact-time-ago";
import { formatCompactTimeAgoAsProse } from "@/utils/time";
import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id"; import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { WorkspaceNewTabMenuContent } from "@/screens/workspace/workspace-new-tab-menu"; import { WorkspaceNewTabMenuContent } from "@/screens/workspace/workspace-new-tab-menu";
@@ -148,12 +149,6 @@ function formatAgentTooltipTitle(singleLineTitle: string): string {
return `${singleLineTitle.slice(0, AGENT_TOOLTIP_TITLE_MAX_LENGTH - 1).trimEnd()}…`; return `${singleLineTitle.slice(0, AGENT_TOOLTIP_TITLE_MAX_LENGTH - 1).trimEnd()}…`;
} }
function formatAgentTooltipActivity(compactActivity: string): string {
if (compactActivity === "now") return "just now";
if (/^\d/.test(compactActivity)) return `${compactActivity} ago`;
return compactActivity;
}
function AgentTabTooltipBody({ function AgentTabTooltipBody({
serverId, serverId,
agentId, agentId,
@@ -169,7 +164,7 @@ function AgentTabTooltipBody({
return state.agentLastActivity.get(agentId) ?? agent?.lastActivityAt ?? null; return state.agentLastActivity.get(agentId) ?? agent?.lastActivityAt ?? null;
}); });
const compactActivity = useCompactTimeAgo(lastActivityAt); const compactActivity = useCompactTimeAgo(lastActivityAt);
const activity = formatAgentTooltipActivity(compactActivity); const activity = formatCompactTimeAgoAsProse(compactActivity);
return ( return (
<View style={styles.tooltipAgentContent}> <View style={styles.tooltipAgentContent}>
@@ -2,14 +2,14 @@ import { useMemo } from "react";
import { Text, View, type StyleProp, type ViewStyle } from "react-native"; import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { clampPct, formatAmount, formatResetLabel } from "./format"; import { clampPct, formatAmount, formatResetLabel } from "./format";
import type { ProviderUsageBalance, ProviderUsageTone } from "./types"; import type { UsageBalance, UsageTone } from "./types";
interface ResolvedBalance { interface ResolvedBalance {
amountText: string; amountText: string;
usedPct: number | null; usedPct: number | null;
} }
function resolveBalance(balance: ProviderUsageBalance): ResolvedBalance { function resolveBalance(balance: UsageBalance): ResolvedBalance {
const { used, remaining, limit, unit } = balance; const { used, remaining, limit, unit } = balance;
if (limit != null && limit > 0) { if (limit != null && limit > 0) {
const usedAmount = used ?? (remaining != null ? limit - remaining : null); const usedAmount = used ?? (remaining != null ? limit - remaining : null);
@@ -26,7 +26,7 @@ function resolveBalance(balance: ProviderUsageBalance): ResolvedBalance {
return { amountText: "—", usedPct: null }; return { amountText: "—", usedPct: null };
} }
function fillToneStyle(tone: ProviderUsageTone) { function fillToneStyle(tone: UsageTone) {
switch (tone) { switch (tone) {
case "ok": case "ok":
return styles.fillOk; return styles.fillOk;
@@ -39,7 +39,7 @@ function fillToneStyle(tone: ProviderUsageTone) {
} }
} }
export function ProviderUsageBalanceBar({ balance }: { balance: ProviderUsageBalance }) { export function UsageBalanceBar({ balance }: { balance: UsageBalance }) {
const { amountText, usedPct } = resolveBalance(balance); const { amountText, usedPct } = resolveBalance(balance);
const tone = balance.tone ?? "default"; const tone = balance.tone ?? "default";
const resetLabel = formatResetLabel(balance.resetsAt); const resetLabel = formatResetLabel(balance.resetsAt);
+308
View File
@@ -0,0 +1,308 @@
import { RefreshCw } from "lucide-react-native";
import { useMemo } from "react";
import { Text, View, type StyleProp, type TextStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
iconButtonChromeGlyphSize,
mutedIconColorMapping,
smallIconButtonChromeFrameSize,
} from "@/components/ui/icon-button-chrome";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ToolbarButton } from "@/components/ui/pane-content-toolbar";
import { StatusBadge } from "@/components/ui/status-badge";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { useCompactTimeAgo } from "@/hooks/use-compact-time-ago";
import { UsageBalanceBar } from "./balance-bar";
import { usageCopy } from "./copy";
import { formatUsageFreshness, type UsageRefresh } from "./model";
import { useReportRefresh } from "./queries";
import { UsageSourceIcon } from "./source-icon";
import type { UsageReport, UsageReportEntry } from "./types";
import { UsageWindowBar } from "./window-bar";
function statusText(report: UsageReport): string | null {
if (report.status === "available") return null;
return report.status === "error" ? "Error" : "Unavailable";
}
const ThemedRefreshIcon = withUnistyles(RefreshCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
export function UsageCard({
serverId,
entry,
compact = false,
}: {
serverId: string;
entry: UsageReportEntry;
compact?: boolean;
}) {
const isCompact = useIsCompactFormFactor();
const { refresh, refreshState } = useReportRefresh(serverId, entry.id);
// Where there is no hover the freshness is printed on the card; elsewhere the Refresh tooltip.
const showsFreshnessInline = isNative || isCompact;
const usage = entry.report;
const status = statusText(usage);
const footer = entry.account.label ?? null;
const balances = usage.balances ?? [];
const details = usage.details ?? [];
const containerStyle = useMemo(
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
[compact],
);
const dotStyle = useMemo(
() => [
styles.statusDot,
usage.status === "available" && styles.statusDotAvailable,
usage.status === "error" && styles.statusDotError,
],
[usage.status],
);
return (
<View style={containerStyle}>
<View style={styles.header}>
<UsageSourceIcon svg={entry.icon ?? null} size={14} />
<Text style={styles.name} numberOfLines={1}>
{entry.sourceLabel}
</Text>
{usage.planLabel ? <StatusBadge label={usage.planLabel} variant="muted" /> : null}
<View style={styles.headerSpacer} />
{status ? (
<View style={styles.statusRow}>
<View style={dotStyle} />
<Text style={styles.statusLabel}>{status}</Text>
</View>
) : null}
<UsageRefreshButton
sourceLabel={entry.sourceLabel}
fetchedAt={entry.fetchedAt}
refreshState={refreshState}
onRefresh={refresh}
compact={isCompact}
/>
</View>
{usage.error ? (
<Text style={styles.error} numberOfLines={3}>
{usage.error}
</Text>
) : null}
{usage.windows.length > 0 || balances.length > 0 ? (
<View style={styles.bars}>
{usage.windows.map((window) => (
<UsageWindowBar key={window.id} window={window} />
))}
{balances.map((balance) => (
<UsageBalanceBar key={balance.id} balance={balance} />
))}
</View>
) : null}
{details.length > 0 ? (
<View style={styles.details}>
{details.map((detail) => (
<View key={detail.id} style={styles.detailRow}>
<Text style={styles.detailLabel} numberOfLines={1}>
{detail.label}
</Text>
<Text style={styles.detailValue} numberOfLines={1}>
{detail.value}
</Text>
</View>
))}
</View>
) : null}
{footer || showsFreshnessInline ? (
<View style={styles.footerRow}>
<Text style={styles.footer} numberOfLines={1}>
{footer}
</Text>
{showsFreshnessInline ? (
<UsageFreshness
fetchedAt={entry.fetchedAt}
style={styles.freshness}
testID="usage-freshness"
/>
) : null}
</View>
) : null}
{refreshState === "failed" ? (
<Text style={styles.error} testID="usage-refresh-error">
{usageCopy.refreshFailed}
</Text>
) : null}
</View>
);
}
/** Refreshes this one report. Its tooltip says when the report on screen was fetched. */
function UsageRefreshButton({
sourceLabel,
fetchedAt,
refreshState,
onRefresh,
compact,
}: {
sourceLabel: string;
fetchedAt: string;
refreshState: UsageRefresh;
onRefresh: () => void;
compact: boolean;
}) {
const isPending = refreshState === "pending";
const iconSize = iconButtonChromeGlyphSize("small", compact);
const freshness = useMemo(
() => (
<UsageFreshness
fetchedAt={fetchedAt}
style={styles.tooltipText}
testID="usage-freshness-tooltip"
/>
),
[fetchedAt],
);
return (
<ToolbarButton
label={`${usageCopy.refresh} ${sourceLabel}`}
tooltip={freshness}
tooltipSide="top"
compact={compact}
disabled={isPending}
onPress={onRefresh}
style={compact ? styles.refreshButtonCompact : styles.refreshButton}
testID="usage-refresh"
>
{isPending ? (
<ThemedLoadingSpinner size={iconSize} uniProps={mutedIconColorMapping} />
) : (
<ThemedRefreshIcon size={iconSize} uniProps={mutedIconColorMapping} />
)}
</ToolbarButton>
);
}
/** Its own component so the relative-time clock re-renders one `<Text>`, not the card. */
function UsageFreshness({
fetchedAt,
style,
testID,
}: {
fetchedAt: string;
style: StyleProp<TextStyle>;
testID: string;
}) {
const elapsed = useCompactTimeAgo(new Date(fetchedAt));
return (
<Text style={style} numberOfLines={1} testID={testID}>
{formatUsageFreshness(elapsed)}
</Text>
);
}
// The Refresh glyph lands on the card's right rail and the header keeps its text height;
// the button's larger hitbox overhangs both instead of pushing them.
function iconHitboxOverhang(compact: boolean) {
const overhang =
(smallIconButtonChromeFrameSize(compact) - iconButtonChromeGlyphSize("small", compact)) / 2;
return { marginRight: -overhang, marginVertical: -overhang };
}
const styles = StyleSheet.create((theme) => ({
container: {
gap: theme.spacing[3],
},
containerPadded: {
gap: theme.spacing[4],
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
},
containerCompact: {
gap: theme.spacing[3],
},
header: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
name: {
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
headerSpacer: {
flex: 1,
},
statusRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: theme.colors.foregroundMuted,
},
statusDotAvailable: {
backgroundColor: theme.colors.statusSuccess,
},
statusDotError: {
backgroundColor: theme.colors.statusDanger,
},
statusLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
bars: {
gap: theme.spacing[3],
},
details: {
gap: theme.spacing[1],
},
detailRow: {
flexDirection: "row",
justifyContent: "space-between",
gap: theme.spacing[2],
},
detailLabel: {
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
detailValue: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
error: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.4,
},
footerRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
footer: {
flex: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
freshness: {
flexShrink: 0,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
tooltipText: {
color: theme.colors.popoverForeground,
fontSize: theme.fontSize.sm,
},
refreshButton: iconHitboxOverhang(false),
refreshButtonCompact: iconHitboxOverhang(true),
}));
+82
View File
@@ -0,0 +1,82 @@
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { MenuTriggerState } from "@/components/ui/menu";
import {
isToolbarLabelTriggerHighlighted,
toolbarLabelTriggerStyle,
toolbarLabelTriggerTextStyle,
} from "@/components/ui/toolbar-label-trigger";
import { UsageCard } from "./card";
import { usageCopy } from "./copy";
import type { UsagePill } from "./model";
import { useAgentUsage } from "./queries";
import { UsageSourceIcon } from "./source-icon";
function pillTriggerStyle(state: MenuTriggerState) {
return [toolbarLabelTriggerStyle(state), styles.trigger];
}
/**
* The agent's plan usage beside the composer controls. Opens the cached usage card;
* only the card's Refresh asks the source for a new report.
*/
export function UsageComposerPill({ serverId, agentId }: { serverId: string; agentId: string }) {
const { pill, entry } = useAgentUsage(serverId, agentId);
if (!pill || !entry) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger
style={pillTriggerStyle}
accessibilityRole="button"
accessibilityLabel={`${pill.sourceLabel} ${usageCopy.planUsage}`}
testID="usage-composer-pill"
>
{(state) => (
<UsagePillContent pill={pill} highlighted={isToolbarLabelTriggerHighlighted(state)} />
)}
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="end"
offset={8}
width={300}
testID="usage-composer-popover"
sheetTitle={usageCopy.planUsage}
>
<View style={styles.popover}>
<UsageCard serverId={serverId} entry={entry} compact />
</View>
</DropdownMenuContent>
</DropdownMenu>
);
}
function UsagePillContent({ pill, highlighted }: { pill: UsagePill; highlighted: boolean }) {
return (
<>
<UsageSourceIcon svg={pill.icon} size={14} />
{pill.text ? (
<Text style={toolbarLabelTriggerTextStyle(highlighted)} numberOfLines={1}>
{pill.text}
</Text>
) : null}
</>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
height: 28,
flexShrink: 0,
},
popover: {
padding: theme.spacing[3],
},
}));
+18
View File
@@ -0,0 +1,18 @@
// User-facing copy for the usage surfaces, kept in one file so localization is a
// single-file change.
export const usageCopy = {
title: "Usage",
planUsage: "Plan usage",
refresh: "Refresh",
refreshing: "Refreshing...",
refreshFailed: "Unable to refresh usage",
updated: "Updated",
loading: "Loading usage...",
empty: "No usage data",
noHosts: "No connected hosts",
errorTitle: "Unable to load usage",
hostUnavailable: "Connect to this host to see usage",
hostUpgradeRequired: "Update the host to see usage",
clientUnavailable: "Host connection is not ready",
retry: "Try again",
} as const;
@@ -1,5 +1,5 @@
import { formatTokenCount } from "@/components/context-window-meter.utils"; import { formatTokenCount } from "@/components/context-window-meter.utils";
import type { ProviderUsageBalanceUnit } from "./types"; import type { UsageBalanceUnit } from "./types";
export function clampPct(value: number): number { export function clampPct(value: number): number {
return Math.max(0, Math.min(100, value)); return Math.max(0, Math.min(100, value));
@@ -28,20 +28,7 @@ export function formatResetLabel(iso: string | null | undefined): string | null
return rel === "now" ? "resetting now" : `resets ${rel}`; return rel === "now" ? "resetting now" : `resets ${rel}`;
} }
export function formatAgo(iso: string | null | undefined): string | null { export function formatAmount(value: number, unit: UsageBalanceUnit): string {
if (!iso) return null;
const diffMs = Date.now() - new Date(iso).getTime();
if (!Number.isFinite(diffMs)) return null;
if (diffMs < 60_000) return "just now";
const diffMinutes = Math.floor(diffMs / 60_000);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) return `${diffDays}d ago`;
if (diffHours > 0) return `${diffHours}h ago`;
return `${diffMinutes}m ago`;
}
export function formatAmount(value: number, unit: ProviderUsageBalanceUnit): string {
switch (unit) { switch (unit) {
case "usd": case "usd":
return `$${value.toFixed(2)}`; return `$${value.toFixed(2)}`;
@@ -0,0 +1,17 @@
import { usageCopy } from "./copy";
import { useHostUsage } from "./queries";
import { UsageSection } from "./usage-section";
/** A host's usage reports, for its settings page. */
export function HostUsageSection({ serverId }: { serverId: string }) {
const { view, refresh } = useHostUsage(serverId);
return (
<UsageSection
serverId={serverId}
title={usageCopy.planUsage}
view={view}
onRefresh={refresh}
testID="usage-card"
/>
);
}
+3
View File
@@ -0,0 +1,3 @@
export { UsageComposerPill } from "./composer-pill";
export { HostUsageSection } from "./host-usage-section";
export { UsageScreen } from "./usage-screen";
@@ -2,16 +2,22 @@ import { Fragment } from "react";
import { View } from "react-native"; import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings"; import { settingsStyles } from "@/styles/settings";
import { ProviderUsageCard } from "./card"; import { UsageCard } from "./card";
import type { ProviderUsage } from "./types"; import type { UsageReportEntry } from "./types";
export function ProviderUsageList({ providers }: { providers: ProviderUsage[] }) { export function UsageList({
serverId,
reports,
}: {
serverId: string;
reports: UsageReportEntry[];
}) {
return ( return (
<View style={settingsStyles.card}> <View style={settingsStyles.card}>
{providers.map((usage, index) => ( {reports.map((entry, index) => (
<Fragment key={usage.providerId}> <Fragment key={entry.id}>
{index > 0 ? <View style={styles.divider} /> : null} {index > 0 ? <View style={styles.divider} /> : null}
<ProviderUsageCard usage={usage} /> <UsageCard serverId={serverId} entry={entry} />
</Fragment> </Fragment>
))} ))}
</View> </View>
+211
View File
@@ -0,0 +1,211 @@
import { describe, expect, it } from "vitest";
import {
formatUsageFreshness,
groupUsageByHost,
replaceReport,
headlineWindow,
resolveUsagePill,
resolveUsageRefresh,
resolveUsageView,
type UsageQueryState,
} from "./model";
import type { UsageReportEntry, UsageWindow } from "./types";
function entry(input: {
windows?: UsageWindow[];
planLabel?: string;
icon?: string;
sourceId?: string;
}): UsageReportEntry {
return {
id: `${input.sourceId ?? "fixture"}:account-1`,
account: {},
fetchedAt: "2026-01-01T00:00:00.000Z",
sourceId: input.sourceId ?? "fixture",
sourceLabel: "Fixture source",
...(input.icon ? { icon: input.icon } : {}),
report: {
status: "available",
...(input.planLabel ? { planLabel: input.planLabel } : {}),
windows: input.windows ?? [],
},
};
}
function ready(data: UsageReportEntry[]): UsageQueryState {
return { data, error: null, isFetching: false };
}
describe("resolveUsagePill", () => {
it("shows the source icon and the headline window percent", () => {
const pill = resolveUsagePill({
supportsUsage: true,
entry: entry({
icon: "<svg/>",
windows: [
{ id: "session", label: "Session", usedPct: 12 },
{ id: "weekly", label: "Weekly", usedPct: 64.6, headline: true },
],
}),
});
expect(pill).toEqual({ icon: "<svg/>", sourceLabel: "Fixture source", text: "65%" });
});
it("derives the headline percent from remaining percent", () => {
const pill = resolveUsagePill({
supportsUsage: true,
entry: entry({
windows: [{ id: "daily", label: "Daily", remainingPct: 30, headline: true }],
}),
});
expect(pill?.text).toBe("70%");
});
it("shows the plan label when no window is the headline", () => {
const pill = resolveUsagePill({
supportsUsage: true,
entry: entry({
planLabel: "Pro",
windows: [{ id: "session", label: "Session", usedPct: 40 }],
}),
});
expect(pill).toEqual({ icon: null, sourceLabel: "Fixture source", text: "Pro" });
});
it("shows only the icon with neither a headline window nor a plan label", () => {
const pill = resolveUsagePill({ supportsUsage: true, entry: entry({}) });
expect(pill).toEqual({ icon: null, sourceLabel: "Fixture source", text: null });
});
it("is hidden when the agent has no usage report", () => {
expect(resolveUsagePill({ supportsUsage: true, entry: null })).toBeNull();
});
it("is hidden on a host without usage sources, whatever data is cached", () => {
const cached = entry({ windows: [{ id: "w", label: "W", usedPct: 5, headline: true }] });
expect(resolveUsagePill({ supportsUsage: false, entry: cached })).toBeNull();
});
});
describe("headlineWindow", () => {
it("does not fall back to the first window", () => {
expect(headlineWindow(entry({ windows: [{ id: "a", label: "A", usedPct: 1 }] }).report)).toBe(
null,
);
});
});
describe("resolveUsageView", () => {
it("asks for a host update when the host lacks usage sources", () => {
expect(
resolveUsageView({ isConnected: true, supportsUsage: false, query: ready([entry({})]) }),
).toEqual({ kind: "unavailable", message: "Update the host to see usage" });
});
it("asks for a connection before anything else", () => {
expect(
resolveUsageView({ isConnected: false, supportsUsage: false, query: undefined }),
).toEqual({ kind: "unavailable", message: "Connect to this host to see usage" });
});
it("moves from loading to ready to error", () => {
const base = { isConnected: true, supportsUsage: true };
expect(resolveUsageView({ ...base, query: undefined })).toEqual({ kind: "loading" });
expect(
resolveUsageView({ ...base, query: { data: [], error: null, isFetching: true } }),
).toEqual({ kind: "ready", reports: [], isRefreshing: true });
expect(
resolveUsageView({
...base,
query: { data: undefined, error: new Error("boom"), isFetching: false },
}),
).toEqual({ kind: "error", message: "boom" });
});
});
describe("groupUsageByHost", () => {
it("groups reports under each connected host in host order", () => {
const first = entry({ sourceId: "one" });
const second = entry({ sourceId: "two" });
const groups = groupUsageByHost(
[
{ serverId: "b", label: "Beta", isConnected: true, supportsUsage: true },
{ serverId: "offline", label: "Offline", isConnected: false, supportsUsage: true },
{ serverId: "a", label: "Alpha", isConnected: true, supportsUsage: true },
],
new Map([
["a", ready([first])],
["b", ready([second, first])],
]),
);
expect(groups).toEqual([
{
serverId: "b",
label: "Beta",
view: { kind: "ready", reports: [second, first], isRefreshing: false },
},
{
serverId: "a",
label: "Alpha",
view: { kind: "ready", reports: [first], isRefreshing: false },
},
]);
});
it("shows the update message in an old host's group", () => {
const groups = groupUsageByHost(
[
{ serverId: "new", label: "New", isConnected: true, supportsUsage: true },
{ serverId: "old", label: "Old", isConnected: true, supportsUsage: false },
],
new Map([["new", ready([])]]),
);
expect(groups.map((group) => [group.label, group.view])).toEqual([
["New", { kind: "ready", reports: [], isRefreshing: false }],
["Old", { kind: "unavailable", message: "Update the host to see usage" }],
]);
});
});
describe("formatUsageFreshness", () => {
it("says when the report was fetched", () => {
expect(formatUsageFreshness("now")).toBe("Updated just now");
expect(formatUsageFreshness("3m")).toBe("Updated 3m ago");
expect(formatUsageFreshness("Jan 15")).toBe("Updated Jan 15");
});
});
describe("resolveUsageRefresh", () => {
it("is pending while a refresh runs, even after an earlier failure", () => {
expect(resolveUsageRefresh({ isPending: true, error: new Error("boom") })).toBe("pending");
});
it("is failed after a refresh errors", () => {
expect(resolveUsageRefresh({ isPending: false, error: new Error("boom") })).toBe("failed");
});
it("is idle before and after a successful refresh", () => {
expect(resolveUsageRefresh({ isPending: false, error: null })).toBe("idle");
});
});
describe("replaceReport", () => {
const alpha = entry({ sourceId: "alpha", planLabel: "Old" });
const beta = entry({ sourceId: "beta" });
it("swaps only the refreshed report, in place", () => {
const refreshed = entry({ sourceId: "alpha", planLabel: "New" });
expect(replaceReport([alpha, beta], alpha.id, refreshed)).toEqual([refreshed, beta]);
});
it("drops a report the daemon no longer knows", () => {
expect(replaceReport([alpha, beta], alpha.id, null)).toEqual([beta]);
});
});
+126
View File
@@ -0,0 +1,126 @@
import { formatCompactTimeAgoAsProse } from "@/utils/time";
import { usageCopy } from "./copy";
import type { UsageReport, UsageReportEntry, UsageView, UsageWindow } from "./types";
export function usedPercent(window: UsageWindow): number | null {
if (window.usedPct != null) return window.usedPct;
if (window.remainingPct != null) return 100 - window.remainingPct;
return null;
}
/** The window the source marked as its headline. Sources own that choice; there is no fallback. */
export function headlineWindow(report: UsageReport): UsageWindow | null {
return report.windows.find((window) => window.headline === true) ?? null;
}
export interface UsagePill {
icon: string | null;
sourceLabel: string;
/** Headline percent, else the plan label, else nothing beside the icon. */
text: string | null;
}
export function resolveUsagePill(input: {
supportsUsage: boolean;
entry: UsageReportEntry | null | undefined;
}): UsagePill | null {
const { supportsUsage, entry } = input;
if (!supportsUsage || !entry) return null;
const window = headlineWindow(entry.report);
const percent = window ? usedPercent(window) : null;
return {
icon: entry.icon ?? null,
sourceLabel: entry.sourceLabel,
text:
percent != null
? `${Math.round(Math.max(0, Math.min(100, percent)))}%`
: (entry.report.planLabel ?? null),
};
}
/** When a report was fetched, from its compact relative time: "Updated 3m ago". */
export function formatUsageFreshness(compactTimeAgo: string): string {
return `${usageCopy.updated} ${formatCompactTimeAgoAsProse(compactTimeAgo)}`;
}
/** A user-requested refresh of one report. The previous report stays on screen throughout. */
export type UsageRefresh = "idle" | "pending" | "failed";
export function resolveUsageRefresh(mutation: {
isPending: boolean;
error: unknown;
}): UsageRefresh {
if (mutation.isPending) return "pending";
if (mutation.error) return "failed";
return "idle";
}
/**
* A host's report list with one report swapped for its refreshed copy, in place.
* `null` means the daemon no longer knows the ID, so the report leaves the list.
*/
export function replaceReport(
reports: readonly UsageReportEntry[],
reportId: string,
refreshed: UsageReportEntry | null,
): UsageReportEntry[] {
if (!refreshed) return reports.filter((report) => report.id !== reportId);
return reports.map((report) => (report.id === reportId ? refreshed : report));
}
export interface UsageQueryState {
data: UsageReportEntry[] | undefined;
error: unknown;
isFetching: boolean;
}
export function resolveUsageView(input: {
isConnected: boolean;
supportsUsage: boolean;
query: UsageQueryState | undefined;
}): UsageView {
const { isConnected, supportsUsage, query } = input;
if (!isConnected) return { kind: "unavailable", message: usageCopy.hostUnavailable };
if (!supportsUsage) return { kind: "unavailable", message: usageCopy.hostUpgradeRequired };
if (query?.data) {
return { kind: "ready", reports: query.data, isRefreshing: query.isFetching };
}
if (query?.error) {
return {
kind: "error",
message: query.error instanceof Error ? query.error.message : String(query.error),
};
}
return { kind: "loading" };
}
export interface UsageHost {
serverId: string;
label: string;
isConnected: boolean;
supportsUsage: boolean;
}
export interface UsageHostGroup {
serverId: string;
label: string;
view: UsageView;
}
/** One group per connected host, in host order. */
export function groupUsageByHost(
hosts: readonly UsageHost[],
queries: ReadonlyMap<string, UsageQueryState>,
): UsageHostGroup[] {
return hosts
.filter((host) => host.isConnected)
.map((host) => ({
serverId: host.serverId,
label: host.label,
view: resolveUsageView({
isConnected: true,
supportsUsage: host.supportsUsage,
query: queries.get(host.serverId),
}),
}));
}
+220
View File
@@ -0,0 +1,220 @@
import { useCallback, useMemo } from "react";
import { useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
import { useShallow } from "zustand/shallow";
import { useFetchQueries, useFetchQuery } from "@/data/query";
import {
getHostRuntimeStore,
useHostRuntimeConnectionStatuses,
useHostRuntimeIsConnected,
useHosts,
} from "@/runtime/host-runtime";
import { useSessionStore, type SessionState } from "@/stores/session-store";
import { usageCopy } from "./copy";
import {
groupUsageByHost,
replaceReport,
resolveUsagePill,
resolveUsageRefresh,
resolveUsageView,
type UsageHostGroup,
type UsagePill,
type UsageQueryState,
type UsageRefresh,
} from "./model";
import type { UsageReportEntry, UsageView } from "./types";
// The daemon caches each report for five minutes, so re-reading it is cheap. Only
// an explicit refresh passes `forceRefresh` and reaches the source's API.
const REPORTS_STALE_TIME_MS = 60_000;
function usageReportsQueryKey(serverId: string) {
return ["usage", "reports", serverId] as const;
}
function agentUsageQueryKey(serverId: string, agentId: string, model: string | null) {
return ["usage", "agent", serverId, agentId, model] as const;
}
function requireClient(serverId: string) {
const client = getHostRuntimeStore().getClient(serverId);
if (!client) throw new Error(usageCopy.clientUnavailable);
return client;
}
async function listReports(serverId: string, forceRefresh = false): Promise<UsageReportEntry[]> {
return (await requireClient(serverId).listUsageReports({ forceRefresh })).reports;
}
function reportQueryKey(serverId: string, reportId: string) {
return ["usage", "report", serverId, reportId] as const;
}
async function getReport(
serverId: string,
reportId: string,
forceRefresh = false,
): Promise<UsageReportEntry | null> {
return (
(await requireClient(serverId).listUsageReports({ reportIds: [reportId], forceRefresh }))
.reports[0] ?? null
);
}
function supportsUsage(session: SessionState | undefined): boolean {
return session?.serverInfo?.features?.usageSources === true;
}
async function refreshReports(queryClient: QueryClient, serverId: string): Promise<void> {
await queryClient.fetchQuery({
queryKey: usageReportsQueryKey(serverId),
queryFn: async () => {
const reports = await listReports(serverId, true);
for (const report of reports)
queryClient.setQueryData(reportQueryKey(serverId, report.id), report);
return reports;
},
staleTime: 0,
});
}
function toQueryState(query: {
data: UsageReportEntry[] | undefined;
error: unknown;
isFetching: boolean;
}): UsageQueryState {
return { data: query.data, error: query.error, isFetching: query.isFetching };
}
/** Usage reports for one host, as shown on its settings page. */
export function useHostUsage(serverId: string): { view: UsageView; refresh: () => void } {
const queryClient = useQueryClient();
const isConnected = useHostRuntimeIsConnected(serverId);
const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId]));
const query = useFetchQuery({
queryKey: usageReportsQueryKey(serverId),
queryFn: async () => {
const reports = await listReports(serverId);
for (const report of reports)
queryClient.setQueryData(reportQueryKey(serverId, report.id), report);
return reports;
},
enabled: isConnected && isSupported,
dataShape: "list",
staleTimeMs: REPORTS_STALE_TIME_MS,
});
const refresh = useCallback(() => {
void refreshReports(queryClient, serverId).catch(() => undefined);
}, [queryClient, serverId]);
const view = resolveUsageView({
isConnected,
supportsUsage: isSupported,
query: toQueryState(query),
});
return { view, refresh };
}
/** Usage reports for every connected host, grouped by host. */
export function useUsageByHost(): {
groups: UsageHostGroup[];
refresh: (serverId: string) => void;
} {
const queryClient = useQueryClient();
const hosts = useHosts();
const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
const connectionStatuses = useHostRuntimeConnectionStatuses(serverIds);
const supportedServerIds = useSessionStore(
useShallow((state) => serverIds.filter((serverId) => supportsUsage(state.sessions[serverId]))),
);
const usageHosts = useMemo(
() =>
hosts.map((host) => ({
serverId: host.serverId,
label: host.label,
isConnected: connectionStatuses.get(host.serverId) === "online",
supportsUsage: supportedServerIds.includes(host.serverId),
})),
[connectionStatuses, hosts, supportedServerIds],
);
const results = useFetchQueries<UsageReportEntry[]>(
usageHosts.map((host) => ({
queryKey: usageReportsQueryKey(host.serverId),
queryFn: () => listReports(host.serverId),
enabled: host.isConnected && host.supportsUsage,
dataShape: "list",
staleTimeMs: REPORTS_STALE_TIME_MS,
})),
);
const groups = groupUsageByHost(
usageHosts,
new Map(usageHosts.map((host, index) => [host.serverId, toQueryState(results[index])])),
);
const refresh = useCallback(
(serverId: string) => {
void refreshReports(queryClient, serverId).catch(() => undefined);
},
[queryClient],
);
return { groups, refresh };
}
/**
* The usage report for the account an agent is spending. The daemon resolves the
* report ID, which is re-resolved when the model changes (a new key) and when a
* turn completes (the query is paused while the agent runs, and its data is always
* stale, so resuming refetches). The report itself is read from the shared cache.
*/
export function useAgentUsage(
serverId: string,
agentId: string,
): { pill: UsagePill | null; entry: UsageReportEntry | null } {
const isConnected = useHostRuntimeIsConnected(serverId);
const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId]));
const { model, isRunning } = useSessionStore(
useShallow((state) => {
const agent = state.sessions[serverId]?.agents?.get(agentId);
return { model: agent?.model ?? null, isRunning: agent?.status === "running" };
}),
);
const identity = useFetchQuery({
queryKey: agentUsageQueryKey(serverId, agentId, model),
queryFn: async () =>
(await requireClient(serverId).resolveAgentUsageReport({ agentId })).reportId,
enabled: isConnected && isSupported && !isRunning,
dataShape: "value",
staleTimeMs: 0,
});
const reportId = identity.data ?? null;
const query = useFetchQuery({
queryKey: reportQueryKey(serverId, reportId ?? "none"),
queryFn: () => getReport(serverId, reportId!),
enabled: isConnected && isSupported && reportId !== null,
dataShape: "value",
staleTimeMs: REPORTS_STALE_TIME_MS,
});
const entry = isSupported ? (query.data ?? null) : null;
return { pill: resolveUsagePill({ supportsUsage: isSupported, entry }), entry };
}
/**
* Forces the source to fetch one report, and only that report. The result replaces
* the report wherever it is cached — its own entry and its host's list — so every
* surface showing it moves together; until then the previous report stays on screen.
*/
export function useReportRefresh(
serverId: string,
reportId: string,
): { refresh: () => void; refreshState: UsageRefresh } {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: () => getReport(serverId, reportId, true),
onSuccess: (report) => {
queryClient.setQueryData(reportQueryKey(serverId, reportId), report);
queryClient.setQueryData<UsageReportEntry[]>(usageReportsQueryKey(serverId), (reports) =>
reports ? replaceReport(reports, reportId, report) : reports,
);
},
});
const { mutate } = mutation;
const refresh = useCallback(() => mutate(), [mutate]);
return { refresh, refreshState: resolveUsageRefresh(mutation) };
}
+26
View File
@@ -0,0 +1,26 @@
import { Gauge } from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import { SvgIcon } from "@/components/provider-icons";
import type { Theme } from "@/styles/theme";
function UsageSourceIconBase({
svg,
size,
color = "",
}: {
svg: string | null;
size: number;
color?: string;
}) {
if (!svg) return <Gauge size={size} color={color} />;
return <SvgIcon svg={svg} size={size} color={color} />;
}
const ThemedUsageSourceIcon = withUnistyles(UsageSourceIconBase);
const mutedIconColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
/** The source's catalog SVG, or a generic gauge when the source ships none. */
export function UsageSourceIcon({ svg, size }: { svg: string | null; size: number }) {
return <ThemedUsageSourceIcon svg={svg} size={size} uniProps={mutedIconColor} />;
}
@@ -1,6 +1,6 @@
import type { ProviderUsageTone } from "./types"; import type { UsageTone } from "./types";
export function deriveTone(usedPct: number | null | undefined): ProviderUsageTone { export function deriveTone(usedPct: number | null | undefined): UsageTone {
if (usedPct == null) return "default"; if (usedPct == null) return "default";
if (usedPct > 90) return "danger"; if (usedPct > 90) return "danger";
if (usedPct >= 70) return "warning"; if (usedPct >= 70) return "warning";
+15
View File
@@ -0,0 +1,15 @@
import type { ProviderUsageTone, UsageReportEntry } from "@getpaseo/protocol/messages";
export type { UsageReportEntry };
export type UsageReport = UsageReportEntry["report"];
export type UsageTone = ProviderUsageTone;
export type UsageWindow = UsageReport["windows"][number];
export type UsageBalance = NonNullable<UsageReport["balances"]>[number];
export type UsageBalanceUnit = UsageBalance["unit"];
/** What a host's usage surface shows. `unavailable` is a host state, not a failed request. */
export type UsageView =
| { kind: "unavailable"; message: string }
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; reports: UsageReportEntry[]; isRefreshing: boolean };
+57
View File
@@ -0,0 +1,57 @@
import { useCallback } from "react";
import { useIsFocused } from "@react-navigation/native";
import { router } from "expo-router";
import { PageLayout } from "@/components/page-layout";
import { usageCopy } from "./copy";
import type { UsageHostGroup } from "./model";
import { useUsageByHost } from "./queries";
import { UsageMessage, UsageSection } from "./usage-section";
// The screen is reachable by URL, so there may be no history to go back to.
function leaveUsage(): void {
if (router.canGoBack()) {
router.back();
return;
}
router.replace("/");
}
export function UsageScreen() {
const isFocused = useIsFocused();
return (
<PageLayout title={usageCopy.title} onBack={leaveUsage} testID="usage-screen">
{isFocused ? <UsageScreenContent /> : null}
</PageLayout>
);
}
function UsageScreenContent() {
const { groups, refresh } = useUsageByHost();
return (
<>
{groups.length === 0 ? <UsageMessage text={usageCopy.noHosts} /> : null}
{groups.map((group) => (
<HostUsageGroup key={group.serverId} group={group} onRefresh={refresh} />
))}
</>
);
}
function HostUsageGroup({
group,
onRefresh,
}: {
group: UsageHostGroup;
onRefresh: (serverId: string) => void;
}) {
const handleRefresh = useCallback(() => onRefresh(group.serverId), [group.serverId, onRefresh]);
return (
<UsageSection
serverId={group.serverId}
title={group.label}
view={group.view}
onRefresh={handleRefresh}
testID={`usage-host-${group.serverId}`}
/>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { RefreshCw } from "lucide-react-native";
import { useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { usageCopy } from "./copy";
import { UsageList } from "./list";
import type { UsageView } from "./types";
export function UsageSection({
serverId,
title,
view,
onRefresh,
testID,
}: {
serverId: string;
title: string;
view: UsageView;
onRefresh: () => void;
testID?: string;
}) {
const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing);
const refreshButton = useMemo(
() =>
view.kind === "unavailable" ? null : (
<Button
variant="ghost"
size="sm"
leftIcon={RefreshCw}
loading={busy}
onPress={onRefresh}
accessibilityLabel={usageCopy.refresh}
>
{busy ? usageCopy.refreshing : usageCopy.refresh}
</Button>
),
[busy, onRefresh, view.kind],
);
return (
<SettingsSection title={title} testID={testID} trailing={refreshButton}>
<UsageBody serverId={serverId} view={view} onRefresh={onRefresh} />
</SettingsSection>
);
}
function UsageBody({
serverId,
view,
onRefresh,
}: {
serverId: string;
view: UsageView;
onRefresh: () => void;
}) {
if (view.kind === "unavailable") {
return <UsageMessage text={view.message} />;
}
if (view.kind === "loading") {
return <UsageMessage text={usageCopy.loading} />;
}
if (view.kind === "error") {
return (
<Alert size="sm" variant="error" title={usageCopy.errorTitle} description={view.message}>
<Button variant="outline" size="sm" onPress={onRefresh}>
{usageCopy.retry}
</Button>
</Alert>
);
}
if (view.reports.length === 0) {
return <UsageMessage text={usageCopy.empty} />;
}
return <UsageList serverId={serverId} reports={view.reports} />;
}
export function UsageMessage({ text }: { text: string }) {
return (
<View style={[settingsStyles.card, styles.emptyCard]}>
<Text style={styles.emptyText}>{text}</Text>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
emptyCard: {
padding: theme.spacing[4],
alignItems: "center",
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
}));
@@ -2,16 +2,11 @@ import { useMemo } from "react";
import { Text, View, type StyleProp, type ViewStyle } from "react-native"; import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { clampPct, formatPct, formatResetLabel } from "./format"; import { clampPct, formatPct, formatResetLabel } from "./format";
import { usedPercent } from "./model";
import { deriveTone } from "./tone"; import { deriveTone } from "./tone";
import type { ProviderUsageTone, ProviderUsageWindow } from "./types"; import type { UsageTone, UsageWindow } from "./types";
function resolveUsedPct(window: ProviderUsageWindow): number | null { function fillToneStyle(tone: UsageTone) {
if (window.usedPct != null) return window.usedPct;
if (window.remainingPct != null) return 100 - window.remainingPct;
return null;
}
function fillToneStyle(tone: ProviderUsageTone) {
switch (tone) { switch (tone) {
case "ok": case "ok":
return styles.fillOk; return styles.fillOk;
@@ -24,8 +19,8 @@ function fillToneStyle(tone: ProviderUsageTone) {
} }
} }
export function ProviderUsageWindowBar({ window }: { window: ProviderUsageWindow }) { export function UsageWindowBar({ window }: { window: UsageWindow }) {
const usedPct = resolveUsedPct(window); const usedPct = usedPercent(window);
const tone = window.tone ?? deriveTone(usedPct); const tone = window.tone ?? deriveTone(usedPct);
const fillWidth = clampPct(usedPct ?? 0); const fillWidth = clampPct(usedPct ?? 0);
+4
View File
@@ -428,6 +428,10 @@ export function buildSchedulesRoute() {
return "/schedules" as const; return "/schedules" as const;
} }
export function buildUsageRoute() {
return "/usage" as const;
}
export function buildOpenProjectRoute() { export function buildOpenProjectRoute() {
return "/open-project" as const; return "/open-project" as const;
} }
+10
View File
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
import { import {
describeCompactTimeAgo, describeCompactTimeAgo,
formatCompactTimeAgo, formatCompactTimeAgo,
formatCompactTimeAgoAsProse,
formatDuration, formatDuration,
formatMessageTimestamp, formatMessageTimestamp,
formatTimeAgo, formatTimeAgo,
@@ -63,6 +64,15 @@ describe("describeCompactTimeAgo", () => {
}); });
}); });
describe("formatCompactTimeAgoAsProse", () => {
it("puts elapsed labels back into prose and leaves dates alone", () => {
expect(formatCompactTimeAgoAsProse("now")).toBe("just now");
expect(formatCompactTimeAgoAsProse("3m")).toBe("3m ago");
expect(formatCompactTimeAgoAsProse("2h")).toBe("2h ago");
expect(formatCompactTimeAgoAsProse("Jan 15")).toBe("Jan 15");
});
});
describe("formatDuration", () => { describe("formatDuration", () => {
it("renders sub-minute durations as whole seconds", () => { it("renders sub-minute durations as whole seconds", () => {
expect(formatDuration(0)).toBe("0s"); expect(formatDuration(0)).toBe("0s");
+10
View File
@@ -91,6 +91,16 @@ export function formatCompactTimeAgo(date: Date, now: Date = new Date()): string
return describeCompactTimeAgo(date, now).label; return describeCompactTimeAgo(date, now).label;
} }
/**
* A compact label put back into a sentence, for a tooltip or a line with room for prose:
* "now" → "just now", "5m" → "5m ago", "Jan 15" stays a date.
*/
export function formatCompactTimeAgoAsProse(label: string): string {
if (label === "now") return "just now";
if (/^\d/.test(label)) return `${label} ago`;
return label;
}
/** /**
* How many local midnights lie between two instants: 0 for the same day, 1 for yesterday. * How many local midnights lie between two instants: 0 for the same day, 1 for yesterday.
* Counted on the calendar rather than in elapsed time, so six days and 23 hours ago on * Counted on the calendar rather than in elapsed time, so six days and 23 hours ago on
+32
View File
@@ -94,6 +94,8 @@ import type {
RefreshProvidersSnapshotResponseMessage, RefreshProvidersSnapshotResponseMessage,
ProviderDiagnosticResponseMessage, ProviderDiagnosticResponseMessage,
ProviderUsageListResponseMessage, ProviderUsageListResponseMessage,
UsageListReportsResponseMessage,
AgentResolveUsageReportResponseMessage,
DaemonGetStatusResponse, DaemonGetStatusResponse,
DaemonGetPairingOfferResponse, DaemonGetPairingOfferResponse,
DaemonConfigReloadResponse, DaemonConfigReloadResponse,
@@ -559,6 +561,8 @@ type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"
type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"]; type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"];
type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"]; type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"];
type ProviderUsageListPayload = ProviderUsageListResponseMessage["payload"]; type ProviderUsageListPayload = ProviderUsageListResponseMessage["payload"];
type UsageListReportsPayload = UsageListReportsResponseMessage["payload"];
type AgentResolveUsageReportPayload = AgentResolveUsageReportResponseMessage["payload"];
type DaemonStatusPayload = DaemonGetStatusResponse["payload"]; type DaemonStatusPayload = DaemonGetStatusResponse["payload"];
type DaemonPairingOfferPayload = DaemonGetPairingOfferResponse["payload"]; type DaemonPairingOfferPayload = DaemonGetPairingOfferResponse["payload"];
type DiagnosticsPayload = DiagnosticsResponse["payload"]; type DiagnosticsPayload = DiagnosticsResponse["payload"];
@@ -5238,6 +5242,34 @@ export class DaemonClient {
}); });
} }
async listUsageReports(options?: {
requestId?: string;
forceRefresh?: boolean;
reportIds?: string[];
}): Promise<UsageListReportsPayload> {
return this.sendNamespacedCorrelatedSessionRequest({
requestId: options?.requestId,
message: {
type: "usage.list_reports.request",
forceRefresh: options?.forceRefresh,
reportIds: options?.reportIds,
},
});
}
async resolveAgentUsageReport(options: {
agentId: string;
requestId?: string;
}): Promise<AgentResolveUsageReportPayload> {
return this.sendNamespacedCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "agent.resolve_usage_report.request",
agentId: options.agentId,
},
});
}
async listCommands(options: ListCommandsOptions): Promise<ListCommandsPayload>; async listCommands(options: ListCommandsOptions): Promise<ListCommandsPayload>;
async listCommands(agentId: string, requestId?: string): Promise<ListCommandsPayload>; async listCommands(agentId: string, requestId?: string): Promise<ListCommandsPayload>;
async listCommands( async listCommands(
+5
View File
@@ -23,6 +23,11 @@
"source": "./src/server/provider.ts", "source": "./src/server/provider.ts",
"default": "./dist/server/provider.js" "default": "./dist/server/provider.js"
}, },
"./server/usage": {
"types": "./dist/server/usage.d.ts",
"source": "./src/server/usage.ts",
"default": "./dist/server/usage.js"
},
"./server/acp": { "./server/acp": {
"types": "./dist/server/acp.d.ts", "types": "./dist/server/acp.d.ts",
"source": "./src/server/acp.ts", "source": "./src/server/acp.ts",
+1
View File
@@ -10,6 +10,7 @@ const entries = {
".": "shared", ".": "shared",
"./server": "server", "./server": "server",
"./server/provider": "server", "./server/provider": "server",
"./server/usage": "server",
"./server/acp": "server", "./server/acp": "server",
"./client": "client", "./client": "client",
"./client/host": "client", "./client/host": "client",
+2
View File
@@ -3,6 +3,7 @@ import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type { PluginRpcContract } from "../rpc.js"; import type { PluginRpcContract } from "../rpc.js";
import type { PluginCleanup } from "../contracts.js"; import type { PluginCleanup } from "../contracts.js";
import type { ProviderRegistration } from "./provider.js"; import type { ProviderRegistration } from "./provider.js";
import type { UsageSourceRegistration } from "./usage.js";
import type { PluginLifecycleRegistration } from "./lifecycle.js"; import type { PluginLifecycleRegistration } from "./lifecycle.js";
export interface PluginHandlerContext { export interface PluginHandlerContext {
@@ -38,6 +39,7 @@ export interface PluginServerContext extends PluginLifecycleRegistration {
) => ZodInput<OutputSchema> | Promise<ZodInput<OutputSchema>>, ) => ZodInput<OutputSchema> | Promise<ZodInput<OutputSchema>>,
): void; ): void;
registerProvider(provider: ProviderRegistration): void; registerProvider(provider: ProviderRegistration): void;
registerUsageSource(source: UsageSourceRegistration): void;
} }
export type PluginServerContribution = (server: PluginServerContext) => PluginCleanup; export type PluginServerContribution = (server: PluginServerContext) => PluginCleanup;
+7
View File
@@ -1,3 +1,10 @@
export type {
UsageSourceRegistration,
UsageReport,
UsageWindow,
UsageBalance,
UsageDetail,
} from "./usage.js";
export type { export type {
PluginHandlerContext, PluginHandlerContext,
PluginServerContext, PluginServerContext,
+23
View File
@@ -18,6 +18,7 @@ export const PROVIDER_CAPABILITIES = [
"session.revert.files", "session.revert.files",
"session.subsession", "session.subsession",
"session.unarchive", "session.unarchive",
"session.usage_reference",
"permission", "permission",
"permission.tool_policy", "permission.tool_policy",
"timeline.plugin", "timeline.plugin",
@@ -227,6 +228,7 @@ export type ProviderInput =
} }
| { type: "session.prompt"; sessionId: string; prompt: ProviderPrompt } | { type: "session.prompt"; sessionId: string; prompt: ProviderPrompt }
| { type: "session.interrupt"; requestId: string; sessionId: string } | { type: "session.interrupt"; requestId: string; sessionId: string }
| { type: "session.usage_reference"; requestId: string; sessionId: string }
| { | {
type: "session.permission"; type: "session.permission";
sessionId: string; sessionId: string;
@@ -526,6 +528,11 @@ export type ProviderEvent =
| { type: "catalog"; requestId: string; catalog: ProviderCatalog } | { type: "catalog"; requestId: string; catalog: ProviderCatalog }
| { type: "sessions"; requestId: string; sessions: ProviderSessionSummary[] } | { type: "sessions"; requestId: string; sessions: ProviderSessionSummary[] }
| { type: "request.completed"; requestId: string } | { type: "request.completed"; requestId: string }
| {
type: "usage_reference";
requestId: string;
reference: { source: string; input: JsonValue } | null;
}
| { type: "request.failed"; requestId: string; error: ProviderError } | { type: "request.failed"; requestId: string; error: ProviderError }
| { | {
type: "session.opened"; type: "session.opened";
@@ -609,6 +616,8 @@ export function requiredProviderCapabilities(input: ProviderInput): readonly Pro
case "session.interrupt": case "session.interrupt":
case "session.close": case "session.close":
return []; return [];
case "session.usage_reference":
return ["session.usage_reference"];
case "sessions": case "sessions":
return ["session.list"]; return ["session.list"];
case "session.open": { case "session.open": {
@@ -887,6 +896,13 @@ export const ProviderInputSchema: z.ZodType<ProviderInput> = z.discriminatedUnio
z z
.object({ type: z.literal("session.interrupt"), requestId: idSchema, sessionId: idSchema }) .object({ type: z.literal("session.interrupt"), requestId: idSchema, sessionId: idSchema })
.strict(), .strict(),
z
.object({
type: z.literal("session.usage_reference"),
requestId: idSchema,
sessionId: idSchema,
})
.strict(),
z z
.object({ .object({
type: z.literal("session.permission"), type: z.literal("session.permission"),
@@ -1272,6 +1288,13 @@ export const ProviderEventSchema: z.ZodType<ProviderEvent> = z.discriminatedUnio
}) })
.strip(), .strip(),
z.object({ type: z.literal("request.completed"), requestId: idSchema }).strip(), z.object({ type: z.literal("request.completed"), requestId: idSchema }).strip(),
z
.object({
type: z.literal("usage_reference"),
requestId: idSchema,
reference: z.object({ source: idSchema, input: z.json() }).nullable(),
})
.strip(),
z z
.object({ type: z.literal("request.failed"), requestId: idSchema, error: providerErrorSchema }) .object({ type: z.literal("request.failed"), requestId: idSchema, error: providerErrorSchema })
.strip(), .strip(),
@@ -1,5 +1,41 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it, test } from "vitest";
import { balanceToneFromRemaining, toneFromUsedPct, usedPctOf } from "./usage.js"; import {
balanceToneFromRemaining,
toneFromUsedPct,
usedPctOf,
windowFromUsedPct,
} from "./usage.js";
test("shared usage tones preserve the app threshold contract", () => {
expect([null, 69.9, 70, 90, 90.1].map(toneFromUsedPct)).toEqual([
"default",
"ok",
"warning",
"warning",
"danger",
]);
expect([null, 0, 0.01].map(balanceToneFromRemaining)).toEqual(["default", "danger", "ok"]);
});
test("shared windows retain percentage, reset time, and optional headline", () => {
expect(windowFromUsedPct({ id: "a", label: "A", utilizationPct: 30, headline: true })).toEqual({
id: "a",
label: "A",
usedPct: 30,
remainingPct: 70,
resetsAt: null,
headline: true,
});
expect(windowFromUsedPct({ id: "b", label: "B", utilizationPct: null })).toEqual({
id: "b",
label: "B",
usedPct: null,
remainingPct: null,
resetsAt: null,
});
expect(usedPctOf(25, 100)).toBe(25);
expect(usedPctOf(25, 0)).toBeNull();
});
describe("toneFromUsedPct", () => { describe("toneFromUsedPct", () => {
// Thresholds must match deriveTone in the app's provider-usage/tone.ts, which is what // Thresholds must match deriveTone in the app's provider-usage/tone.ts, which is what
@@ -1,50 +1,55 @@
import { z } from "zod"; import { createHash } from "node:crypto";
import type { import type { ZodType } from "zod";
ProviderUsage, import type { JsonValue } from "@getpaseo/protocol/agent-types";
ProviderUsageBalance,
ProviderUsageTone,
ProviderUsageWindow,
} from "../../server/messages.js";
import type { ProviderApiFetch } from "./provider.js";
const PROVIDER_HTTP_TIMEOUT_MS = 15_000; export interface UsageWindow {
id: string;
export const ApiNumberSchema = z.coerce.number().finite(); label: string;
export const ApiNullableNumberSchema = z.preprocess( usedPct?: number | null;
(value) => (value == null ? null : value), remainingPct?: number | null;
ApiNumberSchema.nullable(), resetsAt?: string | null;
); runsOutAt?: string | null;
export const ApiOptionalStringSchema = z.preprocess( shortfallPct?: number | null;
(value) => (value == null ? undefined : value), tone?: "default" | "ok" | "warning" | "danger";
z.coerce.string().optional(), headline?: boolean;
);
export function fetchProviderApi(
fetchApi: ProviderApiFetch,
input: RequestInfo | URL,
init: RequestInit = {},
): Promise<Response> {
return fetchApi(input, {
...init,
signal: init.signal ?? AbortSignal.timeout(PROVIDER_HTTP_TIMEOUT_MS),
});
} }
export function unavailableUsage(provider: { export interface UsageBalance {
providerId: string; id: string;
displayName: string; label: string;
error?: string | null; used?: number | null;
}): ProviderUsage { remaining?: number | null;
return { limit?: number | null;
providerId: provider.providerId, unit: "usd" | "credits" | "requests" | "tokens";
displayName: provider.displayName, resetsAt?: string | null;
status: provider.error ? "error" : "unavailable", tone?: UsageWindow["tone"];
planLabel: null, }
windows: [],
balances: [], export interface UsageDetail {
details: [], id: string;
error: provider.error ?? null, label: string;
}; value: string;
tone?: UsageWindow["tone"];
}
export interface UsageReport {
status: "available" | "unavailable" | "error";
planLabel?: string;
windows: UsageWindow[];
balances?: UsageBalance[];
details?: UsageDetail[];
error?: string;
}
export interface UsageSourceRegistration {
id: string;
label: string;
icon?: string;
input: ZodType;
/** Stable account identity, resolved without fetching usage. */
identify(input: unknown): Promise<{ key: string; label?: string } | null>;
fetch(input: unknown): Promise<UsageReport>;
discover?(): Promise<JsonValue[]>;
} }
export function windowFromUsedPct(input: { export function windowFromUsedPct(input: {
@@ -52,19 +57,19 @@ export function windowFromUsedPct(input: {
label: string; label: string;
utilizationPct: number | null | undefined; utilizationPct: number | null | undefined;
resetsAt?: string | null; resetsAt?: string | null;
tone?: ProviderUsageWindow["tone"]; tone?: UsageWindow["tone"];
}): ProviderUsageWindow { headline?: boolean;
}): UsageWindow {
const usedPct = typeof input.utilizationPct === "number" ? input.utilizationPct : null; const usedPct = typeof input.utilizationPct === "number" ? input.utilizationPct : null;
const window: ProviderUsageWindow = { const window: UsageWindow = {
id: input.id, id: input.id,
label: input.label, label: input.label,
usedPct, usedPct,
remainingPct: usedPct === null ? null : Math.max(0, 100 - usedPct), remainingPct: usedPct === null ? null : Math.max(0, 100 - usedPct),
resetsAt: input.resetsAt ?? null, resetsAt: input.resetsAt ?? null,
}; };
if (input.tone) { if (input.tone) window.tone = input.tone;
window.tone = input.tone; if (input.headline) window.headline = true;
}
return window; return window;
} }
@@ -76,7 +81,7 @@ export function windowFromUsedPct(input: {
* "default" because that is what every provider setting a tone has always sent, and it is * "default" because that is what every provider setting a tone has always sent, and it is
* what the bars render today below their thresholds. * what the bars render today below their thresholds.
*/ */
export function toneFromUsedPct(usedPct: number | null | undefined): ProviderUsageTone { export function toneFromUsedPct(usedPct: number | null | undefined): UsageWindow["tone"] {
if (typeof usedPct !== "number") return "default"; if (typeof usedPct !== "number") return "default";
if (usedPct > 90) return "danger"; if (usedPct > 90) return "danger";
if (usedPct >= 70) return "warning"; if (usedPct >= 70) return "warning";
@@ -90,7 +95,7 @@ export function toneFromUsedPct(usedPct: number | null | undefined): ProviderUsa
*/ */
export function balanceToneFromRemaining( export function balanceToneFromRemaining(
remaining: number | null | undefined, remaining: number | null | undefined,
): ProviderUsageBalance["tone"] { ): UsageBalance["tone"] {
if (typeof remaining !== "number") return "default"; if (typeof remaining !== "number") return "default";
if (remaining <= 0) return "danger"; if (remaining <= 0) return "danger";
return "ok"; return "ok";
@@ -105,7 +110,15 @@ export function usedPctOf(
return (used / limit) * 100; return (used / limit) * 100;
} }
export function toIsoStringOrNull(timestampMs: number): string | null { export function hashAccountKey(value: string): string {
const date = new Date(timestampMs); return createHash("sha256").update(value).digest("hex");
return Number.isFinite(date.getTime()) ? date.toISOString() : null; }
export function unavailableUsage(): UsageReport {
return {
status: "unavailable",
windows: [],
balances: [],
details: [],
};
} }
+49
View File
@@ -1774,6 +1774,18 @@ export const ProviderUsageListRequestMessageSchema = z.object({
requestId: z.string(), requestId: z.string(),
}); });
export const UsageListReportsRequestMessageSchema = z.object({
type: z.literal("usage.list_reports.request"),
requestId: z.string(),
reportIds: z.array(z.string()).optional(),
forceRefresh: z.boolean().optional(),
});
export const AgentResolveUsageReportRequestMessageSchema = z.object({
type: z.literal("agent.resolve_usage_report.request"),
requestId: z.string(),
agentId: z.string(),
});
export const ResumeAgentRequestMessageSchema = z.object({ export const ResumeAgentRequestMessageSchema = z.object({
type: z.literal("resume_agent_request"), type: z.literal("resume_agent_request"),
handle: AgentPersistenceHandleSchema, handle: AgentPersistenceHandleSchema,
@@ -3232,6 +3244,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
RefreshProvidersSnapshotRequestMessageSchema, RefreshProvidersSnapshotRequestMessageSchema,
ProviderDiagnosticRequestMessageSchema, ProviderDiagnosticRequestMessageSchema,
ProviderUsageListRequestMessageSchema, ProviderUsageListRequestMessageSchema,
UsageListReportsRequestMessageSchema,
AgentResolveUsageReportRequestMessageSchema,
ResumeAgentRequestMessageSchema, ResumeAgentRequestMessageSchema,
ImportAgentRequestMessageSchema, ImportAgentRequestMessageSchema,
RefreshAgentRequestMessageSchema, RefreshAgentRequestMessageSchema,
@@ -3534,6 +3548,7 @@ export const ServerInfoStatusPayloadSchema = z
// COMPAT(hubAgentRpc): added in v0.8.0; remove gate after 2027-03-05. // COMPAT(hubAgentRpc): added in v0.8.0; remove gate after 2027-03-05.
hubAgentRpc: z.boolean().optional(), hubAgentRpc: z.boolean().optional(),
providersSnapshot: z.boolean().optional(), providersSnapshot: z.boolean().optional(),
usageSources: z.boolean().optional(),
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10. // COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
providersSnapshotCwd: z.boolean().optional(), providersSnapshotCwd: z.boolean().optional(),
// COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12. // COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12.
@@ -6228,6 +6243,32 @@ export const ProviderUsageListResponseMessageSchema = z.object({
}), }),
}); });
export const UsageReportSchema = z.object({
status: ProviderUsageStatusSchema,
planLabel: z.string().optional(),
windows: z.array(ProviderUsageWindowSchema.extend({ headline: z.boolean().optional() })),
balances: z.array(ProviderUsageBalanceSchema).optional(),
details: z.array(ProviderUsageDetailSchema).optional(),
error: z.string().optional(),
});
export const UsageReportEntrySchema = z.object({
id: z.string(),
account: z.object({ label: z.string().optional() }),
fetchedAt: z.string(),
sourceId: z.string(),
sourceLabel: z.string(),
icon: z.string().optional(),
report: UsageReportSchema,
});
export const UsageListReportsResponseMessageSchema = z.object({
type: z.literal("usage.list_reports.response"),
payload: z.object({ requestId: z.string(), reports: z.array(UsageReportEntrySchema) }),
});
export const AgentResolveUsageReportResponseMessageSchema = z.object({
type: z.literal("agent.resolve_usage_report.response"),
payload: z.object({ requestId: z.string(), reportId: z.string().nullable() }),
});
const AgentSlashCommandSchema = z.object({ const AgentSlashCommandSchema = z.object({
name: z.string(), name: z.string(),
description: z.string(), description: z.string(),
@@ -6913,6 +6954,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
RefreshProvidersSnapshotResponseMessageSchema, RefreshProvidersSnapshotResponseMessageSchema,
ProviderDiagnosticResponseMessageSchema, ProviderDiagnosticResponseMessageSchema,
ProviderUsageListResponseMessageSchema, ProviderUsageListResponseMessageSchema,
UsageListReportsResponseMessageSchema,
AgentResolveUsageReportResponseMessageSchema,
ListCommandsResponseSchema, ListCommandsResponseSchema,
ListTerminalsResponseSchema, ListTerminalsResponseSchema,
TerminalsChangedSchema, TerminalsChangedSchema,
@@ -7091,6 +7134,12 @@ export type ProviderDiagnosticResponseMessage = z.infer<
typeof ProviderDiagnosticResponseMessageSchema typeof ProviderDiagnosticResponseMessageSchema
>; >;
export type ProviderUsageTone = z.infer<typeof ProviderUsageToneSchema>; export type ProviderUsageTone = z.infer<typeof ProviderUsageToneSchema>;
export type UsageReport = z.infer<typeof UsageReportSchema>;
export type UsageReportEntry = z.infer<typeof UsageReportEntrySchema>;
export type UsageListReportsResponseMessage = z.infer<typeof UsageListReportsResponseMessageSchema>;
export type AgentResolveUsageReportResponseMessage = z.infer<
typeof AgentResolveUsageReportResponseMessageSchema
>;
export type ProviderUsageStatus = z.infer<typeof ProviderUsageStatusSchema>; export type ProviderUsageStatus = z.infer<typeof ProviderUsageStatusSchema>;
export type ProviderUsage = z.infer<typeof ProviderUsageSchema>; export type ProviderUsage = z.infer<typeof ProviderUsageSchema>;
export type ProviderUsageWindow = z.infer<typeof ProviderUsageWindowSchema>; export type ProviderUsageWindow = z.infer<typeof ProviderUsageWindowSchema>;
+2 -1
View File
@@ -73,7 +73,7 @@
"clean": "node ../../scripts/clean-package-dist.mjs", "clean": "node ../../scripts/clean-package-dist.mjs",
"build": "npm run build:lib && npm run build:scripts", "build": "npm run build:lib && npm run build:scripts",
"build:clean": "npm run clean && npm run build", "build:clean": "npm run clean && npm run build",
"build:lib": "tsc -p tsconfig.server.json --incremental false && node scripts/build-opencode-bridge-plugin.mjs && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.copyFileSync('src/terminal/terminal-ts-loader.mjs','dist/server/terminal/terminal-ts-loader.mjs'); fs.rmSync('dist/server/skills',{recursive:true,force:true}); fs.cpSync('../../skills','dist/server/skills',{recursive:true});\"", "build:lib": "tsc -p tsconfig.server.json --incremental false && node scripts/build-opencode-bridge-plugin.mjs && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.copyFileSync('src/terminal/terminal-ts-loader.mjs','dist/server/terminal/terminal-ts-loader.mjs'); fs.rmSync('dist/server/skills',{recursive:true,force:true}); fs.cpSync('../../skills','dist/server/skills',{recursive:true}); fs.rmSync('dist/server/builtin-plugins',{recursive:true,force:true}); fs.cpSync('../../plugins','dist/server/builtin-plugins',{recursive:true,filter:(source)=>!source.split(require('node:path').sep).includes('node_modules')&&!/\\.(test|spec)\\.[cm]?[jt]sx?$/.test(source)&&!source.endsWith('.tsbuildinfo')});\"",
"build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"", "build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"",
"prepack": "npm run build:clean && npm --prefix ../.. run build:daemon-web-ui", "prepack": "npm run build:clean && npm --prefix ../.. run build:daemon-web-ui",
"start": "node dist/scripts/supervisor-entrypoint.js", "start": "node dist/scripts/supervisor-entrypoint.js",
@@ -144,6 +144,7 @@
"uuid": "^9.0.1", "uuid": "^9.0.1",
"which": "^5.0.0", "which": "^5.0.0",
"ws": "^8.14.2", "ws": "^8.14.2",
"yaml": "2.9.1",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
@@ -660,6 +660,11 @@ export interface AgentPermissionResult {
followUpPrompt?: AgentPromptInput; followUpPrompt?: AgentPromptInput;
} }
export interface UsageReference {
source: string;
input: JsonValue;
}
export interface AgentSession { export interface AgentSession {
readonly provider: AgentProvider; readonly provider: AgentProvider;
readonly id: string | null; readonly id: string | null;
@@ -668,6 +673,8 @@ export interface AgentSession {
/** New provider-owned rows to commit on registration. streamHistory must also /** New provider-owned rows to commit on registration. streamHistory must also
* replay them at their original timestamps; restored sessions omit old rows. */ * replay them at their original timestamps; restored sessions omit old rows. */
readonly initialTimeline?: ImportedTimelineEntry[]; readonly initialTimeline?: ImportedTimelineEntry[];
/** Resolved at fetch time because model and credentials may change during a session. */
getUsageReference?(): Promise<UsageReference | null>;
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>; run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>; startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>; steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
@@ -396,6 +396,24 @@ describe("PluginAgentClientRegistry", () => {
await registry.shutdown(); await registry.shutdown();
}); });
test("returns no usage reference when a plugin provider lacks the capability", async () => {
const harness = createProviderHarness();
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const client = registry.clients()[harness.registration.id]!;
const session = await client.createSession({
provider: harness.registration.id,
cwd: "/workspace",
});
try {
expect(await session.getUsageReference?.()).toBeNull();
expect(harness.inputs.some((input) => input.type === "session.usage_reference")).toBe(false);
} finally {
await session.close();
registry.replace([]);
}
});
test("terminalizes an active turn exactly once when its plugin provider is removed", async () => { test("terminalizes an active turn exactly once when its plugin provider is removed", async () => {
const harness = createProviderHarness({ completeTurn: false }); const harness = createProviderHarness({ completeTurn: false });
const registry = new PluginAgentClientRegistry(createTestLogger()); const registry = new PluginAgentClientRegistry(createTestLogger());
@@ -51,6 +51,7 @@ import type {
ProviderRefreshContext, ProviderRefreshContext,
SteerActiveTurnOptions, SteerActiveTurnOptions,
SteerResult, SteerResult,
UsageReference,
} from "./agent-sdk-types.js"; } from "./agent-sdk-types.js";
import { import {
isDefaultAgentCreateConfigUnattended, isDefaultAgentCreateConfigUnattended,
@@ -105,6 +106,20 @@ function providerError(error: ProviderError): Error {
}); });
} }
function isProviderRequestReply(
event: ProviderEvent,
): event is Extract<
ProviderEvent,
{ type: "request.completed" | "catalog" | "sessions" | "usage_reference" }
> {
return (
event.type === "request.completed" ||
event.type === "catalog" ||
event.type === "sessions" ||
event.type === "usage_reference"
);
}
class ProviderRuntime { class ProviderRuntime {
private connection: ProviderConnection | null = null; private connection: ProviderConnection | null = null;
private connecting: Promise<ProviderConnection> | null = null; private connecting: Promise<ProviderConnection> | null = null;
@@ -356,11 +371,7 @@ class ProviderRuntime {
this.failRequest(event); this.failRequest(event);
return; return;
} }
if ( if (isProviderRequestReply(event)) {
event.type === "request.completed" ||
event.type === "catalog" ||
event.type === "sessions"
) {
this.finishRequest(event); this.finishRequest(event);
return; return;
} }
@@ -515,7 +526,10 @@ class ProviderRuntime {
} }
private finishRequest( private finishRequest(
event: Extract<ProviderEvent, { type: "request.completed" | "catalog" | "sessions" }>, event: Extract<
ProviderEvent,
{ type: "request.completed" | "catalog" | "sessions" | "usage_reference" }
>,
): void { ): void {
const request = this.requests.get(event.requestId); const request = this.requests.get(event.requestId);
if (!request) return; if (!request) return;
@@ -554,6 +568,17 @@ class ProviderRuntimeSession {
return this.capabilities; return this.capabilities;
} }
async getUsageReference(): Promise<UsageReference | null> {
if (!this.capabilities.includes("session.usage_reference")) return null;
const event = await this.runtime.complete({
type: "session.usage_reference",
requestId: randomUUID(),
sessionId: this.providerSessionId,
});
if (event.type !== "usage_reference") throw new Error("Invalid usage reference response");
return event.reference;
}
onEvent(listener: (event: ProviderEvent) => void): () => void { onEvent(listener: (event: ProviderEvent) => void): () => void {
this.listeners.add(listener); this.listeners.add(listener);
return () => this.listeners.delete(listener); return () => this.listeners.delete(listener);
@@ -1063,6 +1088,10 @@ class PluginAgentSession implements AgentSession {
return agentCapabilities(this.bridge.negotiatedCapabilities); return agentCapabilities(this.bridge.negotiatedCapabilities);
} }
getUsageReference(): Promise<UsageReference | null> {
return this.bridge.getUsageReference();
}
get features(): AgentFeature[] { get features(): AgentFeature[] {
return this.bridge.config.settings.map((setting) => return this.bridge.config.settings.map((setting) =>
setting.type === "toggle" setting.type === "toggle"
@@ -458,6 +458,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
} }
}, },
getRuntimeInfo: async () => mapRuntimeInfo(provider, await inner.getRuntimeInfo()), getRuntimeInfo: async () => mapRuntimeInfo(provider, await inner.getRuntimeInfo()),
getUsageReference: inner.getUsageReference?.bind(inner),
getAvailableModes: () => inner.getAvailableModes(), getAvailableModes: () => inner.getAvailableModes(),
getCurrentMode: () => inner.getCurrentMode(), getCurrentMode: () => inner.getCurrentMode(),
setMode: (modeId) => inner.setMode(modeId), setMode: (modeId) => inner.setMode(modeId),
@@ -222,6 +222,13 @@ function createSessionWithConfig(
); );
} }
test("ACP usage reference uses the provider ID", async () => {
for (const provider of ["copilot", "cursor", "kimi", "custom-source"]) {
const session = createSessionWithConfig({ provider });
expect(await session.getUsageReference()).toEqual({ source: provider, input: {} });
}
});
function createKiroSession( function createKiroSession(
options: { waitForInitialCommands?: boolean; initialCommandsWaitTimeoutMs?: number } = {}, options: { waitForInitialCommands?: boolean; initialCommandsWaitTimeoutMs?: number } = {},
logger: ReturnType<typeof createTestLogger> = createTestLogger(), logger: ReturnType<typeof createTestLogger> = createTestLogger(),
@@ -1633,6 +1633,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
readonly provider: string; readonly provider: string;
readonly capabilities: AgentCapabilityFlags; readonly capabilities: AgentCapabilityFlags;
async getUsageReference() {
return { source: this.provider, input: {} };
}
private readonly logger: Logger; private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly defaultCommand: [string, ...string[]]; private readonly defaultCommand: [string, ...string[]];
@@ -1676,6 +1680,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private connection: ClientSideConnection | null = null; private connection: ClientSideConnection | null = null;
private agentCapabilities: ACPAgentCapabilities | null = null; private agentCapabilities: ACPAgentCapabilities | null = null;
private sessionId: string | null = null; private sessionId: string | null = null;
private readonly earlySessionUpdates: SessionNotification[] = [];
private currentMode: string | null = null; private currentMode: string | null = null;
private availableModes: AgentMode[]; private availableModes: AgentMode[];
private currentModel: string | null = null; private currentModel: string | null = null;
@@ -1752,6 +1757,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.sessionId = response.sessionId; this.sessionId = response.sessionId;
this.bootstrapThreadEventPending = true; this.bootstrapThreadEventPending = true;
this.applySessionState(response); this.applySessionState(response);
for (const update of this.earlySessionUpdates.splice(0)) {
await this.sessionUpdate(update);
}
await this.applyConfiguredOverrides(); await this.applyConfiguredOverrides();
} catch (error) { } catch (error) {
await this.closeAfterInitializationFailure(error); await this.closeAfterInitializationFailure(error);
@@ -2521,6 +2529,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}, },
"provider.acp.raw_event", "provider.acp.raw_event",
); );
if (!this.sessionId) {
this.earlySessionUpdates.push(params);
return;
}
if (params.sessionId !== this.sessionId) { if (params.sessionId !== this.sessionId) {
return; return;
} }
@@ -25,6 +25,7 @@ import type {
} from "../../agent-sdk-types.js"; } from "../../agent-sdk-types.js";
import type { AgentAttachment } from "@getpaseo/protocol/messages"; import type { AgentAttachment } from "@getpaseo/protocol/messages";
import { buildAgentPrompt, renderPromptAttachmentAsText } from "../../prompt-attachments.js"; import { buildAgentPrompt, renderPromptAttachmentAsText } from "../../prompt-attachments.js";
import { buildProviderRegistry } from "../../provider-registry.js";
interface TestClaudeSession { interface TestClaudeSession {
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[]; translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
@@ -527,6 +528,56 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
describe("ClaudeAgentClient binary resolution", () => { describe("ClaudeAgentClient binary resolution", () => {
const logger = createTestLogger(); const logger = createTestLogger();
test("Claude usage reference follows CLAUDE_CONFIG_DIR and excludes API overrides", async () => {
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
const session = await client.createSession(
{ provider: "claude", cwd: process.cwd() },
{ env: { CLAUDE_CONFIG_DIR: "/accounts/second" } },
);
expect(await session.getUsageReference?.()).toEqual({
source: "claude",
input: { configDir: "/accounts/second" },
});
await session.close();
});
test.each(["ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"])(
"Claude usage reference is null with %s override",
async (name) => {
const client = new ClaudeAgentClient({
logger,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession(
{ provider: "claude", cwd: process.cwd() },
{ env: { [name]: "override" } },
);
expect(await session.getUsageReference?.()).toBeNull();
await session.close();
},
);
test("Claude custom alias keeps its CLAUDE_CONFIG_DIR usage reference", async () => {
const registry = buildProviderRegistry(logger, {
providerOverrides: {
"work-claude": {
extends: "claude",
label: "Work Claude",
env: { CLAUDE_CONFIG_DIR: "/accounts/work" },
},
},
});
const session = await registry["work-claude"].createClient(logger).createSession({
provider: "work-claude",
cwd: process.cwd(),
});
expect(await session.getUsageReference?.()).toEqual({
source: "claude",
input: { configDir: "/accounts/work" },
});
await session.close();
});
test("resolves the installed Claude Code version", async () => { test("resolves the installed Claude Code version", async () => {
await expect(resolveClaudeCodeVersion()).resolves.toMatch(/^\d+\.\d+\.\d+$/); await expect(resolveClaudeCodeVersion()).resolves.toMatch(/^\d+\.\d+\.\d+$/);
}); });
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
import fs from "node:fs"; import fs from "node:fs";
import { promises } from "node:fs"; import { promises } from "node:fs";
import path from "node:path"; import path from "node:path";
import { homedir } from "node:os";
import { import {
type AgentDefinition, type AgentDefinition,
type CanUseTool, type CanUseTool,
@@ -2041,6 +2042,15 @@ class ClaudeAgentSession implements AgentSession {
readonly provider = "claude" as const; readonly provider = "claude" as const;
readonly capabilities = CLAUDE_CAPABILITIES; readonly capabilities = CLAUDE_CAPABILITIES;
async getUsageReference() {
const env = this.buildSdkEnv();
if (env.ANTHROPIC_BASE_URL || env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN) return null;
return {
source: "claude",
input: { configDir: env.CLAUDE_CONFIG_DIR || path.join(env.HOME || homedir(), ".claude") },
};
}
private readonly config: ClaudeAgentConfig; private readonly config: ClaudeAgentConfig;
private readonly launchEnv?: Record<string, string>; private readonly launchEnv?: Record<string, string>;
private readonly agentId?: string; private readonly agentId?: string;
@@ -171,6 +171,44 @@ function createSession(
return session; return session;
} }
test("Codex usage reference follows CODEX_HOME and excludes custom base URLs", async () => {
const session = new CodexAppServerAgentSession(
createConfig(),
null,
createTestLogger(),
() => {
throw new Error("unused");
},
{},
false,
false,
false,
undefined,
"interactive",
{ CODEX_HOME: "/accounts/second" },
);
expect(await session.getUsageReference()).toEqual({
source: "codex",
input: { codexHome: "/accounts/second" },
});
const custom = new CodexAppServerAgentSession(
createConfig(),
null,
createTestLogger(),
() => {
throw new Error("unused");
},
{},
false,
false,
false,
undefined,
"interactive",
{ CODEX_HOME: "/accounts/second", OPENAI_BASE_URL: "https://example.test" },
);
expect(await custom.getUsageReference()).toBeNull();
});
function createProviderWithFakeAppServer( function createProviderWithFakeAppServer(
appServer: FakeCodexAppServer, appServer: FakeCodexAppServer,
options?: { options?: {
@@ -3453,6 +3453,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private readonly autoReviewEnabled: boolean = false, private readonly autoReviewEnabled: boolean = false,
private readonly agentId?: string, private readonly agentId?: string,
private readonly initialResumePurpose: "interactive" | "history" = "interactive", private readonly initialResumePurpose: "interactive" | "history" = "interactive",
private readonly usageEnv: NodeJS.ProcessEnv = process.env,
) { ) {
this.logger = logger.child({ this.logger = logger.child({
module: "agent", module: "agent",
@@ -3481,6 +3482,17 @@ export class CodexAppServerAgentSession implements AgentSession {
} }
} }
async getUsageReference() {
if (this.usageEnv.OPENAI_BASE_URL) return null;
return {
source: "codex",
input: {
codexHome:
this.usageEnv.CODEX_HOME || path.join(this.usageEnv.HOME || os.homedir(), ".codex"),
},
};
}
get id(): string | null { get id(): string | null {
return this.currentThreadId; return this.currentThreadId;
} }
@@ -7144,6 +7156,8 @@ export class CodexAppServerAgentClient implements AgentClient {
goalsEnabled, goalsEnabled,
autoReviewEnabled, autoReviewEnabled,
launchContext?.agentId, launchContext?.agentId,
"interactive",
buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env),
); );
await session.connect(); await session.connect();
return session; return session;
@@ -7176,6 +7190,7 @@ export class CodexAppServerAgentClient implements AgentClient {
autoReviewEnabled, autoReviewEnabled,
launchContext?.agentId, launchContext?.agentId,
options?.purpose ?? "interactive", options?.purpose ?? "interactive",
buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env),
); );
await session.connect(); await session.connect();
return session; return session;
@@ -13,9 +13,7 @@ describe("GenericACPAgentClient slash commands", () => {
const client = new GenericACPAgentClient({ const client = new GenericACPAgentClient({
logger: createTestLogger(), logger: createTestLogger(),
command, command,
// Long enough that the update always lands inside the wait, however slowly initialCommandsWaitTimeoutMs: 1_500,
// the fake agent is scheduled.
initialCommandsWaitTimeoutMs: 60_000,
}); });
const session = await client.createSession({ provider: "acp", cwd }); const session = await client.createSession({ provider: "acp", cwd });
try { try {
@@ -83,20 +81,26 @@ rl.on("line", (line) => {
} }
if (message.method === "session/new") { if (message.method === "session/new") {
write({ id: message.id, result: { sessionId: "session-1" } });
if (mode === "commands-after-session-new") { if (mode === "commands-after-session-new") {
setTimeout(() => { process.stdout.write(
write({ [
method: "session/update", { id: message.id, result: { sessionId: "session-1" } },
params: { {
sessionId: "session-1", method: "session/update",
update: { params: {
sessionUpdate: "available_commands_update", sessionId: "session-1",
availableCommands: [{ name: "review", description: "Review the diff" }], update: {
sessionUpdate: "available_commands_update",
availableCommands: [{ name: "review", description: "Review the diff" }],
},
}, },
}, },
}); ]
}, 0); .map((entry) => JSON.stringify({ jsonrpc: "2.0", ...entry }) + "\\n")
.join(""),
);
} else {
write({ id: message.id, result: { sessionId: "session-1" } });
} }
return; return;
} }
@@ -1,5 +1,9 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { setImmediate as waitForImmediate } from "node:timers/promises"; import { setImmediate as waitForImmediate } from "node:timers/promises";
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AgentStreamEvent } from "../../agent-sdk-types.js"; import type { AgentStreamEvent } from "../../agent-sdk-types.js";
import type { PaseoToolCatalog } from "../../tools/types.js"; import type { PaseoToolCatalog } from "../../tools/types.js";
@@ -144,6 +148,79 @@ function createToolCatalog(): PaseoToolCatalog {
} }
describe("OMP agent client and session", () => { describe("OMP agent client and session", () => {
test("usage reference reads current get_state provider and account on each call", async () => {
const home = mkdtempSync(join(tmpdir(), "paseo-omp-reference-"));
const agentDir = join(home, "agent");
mkdirSync(agentDir);
// Upstream: packages/ai/src/auth/sqlite-credential-store.ts:576-581,711-724.
const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite") as {
DatabaseSync: new (path: string) => {
exec(sql: string): void;
prepare(sql: string): { run(...args: unknown[]): void };
close(): void;
};
};
const db = new DatabaseSync(join(agentDir, "agent.db"));
db.exec(`
CREATE TABLE cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER NOT NULL);
CREATE INDEX idx_cache_expires ON cache(expires_at);
CREATE TABLE auth_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL,
credential_type TEXT NOT NULL,
data TEXT NOT NULL,
disabled_cause TEXT DEFAULT NULL,
identity_key TEXT DEFAULT NULL,
created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER)),
updated_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER))
);
`);
const insert = db.prepare(
"INSERT INTO auth_credentials (provider, credential_type, data) VALUES (?, 'oauth', ?)",
);
insert.run(
"openai-codex",
JSON.stringify({
access: "codex-fixture",
accountId: "codex-account",
expires: Date.now() + 60_000,
}),
);
insert.run(
"anthropic",
JSON.stringify({
access: "claude-fixture",
accountId: "claude-account",
expires: Date.now() + 60_000,
}),
);
db.close();
try {
const omp = new OmpHarness();
await omp.start({}, undefined, {
OMP_PROFILE: "",
PI_CODING_AGENT_DIR: agentDir,
XDG_DATA_HOME: "",
});
const runtime = omp.runtime();
runtime.state = { ...runtime.state, model: { provider: "openai-codex", id: "codex-model" } };
const requestsBefore = runtime.getStateRequestCount;
expect(await omp.getUsageReference()).toEqual({
source: "codex",
input: { accessToken: "codex-fixture", accountId: "codex-account" },
});
expect(runtime.getStateRequestCount).toBe(requestsBefore + 1);
runtime.state = { ...runtime.state, model: { provider: "anthropic", id: "claude-model" } };
expect(await omp.getUsageReference()).toEqual({
source: "claude",
input: { accessToken: "claude-fixture" },
});
expect(runtime.getStateRequestCount).toBe(requestsBefore + 2);
await omp.close();
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("owns launch configuration and registers native host tools", async () => { test("owns launch configuration and registers native host tools", async () => {
const omp = new OmpHarness(); const omp = new OmpHarness();
await omp.start({ modeId: "ask" }, createToolCatalog()); await omp.start({ modeId: "ask" }, createToolCatalog());
@@ -108,6 +108,7 @@ import {
mapOmpRpcUiPermissionRequest, mapOmpRpcUiPermissionRequest,
} from "./rpc-ui-permission-mapper.js"; } from "./rpc-ui-permission-mapper.js";
import { DEFAULT_OMP_THINKING_LEVEL, mapOmpModel } from "./map-omp-model.js"; import { DEFAULT_OMP_THINKING_LEVEL, mapOmpModel } from "./map-omp-model.js";
import { resolveOmpUsageReference } from "./usage-reference.js";
const OMP_PROVIDER = "omp"; const OMP_PROVIDER = "omp";
const QUESTION_RESPONSE_HEADER = "Response"; const QUESTION_RESPONSE_HEADER = "Response";
@@ -186,6 +187,7 @@ interface OmpAgentSessionOptions {
noTurnScheduler?: OmpNoTurnScheduler; noTurnScheduler?: OmpNoTurnScheduler;
usagePollScheduler?: OmpUsagePollScheduler; usagePollScheduler?: OmpUsagePollScheduler;
paseoTools?: PaseoToolCatalog; paseoTools?: PaseoToolCatalog;
usageEnv?: NodeJS.ProcessEnv;
/** /**
* When false (resumed sessions), replayed session events are dropped until * When false (resumed sessions), replayed session events are dropped until
* the first prompt or agent_start so history is not re-emitted as live * the first prompt or agent_start so history is not re-emitted as live
@@ -916,6 +918,7 @@ export class OmpAgentSession implements AgentSession {
this.currentModeId = options.currentModeId ?? null; this.currentModeId = options.currentModeId ?? null;
this.logger = options.logger; this.logger = options.logger;
this.paseoTools = options.paseoTools; this.paseoTools = options.paseoTools;
this.usageEnv = options.usageEnv ?? process.env;
this.live = options.live ?? true; this.live = options.live ?? true;
this.providerIdleScheduler = options.providerIdleScheduler ?? createOmpProviderIdleScheduler(); this.providerIdleScheduler = options.providerIdleScheduler ?? createOmpProviderIdleScheduler();
this.noTurnScheduler = options.noTurnScheduler ?? createOmpNoTurnScheduler(); this.noTurnScheduler = options.noTurnScheduler ?? createOmpNoTurnScheduler();
@@ -964,6 +967,12 @@ export class OmpAgentSession implements AgentSession {
private readonly config: AgentSessionConfig; private readonly config: AgentSessionConfig;
private readonly logger: Logger; private readonly logger: Logger;
private readonly paseoTools?: PaseoToolCatalog; private readonly paseoTools?: PaseoToolCatalog;
private readonly usageEnv: NodeJS.ProcessEnv;
async getUsageReference() {
const state = await this.runtimeSession.getState();
return resolveOmpUsageReference(state.sessionId, state.model?.provider ?? "", this.usageEnv);
}
get id(): string | null { get id(): string | null {
return this.state.sessionId; return this.state.sessionId;
@@ -2304,6 +2313,7 @@ export class OmpAgentClient implements AgentClient {
noTurnScheduler: this.noTurnScheduler, noTurnScheduler: this.noTurnScheduler,
usagePollScheduler: this.usagePollScheduler, usagePollScheduler: this.usagePollScheduler,
paseoTools: launchContext?.paseoTools, paseoTools: launchContext?.paseoTools,
usageEnv: { ...process.env, ...this.runtimeSettings?.env, ...launchContext?.env },
}); });
} catch (error) { } catch (error) {
await runtimeSession.close().catch(() => undefined); await runtimeSession.close().catch(() => undefined);
@@ -2346,6 +2356,7 @@ export class OmpAgentClient implements AgentClient {
noTurnScheduler: this.noTurnScheduler, noTurnScheduler: this.noTurnScheduler,
usagePollScheduler: this.usagePollScheduler, usagePollScheduler: this.usagePollScheduler,
paseoTools: launchContext?.paseoTools, paseoTools: launchContext?.paseoTools,
usageEnv: { ...process.env, ...this.runtimeSettings?.env, ...launchContext?.env },
live: false, live: false,
}); });
} catch (error) { } catch (error) {
@@ -93,10 +93,11 @@ export class OmpHarness {
async start( async start(
config: Partial<AgentSessionConfig> = {}, config: Partial<AgentSessionConfig> = {},
paseoTools?: PaseoToolCatalog, paseoTools?: PaseoToolCatalog,
env?: Record<string, string>,
): Promise<void> { ): Promise<void> {
const session = await this.client.createSession( const session = await this.client.createSession(
{ provider: "omp", cwd: CWD, ...config }, { provider: "omp", cwd: CWD, ...config },
paseoTools ? { paseoTools } : undefined, paseoTools || env ? { paseoTools, env } : undefined,
); );
if (!(session instanceof OmpAgentSession)) { if (!(session instanceof OmpAgentSession)) {
throw new Error("OMP client returned a non-OMP session"); throw new Error("OMP client returned a non-OMP session");
@@ -482,6 +483,10 @@ export class OmpHarness {
return this.omp.latestSession(); return this.omp.latestSession();
} }
async getUsageReference() {
return this.requireSession().getUsageReference();
}
runningToolCallIds(): string[] { runningToolCallIds(): string[] {
const statusByCall = new Map<string, string>(); const statusByCall = new Map<string, string>();
for (const item of this.timeline()) { for (const item of this.timeline()) {
@@ -0,0 +1,284 @@
import { existsSync, mkdtempSync, readFileSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createRequire } from "node:module";
import { afterEach, expect, test } from "vitest";
import { resolveOmpUsageReference } from "./usage-reference.js";
const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite") as {
DatabaseSync: new (path: string) => {
exec(sql: string): void;
prepare(sql: string): { run(...args: unknown[]): void };
close(): void;
};
};
// Upstream: packages/ai/src/auth/sqlite-credential-store.ts:576-581,711-724.
const OMP_AUTH_SCHEMA = `
CREATE TABLE cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER NOT NULL);
CREATE INDEX idx_cache_expires ON cache(expires_at);
CREATE TABLE auth_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL,
credential_type TEXT NOT NULL,
data TEXT NOT NULL,
disabled_cause TEXT DEFAULT NULL,
identity_key TEXT DEFAULT NULL,
created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER)),
updated_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER))
);
`;
const dirs: string[] = [];
const future = Date.now() + 3_600_000;
function fixture() {
const home = mkdtempSync(join(tmpdir(), "omp-usage-"));
dirs.push(home);
const agentDir = join(home, "agent");
mkdirSync(agentDir);
const dbFile = join(agentDir, "agent.db");
const db = new DatabaseSync(dbFile);
db.exec(OMP_AUTH_SCHEMA);
const env = { OMP_PROFILE: "", PI_CODING_AGENT_DIR: agentDir, XDG_DATA_HOME: "" };
const row = (
id: number,
provider = "openai-codex",
options: { disabled?: boolean; expires?: number; type?: string } = {},
) =>
db
.prepare(
"INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause) VALUES (?, ?, ?, ?, ?)",
)
.run(
id,
provider,
options.type ?? "oauth",
JSON.stringify({
access: `fixture-token-${id}`,
expires: options.expires ?? future,
accountId: `account-${id}`,
}),
options.disabled ? "disabled" : null,
);
const sticky = (id: number, expires = Math.floor(Date.now() / 1000) + 100) =>
db
.prepare("INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?)")
.run(
"session:sticky:openai-codex:session-1",
JSON.stringify({ type: "oauth", index: 0, credentialId: id }),
expires,
);
const read = (override = {}) =>
resolveOmpUsageReference("session-1", "openai-codex", { ...env, ...override });
return { db, dbFile, agentDir, row, sticky, read };
}
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
test("sticky id selects the right OAuth row and leaves SQLite byte-identical", () => {
const f = fixture();
f.row(1);
f.row(2);
f.sticky(2);
f.db.close();
const before = readFileSync(f.dbFile);
expect(f.read()).toEqual({
source: "codex",
input: { accessToken: "fixture-token-2", accountId: "account-2" },
});
expect(f.read()).toEqual({
source: "codex",
input: { accessToken: "fixture-token-2", accountId: "account-2" },
});
expect(readFileSync(f.dbFile)).toEqual(before);
});
test("single OAuth row resolves without sticky", () => {
const f = fixture();
f.row(1);
f.db.close();
expect(f.read()).toEqual({
source: "codex",
input: { accessToken: "fixture-token-1", accountId: "account-1" },
});
});
test("multiple rows without sticky are ambiguous", () => {
const f = fixture();
f.row(1);
f.row(2);
f.db.close();
expect(f.read()).toBeNull();
});
test("fallback counts only valid OAuth rows", () => {
const f = fixture();
f.row(1);
f.row(2, "openai-codex", { expires: Date.now() - 1 });
f.row(3, "openai-codex", { disabled: true });
f.db
.prepare(
"INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause) VALUES (?, ?, ?, ?, ?)",
)
.run(4, "openai-codex", "oauth", "invalid-json", null);
f.db.close();
expect(f.read()).toEqual({
source: "codex",
input: { accessToken: "fixture-token-1", accountId: "account-1" },
});
});
test("disabled and expired rows do not resolve", () => {
const a = fixture();
a.row(1, "openai-codex", { disabled: true });
a.sticky(1);
a.db.close();
expect(a.read()).toBeNull();
const b = fixture();
b.row(1, "openai-codex", { expires: Date.now() - 1 });
b.db.close();
expect(b.read()).toBeNull();
});
test("expired sticky falls back to a single row", () => {
const f = fixture();
f.row(1);
f.sticky(1, 1);
f.db.close();
expect(f.read()?.source).toBe("codex");
});
test("env key does not displace OAuth with or without sticky", () => {
const f = fixture();
f.row(1);
f.db.close();
expect(f.read({ OPENAI_CODEX_OAUTH_TOKEN: "fixture-key" })?.source).toBe("codex");
const g = fixture();
g.row(1);
g.sticky(1);
g.db.close();
expect(g.read({ OPENAI_CODEX_OAUTH_TOKEN: "fixture-key" })?.source).toBe("codex");
});
test("broker environment and config URLs block attribution", () => {
const f = fixture();
f.row(1);
f.db.close();
expect(f.read({ OMP_AUTH_BROKER_URL: "https://broker.invalid" })).toBeNull();
writeFileSync(
join(f.agentDir, "config.yml"),
"auth:\n broker:\n url: https://broker.invalid\n",
);
expect(f.read()).toBeNull();
});
test("broker config.yaml and flat keys block attribution with config.yml precedence", () => {
const f = fixture();
f.row(1);
f.db.close();
writeFileSync(join(f.agentDir, "config.yaml"), '"auth.broker.url": https://broker.invalid\n');
expect(f.read()).toBeNull();
writeFileSync(join(f.agentDir, "config.yml"), "auth:\n broker: {}\n");
expect(f.read()).toEqual({
source: "codex",
input: { accessToken: "fixture-token-1", accountId: "account-1" },
});
writeFileSync(join(f.agentDir, "config.yml"), '"auth.broker.url": https://broker.invalid\n');
expect(f.read()).toBeNull();
});
test("models config API key overrides sticky OAuth", () => {
const f = fixture();
f.row(1);
f.sticky(1);
f.db.close();
writeFileSync(
join(f.agentDir, "models.yml"),
"providers:\n openai-codex:\n apiKey: fixture-key\n",
);
expect(f.read()).toBeNull();
});
test("provider mismatch and malformed sticky fail closed", () => {
const f = fixture();
f.row(1, "anthropic");
f.sticky(1);
f.db.close();
expect(f.read()).toBeNull();
});
test("Anthropic OAuth maps to Claude and changes are read on each request", () => {
const f = fixture();
f.row(1, "anthropic");
f.db.close();
expect(
resolveOmpUsageReference("session-1", "anthropic", {
OMP_PROFILE: "",
PI_CODING_AGENT_DIR: f.agentDir,
}),
).toEqual({
source: "claude",
input: { accessToken: "fixture-token-1" },
});
writeFileSync(
join(f.agentDir, "config.yml"),
"auth:\n broker:\n url: https://broker.invalid\n",
);
expect(
resolveOmpUsageReference("session-1", "anthropic", {
OMP_PROFILE: "",
PI_CODING_AGENT_DIR: f.agentDir,
}),
).toBeNull();
});
test("named profile ignores agent override and uses existing XDG profile data", () => {
const f = fixture();
f.db.close();
const root = mkdtempSync(join(tmpdir(), "omp-profile-"));
dirs.push(root);
const data = join(root, "xdg", "omp", "profiles", "work");
mkdirSync(data, { recursive: true });
const profileDb = new DatabaseSync(join(data, "agent.db"));
profileDb.exec(OMP_AUTH_SCHEMA);
profileDb
.prepare(
"INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause) VALUES (?, ?, ?, ?, ?)",
)
.run(
1,
"openai-codex",
"oauth",
JSON.stringify({ access: "profile-token", expires: future }),
null,
);
profileDb.close();
expect(
resolveOmpUsageReference(
"session-1",
"openai-codex",
{
OMP_PROFILE: "work",
PI_PROFILE: "other",
PI_CODING_AGENT_DIR: f.agentDir,
PI_CONFIG_DIR: join(root, "config"),
XDG_DATA_HOME: join(root, "xdg"),
},
"linux",
),
).toEqual({ source: "codex", input: { accessToken: "profile-token" } });
});
test("missing database returns null without creating a file", () => {
const home = mkdtempSync(join(tmpdir(), "omp-missing-db-"));
dirs.push(home);
const agentDir = join(home, "agent");
mkdirSync(agentDir);
expect(
resolveOmpUsageReference("session-1", "openai-codex", {
OMP_PROFILE: "",
PI_CODING_AGENT_DIR: agentDir,
XDG_DATA_HOME: "",
}),
).toBeNull();
expect(existsSync(join(agentDir, "agent.db"))).toBe(false);
});
test("missing cache or credentials table returns null", () => {
const first = fixture();
first.db.exec("DROP TABLE cache");
first.db.close();
expect(first.read()).toBeNull();
const second = fixture();
second.db.exec("DROP TABLE auth_credentials");
second.db.close();
expect(second.read()).toBeNull();
});
@@ -0,0 +1,169 @@
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { join } from "node:path";
import { z } from "zod";
import { parse as parseYaml } from "yaml";
import type { UsageReference } from "../../agent-sdk-types.js";
import { resolveOmpDiagnosticPaths } from "./provider-config.js";
const stickySchema = z.object({ type: z.string(), credentialId: z.number().int().positive() });
const rowSchema = z.object({
id: z.number().int().positive(),
provider: z.string(),
credential_type: z.string(),
data: z.string(),
disabled_cause: z.string().nullable(),
});
const oauthSchema = z.object({
access: z.string().min(1),
expires: z.number(),
accountId: z.string().optional(),
orgId: z.string().optional(),
});
const configSchema = z
.object({
"auth.broker.url": z.string().optional(),
auth: z
.object({ broker: z.object({ url: z.string().optional() }).passthrough().optional() })
.passthrough()
.optional(),
})
.passthrough();
const modelsSchema = z
.object({
providers: z
.record(z.string(), z.object({ apiKey: z.unknown().optional() }).passthrough())
.optional(),
})
.passthrough();
interface UsageDb {
prepare(sql: string): { get(...args: unknown[]): unknown; all(...args: unknown[]): unknown[] };
close(): void;
}
const localRequire = createRequire(import.meta.url);
function readYaml(file: string): unknown {
try {
return parseYaml(readFileSync(file, "utf8"));
} catch {
return undefined;
}
}
function hasKeyOverride(provider: string, env: NodeJS.ProcessEnv, agentDir: string): boolean {
// Upstream: packages/ai/src/auth-broker/discover.ts:87-110,197-225,321-329.
const configFile = ["config.yml", "config.yaml"]
.map((name) => join(agentDir, name))
.find((file) => existsSync(file));
const config = configSchema.safeParse(configFile ? readYaml(configFile) : undefined);
if (
env.OMP_AUTH_BROKER_URL ||
(config.success && (config.data.auth?.broker?.url ?? config.data["auth.broker.url"]))
)
return true;
// Upstream: packages/ai/src/auth/cascade.ts:22-84.
const models = modelsSchema.safeParse(readYaml(join(agentDir, "models.yml")));
return models.success && models.data.providers?.[provider]?.apiKey !== undefined;
}
function readCredential(
db: UsageDb,
provider: string,
sessionId: string,
): z.infer<typeof oauthSchema> | null {
// Upstream: packages/ai/src/auth/affinity.ts:83-88,115-132; sqlite-credential-store.ts:447-448,711-722.
const cache = db
.prepare(
"SELECT value FROM cache WHERE key = ? AND expires_at > CAST(strftime('%s','now') AS INTEGER)",
)
.get(`session:sticky:${provider}:${sessionId}`) as { value?: unknown } | undefined;
if (cache) {
const sticky = stickySchema.safeParse(JSON.parse(String(cache.value)));
if (!sticky.success || sticky.data.type !== "oauth") return null;
return parseOAuthCredential(
db
.prepare(
"SELECT id, provider, credential_type, data, disabled_cause FROM auth_credentials WHERE id = ?",
)
.get(sticky.data.credentialId),
provider,
);
}
// Upstream: packages/ai/src/auth/cascade.ts:276-345 selects OAuth before env or stored API keys.
const rows = db
.prepare(
"SELECT id, provider, credential_type, data, disabled_cause FROM auth_credentials WHERE provider = ? AND credential_type = 'oauth' AND disabled_cause IS NULL",
)
.all(provider);
const validRows = rows
.map((row) => parseOAuthCredential(row, provider))
.filter((row): row is z.infer<typeof oauthSchema> => row !== null);
return validRows.length === 1 ? validRows[0] : null;
}
function parseOAuthCredential(
value: unknown,
provider: string,
): z.infer<typeof oauthSchema> | null {
const row = rowSchema.safeParse(value);
if (
!row.success ||
row.data.provider !== provider ||
row.data.credential_type !== "oauth" ||
row.data.disabled_cause !== null
)
return null;
try {
// Upstream: packages/ai/src/auth/sqlite-credential-store.ts:117-155 stores OAuth data as JSON without type.
const oauth = oauthSchema.safeParse(JSON.parse(row.data.data));
return oauth.success && oauth.data.expires > Date.now() ? oauth.data : null;
} catch {
return null;
}
}
/** Read OMP's current credential attribution without changing its store. */
export function resolveOmpUsageReference(
sessionId: string,
provider: string,
env: NodeJS.ProcessEnv,
platform: NodeJS.Platform = process.platform,
): UsageReference | null {
let source: string;
if (provider === "openai-codex") source = "codex";
else if (provider === "anthropic") source = "claude";
else return null;
if (!sessionId) return null;
// Upstream: packages/utils/src/dirs.ts:82-94,330-343,583-585 (profile, override, agent dir).
const paths = resolveOmpDiagnosticPaths(env, undefined, platform);
if (hasKeyOverride(provider, env, paths.agentDir)) return null;
let db: UsageDb;
try {
const sqlite = localRequire("node:sqlite") as {
DatabaseSync: new (path: string, options: { readOnly: boolean }) => UsageDb;
};
db = new sqlite.DatabaseSync(paths.agentDb, { readOnly: true });
} catch {
return null;
}
try {
const oauth = readCredential(db, provider, sessionId);
if (!oauth) return null;
if (source === "claude") {
return { source, input: { accessToken: oauth.access } };
}
return {
source,
input: {
accessToken: oauth.access,
...(oauth.accountId ? { accountId: oauth.accountId } : {}),
},
};
} catch {
return null;
} finally {
db.close();
}
}
@@ -333,6 +333,94 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
model: TEST_MODEL, model: TEST_MODEL,
}); });
test("usage reference follows the active OpenCode model and OAuth account", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeHarness();
const openCode = new TestOpenCodeClient();
runtime.enqueueClient(openCode);
const client = new OpenCodeAgentClient(
logger,
{
env: {
OPENCODE_AUTH_CONTENT: JSON.stringify({
openai: { type: "oauth", access: "oauth-token", accountId: "acct-1" },
"opencode-go": { type: "api", key: "go-key" },
}),
},
},
{ serverManager: runtime, createClient: runtime.createClient },
);
const session = await client.createSession(buildConfig(cwd));
await session.setModel?.("openai/gpt-5");
expect(await session.getUsageReference?.()).toEqual({
source: "codex",
input: { accessToken: "oauth-token", accountId: "acct-1" },
});
await session.setModel?.("opencode-go/qwen");
expect(await session.getUsageReference?.()).toEqual({
source: "opencode-go",
input: { apiKey: "go-key" },
});
await session.setModel?.("other/model");
expect(await session.getUsageReference?.()).toBeNull();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const sessionId = (await session.getRuntimeInfo()).sessionId;
openCode.emitEvent({
type: "message.updated",
properties: {
info: {
id: "msg_changed_model",
sessionID: sessionId,
role: "user",
model: { providerID: "openai", modelID: "gpt-5" },
},
},
} as OpenCodeEvent);
await vi.waitFor(async () =>
expect(await session.getUsageReference?.()).toEqual({
source: "codex",
input: { accessToken: "oauth-token", accountId: "acct-1" },
}),
);
expect(events).toContainEqual({
type: "model_changed",
provider: "opencode",
runtimeInfo: { provider: "opencode", sessionId, model: "openai/gpt-5", modeId: null },
});
await session.close();
rmSync(cwd, { recursive: true, force: true });
});
test("usage reference ignores another auth entry with a different type", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeHarness();
runtime.enqueueClient(new TestOpenCodeClient());
const client = new OpenCodeAgentClient(
logger,
{
env: {
OPENCODE_AUTH_CONTENT: JSON.stringify({
openai: { type: "api", key: "other-key" },
"opencode-go": { type: "api", key: "go-key" },
}),
},
},
{ serverManager: runtime, createClient: runtime.createClient },
);
const session = await client.createSession(buildConfig(cwd));
try {
await session.setModel?.("opencode-go/qwen");
expect(await session.getUsageReference?.()).toEqual({
source: "opencode-go",
input: { apiKey: "go-key" },
});
} finally {
await session.close();
rmSync(cwd, { recursive: true, force: true });
}
});
test("creates a session with valid id and provider", async () => { test("creates a session with valid id and provider", async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
const runtime = new TestOpenCodeHarness(); const runtime = new TestOpenCodeHarness();
@@ -12,6 +12,8 @@ import {
type TextPartInput as OpenCodeTextPartInput, type TextPartInput as OpenCodeTextPartInput,
} from "@opencode-ai/sdk/v2/client"; } from "@opencode-ai/sdk/v2/client";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { isDeepStrictEqual } from "node:util"; import { isDeepStrictEqual } from "node:util";
import { createPathEquivalenceMatcher } from "../../../utils/path.js"; import { createPathEquivalenceMatcher } from "../../../utils/path.js";
@@ -56,6 +58,7 @@ import {
type SteerResult, type SteerResult,
type ToolCallDetail, type ToolCallDetail,
type ToolCallTimelineItem, type ToolCallTimelineItem,
type UsageReference,
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
import { importSessionFromPersistence } from "../provider-session-import.js"; import { importSessionFromPersistence } from "../provider-session-import.js";
import { import {
@@ -1500,6 +1503,7 @@ export class OpenCodeAgentClient implements AgentClient {
false, false,
unbindBridge, unbindBridge,
connectServer, connectServer,
{ ...process.env, ...this.runtimeSettings?.env, ...launchContext?.env },
); );
} catch (error) { } catch (error) {
await connection.release(); await connection.release();
@@ -1554,6 +1558,7 @@ export class OpenCodeAgentClient implements AgentClient {
registeredAcquisition !== null, registeredAcquisition !== null,
unbindBridge, unbindBridge,
connectServer, connectServer,
{ ...process.env, ...this.runtimeSettings?.env, ...launchContext?.env },
); );
} catch (error) { } catch (error) {
await connection.release(); await connection.release();
@@ -3279,7 +3284,7 @@ function isOpenCodeTerminalEvent(event: OpenCodeEvent, sessionId: string): boole
} }
function isOpenCodeProviderInternalEvent(event: AgentStreamEvent): boolean { function isOpenCodeProviderInternalEvent(event: AgentStreamEvent): boolean {
return event.type === "provider_subagent"; return event.type === "provider_subagent" || event.type === "model_changed";
} }
function readOpenCodeChildSessionInfo(value: unknown): OpenCodeChildSessionInfo | null { function readOpenCodeChildSessionInfo(value: unknown): OpenCodeChildSessionInfo | null {
@@ -3360,6 +3365,67 @@ interface OpenCodeServerConnection {
release: () => Promise<void>; release: () => Promise<void>;
} }
const openCodeOAuthAuthSchema = z
.object({
openai: z
.object({
type: z.literal("oauth"),
access: z.string().min(1),
accountId: z.string().optional(),
})
.passthrough()
.optional(),
})
.passthrough();
const openCodeGoAuthSchema = z
.object({
"opencode-go": z
.object({ type: z.literal("api"), key: z.string().min(1) })
.passthrough()
.optional(),
})
.passthrough();
async function readOpenCodeUsageAuth(env: NodeJS.ProcessEnv): Promise<unknown> {
try {
const content =
env.OPENCODE_AUTH_CONTENT ??
(await fs.readFile(
path.join(
env.XDG_DATA_HOME || path.join(env.HOME || os.homedir(), ".local", "share"),
"opencode",
"auth.json",
),
"utf8",
));
return JSON.parse(content);
} catch {
return null;
}
}
function resolveOpenCodeUsageReference(model: string, auth: unknown): UsageReference | null {
if (!auth) return null;
if (model.startsWith("openai/")) {
const parsed = openCodeOAuthAuthSchema.safeParse(auth);
if (!parsed.success) return null;
const credential = parsed.data.openai;
if (!credential) return null;
return {
source: "codex",
input: {
accessToken: credential.access,
...(credential.accountId ? { accountId: credential.accountId } : {}),
},
};
}
const parsed = openCodeGoAuthSchema.safeParse(auth);
if (!parsed.success) return null;
const credential = parsed.data["opencode-go"];
if (!credential) return null;
return { source: "opencode-go", input: { apiKey: credential.key } };
}
class OpenCodeAgentSession implements AgentSession { class OpenCodeAgentSession implements AgentSession {
readonly provider = "opencode" as const; readonly provider = "opencode" as const;
readonly capabilities = OPENCODE_CAPABILITIES; readonly capabilities = OPENCODE_CAPABILITIES;
@@ -3448,6 +3514,7 @@ class OpenCodeAgentSession implements AgentSession {
private readonly externallyDriven = false, private readonly externallyDriven = false,
releaseBridge?: () => void, releaseBridge?: () => void,
connectServer?: () => Promise<OpenCodeServerConnection>, connectServer?: () => Promise<OpenCodeServerConnection>,
private readonly usageEnv: NodeJS.ProcessEnv = process.env,
) { ) {
this.config = config; this.config = config;
this.server = { client, events, url: serverUrl ?? "", release: releaseServer }; this.server = { client, events, url: serverUrl ?? "", release: releaseServer };
@@ -3552,6 +3619,13 @@ class OpenCodeAgentSession implements AgentSession {
}; };
} }
async getUsageReference(): Promise<UsageReference | null> {
const model = this.config.model;
if (!model?.startsWith("openai/") && !model?.startsWith("opencode-go/")) return null;
const auth = await readOpenCodeUsageAuth(this.usageEnv);
return resolveOpenCodeUsageReference(model, auth);
}
async setModel(modelId: string | null): Promise<void> { async setModel(modelId: string | null): Promise<void> {
const normalizedModelId = const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null; typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
@@ -5346,6 +5420,7 @@ class OpenCodeAgentSession implements AgentSession {
private async translateEvent(event: OpenCodeEvent): Promise<AgentStreamEvent[]> { private async translateEvent(event: OpenCodeEvent): Promise<AgentStreamEvent[]> {
const eventSessionId = getOpenCodeEventSessionId(event); const eventSessionId = getOpenCodeEventSessionId(event);
const runtimeModelChanged = this.syncRuntimeModel(event, eventSessionId);
if ( if (
event.type !== "session.created" && event.type !== "session.created" &&
eventSessionId && eventSessionId &&
@@ -5362,6 +5437,12 @@ class OpenCodeAgentSession implements AgentSession {
} }
} }
const translated = translateOpenCodeEvent(event, this.createTranslationState()); const translated = translateOpenCodeEvent(event, this.createTranslationState());
if (runtimeModelChanged)
translated.push({
type: "model_changed",
provider: this.provider,
runtimeInfo: await this.getRuntimeInfo(),
});
this.appendProviderSubagentEvents(event, translated); this.appendProviderSubagentEvents(event, translated);
const events: AgentStreamEvent[] = []; const events: AgentStreamEvent[] = [];
@@ -5402,6 +5483,20 @@ class OpenCodeAgentSession implements AgentSession {
return events; return events;
} }
private syncRuntimeModel(event: OpenCodeEvent, eventSessionId: string | null): boolean {
if (event.type !== "message.updated" || eventSessionId !== this.sessionId) return false;
const info = event.properties.info;
let model: string | undefined;
if (info.role === "assistant") model = resolveOpenCodeModelLookupKeyFromAssistantMessage(info);
if (info.role === "user" && info.model)
model = buildOpenCodeModelLookupKey(info.model.providerID, info.model.modelID);
if (!model || model === this.config.model) return false;
this.config.model = model;
this.selectedModelContextWindowMaxTokens =
this.resolveConfiguredModelContextWindowMaxTokens(model);
return true;
}
private async tryAutoApproveToolPermission( private async tryAutoApproveToolPermission(
request: AgentPermissionRequest, request: AgentPermissionRequest,
directory: string, directory: string,
@@ -23,7 +23,7 @@ import {
PiRpcAgentSession, PiRpcAgentSession,
transformPiModels, transformPiModels,
} from "./agent.js"; } from "./agent.js";
import { FakePi } from "./test-utils/fake-pi.js"; import { FakePi, type FakePiSession } from "./test-utils/fake-pi.js";
import { createPiExtensionHost } from "./extensions/index.js"; import { createPiExtensionHost } from "./extensions/index.js";
import { PiExtensionHost } from "./extensions/host.js"; import { PiExtensionHost } from "./extensions/host.js";
import type { PiModel, PiThinkingLevel } from "./rpc-types.js"; import type { PiModel, PiThinkingLevel } from "./rpc-types.js";
@@ -202,6 +202,71 @@ async function createSession(
return { pi, session, events }; return { pi, session, events };
} }
test("Pi usage reference follows the current OAuth model and agent directory", async () => {
const agentDir = mkdtempSync(path.join(tmpdir(), "paseo-pi-usage-"));
try {
writeFileSync(
path.join(agentDir, "auth.json"),
JSON.stringify({
"openai-codex": {
type: "oauth",
access: "codex-token",
accountId: "account-1",
refresh: "refresh",
expires: Date.now() + 60_000,
},
anthropic: {
type: "oauth",
access: "claude-token",
refresh: "refresh",
expires: Date.now() + 60_000,
},
}),
);
const pi = new FakePi();
let runtime!: FakePiSession;
pi.queueSessionSetup((sessionRuntime) => {
runtime = sessionRuntime;
runtime.state.model = { provider: "openai-codex", id: "gpt", name: "GPT" };
});
const session = await createClient(pi).createSession(createConfig(), {
env: { PI_CODING_AGENT_DIR: agentDir },
});
expect(await session.getUsageReference?.()).toEqual({
source: "codex",
input: { accessToken: "codex-token", accountId: "account-1" },
});
runtime.state.model = { provider: "anthropic", id: "claude", name: "Claude" };
expect(await session.getUsageReference?.()).toEqual({
source: "claude",
input: { accessToken: "claude-token" },
});
writeFileSync(
path.join(agentDir, "auth.json"),
JSON.stringify({
"openai-codex": { type: "oauth", access: "codex-token" },
anthropic: { type: "api_key", key: "api-key" },
}),
);
runtime.state.model = { provider: "openai-codex", id: "gpt", name: "GPT" };
expect(await session.getUsageReference?.()).toEqual({
source: "codex",
input: { accessToken: "codex-token" },
});
runtime.state.model = { provider: "anthropic", id: "claude", name: "Claude" };
writeFileSync(
path.join(agentDir, "auth.json"),
JSON.stringify({ anthropic: { type: "api_key", key: "api-key" } }),
);
expect(await session.getUsageReference?.()).toBeNull();
runtime.state.model = { provider: "other", id: "other", name: "Other" };
expect(await session.getUsageReference?.()).toBeNull();
await session.close();
} finally {
rmSync(agentDir, { recursive: true, force: true });
}
});
test("forwards launch-context env to the Pi process launch", async () => { test("forwards launch-context env to the Pi process launch", async () => {
const pi = new FakePi(); const pi = new FakePi();
const client = createClient(pi); const client = createClient(pi);
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { homedir, tmpdir } from "node:os"; import { homedir, tmpdir } from "node:os";
import { join, resolve as resolvePath } from "node:path"; import { join, resolve as resolvePath } from "node:path";
import type { Logger } from "pino"; import type { Logger } from "pino";
@@ -242,6 +243,7 @@ interface PiRpcAgentSessionOptions {
extensionTimeoutMs?: number; extensionTimeoutMs?: number;
logger: Logger; logger: Logger;
usagePollScheduler?: PiUsagePollScheduler; usagePollScheduler?: PiUsagePollScheduler;
usageEnv?: Record<string, string>;
} }
interface PiResumeConfig { interface PiResumeConfig {
@@ -528,6 +530,27 @@ function resolvePiAgentDir(env: Record<string, string> | undefined): string {
return resolvePath(configured); return resolvePath(configured);
} }
const piCodexUsageAuthSchema = z
.object({
"openai-codex": z
.object({
type: z.literal("oauth"),
access: z.string().min(1),
accountId: z.string().optional(),
})
.passthrough()
.optional(),
})
.passthrough();
const piClaudeUsageAuthSchema = z
.object({
anthropic: z
.object({ type: z.literal("oauth"), access: z.string().min(1) })
.passthrough()
.optional(),
})
.passthrough();
function readPiGlobalMcpConfig(env: Record<string, string> | undefined): Record<string, unknown> { function readPiGlobalMcpConfig(env: Record<string, string> | undefined): Record<string, unknown> {
const globalConfigPath = join(resolvePiAgentDir(env), "mcp.json"); const globalConfigPath = join(resolvePiAgentDir(env), "mcp.json");
if (!existsSync(globalConfigPath)) { if (!existsSync(globalConfigPath)) {
@@ -1122,6 +1145,35 @@ export class PiRpcAgentSession implements AgentSession {
readonly provider: AgentProvider; readonly provider: AgentProvider;
readonly capabilities: AgentCapabilityFlags; readonly capabilities: AgentCapabilityFlags;
async getUsageReference() {
await this.refreshState();
const provider = this.state.model?.provider;
let source: string | null = null;
if (provider === "openai-codex") source = "codex";
if (provider === "anthropic") source = "claude";
if (!source || !provider) return null;
try {
const auth: unknown = JSON.parse(
await readFile(join(resolvePiAgentDir(this.usageEnv), "auth.json"), "utf8"),
);
if (provider === "openai-codex") {
const credential = piCodexUsageAuthSchema.parse(auth)["openai-codex"];
if (!credential) return null;
return {
source,
input: {
accessToken: credential.access,
...(credential.accountId ? { accountId: credential.accountId } : {}),
},
};
}
const credential = piClaudeUsageAuthSchema.parse(auth).anthropic;
return credential ? { source, input: { accessToken: credential.access } } : null;
} catch {
return null;
}
}
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private readonly activeToolCalls = new Map<string, PiTrackedToolCall>(); private readonly activeToolCalls = new Map<string, PiTrackedToolCall>();
private readonly pendingExtensionUiRequests = new Map<string, AgentPermissionRequest>(); private readonly pendingExtensionUiRequests = new Map<string, AgentPermissionRequest>();
@@ -1149,6 +1201,7 @@ export class PiRpcAgentSession implements AgentSession {
private readonly currentModeId: string | null; private readonly currentModeId: string | null;
private readonly logger: Logger; private readonly logger: Logger;
private readonly usagePoller: PiUsagePoller; private readonly usagePoller: PiUsagePoller;
private readonly usageEnv?: Record<string, string>;
private closed = false; private closed = false;
private readonly closeController = new AbortController(); private readonly closeController = new AbortController();
private readonly pendingExtensionHydrations = new Set<Promise<void>>(); private readonly pendingExtensionHydrations = new Set<Promise<void>>();
@@ -1168,6 +1221,7 @@ export class PiRpcAgentSession implements AgentSession {
this.extensionTimeoutMs = options.extensionTimeoutMs ?? DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS; this.extensionTimeoutMs = options.extensionTimeoutMs ?? DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS;
this.logger = options.logger; this.logger = options.logger;
this.extensionHost = createPiExtensionHost(this.logger); this.extensionHost = createPiExtensionHost(this.logger);
this.usageEnv = options.usageEnv;
this.usagePoller = new PiUsagePoller({ this.usagePoller = new PiUsagePoller({
scheduler: options.usagePollScheduler, scheduler: options.usagePollScheduler,
readStats: () => this.runtimeSession.getSessionStats(), readStats: () => this.runtimeSession.getSessionStats(),
@@ -2461,6 +2515,7 @@ export class PiRpcAgentClient implements AgentClient {
extensionTimeoutMs: this.providerParams.extensionTimeoutMs, extensionTimeoutMs: this.providerParams.extensionTimeoutMs,
logger: this.logger, logger: this.logger,
usagePollScheduler: this.usagePollScheduler, usagePollScheduler: this.usagePollScheduler,
usageEnv: mcpEnv,
}); });
} catch (error) { } catch (error) {
await runtimeSession.close().catch(() => undefined); await runtimeSession.close().catch(() => undefined);
@@ -2524,6 +2579,7 @@ export class PiRpcAgentClient implements AgentClient {
extensionTimeoutMs: this.providerParams.extensionTimeoutMs, extensionTimeoutMs: this.providerParams.extensionTimeoutMs,
logger: this.logger, logger: this.logger,
usagePollScheduler: this.usagePollScheduler, usagePollScheduler: this.usagePollScheduler,
usageEnv: mcpEnv,
}); });
} catch (error) { } catch (error) {
await runtimeSession.close().catch(() => undefined); await runtimeSession.close().catch(() => undefined);
@@ -149,6 +149,8 @@ const INBOUND_PERMISSION = {
"project.rename.request": "workspace.manage", "project.rename.request": "workspace.manage",
project_icon_request: "workspace.read", project_icon_request: "workspace.read",
"provider.usage.list.request": "daemon.read", "provider.usage.list.request": "daemon.read",
"usage.list_reports.request": "daemon.read",
"agent.resolve_usage_report.request": "daemon.read",
provider_diagnostic_request: "daemon.read", provider_diagnostic_request: "daemon.read",
pull_request_timeline_request: "workspace.read", pull_request_timeline_request: "workspace.read",
"push.unregister.request": "workspace.read", "push.unregister.request": "workspace.read",
@@ -373,6 +375,8 @@ const OUTBOUND_PERMISSION = {
"project.update": "workspace.read", "project.update": "workspace.read",
project_icon_response: "workspace.read", project_icon_response: "workspace.read",
"provider.usage.list.response": "daemon.read", "provider.usage.list.response": "daemon.read",
"usage.list_reports.response": "daemon.read",
"agent.resolve_usage_report.response": "daemon.read",
provider_diagnostic_response: "daemon.read", provider_diagnostic_response: "daemon.read",
providers_snapshot_update: ["daemon.read", "hub.execute"], providers_snapshot_update: ["daemon.read", "hub.execute"],
pull_request_timeline_response: "workspace.read", pull_request_timeline_response: "workspace.read",
+7
View File
@@ -231,6 +231,7 @@ import {
} from "./hub/relationship-remote.js"; } from "./hub/relationship-remote.js";
import { DaemonExecutions } from "./hub/daemon-executions.js"; import { DaemonExecutions } from "./hub/daemon-executions.js";
import { PluginService } from "./plugins/index.js"; import { PluginService } from "./plugins/index.js";
import { BuiltinPluginLoader } from "./plugins/builtin/index.js";
import { ManagedPluginSources } from "./plugins/managed-source.js"; import { ManagedPluginSources } from "./plugins/managed-source.js";
const MCP_DEBUG_BATCH_LIMIT = 10; const MCP_DEBUG_BATCH_LIMIT = 10;
@@ -476,6 +477,7 @@ export interface PaseoDaemon {
} }
export interface PaseoDaemonDependencies { export interface PaseoDaemonDependencies {
builtinPlugins?: BuiltinPluginLoader;
hubRelationshipRemote?: HubRelationshipRemote; hubRelationshipRemote?: HubRelationshipRemote;
hubRelationshipClock?: HubRelationshipClock; hubRelationshipClock?: HubRelationshipClock;
hubRelationshipRetryPolicy?: HubRelationshipRetryPolicy; hubRelationshipRetryPolicy?: HubRelationshipRetryPolicy;
@@ -486,6 +488,10 @@ export interface PaseoDaemonDependencies {
}; };
} }
function resolveBuiltinPluginLoader(dependencies: PaseoDaemonDependencies): BuiltinPluginLoader {
return dependencies.builtinPlugins ?? new BuiltinPluginLoader();
}
function createBootstrapManagedProcessRegistry( function createBootstrapManagedProcessRegistry(
config: Pick<PaseoDaemonConfig, "paseoHome" | "managedProcesses">, config: Pick<PaseoDaemonConfig, "paseoHome" | "managedProcesses">,
logger: Logger, logger: Logger,
@@ -611,6 +617,7 @@ export async function createPaseoDaemon(
const browserToolsBroker = new BrowserToolsBroker({}); const browserToolsBroker = new BrowserToolsBroker({});
const pluginRuntime = new PluginService(logger, daemonConfigStore, daemonVersion, { const pluginRuntime = new PluginService(logger, daemonConfigStore, daemonVersion, {
managedSources: new ManagedPluginSources(config.paseoHome), managedSources: new ManagedPluginSources(config.paseoHome),
builtinPlugins: resolveBuiltinPluginLoader(dependencies),
settingsDirectory: path.join(config.paseoHome, "plugin-settings"), settingsDirectory: path.join(config.paseoHome, "plugin-settings"),
}); });
@@ -0,0 +1,134 @@
import { execFileSync } from "node:child_process";
import { mkdtemp, mkdir, rm, stat, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, expect, test } from "vitest";
import { compilePlugin } from "../compiler.js";
import { readPluginManifest } from "../manifest.js";
import { DaemonClient } from "../../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../../test-utils/paseo-daemon.js";
import { BuiltinPluginLoader, builtinPlugins, resolveBuiltinPluginsRoot } from "./index.js";
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
async function fixture(root: string, id: string, client = false): Promise<string> {
const directory = path.join(root, id);
await mkdir(directory, { recursive: true });
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id, requirements: { paseo: ">=0.9.2" } }),
);
await writeFile(
path.join(directory, "index.server.ts"),
"export default function contribute() { return () => {}; }",
);
if (client) {
await writeFile(
path.join(directory, "index.client.tsx"),
"export default function contribute() { const marker = 'builtin-client-marker'; void marker; return () => {}; }",
);
}
return directory;
}
test("listed built-ins resolve to matching manifests and compile", async () => {
const root = resolveBuiltinPluginsRoot();
for (const id of builtinPlugins) {
const directory = path.join(root, id);
expect((await readPluginManifest(directory)).id).toBe(id);
const findEntry = async (names: string[]) => {
for (const name of names) {
const entry = path.join(directory, name);
if ((await stat(entry).catch(() => null))?.isFile()) return entry;
}
return null;
};
const bundles = await compilePlugin({
server: await findEntry(["index.server.ts", "index.server.tsx"]),
client: await findEntry(["index.client.ts", "index.client.tsx"]),
});
expect(bundles.serverBundle).toBeTruthy();
}
});
test("listed client bundle is published while plugins are disabled; unlisted directory is inert", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-builtin-fixture-"));
roots.push(root);
await fixture(root, "listed", true);
await fixture(root, "unlisted", true);
const daemon = await createTestPaseoDaemon({
daemonVersion: "0.9.2",
pluginsEnabled: false,
builtinPlugins: new BuiltinPluginLoader(root, ["listed"]),
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.9.2" });
try {
await client.connect();
expect(await client.listPlugins()).toEqual([]);
const catalog = await client.getPluginCatalog();
expect(catalog.map(({ id }) => id)).toEqual(["listed"]);
expect(catalog[0]?.clientBundle).toContain("builtin-client-marker");
} finally {
await client.close();
await daemon.close();
}
}, 60_000);
test("directory, Git, and npm installs reject a built-in ID", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-builtin-install-"));
roots.push(root);
const directory = await fixture(root, "reserved");
const gitDirectory = await fixture(root, "git-source");
await writeFile(
path.join(gitDirectory, "paseo-plugin.json"),
JSON.stringify({ id: "reserved", requirements: { paseo: ">=0.9.2" } }),
);
execFileSync("git", ["init", "-q", gitDirectory]);
execFileSync("git", [
"-C",
gitDirectory,
"-c",
"user.name=Paseo Tests",
"-c",
"user.email=paseo@example.test",
"add",
".",
]);
execFileSync("git", [
"-C",
gitDirectory,
"-c",
"user.name=Paseo Tests",
"-c",
"user.email=paseo@example.test",
"commit",
"-qm",
"fixture",
]);
const daemon = await createTestPaseoDaemon({
daemonVersion: "0.9.2",
pluginsEnabled: false,
builtinPlugins: new BuiltinPluginLoader(root, ["reserved"]),
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.9.2" });
try {
await client.connect();
await expect(client.installDirectoryPlugin(directory)).rejects.toThrow(
/reserved for a built-in/,
);
await expect(
client.installPluginSource({ source: pathToFileURL(gitDirectory).href }),
).rejects.toThrow(/reserved for a built-in/);
await expect(
client.installPluginSource({ source: "npm:unused-fixture", id: "reserved" }),
).rejects.toThrow(/reserved for a built-in/);
expect(await client.listPlugins()).toEqual([]);
} finally {
await client.close();
await daemon.close();
}
}, 60_000);
@@ -0,0 +1,46 @@
import { existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
export const builtinPlugins = [
"claude-usage-source",
"codex-usage-source",
"copilot-usage-source",
"cursor-usage-source",
"grok-usage-source",
"kimi-usage-source",
"minimax-usage-source",
"opencode-go-usage-source",
"zai-usage-source",
] as const;
export function resolveBuiltinPluginsRoot(moduleUrl: string | URL = import.meta.url): string {
const moduleDir = path.dirname(fileURLToPath(moduleUrl));
const candidates = [
path.resolve(moduleDir, "..", "..", "..", "builtin-plugins"),
path.resolve(moduleDir, "..", "..", "..", "..", "..", "..", "plugins"),
];
return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0]!;
}
export interface BuiltinPlugin {
id: string;
directory: string;
}
export class BuiltinPluginLoader {
readonly ids: ReadonlySet<string>;
constructor(
private readonly root = resolveBuiltinPluginsRoot(),
private readonly list: readonly string[] = builtinPlugins,
) {
this.ids = new Set(list);
}
async load(start: (plugin: BuiltinPlugin) => Promise<void>): Promise<void> {
for (const id of this.list) {
await start({ id, directory: path.join(this.root, id) });
}
}
}
@@ -0,0 +1,38 @@
import { createRequire } from "node:module";
import * as pluginSharedRuntime from "@getpaseo/plugin";
import * as pluginProviderRuntime from "@getpaseo/plugin/server/provider";
import * as pluginAcpRuntime from "@getpaseo/plugin/server/acp";
import * as pluginUsageRuntime from "@getpaseo/plugin/server/usage";
import type { PluginServerContribution } from "@getpaseo/plugin/server";
import * as zod from "zod";
import { isPluginClientOnlySdkSpecifier } from "./plugin-sdk-specifiers.js";
const nodeRequire = createRequire(import.meta.url);
function runtimeRequire(name: string): unknown {
if (isPluginClientOnlySdkSpecifier(name)) {
throw new Error(`${name} is available only in plugin client code`);
}
if (name === "@getpaseo/plugin") return pluginSharedRuntime;
if (name === "@getpaseo/plugin/server") return {};
if (name === "@getpaseo/plugin/server/provider") return pluginProviderRuntime;
if (name === "@getpaseo/plugin/server/acp") return pluginAcpRuntime;
if (name === "@getpaseo/plugin/server/usage") return pluginUsageRuntime;
if (name === "zod") return zod;
if (name === "@getpaseo/plugin/client/host")
throw new Error(`${name} is private to the app host`);
return nodeRequire(name);
}
export function evaluateBundle(bundle: string): PluginServerContribution {
const evaluate: (source: string) => unknown = globalThis.eval;
const factory = evaluate(bundle);
if (typeof factory !== "function") throw new Error("Plugin server bundle is not executable");
const exports = factory(runtimeRequire);
const setup =
exports !== null && typeof exports === "object" ? Reflect.get(exports, "default") : undefined;
if (typeof setup !== "function") {
throw new Error("Plugin server bundle must default export a function");
}
return setup as PluginServerContribution;
}
@@ -10,6 +10,7 @@ import {
} from "./plugin-sdk-specifiers.js"; } from "./plugin-sdk-specifiers.js";
const nodeRequire = createRequire(import.meta.url); const nodeRequire = createRequire(import.meta.url);
export const SERVER_HOST_MODULES = [...PLUGIN_SDK_SPECIFIERS, "zod"];
const ESBUILD_BINARY_PATH = "ESBUILD_BINARY_PATH"; const ESBUILD_BINARY_PATH = "ESBUILD_BINARY_PATH";
// esbuild resolves its own platform binary via require.resolve() the first time its // esbuild resolves its own platform binary via require.resolve() the first time its
@@ -403,7 +404,7 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom
"react-native", "react-native",
"zod", "zod",
] ]
: [...PLUGIN_SDK_SPECIFIERS, "zod"], : SERVER_HOST_MODULES,
plugins: [createRuntimeBoundaryPlugin(target, pluginDirectory)], plugins: [createRuntimeBoundaryPlugin(target, pluginDirectory)],
metafile: true, metafile: true,
logLevel: "silent", logLevel: "silent",
@@ -17,6 +17,12 @@ import { runGitCommand } from "../../utils/run-git-command.js";
const roots: string[] = []; const roots: string[] = [];
type TestPluginRuntime = NonNullable<ConstructorParameters<typeof PluginService>[3]["runtime"]>; type TestPluginRuntime = NonNullable<ConstructorParameters<typeof PluginService>[3]["runtime"]>;
const emptyUsageRuntime = {
getUsageSourceRegistrations: () => [],
fetchUsage: async () => undefined,
discoverUsage: async () => [],
} satisfies Pick<TestPluginRuntime, "getUsageSourceRegistrations" | "fetchUsage" | "discoverUsage">;
async function createPlugin(id: string, source: string): Promise<string> { async function createPlugin(id: string, source: string): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-service-")); const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-service-"));
roots.push(directory); roots.push(directory);
@@ -104,6 +110,7 @@ function createPausedRuntime() {
}); });
const running = new Set<string>(); const running = new Set<string>();
const runtime: TestPluginRuntime = { const runtime: TestPluginRuntime = {
...emptyUsageRuntime,
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })), catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
invoke: async () => undefined, invoke: async () => undefined,
getLogs: () => [], getLogs: () => [],
@@ -136,6 +143,7 @@ function createPluginSelectivePausedRuntime(pausedPluginId: string) {
const starts: string[] = []; const starts: string[] = [];
const running = new Set<string>(); const running = new Set<string>();
const runtime: TestPluginRuntime = { const runtime: TestPluginRuntime = {
...emptyUsageRuntime,
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })), catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
invoke: async () => undefined, invoke: async () => undefined,
getLogs: () => [], getLogs: () => [],
@@ -235,6 +243,7 @@ describe("PluginService", () => {
]; ];
const cleared: string[] = []; const cleared: string[] = [];
const runtime: TestPluginRuntime = { const runtime: TestPluginRuntime = {
...emptyUsageRuntime,
catalog: () => [], catalog: () => [],
invoke: async () => undefined, invoke: async () => undefined,
getLogs: () => entries, getLogs: () => entries,
@@ -620,6 +629,7 @@ export default function contribute(server: PluginServerContext) {
const events: string[] = []; const events: string[] = [];
const running = new Set<string>(); const running = new Set<string>();
const runtime: TestPluginRuntime = { const runtime: TestPluginRuntime = {
...emptyUsageRuntime,
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })), catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
invoke: async () => undefined, invoke: async () => undefined,
getLogs: () => [], getLogs: () => [],
@@ -703,6 +713,7 @@ export default function contribute(server: PluginServerContext) {
const starts: string[] = []; const starts: string[] = [];
let failNextStart = true; let failNextStart = true;
const runtime: TestPluginRuntime = { const runtime: TestPluginRuntime = {
...emptyUsageRuntime,
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })), catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
invoke: async () => undefined, invoke: async () => undefined,
getLogs: () => [], getLogs: () => [],
@@ -766,6 +777,7 @@ export default function contribute(server: PluginServerContext) {
const running = new Set<string>(); const running = new Set<string>();
let starts = 0; let starts = 0;
const runtime: TestPluginRuntime = { const runtime: TestPluginRuntime = {
...emptyUsageRuntime,
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })), catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
invoke: async () => undefined, invoke: async () => undefined,
getLogs: () => [], getLogs: () => [],
+106 -2
View File
@@ -23,8 +23,12 @@ import { type ManagedPluginCandidate, ManagedPluginSources } from "./managed-sou
import { readPluginManifest } from "./manifest.js"; import { readPluginManifest } from "./manifest.js";
import { runPluginBuild } from "./preparation.js"; import { runPluginBuild } from "./preparation.js";
import { PluginRuntime } from "./runtime.js"; import { PluginRuntime } from "./runtime.js";
import { BuiltinPluginLoader, type BuiltinPlugin } from "./builtin/index.js";
import type { PluginProviderMetadata } from "./plugin-process-protocol.js"; import type { PluginProviderMetadata } from "./plugin-process-protocol.js";
import { readPluginProviderIcon } from "./provider-icon.js"; import { readPluginProviderIcon } from "./provider-icon.js";
import { UsageSourceRegistry } from "./usage-sources/index.js";
import type { PluginUsageSourceMetadata } from "./plugin-process-protocol.js";
import type { UsageReference } from "../agent/agent-sdk-types.js";
const BUILTIN_PROVIDER_ID_SET: ReadonlySet<string> = new Set(BUILTIN_PROVIDER_IDS); const BUILTIN_PROVIDER_ID_SET: ReadonlySet<string> = new Set(BUILTIN_PROVIDER_IDS);
@@ -36,10 +40,15 @@ interface PluginRuntimePort {
getLogs(pluginId: string): PluginLogEntry[]; getLogs(pluginId: string): PluginLogEntry[];
clearLogs(pluginId: string): void; clearLogs(pluginId: string): void;
getProviderRegistrations?(pluginId: string): readonly PluginProviderMetadata[]; getProviderRegistrations?(pluginId: string): readonly PluginProviderMetadata[];
getUsageSourceRegistrations(pluginId: string): readonly PluginUsageSourceMetadata[];
identifyUsage: PluginRuntime["identifyUsage"];
fetchUsage: PluginRuntime["fetchUsage"];
discoverUsage: PluginRuntime["discoverUsage"];
connectProvider: PluginRuntime["connectProvider"]; connectProvider: PluginRuntime["connectProvider"];
getProviderCatalogCacheKey?: PluginRuntime["getProviderCatalogCacheKey"]; getProviderCatalogCacheKey?: PluginRuntime["getProviderCatalogCacheKey"];
validatePlugin?(path: string): Promise<void>; validatePlugin?(path: string): Promise<void>;
startPlugin(pluginId: string, path: string, canPublish: () => boolean): Promise<void>; startPlugin(pluginId: string, path: string, canPublish: () => boolean): Promise<void>;
startBuiltinPlugin?(plugin: BuiltinPlugin): Promise<void>;
stopPluginById(pluginId: string): Promise<boolean>; stopPluginById(pluginId: string): Promise<boolean>;
stopAll(): Promise<void>; stopAll(): Promise<void>;
subscribe(listener: (pluginId: string, error?: string) => void): () => void; subscribe(listener: (pluginId: string, error?: string) => void): () => void;
@@ -50,6 +59,7 @@ interface PluginServiceDependencies {
settingsDirectory?: string; settingsDirectory?: string;
runtime?: PluginRuntimePort; runtime?: PluginRuntimePort;
managedSources?: ManagedPluginSources; managedSources?: ManagedPluginSources;
builtinPlugins?: BuiltinPluginLoader;
} }
function resolvePluginStatus(input: { function resolvePluginStatus(input: {
@@ -64,10 +74,14 @@ function resolvePluginStatus(input: {
export class PluginService { export class PluginService {
private readonly runtime: PluginRuntimePort; private readonly runtime: PluginRuntimePort;
private readonly managedSources: ManagedPluginSources | null; private readonly managedSources: ManagedPluginSources | null;
private readonly builtinPluginIds: ReadonlySet<string>;
private readonly builtinPlugins: BuiltinPluginLoader;
private readonly logger: pino.Logger; private readonly logger: pino.Logger;
private readonly errors = new Map<string, string>(); private readonly errors = new Map<string, string>();
private readonly listeners = new Set<(pluginId: string) => void>(); private readonly listeners = new Set<(pluginId: string) => void>();
private readonly providers = new Map<string, ProviderRegistration>(); private readonly providers = new Map<string, ProviderRegistration>();
private readonly usageSources = new UsageSourceRegistry();
private readonly usageSourceIdsByPlugin = new Map<string, string[]>();
private readonly providerIdsByPlugin = new Map<string, readonly string[]>(); private readonly providerIdsByPlugin = new Map<string, readonly string[]>();
private readonly providerListeners = new Set<() => void>(); private readonly providerListeners = new Set<() => void>();
private lifecycle = Promise.resolve(); private lifecycle = Promise.resolve();
@@ -91,8 +105,11 @@ export class PluginService {
}, },
}); });
this.managedSources = dependencies.managedSources ?? null; this.managedSources = dependencies.managedSources ?? null;
this.builtinPlugins = dependencies.builtinPlugins ?? new BuiltinPluginLoader(undefined, []);
this.builtinPluginIds = this.builtinPlugins.ids;
this.runtime.subscribe((pluginId, error) => { this.runtime.subscribe((pluginId, error) => {
this.removeProviderRegistrations(pluginId); this.removeProviderRegistrations(pluginId);
this.removeUsageSources(pluginId);
if (error) this.errors.set(pluginId, error); if (error) this.errors.set(pluginId, error);
this.notify(pluginId); this.notify(pluginId);
}); });
@@ -127,6 +144,22 @@ export class PluginService {
return [...this.providers.values()].sort((left, right) => left.id.localeCompare(right.id)); return [...this.providers.values()].sort((left, right) => left.id.localeCompare(right.id));
} }
listUsageReports(options?: {
forceRefresh?: boolean;
reportIds?: string[];
references?: UsageReference[];
}) {
return this.usageSources.listReports(options);
}
resolveUsageReference(reference: UsageReference) {
return this.usageSources.resolveReference(reference);
}
listLegacyUsage() {
return this.usageSources.listLegacyUsage();
}
subscribeProviderRegistrations(listener: () => void): () => void { subscribeProviderRegistrations(listener: () => void): () => void {
this.providerListeners.add(listener); this.providerListeners.add(listener);
return () => this.providerListeners.delete(listener); return () => this.providerListeners.delete(listener);
@@ -137,6 +170,15 @@ export class PluginService {
this.started = true; this.started = true;
const config = this.configStore.get(); const config = this.configStore.get();
this.globalStartsBlocked = config.pluginsEnabled !== true; this.globalStartsBlocked = config.pluginsEnabled !== true;
await this.builtinPlugins.load(async (plugin) => {
try {
await this.runtime.startBuiltinPlugin?.(plugin);
await this.publishProviderRegistrations(plugin.id, plugin.directory);
this.publishUsageSources(plugin.id);
} catch (error) {
this.logger.error({ err: error, pluginId: plugin.id }, "Failed to start built-in plugin");
}
});
if (config.pluginsEnabled === true) { if (config.pluginsEnabled === true) {
for (const [pluginId, source] of Object.entries(config.plugins ?? {})) { for (const [pluginId, source] of Object.entries(config.plugins ?? {})) {
if (source.enabled === false) continue; if (source.enabled === false) continue;
@@ -186,7 +228,9 @@ export class PluginService {
} }
getLogs(pluginId: string): PluginLogEntry[] { getLogs(pluginId: string): PluginLogEntry[] {
this.requireSource(pluginId); if (!this.builtinPluginIds.has(pluginId)) {
this.requireSource(pluginId);
}
return this.runtime.getLogs(pluginId); return this.runtime.getLogs(pluginId);
} }
@@ -200,6 +244,9 @@ export class PluginService {
const manifest = await readPluginManifest(directory); const manifest = await readPluginManifest(directory);
assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" }); assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" });
const pluginId = PluginIdSchema.parse(input.id ?? manifest.id); const pluginId = PluginIdSchema.parse(input.id ?? manifest.id);
if (this.builtinPluginIds.has(pluginId)) {
throw new Error(`Plugin ID "${pluginId}" is reserved for a built-in plugin`);
}
if (this.configStore.get().plugins?.[pluginId]) { if (this.configStore.get().plugins?.[pluginId]) {
throw new Error( throw new Error(
`Plugin ID "${pluginId}" is already configured; choose another ID with --id`, `Plugin ID "${pluginId}" is already configured; choose another ID with --id`,
@@ -229,6 +276,9 @@ export class PluginService {
id?: string; id?: string;
ref?: string; ref?: string;
}): Promise<PluginListItem> { }): Promise<PluginListItem> {
if (input.id && this.builtinPluginIds.has(input.id)) {
throw new Error(`Plugin ID "${input.id}" is reserved for a built-in plugin`);
}
const directDirectory = path.resolve(input.source); const directDirectory = path.resolve(input.source);
const explicit = /^(npm:|github:|git:(?!\/\/))/.test(input.source); const explicit = /^(npm:|github:|git:(?!\/\/))/.test(input.source);
const directInfo = await stat(directDirectory).catch(() => null); const directInfo = await stat(directDirectory).catch(() => null);
@@ -254,6 +304,9 @@ export class PluginService {
try { try {
await this.checkRequirements(candidate.directory); await this.checkRequirements(candidate.directory);
pluginId = PluginIdSchema.parse(input.id ?? candidate.defaultId); pluginId = PluginIdSchema.parse(input.id ?? candidate.defaultId);
if (this.builtinPluginIds.has(pluginId)) {
throw new Error(`Plugin ID "${pluginId}" is reserved for a built-in plugin`);
}
if (this.configStore.get().plugins?.[pluginId]) { if (this.configStore.get().plugins?.[pluginId]) {
throw new Error( throw new Error(
`Plugin ID "${pluginId}" is already configured; choose another ID with --id`, `Plugin ID "${pluginId}" is already configured; choose another ID with --id`,
@@ -428,7 +481,7 @@ export class PluginService {
private handleGlobalSwitch(enabled: boolean): void { private handleGlobalSwitch(enabled: boolean): void {
if (!enabled) { if (!enabled) {
this.globalStartsBlocked = true; this.globalStartsBlocked = true;
const stopping = this.stopAll(); const stopping = this.stopConfiguredPlugins();
for (const id of Object.keys(this.configStore.get().plugins ?? {})) this.notify(id); for (const id of Object.keys(this.configStore.get().plugins ?? {})) this.notify(id);
void this.enqueue(async () => { void this.enqueue(async () => {
await stopping; await stopping;
@@ -481,9 +534,11 @@ export class PluginService {
await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId)); await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId));
try { try {
await this.publishProviderRegistrations(pluginId, sourcePath); await this.publishProviderRegistrations(pluginId, sourcePath);
this.publishUsageSources(pluginId);
} catch (error) { } catch (error) {
try { try {
this.removeProviderRegistrations(pluginId); this.removeProviderRegistrations(pluginId);
this.removeUsageSources(pluginId);
} finally { } finally {
await this.runtime.stopPluginById(pluginId); await this.runtime.stopPluginById(pluginId);
} }
@@ -493,16 +548,65 @@ export class PluginService {
private stopPlugin(pluginId: string): Promise<boolean> { private stopPlugin(pluginId: string): Promise<boolean> {
this.removeProviderRegistrations(pluginId); this.removeProviderRegistrations(pluginId);
this.removeUsageSources(pluginId);
return this.runtime.stopPluginById(pluginId); return this.runtime.stopPluginById(pluginId);
} }
private async stopConfiguredPlugins(): Promise<void> {
const stopping = Object.keys(this.configStore.get().plugins ?? {}).map((id) =>
this.stopPlugin(id),
);
await Promise.all(stopping);
}
private async stopAll(): Promise<void> { private async stopAll(): Promise<void> {
for (const pluginId of this.providerIdsByPlugin.keys()) { for (const pluginId of this.providerIdsByPlugin.keys()) {
this.removeProviderRegistrations(pluginId); this.removeProviderRegistrations(pluginId);
} }
for (const pluginId of this.usageSourceIdsByPlugin.keys()) this.removeUsageSources(pluginId);
await this.runtime.stopAll(); await this.runtime.stopAll();
} }
private publishUsageSources(pluginId: string): void {
const metadata = this.runtime.getUsageSourceRegistrations(pluginId);
const registered: string[] = [];
try {
for (const source of metadata) {
this.usageSources.register({
id: source.id,
label: source.label,
icon: source.icon,
discover: async () => {
if (!source.discover) return [];
const result = await this.runtime.discoverUsage(pluginId, source.id);
if (!Array.isArray(result))
throw new Error(`Invalid usage discovery from ${source.id}`);
return result;
},
identify: (input) =>
this.runtime.identifyUsage(pluginId, source.id, input) as Promise<{
key: string;
label?: string;
} | null>,
fetch: (input) => {
return this.runtime.fetchUsage(pluginId, source.id, input);
},
});
registered.push(source.id);
}
} catch (error) {
for (const id of registered) this.usageSources.unregister(id);
throw error;
}
this.usageSourceIdsByPlugin.set(pluginId, registered);
}
private removeUsageSources(pluginId: string): void {
for (const id of this.usageSourceIdsByPlugin.get(pluginId) ?? [])
this.usageSources.unregister(id);
this.usageSourceIdsByPlugin.delete(pluginId);
}
private async publishProviderRegistrations( private async publishProviderRegistrations(
pluginId: string, pluginId: string,
pluginDirectory: string, pluginDirectory: string,
@@ -0,0 +1,66 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import type { PluginServerContribution } from "@getpaseo/plugin/server";
import { createPluginWorker, type PluginWorkerChannel } from "./plugin-process.js";
import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js";
export class InternalPluginChild extends EventEmitter {
connected = true;
killed = false;
readonly stdout = new PassThrough();
readonly stderr = new PassThrough();
private readonly worker: ReturnType<typeof createPluginWorker>;
private readonly workerMessages = new Set<(message: PluginProcessRequest) => void>();
private closing: Promise<void> | null = null;
constructor(contribute: PluginServerContribution) {
super();
const channel: PluginWorkerChannel = {
send: (message: PluginProcessMessage, callback?: () => void) => {
const copy = structuredClone(message);
queueMicrotask(() => {
if (this.connected) this.emit("message", copy);
callback?.();
});
},
onMessage: (handler) => {
this.workerMessages.add(handler);
return () => this.workerMessages.delete(handler);
},
disconnect: () => this.disconnect(),
};
this.worker = createPluginWorker({ channel, contribute });
}
send(message: PluginProcessRequest, callback?: (error: Error | null) => void): boolean {
if (!this.connected) {
callback?.(new Error("Plugin child is disconnected"));
return false;
}
const copy = structuredClone(message);
queueMicrotask(() => {
for (const handler of this.workerMessages) handler(copy);
callback?.(null);
});
return true;
}
kill(): boolean {
if (this.killed) return false;
this.killed = true;
this.disconnect();
return true;
}
disconnect(): void {
if (this.closing) return;
this.closing = Promise.resolve()
.then(() => this.worker.shutdown())
.finally(() => {
this.connected = false;
this.stdout.end();
this.stderr.end();
this.emit("close", 0, null);
});
}
}
@@ -0,0 +1,82 @@
import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "vitest";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { BuiltinPluginLoader } from "./builtin/index.js";
const directory = fileURLToPath(new URL("./test-fixtures/internal-seam/", import.meta.url));
const fixtureRoot = fileURLToPath(new URL("./test-fixtures/", import.meta.url));
test("built-in and directory plugins share RPC and lifecycle behavior while built-ins stay hidden and enabled", async () => {
const workspaceDirectory = await mkdtemp(path.join(tmpdir(), "paseo-internal-seam-"));
const daemon = await createTestPaseoDaemon({
daemonVersion: "0.8.0",
pluginsEnabled: false,
builtinPlugins: new BuiltinPluginLoader(fixtureRoot, ["internal-seam"]),
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
try {
await client.connect();
expect(await client.listPlugins()).toEqual([]);
expect(await client.invokePluginRpc("internal-seam", "state", {})).toEqual({ workspaces: 0 });
expect((await client.getPluginLogs("internal-seam")).map(({ message }) => message)).toContain(
"[paseo] Plugin ready",
);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory, "directory-seam");
expect((await client.listPlugins()).map(({ id }) => id)).toEqual(["directory-seam"]);
expect(await client.invokePluginRpc("directory-seam", "state", {})).toEqual({ workspaces: 0 });
await client.createWorkspace({
idempotencyKey: "internal-seam-workspace",
source: { kind: "directory", path: workspaceDirectory },
});
await expect
.poll(() => client.invokePluginRpc("internal-seam", "state", {}))
.toEqual({ workspaces: 1 });
await expect
.poll(() => client.invokePluginRpc("directory-seam", "state", {}))
.toEqual({ workspaces: 1 });
await client.patchDaemonConfig({ pluginsEnabled: false });
expect(await client.invokePluginRpc("internal-seam", "state", {})).toEqual({ workspaces: 1 });
expect(await client.listPlugins()).toMatchObject([
{ id: "directory-seam", status: "disabled" },
]);
} finally {
await client.close();
await daemon.close();
await rm(workspaceDirectory, { recursive: true, force: true });
}
}, 60_000);
test("a failing built-in does not stop the daemon or the next built-in", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-builtin-failure-"));
const failingDirectory = path.join(root, "failing-builtin");
await mkdir(failingDirectory);
await writeFile(
path.join(failingDirectory, "paseo-plugin.json"),
JSON.stringify({ id: "failing-builtin", requirements: { paseo: ">=0.8.0" } }),
);
await writeFile(
path.join(failingDirectory, "index.server.ts"),
"export default function contribute() { throw new Error('fixture startup failure'); }",
);
await cp(directory, path.join(root, "internal-seam"), { recursive: true });
const daemon = await createTestPaseoDaemon({
daemonVersion: "0.8.0",
pluginsEnabled: false,
builtinPlugins: new BuiltinPluginLoader(root, ["failing-builtin", "internal-seam"]),
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
try {
await client.connect();
expect(await client.invokePluginRpc("internal-seam", "state", {})).toEqual({ workspaces: 0 });
expect(await client.listPlugins()).toEqual([]);
} finally {
await client.close();
await daemon.close();
await rm(root, { recursive: true, force: true });
}
}, 60_000);
@@ -15,12 +15,20 @@ export interface PluginProviderMetadata {
iconPath?: string; iconPath?: string;
} }
export interface PluginUsageSourceMetadata {
id: string;
label: string;
icon?: string;
discover: boolean;
}
export type PluginProcessRequest = export type PluginProcessRequest =
| { | {
type: "initialize"; type: "initialize";
pluginId: string; pluginId: string;
bundle: string; bundle: string;
appVersion: string; appVersion: string;
pluginDirectory: string;
settingsDirectory?: string; settingsDirectory?: string;
} }
| { | {
@@ -31,6 +39,9 @@ export type PluginProcessRequest =
} }
| { type: "hook"; requestId: string; kind: "event" | "before"; name: string; input: unknown } | { type: "hook"; requestId: string; kind: "event" | "before"; name: string; input: unknown }
| { type: "hook.cancel"; requestId: string } | { type: "hook.cancel"; requestId: string }
| { type: "usage.identify"; requestId: string; sourceId: string; input: unknown }
| { type: "usage.fetch"; requestId: string; sourceId: string; input: unknown }
| { type: "usage.discover"; requestId: string; sourceId: string }
| { type: "invoke"; requestId: string; method: string; input: unknown } | { type: "invoke"; requestId: string; method: string; input: unknown }
| { | {
type: "provider.connect"; type: "provider.connect";
@@ -56,6 +67,7 @@ export type PluginProcessMessage =
type: "ready"; type: "ready";
methods: string[]; methods: string[];
providers: PluginProviderMetadata[]; providers: PluginProviderMetadata[];
usageSources?: PluginUsageSourceMetadata[];
hooks?: { events: string[]; before: string[] }; hooks?: { events: string[]; before: string[] };
} }
| { type: "result"; requestId: string; output: unknown } | { type: "result"; requestId: string; output: unknown }
@@ -91,6 +103,14 @@ const providerMetadataSchema = z
hasCatalogCacheKey: z.boolean().optional(), hasCatalogCacheKey: z.boolean().optional(),
}) })
.strict(); .strict();
const usageSourceMetadataSchema = z
.object({
id: z.string().min(1),
label: z.string().min(1),
icon: z.string().optional(),
discover: z.boolean(),
})
.strict();
const providerConnectRequestSchema = z const providerConnectRequestSchema = z
.object({ .object({
versions: z.array(z.number().int().positive()), versions: z.array(z.number().int().positive()),
@@ -111,6 +131,7 @@ export const PluginProcessRequestSchema: z.ZodType<PluginProcessRequest> = z.dis
pluginId: z.string().min(1), pluginId: z.string().min(1),
bundle: z.string(), bundle: z.string(),
appVersion: z.string(), appVersion: z.string(),
pluginDirectory: z.string(),
settingsDirectory: z.string().optional(), settingsDirectory: z.string().optional(),
}) })
.strict(), .strict(),
@@ -141,6 +162,25 @@ export const PluginProcessRequestSchema: z.ZodType<PluginProcessRequest> = z.dis
}) })
.strict(), .strict(),
z.object({ type: z.literal("hook.cancel"), requestId: z.string() }).strict(), z.object({ type: z.literal("hook.cancel"), requestId: z.string() }).strict(),
z
.object({
type: z.literal("usage.identify"),
requestId: z.string(),
sourceId: z.string(),
input: z.unknown(),
})
.strict(),
z
.object({
type: z.literal("usage.fetch"),
requestId: z.string(),
sourceId: z.string(),
input: z.unknown(),
})
.strict(),
z
.object({ type: z.literal("usage.discover"), requestId: z.string(), sourceId: z.string() })
.strict(),
z z
.object({ .object({
type: z.literal("invoke"), type: z.literal("invoke"),
@@ -182,6 +222,7 @@ export const PluginProcessMessageSchema: z.ZodType<PluginProcessMessage> = z.dis
type: z.literal("ready"), type: z.literal("ready"),
methods: z.array(z.string()), methods: z.array(z.string()),
providers: z.array(providerMetadataSchema), providers: z.array(providerMetadataSchema),
usageSources: z.array(usageSourceMetadataSchema).optional(),
hooks: hooksSchema.optional(), hooks: hooksSchema.optional(),
}) })
.strict(), .strict(),
@@ -1,15 +1,14 @@
import { PluginHookHandlers } from "./lifecycle/index.js"; import { PluginHookHandlers } from "./lifecycle/index.js";
import { evaluateBundle } from "./bundle-evaluator.js";
import { import {
PluginProcessRequestSchema, PluginProcessRequestSchema,
type PluginProcessMessage, type PluginProcessMessage,
type PluginProcessRequest, type PluginProcessRequest,
} from "./plugin-process-protocol.js"; } from "./plugin-process-protocol.js";
import { createRequire } from "node:module"; import type { UsageSourceRegistration } from "@getpaseo/plugin/server/usage";
import * as pluginSharedRuntime from "@getpaseo/plugin";
import * as pluginProviderRuntime from "@getpaseo/plugin/server/provider";
import * as pluginAcpRuntime from "@getpaseo/plugin/server/acp";
import type { SettingsDefinition, PluginRpcContract } from "@getpaseo/plugin"; import type { SettingsDefinition, PluginRpcContract } from "@getpaseo/plugin";
import type { PluginHandlerContext } from "@getpaseo/plugin/server"; import type { PluginHandlerContext, PluginServerContribution } from "@getpaseo/plugin/server";
import { fileURLToPath } from "node:url";
import type { ZodType } from "zod"; import type { ZodType } from "zod";
import { import {
ProviderEventSchema, ProviderEventSchema,
@@ -19,341 +18,345 @@ import {
import { createPaseoApi, type PaseoApi } from "@getpaseo/client"; import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { createPluginDaemonTransportFactory } from "./daemon-transport.js"; import { createPluginDaemonTransportFactory } from "./daemon-transport.js";
import { isPluginClientOnlySdkSpecifier } from "./plugin-sdk-specifiers.js";
import { createPluginClientId } from "./plugin-session-identity.js"; import { createPluginClientId } from "./plugin-session-identity.js";
import { PluginSettingsStore } from "./settings/index.js"; import { PluginSettingsStore } from "./settings/index.js";
let settingsStore: PluginSettingsStore | null = null; import { readPluginProviderIcon } from "./provider-icon.js";
function registerSettings<Schema extends ZodType>(definition: SettingsDefinition<Schema>) {
if (!settingsStore) throw new Error("Plugin settings storage is unavailable");
const handlers = settingsStore.register(definition);
register(handlers.read.contract, handlers.read.handle);
register(handlers.write.contract, (input) =>
handlers.write.handle(handlers.write.contract.input.parse(input)),
);
register(handlers.reset.contract, (input) =>
handlers.reset.handle(handlers.reset.contract.input.parse(input)),
);
return handlers.settings;
}
type RpcHandler = (input: unknown, context: PluginHandlerContext) => unknown | Promise<unknown>;
interface RegisteredRpc {
contract: PluginRpcContract;
handler: RpcHandler;
}
const hooks = new PluginHookHandlers(() => {
send({ type: "hooks.changed", hooks: hooks.catalog() });
});
const handlers = new Map<string, RegisteredRpc>();
const providers = new Map<string, ProviderRegistration>();
const providerConnections = new Map<
string,
{ connection: ProviderConnection; unsubscribe: () => void; closing?: Promise<void> }
>();
const pendingProviderConnections = new Map<string, { tombstoned: boolean }>();
let cleanup: (() => void | Promise<void>) | null = null;
let daemonClient: DaemonClient | null = null;
let paseo: PaseoApi | null = null;
let stopping = false;
const nodeRequire = createRequire(import.meta.url);
function send(message: PluginProcessMessage): void {
process.send?.(message);
}
function sendAndWait(message: PluginProcessMessage): Promise<void> {
return new Promise((resolve) => {
if (!process.send) {
resolve();
return;
}
process.send(message, () => resolve());
});
}
function describeError(error: unknown): string { function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error); return error instanceof Error ? error.message : String(error);
} }
function jsonTransportValue<Value>(value: Value): Value { export interface PluginWorkerChannel {
const encoded = JSON.stringify(value); send(message: PluginProcessMessage, callback?: () => void): void;
if (encoded === undefined) throw new Error("Provider value is not JSON-serializable"); onMessage(handler: (message: PluginProcessRequest) => void): () => void;
return JSON.parse(encoded) as Value; disconnect(): void;
} }
function validateMethod(method: string): string { export function createPluginWorker(options: {
const normalized = method.trim(); channel: PluginWorkerChannel;
if (!/^[a-z][a-z0-9._-]*$/.test(normalized)) { contribute: PluginServerContribution;
throw new Error(`Invalid plugin RPC method: ${method}`); }): { shutdown(): Promise<void> } {
const { channel, contribute } = options;
let settingsStore: PluginSettingsStore | null = null;
function registerSettings<Schema extends ZodType>(definition: SettingsDefinition<Schema>) {
if (!settingsStore) throw new Error("Plugin settings storage is unavailable");
const handlers = settingsStore.register(definition);
register(handlers.read.contract, handlers.read.handle);
register(handlers.write.contract, (input) =>
handlers.write.handle(handlers.write.contract.input.parse(input)),
);
register(handlers.reset.contract, (input) =>
handlers.reset.handle(handlers.reset.contract.input.parse(input)),
);
return handlers.settings;
} }
if (handlers.has(normalized)) {
throw new Error(`Duplicate plugin RPC method: ${normalized}`);
}
return normalized;
}
function register(contract: PluginRpcContract, handler: RpcHandler): void { type RpcHandler = (input: unknown, context: PluginHandlerContext) => unknown | Promise<unknown>;
if (typeof handler !== "function") {
throw new Error(`Plugin RPC ${contract.name} must provide a handler`);
}
const method = validateMethod(contract.name);
handlers.set(method, { contract: { ...contract, name: method }, handler });
}
function registerProvider(provider: ProviderRegistration): void { interface RegisteredRpc {
const id = provider.id.trim(); contract: PluginRpcContract;
if (!/^[a-z][a-z0-9._-]*$/.test(id)) { handler: RpcHandler;
throw new Error(`Invalid plugin provider ID: ${provider.id}`);
} }
if (!provider.label.trim()) throw new Error(`Plugin provider ${id} requires a label`);
if (typeof provider.connect !== "function") {
throw new Error(`Plugin provider ${id} must implement connect()`);
}
if (
provider.getCatalogCacheKey !== undefined &&
typeof provider.getCatalogCacheKey !== "function"
) {
throw new Error(`Invalid catalogue key callback for plugin provider ${id}`);
}
if (providers.has(id)) throw new Error(`Duplicate plugin provider ID: ${id}`);
providers.set(id, { ...provider, id });
}
function providerMetadata(provider: ProviderRegistration) { const hooks = new PluginHookHandlers(() => {
return { send({ type: "hooks.changed", hooks: hooks.catalog() });
id: provider.id, });
label: provider.label, const handlers = new Map<string, RegisteredRpc>();
description: provider.description, const providers = new Map<string, ProviderRegistration>();
iconPath: provider.icon, const usageSources = new Map<string, UsageSourceRegistration>();
hasCatalogCacheKey: provider.getCatalogCacheKey !== undefined, const providerConnections = new Map<
}; string,
} { connection: ProviderConnection; unsubscribe: () => void; closing?: Promise<void> }
>();
async function connectProvider( const pendingProviderConnections = new Map<string, { tombstoned: boolean }>();
message: Extract<PluginProcessRequest, { type: "provider.connect" }>, let cleanup: (() => void | Promise<void>) | null = null;
): Promise<void> { let daemonClient: DaemonClient | null = null;
if (stopping) throw new Error("Plugin is stopping"); let paseo: PaseoApi | null = null;
const provider = providers.get(message.providerId); let stopping = false;
if (!provider) throw new Error(`Unknown plugin provider: ${message.providerId}`); function send(message: PluginProcessMessage): void {
if ( channel.send(message);
providerConnections.has(message.connectionId) ||
pendingProviderConnections.has(message.connectionId)
) {
throw new Error(`Duplicate provider connection: ${message.connectionId}`);
} }
const pending = { tombstoned: false };
pendingProviderConnections.set(message.connectionId, pending); function sendAndWait(message: PluginProcessMessage): Promise<void> {
let connection: ProviderConnection; return new Promise((resolve) => {
try { channel.send(message, () => resolve());
connection = await provider.connect(message.request); });
} catch (error) { }
function jsonTransportValue<Value>(value: Value): Value {
const encoded = JSON.stringify(value);
if (encoded === undefined) throw new Error("Provider value is not JSON-serializable");
return JSON.parse(encoded) as Value;
}
function validateMethod(method: string): string {
const normalized = method.trim();
if (!/^[a-z][a-z0-9._-]*$/.test(normalized)) {
throw new Error(`Invalid plugin RPC method: ${method}`);
}
if (handlers.has(normalized)) {
throw new Error(`Duplicate plugin RPC method: ${normalized}`);
}
return normalized;
}
function register(contract: PluginRpcContract, handler: RpcHandler): void {
if (typeof handler !== "function") {
throw new Error(`Plugin RPC ${contract.name} must provide a handler`);
}
const method = validateMethod(contract.name);
handlers.set(method, { contract: { ...contract, name: method }, handler });
}
function registerProvider(provider: ProviderRegistration): void {
const id = provider.id.trim();
if (!/^[a-z][a-z0-9._-]*$/.test(id)) {
throw new Error(`Invalid plugin provider ID: ${provider.id}`);
}
if (!provider.label.trim()) throw new Error(`Plugin provider ${id} requires a label`);
if (typeof provider.connect !== "function") {
throw new Error(`Plugin provider ${id} must implement connect()`);
}
if (
provider.getCatalogCacheKey !== undefined &&
typeof provider.getCatalogCacheKey !== "function"
) {
throw new Error(`Invalid catalogue key callback for plugin provider ${id}`);
}
if (providers.has(id)) throw new Error(`Duplicate plugin provider ID: ${id}`);
providers.set(id, { ...provider, id });
}
function registerUsageSource(source: UsageSourceRegistration): void {
const id = source.id.trim();
if (
!/^[a-z][a-z0-9._-]*$/.test(id) ||
!source.label.trim() ||
typeof source.fetch !== "function" ||
!source.input ||
typeof source.input.parseAsync !== "function"
) {
throw new Error(`Invalid usage source: ${source.id}`);
}
if (usageSources.has(id)) throw new Error(`Duplicate usage source: ${id}`);
usageSources.set(id, { ...source, id });
}
function providerMetadata(provider: ProviderRegistration) {
return {
id: provider.id,
label: provider.label,
description: provider.description,
iconPath: provider.icon,
hasCatalogCacheKey: provider.getCatalogCacheKey !== undefined,
};
}
async function connectProvider(
message: Extract<PluginProcessRequest, { type: "provider.connect" }>,
): Promise<void> {
if (stopping) throw new Error("Plugin is stopping");
const provider = providers.get(message.providerId);
if (!provider) throw new Error(`Unknown plugin provider: ${message.providerId}`);
if (
providerConnections.has(message.connectionId) ||
pendingProviderConnections.has(message.connectionId)
) {
throw new Error(`Duplicate provider connection: ${message.connectionId}`);
}
const pending = { tombstoned: false };
pendingProviderConnections.set(message.connectionId, pending);
let connection: ProviderConnection;
try {
connection = await provider.connect(message.request);
} catch (error) {
pendingProviderConnections.delete(message.connectionId);
if (pending.tombstoned || stopping) return;
throw error;
}
pendingProviderConnections.delete(message.connectionId); pendingProviderConnections.delete(message.connectionId);
if (pending.tombstoned || stopping) return; if (pending.tombstoned || stopping) {
throw error; await connection.close().catch(() => undefined);
}
pendingProviderConnections.delete(message.connectionId);
if (pending.tombstoned || stopping) {
await connection.close().catch(() => undefined);
return;
}
let unsubscribe = () => {};
unsubscribe = connection.onEvent((event) => {
try {
send({
type: "provider.event",
connectionId: message.connectionId,
event: ProviderEventSchema.parse(jsonTransportValue(event)),
});
} catch (error) {
providerConnections.delete(message.connectionId);
unsubscribe();
void connection.close().catch(() => undefined);
send({
type: "provider.closed",
connectionId: message.connectionId,
error: describeError(error),
});
}
});
providerConnections.set(message.connectionId, { connection, unsubscribe });
send({
type: "provider.connected",
connectionId: message.connectionId,
version: connection.version,
capabilities: connection.capabilities,
});
}
async function sendProviderInput(
message: Extract<PluginProcessRequest, { type: "provider.send" }>,
): Promise<void> {
if (stopping) throw new Error("Plugin is stopping");
const current = providerConnections.get(message.connectionId);
if (!current) throw new Error(`Unknown provider connection: ${message.connectionId}`);
if (current.closing) throw new Error("Provider connection is closing");
await current.connection.send(message.input);
send({
type: "provider.accepted",
connectionId: message.connectionId,
acceptanceId: message.acceptanceId,
});
}
// The connection stays registered until its close has reported, so shutdown
// waits for a close already in flight instead of disconnecting underneath it.
async function closeProviderConnection(connectionId: string): Promise<void> {
const current = providerConnections.get(connectionId);
if (!current) return;
if (current.closing) return current.closing;
const closing = (async () => {
current.unsubscribe();
try {
await current.connection.close();
send({ type: "provider.closed", connectionId });
} catch (error) {
send({ type: "provider.closed", connectionId, error: describeError(error) });
} finally {
providerConnections.delete(connectionId);
}
})();
current.closing = closing;
return closing;
}
function runtimeRequire(name: string): unknown {
if (isPluginClientOnlySdkSpecifier(name)) {
throw new Error(`${name} is available only in plugin client code`);
}
if (name === "@getpaseo/plugin") return pluginSharedRuntime;
if (name === "@getpaseo/plugin/server") return {};
if (name === "@getpaseo/plugin/server/provider") return pluginProviderRuntime;
if (name === "@getpaseo/plugin/server/acp") return pluginAcpRuntime;
if (name === "@getpaseo/plugin/client/host")
throw new Error(`${name} is private to the app host`);
return nodeRequire(name);
}
function evaluateBundle(bundle: string): void {
const evaluate: (source: string) => unknown = globalThis.eval;
const factory = evaluate(bundle);
if (typeof factory !== "function") throw new Error("Plugin server bundle is not executable");
const exports = factory(runtimeRequire);
const setup =
exports !== null && typeof exports === "object" ? Reflect.get(exports, "default") : undefined;
if (typeof setup !== "function") {
throw new Error("Plugin server bundle must default export a function");
}
const contributedCleanup = setup({
handle: register,
registerProvider,
registerSettings,
on: hooks.on,
before: hooks.before,
});
if (typeof contributedCleanup !== "function") {
throw new Error("Plugin contribution must return a cleanup function");
}
cleanup = contributedCleanup;
}
const transportFactory = createPluginDaemonTransportFactory({
send,
onMessage(handler) {
process.on("message", handler);
return () => process.off("message", handler);
},
});
async function initialize(message: Extract<PluginProcessRequest, { type: "initialize" }>) {
daemonClient = new DaemonClient({
url: `ipc://plugin/${encodeURIComponent(message.pluginId)}`,
clientId: createPluginClientId(message.pluginId),
clientType: "cli",
appVersion: message.appVersion,
// The runtime re-attaches a session when the daemon drops this socket.
reconnect: { enabled: true },
transportFactory,
});
paseo = createPaseoApi(daemonClient);
await daemonClient.connect();
settingsStore = message.settingsDirectory
? new PluginSettingsStore(message.settingsDirectory, (settingsId) =>
send({ type: "settings.changed", settingsId }),
)
: null;
evaluateBundle(message.bundle);
send({
type: "ready",
methods: [...handlers.keys()].sort(),
hooks: hooks.catalog(),
providers: [...providers.values()]
.sort((left, right) => left.id.localeCompare(right.id))
.map(providerMetadata),
});
}
async function shutdown(): Promise<void> {
if (stopping) return;
stopping = true;
const releaseApi = paseo
?.dispose()
.catch((error) => console.error("Plugin API cleanup failed", error));
hooks.close();
for (const pending of pendingProviderConnections.values()) pending.tombstoned = true;
const currentCleanup = cleanup;
cleanup = null;
try {
await currentCleanup?.();
} catch (error) {
console.error("Plugin cleanup failed", error);
}
await Promise.all([...providerConnections.keys()].map(closeProviderConnection));
await releaseApi;
await daemonClient?.close().catch(() => undefined);
await sendAndWait({ type: "paseo_close" });
daemonClient = null;
paseo = null;
process.disconnect();
}
process.on("message", (rawMessage: unknown) => {
const parsed = PluginProcessRequestSchema.safeParse(rawMessage);
if (!parsed.success) {
const value = rawMessage as { connectionId?: unknown; acceptanceId?: unknown } | null;
if (value && typeof value.connectionId === "string" && typeof value.acceptanceId === "string") {
send({
type: "provider.rejected",
connectionId: value.connectionId,
acceptanceId: value.acceptanceId,
error: `Invalid provider input: ${parsed.error.message}`,
});
void closeProviderConnection(value.connectionId);
return; return;
} }
send({ type: "fatal", error: `Invalid plugin process request: ${parsed.error.message}` }); let unsubscribe = () => {};
void shutdown(); unsubscribe = connection.onEvent((event) => {
return; try {
} send({
const message = parsed.data; type: "provider.event",
if (message.type === "initialize") { connectionId: message.connectionId,
void initialize(message).catch(async (error) => { event: ProviderEventSchema.parse(jsonTransportValue(event)),
send({ type: "fatal", error: describeError(error) }); });
await paseo } catch (error) {
?.dispose() providerConnections.delete(message.connectionId);
.catch((failure) => console.error("Plugin API cleanup failed", failure)); unsubscribe();
await daemonClient?.close().catch(() => undefined); void connection.close().catch(() => undefined);
send({
type: "provider.closed",
connectionId: message.connectionId,
error: describeError(error),
});
}
});
providerConnections.set(message.connectionId, { connection, unsubscribe });
send({
type: "provider.connected",
connectionId: message.connectionId,
version: connection.version,
capabilities: connection.capabilities,
}); });
return;
} }
if (message.type === "shutdown") {
void shutdown(); async function sendProviderInput(
return; message: Extract<PluginProcessRequest, { type: "provider.send" }>,
): Promise<void> {
if (stopping) throw new Error("Plugin is stopping");
const current = providerConnections.get(message.connectionId);
if (!current) throw new Error(`Unknown provider connection: ${message.connectionId}`);
if (current.closing) throw new Error("Provider connection is closing");
await current.connection.send(message.input);
send({
type: "provider.accepted",
connectionId: message.connectionId,
acceptanceId: message.acceptanceId,
});
} }
if (stopping) {
if (message.type === "provider.catalog_key") { // The connection stays registered until its close has reported, so shutdown
// waits for a close already in flight instead of disconnecting underneath it.
async function closeProviderConnection(connectionId: string): Promise<void> {
const current = providerConnections.get(connectionId);
if (!current) return;
if (current.closing) return current.closing;
const closing = (async () => {
current.unsubscribe();
try {
await current.connection.close();
send({ type: "provider.closed", connectionId });
} catch (error) {
send({ type: "provider.closed", connectionId, error: describeError(error) });
} finally {
providerConnections.delete(connectionId);
}
})();
current.closing = closing;
return closing;
}
const transportFactory = createPluginDaemonTransportFactory({
send,
onMessage(handler) {
return channel.onMessage(handler);
},
});
async function initialize(message: Extract<PluginProcessRequest, { type: "initialize" }>) {
daemonClient = new DaemonClient({
url: `ipc://plugin/${encodeURIComponent(message.pluginId)}`,
clientId: createPluginClientId(message.pluginId),
clientType: "cli",
appVersion: message.appVersion,
// The runtime re-attaches a session when the daemon drops this socket.
reconnect: { enabled: true },
transportFactory,
});
paseo = createPaseoApi(daemonClient);
await daemonClient.connect();
settingsStore = message.settingsDirectory
? new PluginSettingsStore(message.settingsDirectory, (settingsId) =>
send({ type: "settings.changed", settingsId }),
)
: null;
const contributedCleanup = contribute({
handle: (contract, handler) =>
register(contract, (input, context) => handler(contract.input.parse(input), context)),
registerProvider,
registerUsageSource,
registerSettings,
on: hooks.on,
before: hooks.before,
});
if (typeof contributedCleanup !== "function") {
throw new Error("Plugin contribution must return a cleanup function");
}
cleanup = contributedCleanup;
const usageSourceMetadata = await Promise.all(
[...usageSources.values()]
.sort((a, b) => a.id.localeCompare(b.id))
.map(async (source) => ({
id: source.id,
label: source.label,
icon: source.icon
? await readPluginProviderIcon(message.pluginDirectory, source.icon)
: undefined,
discover: !!source.discover,
})),
);
send({
type: "ready",
methods: [...handlers.keys()].sort(),
hooks: hooks.catalog(),
providers: [...providers.values()]
.sort((left, right) => left.id.localeCompare(right.id))
.map(providerMetadata),
usageSources: usageSourceMetadata,
});
}
async function shutdown(): Promise<void> {
if (stopping) return;
stopping = true;
const releaseApi = paseo
?.dispose()
.catch((error) => console.error("Plugin API cleanup failed", error));
hooks.close();
for (const pending of pendingProviderConnections.values()) pending.tombstoned = true;
const currentCleanup = cleanup;
cleanup = null;
try {
await currentCleanup?.();
} catch (error) {
console.error("Plugin cleanup failed", error);
}
await Promise.all([...providerConnections.keys()].map(closeProviderConnection));
await releaseApi;
await daemonClient?.close().catch(() => undefined);
await sendAndWait({ type: "paseo_close" });
daemonClient = null;
paseo = null;
channel.disconnect();
}
function handleUsageRequest(
message: Extract<
PluginProcessRequest,
{ type: "usage.identify" | "usage.fetch" | "usage.discover" }
>,
): void {
void (async () => {
const source = usageSources.get(message.sourceId);
if (!source) throw new Error(`Unknown usage source: ${message.sourceId}`);
if (message.type === "usage.discover")
return jsonTransportValue(source.discover ? await source.discover() : []);
const input = await source.input.parseAsync(message.input);
return jsonTransportValue(
message.type === "usage.identify"
? await source.identify(input)
: await source.fetch(input),
);
})().then(
(output) => send({ type: "result", requestId: message.requestId, output }),
(error) => send({ type: "error", requestId: message.requestId, error: describeError(error) }),
);
}
function rejectWhileStopping(message: PluginProcessRequest): void {
if (
message.type === "provider.catalog_key" ||
message.type === "usage.identify" ||
message.type === "usage.fetch" ||
message.type === "usage.discover"
) {
send({ type: "error", requestId: message.requestId, error: "Plugin is stopping" }); send({ type: "error", requestId: message.requestId, error: "Plugin is stopping" });
} else if (message.type === "provider.connect") { } else if (message.type === "provider.connect") {
send({ send({
@@ -371,105 +374,190 @@ process.on("message", (rawMessage: unknown) => {
} else if (message.type === "provider.close") { } else if (message.type === "provider.close") {
send({ type: "provider.closed", connectionId: message.connectionId }); send({ type: "provider.closed", connectionId: message.connectionId });
} }
return;
} }
if (message.type === "provider.catalog_key") {
void (async () => {
const provider = providers.get(message.providerId);
if (!provider) throw new Error(`Unknown provider: ${message.providerId}`);
const output = await provider.getCatalogCacheKey?.(message.options);
if (output !== undefined && typeof output !== "string")
throw new Error("Invalid catalogue key");
send({ type: "result", requestId: message.requestId, output });
})().catch((error) =>
send({ type: "error", requestId: message.requestId, error: describeError(error) }),
);
return;
}
if (message.type === "provider.connect") {
void connectProvider(message).catch((error) => {
if (stopping) return;
send({
type: "provider.connect_failed",
connectionId: message.connectionId,
error: describeError(error),
});
});
return;
}
if (message.type === "provider.send") {
void sendProviderInput(message).catch((error) => {
if (stopping) return;
send({
type: "provider.rejected",
connectionId: message.connectionId,
acceptanceId: message.acceptanceId,
error: describeError(error),
});
});
return;
}
if (message.type === "provider.close") {
void closeProviderConnection(message.connectionId);
return;
}
if (message.type === "paseo_frame" || message.type === "paseo_close") return;
if (isHookMessage(message)) {
handleHookMessage(message);
return;
}
const registered = handlers.get(message.method);
if (!registered) {
send({
type: "error",
requestId: message.requestId,
error: `Unknown RPC method: ${message.method}`,
});
return;
}
void registered.contract.input
.parseAsync(message.input)
.then((input) => {
if (!paseo) throw new Error("Plugin Paseo API is unavailable");
return registered.handler(input, { paseo });
})
.then((output) => registered.contract.output.parseAsync(output))
.then(
(output) => send({ type: "result", requestId: message.requestId, output }),
(error) => send({ type: "error", requestId: message.requestId, error: describeError(error) }),
);
});
function handleHookMessage( function handleMessage(rawMessage: unknown): void {
message: Extract<PluginProcessRequest, { type: "hook" | "hook.cancel" }>, const parsed = PluginProcessRequestSchema.safeParse(rawMessage);
): void { if (!parsed.success) {
if (message.type === "hook.cancel") { const value = rawMessage as { connectionId?: unknown; acceptanceId?: unknown } | null;
hooks.cancel(message.requestId); if (
return; value &&
} typeof value.connectionId === "string" &&
if (message.type === "hook") { typeof value.acceptanceId === "string"
if (!paseo) { ) {
send({ send({
type: "error", type: "provider.rejected",
requestId: message.requestId, connectionId: value.connectionId,
error: "Plugin Paseo API is unavailable", acceptanceId: value.acceptanceId,
error: `Invalid provider input: ${parsed.error.message}`,
});
void closeProviderConnection(value.connectionId);
return;
}
send({ type: "fatal", error: `Invalid plugin process request: ${parsed.error.message}` });
void shutdown();
return;
}
const message = parsed.data;
if (message.type === "initialize") {
void initialize(message).catch(async (error) => {
send({ type: "fatal", error: describeError(error) });
await paseo
?.dispose()
.catch((failure) => console.error("Plugin API cleanup failed", failure));
await daemonClient?.close().catch(() => undefined);
}); });
return; return;
} }
void hooks.invoke(message.requestId, message.kind, message.name, message.input, paseo).then( if (message.type === "shutdown") {
(output) => { void shutdown();
return send({ type: "result", requestId: message.requestId, output }); return;
}, }
(error) => { if (stopping) {
return send({ type: "error", requestId: message.requestId, error: describeError(error) }); rejectWhileStopping(message);
}, return;
); }
return; if (message.type === "provider.catalog_key") {
void (async () => {
const provider = providers.get(message.providerId);
if (!provider) throw new Error(`Unknown provider: ${message.providerId}`);
const output = await provider.getCatalogCacheKey?.(message.options);
if (output !== undefined && typeof output !== "string")
throw new Error("Invalid catalogue key");
send({ type: "result", requestId: message.requestId, output });
})().catch((error) =>
send({ type: "error", requestId: message.requestId, error: describeError(error) }),
);
return;
}
if (
message.type === "usage.identify" ||
message.type === "usage.fetch" ||
message.type === "usage.discover"
) {
handleUsageRequest(message);
return;
}
if (message.type === "provider.connect") {
void connectProvider(message).catch((error) => {
if (stopping) return;
send({
type: "provider.connect_failed",
connectionId: message.connectionId,
error: describeError(error),
});
});
return;
}
if (message.type === "provider.send") {
void sendProviderInput(message).catch((error) => {
if (stopping) return;
send({
type: "provider.rejected",
connectionId: message.connectionId,
acceptanceId: message.acceptanceId,
error: describeError(error),
});
});
return;
}
if (message.type === "provider.close") {
void closeProviderConnection(message.connectionId);
return;
}
if (message.type === "paseo_frame" || message.type === "paseo_close") return;
if (isHookMessage(message)) {
handleHookMessage(message);
return;
}
const registered = handlers.get(message.method);
if (!registered) {
send({
type: "error",
requestId: message.requestId,
error: `Unknown RPC method: ${message.method}`,
});
return;
}
void registered.contract.input
.parseAsync(message.input)
.then((input) => {
if (!paseo) throw new Error("Plugin Paseo API is unavailable");
return registered.handler(input, { paseo });
})
.then((output) => registered.contract.output.parseAsync(output))
.then(
(output) => send({ type: "result", requestId: message.requestId, output }),
(error) =>
send({ type: "error", requestId: message.requestId, error: describeError(error) }),
);
} }
channel.onMessage(handleMessage);
function handleHookMessage(
message: Extract<PluginProcessRequest, { type: "hook" | "hook.cancel" }>,
): void {
if (message.type === "hook.cancel") {
hooks.cancel(message.requestId);
return;
}
if (message.type === "hook") {
if (!paseo) {
send({
type: "error",
requestId: message.requestId,
error: "Plugin Paseo API is unavailable",
});
return;
}
void hooks.invoke(message.requestId, message.kind, message.name, message.input, paseo).then(
(output) => {
return send({ type: "result", requestId: message.requestId, output });
},
(error) => {
return send({ type: "error", requestId: message.requestId, error: describeError(error) });
},
);
return;
}
}
function isHookMessage(
message: PluginProcessRequest,
): message is Extract<PluginProcessRequest, { type: "hook" | "hook.cancel" }> {
return message.type === "hook" || message.type === "hook.cancel";
}
return { shutdown };
} }
function isHookMessage( if (process.argv[1] === fileURLToPath(import.meta.url)) {
message: PluginProcessRequest, let worker: ReturnType<typeof createPluginWorker> | null = null;
): message is Extract<PluginProcessRequest, { type: "hook" | "hook.cancel" }> { const handlers = new Set<(message: PluginProcessRequest) => void>();
return message.type === "hook" || message.type === "hook.cancel"; const channel: PluginWorkerChannel = {
send(message, callback) {
if (callback) process.send?.(message, () => callback());
else process.send?.(message);
},
onMessage(handler) {
handlers.add(handler);
return () => {
handlers.delete(handler);
};
},
disconnect() {
process.disconnect();
},
};
process.on("message", (raw) => {
const parsed = PluginProcessRequestSchema.safeParse(raw);
if (!worker && parsed.success && parsed.data.type === "initialize") {
try {
worker = createPluginWorker({ channel, contribute: evaluateBundle(parsed.data.bundle) });
} catch (error) {
channel.send({ type: "fatal", error: describeError(error) });
return;
}
}
if (worker) for (const handler of handlers) handler(raw as PluginProcessRequest);
});
} }
@@ -8,6 +8,7 @@ export const PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS = [
const PLUGIN_SERVER_ONLY_SDK_SPECIFIERS = [ const PLUGIN_SERVER_ONLY_SDK_SPECIFIERS = [
"@getpaseo/plugin/server", "@getpaseo/plugin/server",
"@getpaseo/plugin/server/provider", "@getpaseo/plugin/server/provider",
"@getpaseo/plugin/server/usage",
"@getpaseo/plugin/server/acp", "@getpaseo/plugin/server/acp",
] as const; ] as const;
+79 -4
View File
@@ -24,9 +24,12 @@ import type {
PluginProcessMessage, PluginProcessMessage,
PluginProcessRequest, PluginProcessRequest,
PluginProviderMetadata, PluginProviderMetadata,
PluginUsageSourceMetadata,
} from "./plugin-process-protocol.js"; } from "./plugin-process-protocol.js";
import { PluginProcessMessageSchema } from "./plugin-process-protocol.js"; import { PluginProcessMessageSchema } from "./plugin-process-protocol.js";
import { PluginSessionSocket } from "./session-socket.js"; import { PluginSessionSocket } from "./session-socket.js";
import { InternalPluginChild } from "./internal-child.js";
import { evaluateBundle } from "./bundle-evaluator.js";
const CLIENT_ENTRY_FILENAMES = ["index.client.ts", "index.client.tsx"] as const; const CLIENT_ENTRY_FILENAMES = ["index.client.ts", "index.client.tsx"] as const;
const SERVER_ENTRY_FILENAMES = ["index.server.ts", "index.server.tsx"] as const; const SERVER_ENTRY_FILENAMES = ["index.server.ts", "index.server.tsx"] as const;
@@ -65,6 +68,7 @@ interface LoadedPlugin {
methods: ReadonlySet<string>; methods: ReadonlySet<string>;
hooks: { events: string[]; before: string[] }; hooks: { events: string[]; before: string[] };
providers: readonly PluginProviderMetadata[]; providers: readonly PluginProviderMetadata[];
usageSources: readonly PluginUsageSourceMetadata[];
child: PluginChild | null; child: PluginChild | null;
outputCapture: PluginOutputCapture | null; outputCapture: PluginOutputCapture | null;
pending: Map<string, PendingInvocation>; pending: Map<string, PendingInvocation>;
@@ -332,6 +336,29 @@ export class PluginRuntime {
this.appendLog(pluginId, "stdout", "[paseo] Plugin ready"); this.appendLog(pluginId, "stdout", "[paseo] Plugin ready");
} }
async startBuiltinPlugin(input: { id: string; directory: string }): Promise<void> {
if (this.plugins.has(input.id)) throw new Error(`Plugin is already running: ${input.id}`);
this.appendLog(input.id, "stdout", "[paseo] Loading plugin");
const directory = path.resolve(input.directory);
const manifest = await readPluginManifest(directory);
if (manifest.id !== input.id) {
throw new Error(`Built-in plugin ${input.id} has manifest ID ${manifest.id}`);
}
assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" });
const bundles = await compilePlugin(await resolveEntryPaths(directory));
if (!bundles.serverBundle) throw new Error(`Built-in plugin ${input.id} needs a server entry`);
const loaded = await this.launchPlugin({
pluginId: input.id,
pluginDirectory: directory,
requirements: manifest.requirements,
child: new InternalPluginChild(evaluateBundle(bundles.serverBundle)),
bundle: "",
clientBundle: bundles.clientBundle ?? "",
});
this.plugins.set(input.id, loaded);
this.appendLog(input.id, "stdout", "[paseo] Plugin ready");
}
async validatePlugin(configuredPath: string): Promise<void> { async validatePlugin(configuredPath: string): Promise<void> {
const directory = path.resolve(configuredPath); const directory = path.resolve(configuredPath);
const manifest = await readPluginManifest(directory); const manifest = await readPluginManifest(directory);
@@ -359,6 +386,33 @@ export class PluginRuntime {
return this.plugins.get(pluginId)?.providers ?? []; return this.plugins.get(pluginId)?.providers ?? [];
} }
getUsageSourceRegistrations(pluginId: string): readonly PluginUsageSourceMetadata[] {
return this.plugins.get(pluginId)?.usageSources ?? [];
}
identifyUsage(pluginId: string, sourceId: string, input: unknown): Promise<unknown> {
const loaded = this.plugins.get(pluginId);
if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`);
return this.request(loaded, {
type: "usage.identify",
requestId: randomUUID(),
sourceId,
input,
});
}
fetchUsage(pluginId: string, sourceId: string, input: unknown): Promise<unknown> {
const loaded = this.plugins.get(pluginId);
if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`);
return this.request(loaded, { type: "usage.fetch", requestId: randomUUID(), sourceId, input });
}
discoverUsage(pluginId: string, sourceId: string): Promise<unknown> {
const loaded = this.plugins.get(pluginId);
if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`);
return this.request(loaded, { type: "usage.discover", requestId: randomUUID(), sourceId });
}
async connectProvider( async connectProvider(
pluginId: string, pluginId: string,
providerId: string, providerId: string,
@@ -566,6 +620,7 @@ export class PluginRuntime {
methods: new Set(), methods: new Set(),
hooks: { events: [], before: [] }, hooks: { events: [], before: [] },
providers: [], providers: [],
usageSources: [],
child: null, child: null,
outputCapture: null, outputCapture: null,
pending: new Map(), pending: new Map(),
@@ -575,9 +630,27 @@ export class PluginRuntime {
sessionClosed: null, sessionClosed: null,
}; };
} }
return this.launchPlugin({
pluginId,
pluginDirectory: directory,
requirements: manifest.requirements,
child: this.spawnChild(),
bundle: serverBundle,
clientBundle: bundles.clientBundle ?? "",
});
}
private async launchPlugin(input: {
pluginId: string;
pluginDirectory: string;
requirements: PluginRequirements | undefined;
child: PluginChild;
bundle: string;
clientBundle: string;
}): Promise<LoadedPlugin> {
const { pluginId, requirements, child, bundle, clientBundle } = input;
const sessionHost = this.sessionHost; const sessionHost = this.sessionHost;
if (!sessionHost) throw new Error("Plugin Paseo session host is not attached"); if (!sessionHost) throw new Error("Plugin Paseo session host is not attached");
const child = this.spawnChild();
const outputCapture = new PluginOutputCapture(child, (stream, message) => { const outputCapture = new PluginOutputCapture(child, (stream, message) => {
this.appendLog(pluginId, stream, message); this.appendLog(pluginId, stream, message);
}); });
@@ -653,8 +726,9 @@ export class PluginRuntime {
void send(child, { void send(child, {
type: "initialize", type: "initialize",
pluginId, pluginId,
pluginDirectory: input.pluginDirectory,
appVersion: this.daemonVersion, appVersion: this.daemonVersion,
bundle: serverBundle, bundle,
settingsDirectory: this.dependencies.settingsDirectory settingsDirectory: this.dependencies.settingsDirectory
? path.join(this.dependencies.settingsDirectory, pluginId) ? path.join(this.dependencies.settingsDirectory, pluginId)
: undefined, : undefined,
@@ -669,11 +743,12 @@ export class PluginRuntime {
} }
loaded = { loaded = {
id: pluginId, id: pluginId,
clientBundle: bundles.clientBundle ?? "", clientBundle,
requirements: manifest.requirements, requirements,
methods: new Set(ready.methods), methods: new Set(ready.methods),
hooks: ready.hooks ?? { events: [], before: [] }, hooks: ready.hooks ?? { events: [], before: [] },
providers: ready.providers ?? [], providers: ready.providers ?? [],
usageSources: ready.usageSources ?? [],
child, child,
outputCapture, outputCapture,
pending, pending,
@@ -0,0 +1,150 @@
import { fileURLToPath } from "node:url";
import { expect, test } from "vitest";
import { ACPAgentSession } from "../agent/providers/acp-agent.js";
import type { UsageReference } from "../agent/agent-sdk-types.js";
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
import { CodexAppServerAgentSession } from "../agent/providers/codex-app-server-agent.js";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestAgentClient } from "../test-utils/fake-agent-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { BuiltinPluginLoader } from "./builtin/index.js";
const fixtureRoot = fileURLToPath(new URL("./test-fixtures/", import.meta.url));
const fixtureBuiltins = () => new BuiltinPluginLoader(fixtureRoot, ["session-usage-reference"]);
test("resolves a provider plugin session reference through its usage source", async () => {
const directory = fileURLToPath(
new URL("./test-fixtures/session-usage-reference/", import.meta.url),
);
const daemon = await createTestPaseoDaemon({
pluginsEnabled: false,
builtinPlugins: fixtureBuiltins(),
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
const agent = await client.createAgent({
provider: "fixture-session-provider",
cwd: directory,
});
const result = await client.resolveAgentUsageReport({ agentId: agent.id });
expect(result.reportId).toBe("fixture-session-usage:from-session");
expect(
(await client.listUsageReports({ reportIds: [result.reportId!] })).reports[0],
).toMatchObject({
id: result.reportId,
sourceId: "fixture-session-usage",
report: { windows: [{ usedPct: 31 }] },
});
const reports = await client.listUsageReports();
expect(reports.reports.map((entry) => entry.id)).toContain(
"fixture-session-usage:from-session",
);
const missing = await client.createAgent({
provider: "fixture-session-provider",
cwd: directory,
model: "missing",
});
expect((await client.resolveAgentUsageReport({ agentId: missing.id })).reportId).toBeNull();
} finally {
await client.close();
await daemon.close();
}
}, 60_000);
test("agent.resolve_usage_report resolves source IDs from default built-in and ACP sessions", async () => {
const directory = fileURLToPath(
new URL("./test-fixtures/session-usage-reference/", import.meta.url),
);
const providers = ["claude", "codex", "copilot", "cursor", "kimi", "generic-acp"] as const;
const logger = createTestLogger();
const references = new Map<string, UsageReference | null>();
const claude = await new ClaudeAgentClient({
logger,
resolveBinary: async () => "/test/claude/bin",
}).createSession(
{ provider: "claude", cwd: directory },
{
env: {
HOME: directory,
CLAUDE_CONFIG_DIR: "",
ANTHROPIC_BASE_URL: "",
ANTHROPIC_API_KEY: "",
ANTHROPIC_AUTH_TOKEN: "",
},
},
);
references.set("claude", (await claude.getUsageReference?.()) ?? null);
await claude.close();
const codex = new CodexAppServerAgentSession(
{ provider: "codex", cwd: directory },
null,
logger,
() => {
throw new Error("Codex runtime should not start");
},
{},
false,
false,
false,
undefined,
"interactive",
{ HOME: directory, OPENAI_BASE_URL: "" },
);
references.set("codex", await codex.getUsageReference());
for (const provider of providers.slice(2)) {
const session = new ACPAgentSession(
{ provider, cwd: directory },
{
provider,
logger,
defaultCommand: ["unused"],
defaultModes: [],
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
},
);
references.set(provider, await session.getUsageReference());
}
const agentClients = Object.fromEntries(
providers.map((provider) => {
const client = createTestAgentClient(provider);
const createSession = client.createSession.bind(client);
client.createSession = async (...args) => {
const session = await createSession(...args);
session.getUsageReference = async () => references.get(provider) ?? null;
return session;
};
return [provider, client];
}),
);
const daemon = await createTestPaseoDaemon({
pluginsEnabled: false,
builtinPlugins: fixtureBuiltins(),
agentClients,
providerOverrides: {
cursor: { extends: "acp", label: "Cursor", command: ["cursor-agent", "acp"] },
kimi: { extends: "acp", label: "Kimi", command: ["kimi", "acp"] },
"generic-acp": { extends: "acp", label: "Generic ACP", command: ["generic-acp", "acp"] },
},
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
for (const provider of providers) {
const agent = await client.createAgent({ provider, cwd: directory });
const result = await client.resolveAgentUsageReport({ agentId: agent.id });
expect(result.reportId, provider).toBe(`${provider}:default`);
}
} finally {
await client.close();
await daemon.close();
}
}, 60_000);
@@ -0,0 +1,18 @@
import { defineRpc } from "@getpaseo/plugin";
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { z } from "zod";
const state = defineRpc({
name: "state",
input: z.object({}),
output: z.object({ workspaces: z.number() }),
});
export default function contribute(server: PluginServerContext) {
let workspaces = 0;
server.handle(state, () => ({ workspaces }));
server.on("workspace.created", () => {
workspaces += 1;
});
return () => {};
}

Some files were not shown because too many files have changed in this diff Show More