Compare commits

..
Author SHA1 Message Date
Mohamed Boudra d07893d845 test(app): keep the custom model check sensitive to a Remove button pushed off the sheet
scrollIntoViewIfNeeded on the Remove button scrolled the overflow-hidden model
card sideways too, so the check passed with the long-id layout fix undone.
Scroll only the Custom models heading into view, which moves the sheet
vertically and leaves the card at scrollLeft 0. With the layout fix undone the
spec now fails at 1200px and 390px again.
2026-11-13 09:36:11 +01:00
Mohamed Boudra 985ff9dcd4 test(app): scroll custom model rows into view before checking them
The provider sheet now shows an Installation section above the model
lists. At 390x1000 the custom model row sits under the sheet footer
until scrolled. The spec checks that a long model id leaves its Remove
button reachable. It had assumed the row needed no vertical scroll.
2026-11-13 09:15:25 +01:00
Mohamed Boudra 5ef83ac70a fix(muse): report the build the update check compares
Paseo reuses a cached update check while inspect reports the same
version string. Muse reported 1.4.18 but compared its build
(1.4.18-R8600.1), so a new build of the same release kept the stale
"Update available" for up to an hour. The contract docs now say to
include everything the check compares in the reported version.
2026-11-13 08:44:06 +01:00
Mohamed Boudra 48c2067a1d fix(providers): share one update check when two clients reopen after a version change 2026-11-13 08:30:57 +01:00
Mohamed Boudra 0b00e79e17 test(app): assert provider installation states are visible 2026-11-13 08:26:39 +01:00
Mohamed Boudra c2f1cb46ae fix(providers): keep update checks within their own deadline
Tying a cached check to its version made every check wait for a fresh
inspection, which could add the 20 s inspect deadline to the 25 s check
deadline. A check now never starts an inspection. It waits on one only
when the inspection is cached or already running and the check would
reuse a cached answer. When it asks the provider, it records the version
afterwards.
2026-11-13 08:26:39 +01:00
Mohamed Boudra 254c7a0831 fix(app): refresh provider version after an update settles while disconnected
The installation and update queries were invalidated only by a live
succeeded progress event. A connection dropped mid-update delivered the
settled operation as a snapshot on reconnect, which left the old version
and Update on screen. A success the view saw running now rereads them
whether it arrives as progress or as a snapshot.
2026-11-13 08:20:04 +01:00
Mohamed Boudra 45136b1f6f fix(providers): reuse a cached update check only for the version it answered
The previous fix dropped the cached check after an inspection settled
with a new version. On reopen the app sends inspect and check together,
so the check answered from the stale entry before the inspection
settled. A cached check now records the installed version it was
computed against and is reused only when the current inspection, shared
if in flight, reports the same version.
2026-11-13 08:20:04 +01:00
Mohamed Boudra 5359250c47 docs(providers): say when Update instructions shows 2026-11-13 08:07:14 +01:00
Mohamed Boudra 148b58a8bf fix(providers): keep update checks in step with the installed version
A conclusive check is cached for an hour and was not tied to the version
it answered for. After a CLI updated outside Paseo, settings showed the
new version and still offered Update to it. When an inspection reports a
different version, the cached check is dropped. Versions are compared
for equality, so they stay opaque.

The release comparison dropped prerelease suffixes, so Codex
0.200.0-alpha.1 looked current against 0.200.0. A prerelease now comes
before the release with the same numbers. Two different prereleases of
the same release are reported as unknown.
2026-11-13 08:07:14 +01:00
Mohamed Boudra fff89a3c17 fix(app): bound update output and show refresh failures in provider settings
The screen appended every progress chunk with no limit while open, so a
noisy updater grew memory and never showed the truncation note that a
reopened screen shows. Progress now folds with the daemon's 64 KiB limit.

A manual refresh stored its result and failure outside the queries the
screen reads, so a failed refresh brought back the old answer with no
warning. Refresh now runs through those queries. It passes staleTime 0
because the app's default staleTime of Infinity would otherwise answer
from the cache.

The not-connected error uses the translated string the i18n check
requires.
2026-11-13 08:07:14 +01:00
Mohamed Boudra 5b0cb13f02 chore(protocol): mark provider installation as shipping in v0.17.0
main is on the 0.16 beta series, and a promotion ships the tested beta's
content without pulling from main, so this lands in the next minor.
2026-11-13 07:45:31 +01:00
Mohamed Boudra ae45e182bd docs(providers): document the provider installation contract
Plugin authors get the contract in the provider guide, with a worked
example that reports a version, checks for an update and runs the CLI's
own updater, and every field, deadline and cache in the reference.
The internal provider and plugin docs record why the three callbacks are
separate, that versions are opaque, that Paseo adds no verification to
an update, and how the plugin bridge carries and cancels each request.
2026-11-13 07:41:23 +01:00
Mohamed Boudra 59d2fe6958 feat(providers): report installation for the remaining providers
Copilot, OpenCode, Pi, Oh My Pi, Antigravity and Hoplite now show an
install link and the installed version in provider settings. Each one
declares only what its CLI offers:

- Update checks ask the source the CLI's own updater reads: Pi's
  latest-version endpoint, `omp update --check`, and Hoplite's
  latest.json. Copilot and OpenCode resolve their release inside the
  binary, so they declare no check.
- Updates run the CLI's own command: `copilot update`, `opencode
  upgrade`, `pi update --self` (leaves Pi packages alone), `omp update`.
  Antigravity and Hoplite document no self-update.

OpenCode's success message asks for a daemon restart, because all
OpenCode agents share one server that keeps running the old version.
2026-11-13 07:41:22 +01:00
Mohamed Boudra af763060c5 feat(providers): run provider updates from provider settings
Providers can declare `update(context)`, and the Installation section shows an Update
button when an update is available. The daemon owns the operation: one per provider,
joined by later requests, kept in memory past client disconnects, streamed to subscribers
through an owned subscription, and aborted only when the daemon stops. The provider's
answer is the outcome; the daemon adds no verification. After success it drops the cached
inspection and update check and refreshes discovery, without restarting agents.

Codex and Claude run their own `update` subcommand and pass the output through. Muse has
no update subcommand, so it does not declare one.

Updater output is capped at 64 KiB per update so a chatty updater cannot grow daemon memory.
Starting an update requires daemon.manage because it runs commands on the host.
2026-11-13 07:41:05 +01:00
Mohamed Boudra 046c206577 feat(providers): show available updates in provider settings
Providers can now declare `installation.checkForUpdates`, returning
`current`, `available` with opaque `versions` and `message`, or `unknown`.
The provider decides what counts as newer; Paseo shows its answer as
written and never parses or compares versions.

The check is its own RPC, `provider.installation.check_updates`, so the
installed version never waits on a network lookup. The daemon caches a
conclusive answer for an hour and an inconclusive one for 30s, shares
in-flight calls, and deadlines the call at 25s, below the plugin RPC
timeout. A throw or timeout comes back as `error`, which the app shows as
inconclusive, never as up to date. The sheet's Refresh bypasses the cache.

Codex and Claude Code read the release source their own updaters use:
the Homebrew cask for cask installs, otherwise the GitHub latest release
for Codex and the configured `autoUpdatesChannel` for Claude Code.
`codex doctor --json` reports the same answer but scans local state and
took close to a minute, so it is not used. Muse compares its build with
the launcher's channel manifest, the same rule the launcher updates by.

The install link now shows whenever the provider declares `installUrl`
and is not reported installed: the Install button for `not_installed`, a
quieter "Install instructions" link when the check failed or the
provider only links to a guide.
2026-11-13 07:40:57 +01:00
Mohamed Boudra 17e313e19a feat(providers): show installed version and install link in provider settings
Providers can now declare `installation`: install and update links plus an
`inspect` callback that reports installed state and an opaque version string.
Built-in clients and plugin providers share one contract; plugins receive the
daemon-resolved launch with `resolved: false` when the executable is missing,
so they can report not installed instead of failing.

The daemon calls `inspect` only on the new
`provider.installation.inspect.request` RPC, never from availability probing,
discovery or session creation, so a slow version probe cannot delay agents.
Results are cached per client for 30s, deadlined at 20s, and links survive a
failed probe. The app gates the provider sheet's Installation row on
`server_info.features.providerInstallation`, so older hosts show nothing new.
2026-11-13 07:40:50 +01:00
166 changed files with 5855 additions and 3269 deletions
-32
View File
@@ -113,38 +113,6 @@ host that is either a known cloud host or one the CLI is already authenticated
to. Adapter probes must not make anonymous HTTP requests to remote-derived
hosts, and adapters must not route credentials to an unauthenticated host.
### Web links: `forgeWeb`
The daemon publishes `forgeWeb` on the workspace descriptor and on
`checkout_status_response`. The app builds every forge URL from it instead of
parsing `origin` itself.
- `host` is the browsable host: an `ssh -G` alias such as `github.com-work` and
a cloud SSH host such as `ssh.github.com` resolve to the cloud host; self-hosted
hosts pass through.
- `referenceRepository` is the repository that owns issue and change-request
numbers: the parent when `origin` is a fork. `#123` links there; `owner/repo#123`
keeps the repository you wrote.
- Branch and file links stay on the `origin` repository. A feature branch usually
exists only on the fork.
An adapter opts into `referenceRepository` with the optional
`ForgeService.getReferenceRepository`. GitHub reads `parent` from `gh repo view`,
GitLab reads `forked_from_project` from `glab api`, and the Gitea family reads
`parent` from `tea api`. The lookup always names the `origin` repository
explicitly, so it does not depend on `gh`'s default-repository choice, and it
rejects on any CLI failure. `packages/server/src/server/forge-web.ts` keeps a
successful answer for as long as the remote stays the same. The first snapshot
publishes `{ host }` immediately and the reference repository follows in a later
update, so a slow or signed-out forge CLI never holds back the checkout's git
state or pull request status. A failed lookup (signed out, network down) leaves
links on `origin` and is retried by the next refresh or pull request status poll
once a minute has passed.
Daemons older than v0.16.0 omit the field; the app then derives the host from the
remote and links `#N` to `origin`. That fallback is tagged `COMPAT(forgeWeb)` in
`packages/app/src/git/forge-url.ts`.
## App
Each app forge splits into two modules so pure logic never imports the client
+3 -10
View File
@@ -20,16 +20,9 @@ settings the native composer does not implement.
## Native sources
Use the iOS window press stream for hardware keys. The stream reaches JavaScript
after the focused text control has already handled the press, so the pipeline's
`preventDefault` cannot stop a key from typing. A key the text control would type
must be claimed up front. `UIKeyCommand` takes only those keys: composer Return, the
top overlay's Enter and arrows, and the Shift or Option shortcuts whose binding is
active in the current focus scope, such as Shift+Tab in the composer.
`sources/native.ts` computes the claims from the published bindings and recomputes
them when the focus scope changes. Cmd and Ctrl combos are never claimed, because
text controls do not type them. Android takes no claims: a focused `EditText`
types nothing for these combos. Keep the stream as the sole shortcut emitter: a command-owned
Use the iOS window press stream for hardware keys. `UIKeyCommand` only takes keys
that must bypass the focused text control: composer Return and the top overlay's
Enter and arrows. Keep the stream as the sole shortcut emitter: a command-owned
press is still observed by the window.
The source spike used an iPad Pro 11-inch (M4) simulator on iOS 18.6 with a
+12 -15
View File
@@ -510,6 +510,18 @@ before calling status; it exposes your diagnostic through the normal provider di
A command-backed provider without status is available when its executable resolves. Providers with
neither command nor status retain connection-based availability.
Implement optional `installation` to fill the **Installation** section of provider settings. The
bridge sends each callback as a `provider.installation.*` request carrying the launch `status`
receives plus `resolved`. `resolved: false` means the executable was not found and `command` is
the configured name, so answer `not_installed` instead of spawning it. Inspect and update-check
requests use the 30 s plugin request timeout; the daemon's 25 s update-check deadline sits below
it, so a slow check reports that deadline. `update` has no request timeout and streams output
through `provider.installation.report`. When the daemon stops waiting on any of the three, it
sends `provider.installation.cancel`, which aborts the callback's `signal`.
The rules shared with built-ins are in [providers.md](providers.md#installation-and-updates); the
author contract is in the
[public provider guide](../public-docs/plugins/providers.md#report-installation-and-updates).
A same-ID config entry without `extends` overrides the plugin's `enabled`, `command`, `env`, label,
description, and model configuration through the normal provider registry. `enabled: false` disables
selection and discovery. An entry with `extends` defines the user's own provider, shadows the plugin,
@@ -624,21 +636,6 @@ one section's order from built-ins, plugin groups, and the section's preference
remembers under its own key. It shares header ids and the `plugin:<pluginId>:<id>` settings key
with new-API items.
## Contribute workspace row badge items
The [public reference](../public-docs/plugins/reference.md#workspace-row-badge-items) owns the author API.
The host lives in `packages/app/src/plugins/workspace-row-badge-items/`; `WorkspaceMetaRow` draws a host's
items after its own.
- Plugin items render with no wrapper view. The meta line spaces children with `gap`, so an empty
wrapper would leave a gap on rows the plugin has nothing to say about. That is also why
the row adds no separator around plugin items; a plugin renders its slot.
- A row only renders items from plugins on its own host. The Show menu coalesces items across hosts
by `<pluginId>/<itemId>`, the key in the `sidebarPluginRowBadgeItems` setting, and places them in
their own group below a separator. A missing key is visible.
- Previews (Settings > Host appearance) pass no `workspace` to `WorkspaceMetaRow`, so plugin items
stay off them.
## Contribute file panels
Client `addFilePanel` registrations become the body of a file tab. The tab stats the file, picks a
+42
View File
@@ -263,6 +263,48 @@ promise for completion: equal results, including equal discovery timestamps, emi
---
## Installation and updates
The **Installation** section of provider settings reads `AgentClient.installation`. Built-ins set
it on the client; plugins set `ProviderRegistration.installation`, which the plugin bridge maps to
the same interface. `ProviderInstallations` in `packages/server/src/server/agent/provider-installation/`
owns deadlines, caching and the one running update per provider, so a provider only answers.
Declare only what the vendor offers:
- `installUrl` and `updateUrl` link the vendor's own install and update docs.
- `inspect` reports the version the CLI prints. Versions are opaque: Paseo shows them as written
and never parses or compares them.
- `checkForUpdates` asks the source the CLI's own updater reads, such as the vendor's release
endpoint or the CLI's `update --check`. Leave it out when that source is inside the binary or
depends on how the CLI was installed, which is why Copilot and OpenCode have no check. Do not
build a release lookup of your own.
- `update` runs the CLI's own update command and passes its output through, using
`runUpdateCommand`. Paseo adds no verification, signature check or rollback, so the update does
exactly what the same command does in a terminal.
The three calls are separate because they cost differently. `inspect` runs whenever someone opens
provider settings, with a 20 s deadline and a 30 s cache. `checkForUpdates` usually reaches the
network, so the app asks only after `inspect` reports the CLI installed, and a conclusive answer
holds for an hour while `inspect` reports the same version string. Report every part of the version
the check compares, such as Muse's build, or a CLI updated outside Paseo keeps its stale answer. `update` runs for minutes, streams output, and is aborted only when the daemon
stops.
The app shows **Update** only when a check answers `available` and the provider declares `update`.
**Update** and **Update instructions** live in the Updates row, which needs a check. A provider
with `update` or `updateUrl` and no check, such as Copilot or OpenCode, shows neither.
Write the success message for the provider's process model. A CLI that Paseo starts once per agent
uses `updatedForNewAgents()`. OpenCode agents share one server that keeps the old binary running,
so its message asks the user to restart the daemon.
`createCliInstallation()` covers a CLI that prints `--version` and updates with a subcommand. It
resolves the configured command, so a profile that extends a built-in reports and updates the CLI
that profile launches. Clients for `extends: "acp"` providers declare no installation.
Test with fake executables, as `providers/provider-update-checks.test.ts` does. Running a real
updater during development replaces the CLI on your machine.
## Usage sources
See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the
@@ -1,148 +0,0 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { seedWorkspace } from "../support/helpers/seed-client";
import {
closeSidebarDisplayPreferences,
openMobileAgentSidebar,
openSidebarDisplayPage,
} from "../support/helpers/sidebar";
const PLUGIN_ID = "row-badges-e2e";
const TITLE = "Badge host workspace";
const PLUGIN_SOURCE = `import { WorkspaceRowBadge } from "@getpaseo/plugin/client/ui";
function Review() {
return <WorkspaceRowBadge icon="Eye" label="In review" tone="warning" />;
}
function Docs() {
return <WorkspaceRowBadge icon="BookOpen" label="Docs" />;
}
function IconOnly() {
return <WorkspaceRowBadge icon="Star" />;
}
function EmptyLabel() {
return <WorkspaceRowBadge icon="Circle" label="" />;
}
function Quiet() {
return null;
}
function Broken() {
throw new Error("badge failed");
}
export default function contribute(client) {
client.addWorkspaceRowBadgeItem({ id: "review", title: "Review state", icon: "Eye", Component: Review });
client.addWorkspaceRowBadgeItem({ id: "docs", title: "Docs state", icon: "BookOpen", Component: Docs });
client.addWorkspaceRowBadgeItem({ id: "icon", title: "Icon state", icon: "Star", Component: IconOnly });
client.addWorkspaceRowBadgeItem({ id: "empty", title: "Empty label state", icon: "Circle", Component: EmptyLabel });
client.addWorkspaceRowBadgeItem({ id: "quiet", title: "Quiet state", icon: "Moon", Component: Quiet });
client.addWorkspaceRowBadgeItem({ id: "broken", title: "Broken state", icon: "Bug", Component: Broken });
return () => {};
}
`;
// The meta line the badges draw on. Each item sits in one wrapper of its own, so the line is two up.
const LABELS = { review: "In review", docs: "Docs" } as const;
function badgeLine(page: Page, itemId: keyof typeof LABELS) {
return badge(page, itemId).locator("xpath=../..");
}
function badge(page: Page, itemId: keyof typeof LABELS) {
return page.getByLabel(LABELS[itemId], { exact: true }).locator("visible=true");
}
async function toggleShowItem(page: Page, label: string, touch: boolean) {
await openSidebarDisplayPage(page, "sidebar-display-show");
await page.getByRole("menuitem", { name: label, exact: true }).click();
if (touch) {
await page
.getByRole("button", { name: "Bottom sheet backdrop", exact: true })
.click({ position: { x: 5, y: 5 } });
} else {
await closeSidebarDisplayPreferences(page);
}
}
test("a plugin badge shows on workspace rows, hides quietly, and follows its Show toggle", async ({
page,
}, info) => {
test.setTimeout(120_000);
const workspace = await seedWorkspace({ repoPrefix: "row-badges-", title: TITLE });
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const config = await client.getDaemonConfig();
const directory = await mkdtemp(path.join(tmpdir(), "paseo-row-badges-"));
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), PLUGIN_SOURCE);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await page.setViewportSize({ width: 1280, height: 900 });
await gotoAppShell(page);
await test.step("the badge draws on the row; a null item and a throwing item draw nothing", async () => {
await expect(badge(page, "review")).toBeVisible();
await expect(badge(page, "review")).toContainText("In review");
// The null and throwing items after them draw nothing, and the line adds nothing of its own.
await expect(badgeLine(page, "review")).toHaveText("In reviewDocs");
// An icon-only badge is named by its item's title.
await expect(
page.getByLabel("Icon state", { exact: true }).locator("visible=true"),
).toBeVisible();
await expect(
page.getByLabel("Empty label state", { exact: true }).locator("visible=true"),
).toBeVisible();
await expect(page.getByText(TITLE).first()).toBeVisible();
await page.screenshot({ path: info.outputPath("wide.png") });
});
await test.step("every item has a Show toggle, and switching one off removes only that badge", async () => {
await openSidebarDisplayPage(page, "sidebar-display-show");
for (const label of ["Review state", "Quiet state", "Broken state", "Docs state"]) {
await expect(page.getByRole("menuitem", { name: label, exact: true })).toBeVisible();
}
await expect(page.getByTestId("sidebar-plugin-row-badge-items-separator")).toBeVisible();
await page.screenshot({ path: info.outputPath("show-menu.png") });
await page.getByRole("menuitem", { name: "Review state", exact: true }).click();
await closeSidebarDisplayPreferences(page);
await expect(badge(page, "review")).toHaveCount(0);
// The first badge is gone, so the next one leads the line without a "·".
await expect(badgeLine(page, "docs")).toHaveText("Docs");
await toggleShowItem(page, "Review state", false);
await expect(badge(page, "review")).toBeVisible();
});
await test.step("the badge draws on a compact layout", async () => {
await page.setViewportSize({ width: 390, height: 844 });
await openMobileAgentSidebar(page);
await expect(badge(page, "review")).toBeVisible();
await page.screenshot({ path: info.outputPath("compact.png") });
await toggleShowItem(page, "Review state", true);
await expect(badge(page, "review")).toHaveCount(0);
await toggleShowItem(page, "Review state", true);
await expect(badge(page, "review")).toBeVisible();
});
} finally {
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
await client.close();
await rm(directory, { recursive: true, force: true });
await workspace.cleanup();
}
});
+2 -22
View File
@@ -25,7 +25,6 @@ import {
} from "../support/helpers/usage-login";
import {
claudeAndCodexReports,
closeUsage,
expectPinnedUsage,
openCompactSidebar,
openUsageFromIcon,
@@ -80,17 +79,12 @@ function twoHoursAgo(): string {
}
/** Set PASEO_QA_SCREENSHOT_DIR to keep a QA screenshot. */
async function qaScreenshot(page: Page, name: string, target: "page" | "summary" = "page") {
async function qaScreenshot(page: Page, name: string) {
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
if (!directory) return;
await page.waitForTimeout(600);
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
const file = path.join(directory, `${name}.png`);
if (target === "summary") {
await usageItem(page).screenshot({ path: file });
return;
}
await page.screenshot({ path: file });
await page.screenshot({ path: path.join(directory, `${name}.png`) });
}
function hostFilter(page: Page) {
@@ -634,15 +628,6 @@ for (const compact of [false, true]) {
await refreshLoginUsage(page);
await expectRetainedLoginUsage(page, "Claude: Rate limited");
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
await expectPinnedUsage(page, ["42% Weekly"]);
await expect(usageItem(page).getByTestId("sidebar-usage-error")).toBeVisible();
await expect(usageItem(page)).toHaveAccessibleName(/Unable to refresh usage/);
await qaScreenshot(page, `usage-error-detail-${compact ? "compact" : "desktop"}`);
await closeUsage(page);
await qaScreenshot(page, `usage-error-summary-${compact ? "compact" : "desktop"}`, "summary");
await openUsageFromItem(page);
await expectRetainedLoginUsage(page, "Claude: Rate limited");
await refreshLoginUsage(page);
await expectRetainedLoginUsage(page, "Claude: Rate limited");
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
@@ -654,8 +639,6 @@ for (const compact of [false, true]) {
await refreshLoginUsage(page);
await expectRetainedLoginUsage(page, "Claude: No active quota");
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
await expectPinnedUsage(page, ["42% Weekly"]);
await expect(usageItem(page).getByTestId("sidebar-usage-error")).toHaveCount(0);
await fixture.setReport({
status: "available",
@@ -664,9 +647,6 @@ for (const compact of [false, true]) {
await page.clock.setFixedTime(new Date(Date.now() + 150_000));
await refreshLoginUsage(page);
await expectLoginUsageOnly(page, "43% used");
await expectPinnedUsage(page, ["43% Weekly"]);
await expect(usageItem(page).getByTestId("sidebar-usage-error")).toHaveCount(0);
await expect(usageItem(page)).not.toHaveAccessibleName(/Unable to refresh usage/);
await advanceUsageClock(page, 60_000);
await expect(page.getByText("42% used", { exact: true })).toHaveCount(0);
await expectLoginUsageFreshness(page, "Updated 2m ago", compact);
@@ -25,7 +25,7 @@ export async function addCustomModel(page: Page, id: string): Promise<void> {
}
export async function removeCustomModel(page: Page, id: string): Promise<void> {
await page.getByRole("textbox", { name: "Search models" }).fill(id);
await searchModels(page, id);
const remove = page.getByRole("button", { name: `Remove ${id}`, exact: true });
await expect(remove).toBeInViewport({ ratio: 1 });
await remove.click();
@@ -33,8 +33,20 @@ export async function removeCustomModel(page: Page, id: string): Promise<void> {
}
export async function expectCustomModelRetained(page: Page, id: string): Promise<void> {
await page.getByRole("textbox", { name: "Search models" }).fill(id);
await searchModels(page, id);
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toBeInViewport({
ratio: 1,
});
}
/**
* Filters the model list and scrolls the sheet down to the custom models, since other sections push
* them below the fold at phone width. Only the section heading is scrolled into view: scrolling the
* row itself would also slide its card sideways and hide a Remove button pushed past the right edge.
*/
async function searchModels(page: Page, id: string): Promise<void> {
await page.getByRole("textbox", { name: "Search models" }).fill(id);
await page
.getByText("Custom models", { exact: true })
.evaluate((heading) => heading.scrollIntoView({ block: "start" }));
}
@@ -8,7 +8,6 @@ private let hardwareShortcutEventName = "onHardwareKeyboardShortcut"
private weak var activeModule: PaseoHardwareKeyboardModule?
private var isHardwareSubmitEnabled = false
private var overlayKeys: Set<String> = []
private var claimedShortcuts: [ClaimedShortcut] = []
/// UIKit spells non-character keys as sentinel input strings rather than as the
/// character they would type. JS sends the DOM `code` and the mapping lives
@@ -20,42 +19,6 @@ private let namedKeyInputs: [String: String] = [
"Enter": "\r",
]
/// A shortcut the window takes from the focused text control: Shift+Tab would
/// otherwise insert a tab, and Option+Shift+[ would type a quote.
private struct ClaimedShortcut {
let input: String
let modifierFlags: UIKeyModifierFlags
}
struct ClaimedShortcutRecord: Record {
@Field var code: String = ""
@Field var shift: Bool = false
@Field var alt: Bool = false
}
private let claimableKeyInputs: [String: String] = [
"Tab": "\t", "Space": " ", "Enter": "\r", "Escape": UIKeyCommand.inputEscape,
"ArrowUp": UIKeyCommand.inputUpArrow, "ArrowDown": UIKeyCommand.inputDownArrow,
"ArrowLeft": UIKeyCommand.inputLeftArrow, "ArrowRight": UIKeyCommand.inputRightArrow,
"Minus": "-", "Equal": "=", "BracketLeft": "[", "BracketRight": "]", "Backslash": "\\",
"Semicolon": ";", "Quote": "'", "Backquote": "`", "Comma": ",", "Period": ".", "Slash": "/",
]
/// UIKit matches a key command against the unmodified character, so
/// Option+Shift+[ is registered as "[" with both modifier flags.
private func claimedShortcut(_ record: ClaimedShortcutRecord) -> ClaimedShortcut? {
let code = record.code
let input: String?
if code.count == 4 && code.hasPrefix("Key") { input = code.suffix(1).lowercased() }
else if code.count == 6 && code.hasPrefix("Digit") { input = String(code.suffix(1)) }
else { input = claimableKeyInputs[code] }
guard let input else { return nil }
var modifierFlags: UIKeyModifierFlags = []
if record.shift { modifierFlags.insert(.shift) }
if record.alt { modifierFlags.insert(.alternate) }
return ClaimedShortcut(input: input, modifierFlags: modifierFlags)
}
/// Whether the first responder is mid-IME-composition.
///
/// A committing key has to stay out of the way while a CJK candidate is being
@@ -99,14 +62,6 @@ public class PaseoHardwareKeyboardModule: Module {
}
}
Function("setClaimedShortcuts") { (shortcuts: [ClaimedShortcutRecord]) in
let claimed = shortcuts.compactMap(claimedShortcut)
DispatchQueue.main.async {
claimedShortcuts = claimed
UIMenuSystem.main.setNeedsRebuild()
}
}
OnDestroy {
self.keyboardObservers.forEach(NotificationCenter.default.removeObserver)
self.keyboardObservers = []
@@ -114,7 +69,6 @@ public class PaseoHardwareKeyboardModule: Module {
activeModule = nil
isHardwareSubmitEnabled = false
overlayKeys = []
claimedShortcuts = []
}
}
}
@@ -185,10 +139,7 @@ public final class PaseoHardwareKeyboardWindow: UIWindow {
{
commands.append(makeSubmitCommand())
}
if !isComposingText() {
commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand))
commands.append(contentsOf: claimedShortcuts.map(makeClaimedCommand))
}
if !isComposingText() { commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand)) }
return commands
}
@@ -221,18 +172,6 @@ public final class PaseoHardwareKeyboardWindow: UIWindow {
return command
}
/// The window's press stream still emits the shortcut; the command only keeps
/// the key away from the text control.
private func makeClaimedCommand(_ shortcut: ClaimedShortcut) -> UIKeyCommand {
let command = UIKeyCommand(
input: shortcut.input,
modifierFlags: shortcut.modifierFlags,
action: #selector(handleHardwareKeyboardShortcut(_:))
)
if #available(iOS 15.0, *) { command.wantsPriorityOverSystemBehavior = true }
return command
}
@objc
private func handleHardwareKeyboardSubmit(_ sender: UIKeyCommand) {
guard canSubmitCurrentTextInput() else {
@@ -26,6 +26,8 @@ import { resolveProviderLabel } from "@/utils/provider-definitions";
import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match";
import type { AgentModelDefinition, AgentProvider } from "@getpaseo/protocol/agent-types";
import type { ProviderProfileModel } from "@getpaseo/protocol/provider-config";
import { ProviderInstallationSection } from "./provider-installation-section";
import { useProviderInstallation } from "./provider-installation";
import {
resolveProviderDiscoveredModels,
type ProviderDiscoveredModelsCache,
@@ -632,9 +634,12 @@ export function ProviderDiagnosticSheet({
[additionalModels, q],
);
const installation = useProviderInstallation({ serverId, provider, visible });
const refreshInstallation = installation.refresh;
const handleRefreshModels = useCallback(() => {
void refresh([provider]);
}, [provider, refresh]);
refreshInstallation();
}, [provider, refresh, refreshInstallation]);
const handleOpenAddSheet = useCallback(() => setAddSheetOpen(true), []);
const handleCloseAddSheet = useCallback(() => setAddSheetOpen(false), []);
@@ -689,6 +694,11 @@ export function ProviderDiagnosticSheet({
})}
snapPoints={MAIN_SNAP_POINTS}
>
<ProviderInstallationSection
state={installation.state}
updates={installation.updates}
action={installation.action}
/>
<ProviderModalBody
discoveredCount={discoveredModels.length}
additionalCount={additionalModels.length}
@@ -0,0 +1,282 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { within } from "@testing-library/dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n as testI18n } from "@/i18n/i18next";
import {
ProviderInstallationSection,
type ProviderInstallationState,
type ProviderUpdateAction,
type ProviderUpdatesState,
} from "./provider-installation-section";
// Load translations so controls expose their real accessible names.
void testI18n;
// App sources compile against the classic JSX runtime, which expects React on the global.
beforeEach(() => vi.stubGlobal("React", React));
const mounted: { root: Root; container: HTMLDivElement }[] = [];
afterEach(() => {
for (const entry of mounted.splice(0)) {
act(() => entry.root.unmount());
entry.container.remove();
}
});
const INSTALL_URL = "https://example.com/install";
const UPDATE_URL = "https://example.com/update";
function mount(
state: ProviderInstallationState,
updates: ProviderUpdatesState = { status: "hidden" },
action?: ProviderUpdateAction,
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() =>
root.render(<ProviderInstallationSection state={state} updates={updates} action={action} />),
);
mounted.push({ root, container });
const view = within(container);
return {
view,
installButton: () => view.queryByRole("button", { name: "Install" }),
installLink: () => view.queryByRole("link", { name: "Install instructions" }),
};
}
function ready(installation: ProviderInstallationState & { status: "ready" }) {
return installation;
}
describe("install link visibility", () => {
it("offers the Install button when the provider reports it is not installed", () => {
const section = mount(
ready({
status: "ready",
installation: { installUrl: INSTALL_URL, inspection: { status: "not_installed" } },
}),
);
expect(section.installButton()).not.toBeNull();
expect(section.installLink()).toBeNull();
});
it("offers a quiet install link when the installation check failed", async () => {
const section = mount(
ready({
status: "ready",
installation: {
installUrl: INSTALL_URL,
inspection: { status: "error", message: "version probe exploded" },
},
}),
);
await expect.element(section.view.getByText("version probe exploded")).toBeVisible();
expect(section.installLink()).not.toBeNull();
expect(section.installButton()).toBeNull();
});
it("offers a quiet install link for a provider that only declares where to install it", () => {
const section = mount(ready({ status: "ready", installation: { installUrl: INSTALL_URL } }));
expect(section.installLink()).not.toBeNull();
expect(section.installButton()).toBeNull();
});
it("offers no install link once the provider reports it is installed", () => {
const section = mount(
ready({
status: "ready",
installation: {
installUrl: INSTALL_URL,
inspection: { status: "installed", version: "1" },
},
}),
);
expect(section.installLink()).toBeNull();
expect(section.installButton()).toBeNull();
});
});
describe("update check", () => {
const installed = ready({
status: "ready",
installation: {
installUrl: INSTALL_URL,
updateUrl: UPDATE_URL,
inspection: { status: "installed", version: "0.165.0" },
},
});
it("shows the reported versions and message as written, with Update instructions", async () => {
const section = mount(installed, {
status: "ready",
updateCheck: {
status: "available",
versions: ["0.168.0", "nightly (unstable)"],
message: "Restart to finish",
},
});
await expect.element(section.view.getByText("Update available")).toBeVisible();
expect(
section.view.getAllByTestId("provider-updates-version").map((node) => node.textContent),
).toEqual(["0.168.0", "nightly (unstable)"]);
await expect.element(section.view.getByText("Restart to finish")).toBeVisible();
await expect
.element(section.view.getByRole("button", { name: "Update instructions" }))
.toBeVisible();
});
it.each([
{
name: "unknown",
updates: { status: "ready", updateCheck: { status: "unknown", message: "Offline" } },
},
{
name: "a thrown or timed-out check",
updates: { status: "ready", updateCheck: { status: "error", message: "Offline" } },
},
{ name: "a failed request", updates: { status: "error", message: "Offline" } },
] as const)("shows $name as inconclusive, never up to date", async ({ updates }) => {
const section = mount(installed, updates);
await expect.element(section.view.getByText("Couldn't check for updates")).toBeVisible();
await expect.element(section.view.getByText("Offline")).toBeVisible();
expect(section.view.queryByText("Up to date")).toBeNull();
await expect
.element(section.view.getByRole("link", { name: "Update instructions" }))
.toBeVisible();
});
it("shows up to date only when the provider says so", async () => {
const section = mount(installed, { status: "ready", updateCheck: { status: "current" } });
await expect.element(section.view.getByText("Up to date")).toBeVisible();
});
});
describe("update action", () => {
const installed = (canUpdate: boolean) =>
ready({
status: "ready",
installation: {
updateUrl: UPDATE_URL,
inspection: { status: "installed", version: "1.0.0" },
...(canUpdate ? { canUpdate: true } : {}),
},
});
const available: ProviderUpdatesState = {
status: "ready",
updateCheck: { status: "available", versions: ["2.0.0"] },
};
function action(overrides: Partial<ProviderUpdateAction> = {}) {
let starts = 0;
const value: ProviderUpdateAction = {
current: null,
starting: false,
startError: null,
start: () => {
starts += 1;
},
...overrides,
};
return { value, starts: () => starts };
}
const operation = { id: "op", startedAt: "2026-11-13T00:00:00.000Z" };
const finishedAt = "2026-11-13T00:01:00.000Z";
it("offers Update when an update is available and the provider runs its own", () => {
const control = action();
const section = mount(installed(true), available, control.value);
const button = section.view.getByRole("button", { name: "Update" });
act(() => button.click());
expect(control.starts()).toBe(1);
expect(section.view.queryByRole("button", { name: "Update instructions" })).toBeNull();
});
it("keeps Update instructions when the provider does not run its own update", async () => {
const section = mount(installed(false), available, action().value);
expect(section.view.queryByRole("button", { name: "Update" })).toBeNull();
await expect
.element(section.view.getByRole("button", { name: "Update instructions" }))
.toBeVisible();
});
it("shows a running update with its output so far", () => {
const section = mount(
installed(true),
available,
action({
current: {
operation: { ...operation, status: "running" },
output: [
{ type: "message", text: "Running codex update\n" },
{ type: "stdout", text: "added 1 package\n" },
],
},
}).value,
);
expect(section.view.getByTestId("provider-update-status").textContent).toBe("Updating…");
expect(section.view.getByTestId("provider-update-output").textContent).toBe(
"Running codex update\nadded 1 package\n",
);
});
it("shows the provider's success message as written", () => {
const section = mount(
installed(true),
{ status: "ready", updateCheck: { status: "current" } },
action({
current: {
operation: {
...operation,
status: "succeeded",
finishedAt,
message: " Restart agents \n",
},
output: [],
},
}).value,
);
expect(section.view.getByTestId("provider-update-status").textContent).toBe("Updated");
expect(section.view.getByTestId("provider-update-message").textContent).toBe(
" Restart agents \n",
);
expect(section.view.queryByTestId("provider-update-output")).toBeNull();
});
it("shows a failure with its message, its output and the update instructions", async () => {
const section = mount(
installed(true),
available,
action({
current: {
operation: { ...operation, status: "failed", finishedAt, message: "exited with code 1" },
output: [{ type: "stderr", text: "npm error EACCES\n" }],
truncated: true,
},
}).value,
);
expect(section.view.getByTestId("provider-update-status").textContent).toBe("Update failed");
await expect.element(section.view.getByText("exited with code 1")).toBeVisible();
expect(section.view.getByTestId("provider-update-output").textContent).toBe(
"npm error EACCES\n",
);
await expect.element(section.view.getByText("Earlier output was dropped.")).toBeVisible();
await expect
.element(section.view.getByRole("link", { name: "Update instructions" }))
.toBeVisible();
await expect.element(section.view.getByRole("button", { name: "Update" })).toBeVisible();
});
it("shows why the host refused to start the update", async () => {
const section = mount(
installed(true),
available,
action({ startError: "Provider codex cannot update itself" }).value,
);
expect(section.view.getByTestId("provider-update-status").textContent).toBe("Update failed");
await expect
.element(section.view.getByText("Provider codex cannot update itself"))
.toBeVisible();
});
});
@@ -0,0 +1,417 @@
import { useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type {
ProviderInstallation,
ProviderInstallationUpdate,
ProviderInstallationUpdateCheck,
} from "@getpaseo/protocol/messages";
import { Button } from "@/components/ui/button";
import { ExternalLink } from "@/components/ui/external-link";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { settingsStyles } from "@/styles/settings";
import type { Theme } from "@/styles/theme";
import { openExternalUrl } from "@/utils/open-external-url";
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
export type ProviderInstallationState =
| { status: "unsupported" }
| { status: "loading" }
| { status: "ready"; installation: ProviderInstallation | null }
| { status: "error"; message: string };
/** `hidden` until the provider is installed, and when it cannot check for updates. */
export type ProviderUpdatesState =
| { status: "hidden" }
| { status: "loading" }
| { status: "ready"; updateCheck: ProviderInstallationUpdateCheck }
| { status: "error"; message: string };
/** Running the provider's own update. Absent when the host cannot report updates. */
export interface ProviderUpdateAction {
/** The provider's latest update since the host started, or null. */
current: ProviderInstallationUpdate | null;
/** The request to start an update is in flight. */
starting: boolean;
/** Why the host did not start the update. */
startError: string | null;
start(): void;
}
export function ProviderInstallationSection({
state,
updates,
action,
}: {
state: ProviderInstallationState;
updates: ProviderUpdatesState;
action?: ProviderUpdateAction;
}) {
const { t } = useTranslation();
if (state.status === "unsupported") return null;
if (
state.status === "ready" &&
!state.installation?.inspection &&
!state.installation?.installUrl
)
return null;
return (
<View style={settingsStyles.section} testID="provider-installation">
<View style={settingsStyles.sectionHeader}>
<Text style={settingsStyles.sectionHeaderTitle}>
{t("settings.providers.installation.title")}
</Text>
</View>
<View style={settingsStyles.card}>
<InstallationRow state={state} />
{updates.status !== "hidden" && state.status === "ready" ? (
<UpdatesRow
updates={updates}
updateUrl={state.installation?.updateUrl}
action={state.installation?.canUpdate ? action : undefined}
/>
) : null}
{action && (action.current || action.startError) ? (
<UpdateRunRow
action={action}
updateUrl={state.status === "ready" ? state.installation?.updateUrl : undefined}
/>
) : null}
</View>
</View>
);
}
function InstallInstructionsLink({ installUrl }: { installUrl: string }) {
const { t } = useTranslation();
return (
<ExternalLink
href={installUrl}
label={t("settings.providers.installation.installInstructions")}
testID="provider-installation-install-instructions"
/>
);
}
function InstallationRow({ state }: { state: ProviderInstallationState }) {
const { t } = useTranslation();
const installation = state.status === "ready" ? state.installation : null;
const installUrl = installation?.installUrl;
const handleInstall = useCallback(() => {
if (installUrl) void openExternalUrl(installUrl);
}, [installUrl]);
if (state.status === "loading") {
return (
<View style={settingsStyles.row}>
<Text style={settingsStyles.rowTitle}>{t("settings.providers.installation.version")}</Text>
<View style={styles.trailing}>
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<Text style={styles.value}>{t("settings.providers.installation.checking")}</Text>
</View>
</View>
);
}
const inspection = installation?.inspection;
if (inspection?.status === "not_installed") {
return (
<View style={settingsStyles.row}>
<Text style={settingsStyles.rowTitle}>
{t("settings.providers.installation.notInstalled")}
</Text>
{installUrl ? (
<Button variant="outline" onPress={handleInstall}>
{t("settings.providers.installation.install")}
</Button>
) : null}
</View>
);
}
if (inspection?.status === "installed") {
return (
<View style={settingsStyles.row}>
<Text style={settingsStyles.rowTitle}>{t("settings.providers.installation.version")}</Text>
{inspection.version ? (
<Text
style={styles.version}
numberOfLines={1}
selectable
dataSet={CODE_SURFACE_DATASET}
testID="provider-installation-version"
>
{inspection.version}
</Text>
) : (
<Text style={styles.value}>{t("settings.providers.installation.installed")}</Text>
)}
</View>
);
}
// The installed state is unknown: the check failed, or the provider only links to a guide.
let message: string | null = null;
if (state.status === "error") message = state.message;
else if (inspection?.status === "error") message = inspection.message;
if (!message) {
return installUrl ? (
<View style={settingsStyles.row}>
<InstallInstructionsLink installUrl={installUrl} />
</View>
) : null;
}
return (
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.providers.installation.failed")}</Text>
<Text style={settingsStyles.rowHint}>{message}</Text>
{installUrl ? (
<View style={styles.link}>
<InstallInstructionsLink installUrl={installUrl} />
</View>
) : null}
</View>
</View>
);
}
function UpdatesRow({
updates,
updateUrl,
action,
}: {
updates: Exclude<ProviderUpdatesState, { status: "hidden" }>;
updateUrl: string | undefined;
/** Present when the provider runs its own update. */
action: ProviderUpdateAction | undefined;
}) {
const { t } = useTranslation();
if (updates.status === "loading") {
return (
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<Text style={settingsStyles.rowTitle}>{t("settings.providers.installation.updates")}</Text>
<View style={styles.trailing}>
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<Text style={styles.value}>{t("settings.providers.installation.checking")}</Text>
</View>
</View>
);
}
const updateCheck = updates.status === "ready" ? updates.updateCheck : null;
if (updateCheck?.status === "current") {
return (
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<Text style={settingsStyles.rowTitle}>{t("settings.providers.installation.updates")}</Text>
<Text style={styles.value} testID="provider-updates-current">
{t("settings.providers.installation.upToDate")}
</Text>
</View>
);
}
if (updateCheck?.status === "available") {
// Versions are opaque; drop repeats so each one keys its own line.
const versions = [...new Set(updateCheck.versions ?? [])];
return (
<View style={[settingsStyles.row, settingsStyles.rowBorder]} testID="provider-updates">
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>
{t("settings.providers.installation.updateAvailable")}
</Text>
{versions.map((version) => (
<Text
key={version}
style={styles.availableVersion}
selectable
dataSet={CODE_SURFACE_DATASET}
testID="provider-updates-version"
>
{version}
</Text>
))}
{updateCheck.message ? (
<Text style={settingsStyles.rowHint}>{updateCheck.message}</Text>
) : null}
</View>
<AvailableUpdateButton action={action} updateUrl={updateUrl} />
</View>
);
}
// A check that threw, timed out or could not tell is never shown as up to date.
let message: string | undefined;
if (updates.status === "error") message = updates.message;
else if (updateCheck?.status === "unknown" || updateCheck?.status === "error")
message = updateCheck.message;
return (
<View style={[settingsStyles.row, settingsStyles.rowBorder]} testID="provider-updates">
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>
{t("settings.providers.installation.updatesFailed")}
</Text>
{message ? <Text style={settingsStyles.rowHint}>{message}</Text> : null}
{updateUrl ? (
<View style={styles.link}>
<ExternalLink
href={updateUrl}
label={t("settings.providers.installation.updateInstructions")}
/>
</View>
) : null}
</View>
</View>
);
}
/** Update runs the provider's updater; without one, the provider's update guide opens. */
function AvailableUpdateButton({
action,
updateUrl,
}: {
action: ProviderUpdateAction | undefined;
updateUrl: string | undefined;
}) {
const { t } = useTranslation();
const handleOpenInstructions = useCallback(() => {
if (updateUrl) void openExternalUrl(updateUrl);
}, [updateUrl]);
if (action)
return (
<Button
variant="outline"
onPress={action.start}
loading={action.starting || action.current?.operation.status === "running"}
testID="provider-update-start"
>
{t("settings.providers.installation.update")}
</Button>
);
if (!updateUrl) return null;
return (
<Button variant="outline" onPress={handleOpenInstructions}>
{t("settings.providers.installation.updateInstructions")}
</Button>
);
}
function UpdateRunRow({
action,
updateUrl,
}: {
action: ProviderUpdateAction;
updateUrl: string | undefined;
}) {
const { t } = useTranslation();
const scroll = useRef<ScrollView>(null);
const followOutput = useCallback(() => scroll.current?.scrollToEnd({ animated: false }), []);
// A refused start replaces the previous update's result.
const update = action.startError ? null : action.current;
const status = update?.operation.status ?? "failed";
let title = t("settings.providers.installation.updateFailed");
if (status === "running") title = t("settings.providers.installation.updating");
else if (status === "succeeded") title = t("settings.providers.installation.updated");
let message = action.startError ?? undefined;
if (update && update.operation.status !== "running") message = update.operation.message;
const output = update?.output.map((entry) => entry.text).join("") ?? "";
return (
<View style={[settingsStyles.row, settingsStyles.rowBorder]} testID="provider-update-run">
<View style={settingsStyles.rowContent}>
<View style={styles.trailing}>
{status === "running" ? (
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
) : null}
<Text style={settingsStyles.rowTitle} testID="provider-update-status">
{title}
</Text>
</View>
{message ? (
<Text style={settingsStyles.rowHint} selectable testID="provider-update-message">
{message}
</Text>
) : null}
{output ? (
<ScrollView
ref={scroll}
style={styles.output}
contentContainerStyle={styles.outputContent}
onContentSizeChange={followOutput}
>
{update?.truncated ? (
<Text style={styles.outputNote}>
{t("settings.providers.installation.outputTruncated")}
</Text>
) : null}
<Text
style={styles.outputText}
selectable
dataSet={CODE_SURFACE_DATASET}
testID="provider-update-output"
>
{output}
</Text>
</ScrollView>
) : null}
{status === "failed" && updateUrl ? (
<View style={styles.link}>
<ExternalLink
href={updateUrl}
label={t("settings.providers.installation.updateInstructions")}
/>
</View>
) : null}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
output: {
maxHeight: 240,
marginTop: theme.spacing[2],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface0,
},
outputContent: {
padding: theme.spacing[3],
},
outputText: {
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
lineHeight: 20,
color: theme.colors.foreground,
},
outputNote: {
marginBottom: theme.spacing[1],
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
trailing: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
value: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
version: {
flexShrink: 1,
marginLeft: theme.spacing[3],
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
color: theme.colors.foregroundMuted,
},
availableVersion: {
marginTop: theme.spacing[1],
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
color: theme.colors.foregroundMuted,
},
link: {
flexDirection: "row",
marginTop: theme.spacing[1],
},
}));
@@ -0,0 +1,189 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { ProviderInstallationUpdate } from "@getpaseo/protocol/messages";
import { useFetchQuery } from "@/data/query";
import { useHostFeature } from "@/runtime/host-features";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import type {
ProviderInstallationState,
ProviderUpdateAction,
ProviderUpdatesState,
} from "./provider-installation-section";
import { applyProviderUpdateProgress } from "./provider-update-progress";
export interface ProviderInstallationRead {
state: ProviderInstallationState;
updates: ProviderUpdatesState;
/** Absent when the host cannot report provider installations. */
action: ProviderUpdateAction | undefined;
/** Ask the provider again, skipping the host's caches. */
refresh: () => void;
}
/**
* Reads a provider's installation from hosts that advertise `providerInstallation`. The update
* check is a separate request that starts once the provider reports itself installed, so the
* installed version never waits on it.
*/
export function useProviderInstallation(input: {
serverId: string;
provider: string;
visible: boolean;
}): ProviderInstallationRead {
const { serverId, provider, visible } = input;
const supported = useHostFeature(serverId, "providerInstallation");
const client = useHostRuntimeClient(serverId);
const queryClient = useQueryClient();
const { t } = useTranslation();
const enabled = supported && visible && Boolean(client);
const installationKey = ["provider-installation", serverId, provider] as const;
const updatesKey = ["provider-updates", serverId, provider] as const;
const inspect = useCallback(
async (refresh: boolean) => {
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
const result = await client.inspectProviderInstallation(provider, { refresh });
if (result.error) throw new Error(result.error);
return result.installation;
},
[client, provider, t],
);
const checkForUpdates = useCallback(
async (refresh: boolean) => {
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
const result = await client.checkProviderUpdates(provider, { refresh });
if (result.error) throw new Error(result.error);
return result.updateCheck;
},
[client, provider, t],
);
// The host caches both answers, so reading on every open is cheap.
const installationQuery = useFetchQuery({
queryKey: installationKey,
queryFn: () => inspect(false),
enabled,
dataShape: "value",
staleTimeMs: 0,
});
const installed = installationQuery.data?.inspection?.status === "installed";
const updatesQuery = useFetchQuery({
queryKey: updatesKey,
queryFn: () => checkForUpdates(false),
enabled: enabled && installed,
dataShape: "value",
staleTimeMs: 0,
});
// A refresh runs through the queries so its answer or its failure is what the screen shows.
// `staleTime: 0` overrides the app default so the request always goes out.
const refreshInstallation = useMutation({
mutationFn: () =>
queryClient.fetchQuery({
queryKey: installationKey,
queryFn: () => inspect(true),
staleTime: 0,
}),
});
const refreshUpdates = useMutation({
mutationFn: () =>
queryClient.fetchQuery({
queryKey: updatesKey,
queryFn: () => checkForUpdates(true),
staleTime: 0,
}),
});
// The host owns the update; this view follows it while open and catches up on reopen.
const [currentUpdate, setCurrentUpdate] = useState<ProviderInstallationUpdate | null>(null);
useEffect(() => {
if (!enabled || !client) return;
const subscription = client.observeProviderUpdates(provider);
// The update this view saw running. Its success arrives as live progress, or as the snapshot
// a reconnect delivers when the progress was missed; either way the host dropped its cached
// answers, so read the new version and update state.
let runningId: string | null = null;
const follow = (operation: ProviderInstallationUpdate["operation"] | undefined) => {
if (!operation) return;
if (operation.status === "running") {
runningId = operation.id;
return;
}
if (operation.id !== runningId) return;
runningId = null;
if (operation.status !== "succeeded") return;
void queryClient.invalidateQueries({
queryKey: ["provider-installation", serverId, provider],
});
void queryClient.invalidateQueries({ queryKey: ["provider-updates", serverId, provider] });
};
const detach = subscription.subscribe({
snapshot: (response) => {
setCurrentUpdate(response.update);
follow(response.update?.operation);
},
update: (message) => {
if (message.type !== "provider.installation.update.progress") return;
setCurrentUpdate((current) => applyProviderUpdateProgress(current, message.payload));
follow(message.payload.operation);
},
error: (failure) => console.warn("Failed to observe provider updates", failure),
});
return () => {
detach();
void subscription
.release()
.catch((failure) => console.warn("Failed to release provider update observation", failure));
};
}, [client, enabled, provider, queryClient, serverId]);
const startUpdate = useMutation({
mutationFn: async () => {
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
const result = await client.updateProvider(provider);
if (result.error) throw new Error(result.error);
return result.update;
},
onSuccess: (update) => {
if (!update) return;
// Progress for this operation may already have arrived; keep the further-along copy.
setCurrentUpdate((current) =>
current?.operation.id === update.operation.id ? current : update,
);
},
});
const { mutate: mutateStartUpdate } = startUpdate;
const start = useCallback(() => mutateStartUpdate(), [mutateStartUpdate]);
const action: ProviderUpdateAction | undefined = supported
? {
current: currentUpdate,
starting: startUpdate.isPending,
startError: startUpdate.error?.message ?? null,
start,
}
: undefined;
const { mutate: mutateInstallation } = refreshInstallation;
const { mutate: mutateUpdates } = refreshUpdates;
const refresh = useCallback(() => {
if (!supported || !client) return;
mutateInstallation();
if (installed) mutateUpdates();
}, [client, installed, mutateInstallation, mutateUpdates, supported]);
let state: ProviderInstallationState;
if (!supported) state = { status: "unsupported" };
else if (installationQuery.isPending || refreshInstallation.isPending)
state = { status: "loading" };
else if (installationQuery.isError)
state = { status: "error", message: installationQuery.error.message };
else state = { status: "ready", installation: installationQuery.data ?? null };
let updates: ProviderUpdatesState;
if (!supported || !installed) updates = { status: "hidden" };
else if (updatesQuery.isPending || refreshUpdates.isPending) updates = { status: "loading" };
else if (updatesQuery.isError) updates = { status: "error", message: updatesQuery.error.message };
else if (updatesQuery.data) updates = { status: "ready", updateCheck: updatesQuery.data };
else updates = { status: "hidden" };
return { state, updates, action, refresh };
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import type { ProviderInstallationUpdateProgress } from "@getpaseo/protocol/messages";
import { applyProviderUpdateProgress } from "./provider-update-progress";
const running = {
status: "running",
id: "update-1",
startedAt: "2026-11-13T00:00:00.000Z",
} as const;
function progress(...texts: string[]): ProviderInstallationUpdateProgress {
return {
provider: "codex",
operation: running,
output: texts.map((text) => ({ type: "stdout", text })),
};
}
describe("applyProviderUpdateProgress", () => {
it("appends output for the same update", () => {
const first = applyProviderUpdateProgress(null, progress("a"), { outputLimit: 10 });
const second = applyProviderUpdateProgress(first, progress("b"), { outputLimit: 10 });
expect(second.output.map((entry) => entry.text)).toEqual(["a", "b"]);
expect(second.truncated).toBeUndefined();
});
it("drops the oldest output beyond the limit and marks the update truncated", () => {
let update = applyProviderUpdateProgress(null, progress("12345"), { outputLimit: 8 });
update = applyProviderUpdateProgress(update, progress("6789"), { outputLimit: 8 });
expect(update.output.map((entry) => entry.text)).toEqual(["6789"]);
expect(update.truncated).toBe(true);
update = applyProviderUpdateProgress(update, progress("abcdefghij"), { outputLimit: 8 });
expect(update.output.map((entry) => entry.text)).toEqual(["cdefghij"]);
expect(update.truncated).toBe(true);
});
it("starts a new update empty", () => {
const first = applyProviderUpdateProgress(null, progress("123456789"), { outputLimit: 4 });
const next = applyProviderUpdateProgress(
first,
{ ...progress("x"), operation: { ...running, id: "update-2" } },
{ outputLimit: 4 },
);
expect(next.output.map((entry) => entry.text)).toEqual(["x"]);
expect(next.truncated).toBeUndefined();
});
});
@@ -0,0 +1,38 @@
import type {
ProviderInstallationUpdate,
ProviderInstallationUpdateOutput,
ProviderInstallationUpdateProgress,
} from "@getpaseo/protocol/messages";
/** Characters of update output the app keeps, matching what the daemon retains. */
export const PROVIDER_UPDATE_OUTPUT_LIMIT = 64 * 1024;
/**
* Folds a progress message into the update it belongs to; a new operation starts empty. Output
* beyond `outputLimit` characters drops oldest first and marks the update truncated, so a noisy
* updater cannot grow the screen's memory while it stays open.
*/
export function applyProviderUpdateProgress(
current: ProviderInstallationUpdate | null,
progress: ProviderInstallationUpdateProgress,
options: { outputLimit: number } = { outputLimit: PROVIDER_UPDATE_OUTPUT_LIMIT },
): ProviderInstallationUpdate {
const previous = current?.operation.id === progress.operation.id ? current : null;
const limit = options.outputLimit;
const output: ProviderInstallationUpdateOutput[] = [
...(previous?.output ?? []),
...progress.output,
];
let length = output.reduce((total, entry) => total + entry.text.length, 0);
let truncated = previous?.truncated ?? false;
while (length > limit && output.length > 1) {
length -= output.shift()!.text.length;
truncated = true;
}
const last = output.at(-1);
if (last && last.text.length > limit) {
output[output.length - 1] = { type: last.type, text: last.text.slice(-limit) };
truncated = true;
}
return { operation: progress.operation, output, ...(truncated ? { truncated: true } : {}) };
}
@@ -58,8 +58,6 @@ import { SIDEBAR_ROW_ITEMS, type SidebarRowItem } from "./row-items";
import { useWorkspaceLabelProjection } from "@/workspace-labels";
import { WorkspaceLabelDot } from "@/workspace-labels/swatch";
import { WorkspaceLabelManagerModal } from "@/workspace-labels/manager-modal";
import { PluginIconView } from "@/plugins/icons";
import type { PluginWorkspaceRowBadgeItemGroup } from "@/plugins/workspace-row-badge-items";
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -67,7 +65,6 @@ const ThemedSettings2 = withUnistyles(Settings2);
/** CI's mark: the subject of the checks row, and the shape the icon-only option leaves behind. */
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircle = withUnistyles(Circle);
const ThemedPluginIconView = withUnistyles(PluginIconView);
/** Fits the item's 16pt leading slot with a hair of room, matching the trailing check. */
const OPTION_ICON_SIZE = 14;
@@ -610,9 +607,9 @@ function OptionList<Value extends string>({
}
/**
* Three groups: Paseo's own row items, plugin badge items, then the one thing the slot to the
* right of the title holds. Picking the trailing choice already showing empties the slot and
* gives the width back to the title.
* Two groups, split by the separator. Above it, what a row may say about a workspace — each one
* independent. Below it, the one thing the slot to the right of the title holds, so picking the
* one already showing empties the slot and gives the width back to the title.
*
* CI is the one item above the separator with three answers rather than two, so it opens a page
* instead of ticking, and it goes last: a row that navigates does not belong in the middle of a
@@ -635,12 +632,6 @@ function ShowPage({ preferences }: { preferences: Preferences }): ReactElement {
/>
))}
<ChecksSubTrigger />
{preferences.pluginRowBadgeItems.length > 0 ? (
<MenuSeparator testID="sidebar-plugin-row-badge-items-separator" />
) : null}
{preferences.pluginRowBadgeItems.map((item) => (
<PluginRowBadgeItemOption key={item.key} item={item} preferences={preferences} />
))}
<MenuSeparator />
{TRAILING_CHOICES.map((choice) => (
<OptionItem
@@ -658,37 +649,6 @@ function ShowPage({ preferences }: { preferences: Preferences }): ReactElement {
);
}
/** A plugin's workspace row badge item, toggled like the row's own. */
function PluginRowBadgeItemOption({
item,
preferences,
}: {
item: PluginWorkspaceRowBadgeItemGroup;
preferences: Preferences;
}): ReactElement {
const handleSelect = useCallback(
() => preferences.togglePluginRowBadgeItem(item.key),
[item.key, preferences],
);
const leading = useMemo(
() => (
<ThemedPluginIconView icon={item.icon} size={OPTION_ICON_SIZE} uniProps={mutedIconMapping} />
),
[item.icon],
);
return (
<MenuItem
selected={preferences.isPluginRowBadgeItemVisible(item.key)}
leading={leading}
closeOnSelect={false}
onSelect={handleSelect}
testID={`sidebar-row-badge-item-plugin-${item.pluginId}-${item.itemId}`}
>
{item.title}
</MenuItem>
);
}
/**
* No value on the row, only the chevron. The three answers are all long enough that the value
* fills the row and stops reading as an answer sitting at the right edge — it turns into a second
@@ -10,12 +10,6 @@ import {
type SidebarGroupMode,
type SidebarLabelFilter,
} from "@/stores/sidebar-view-store";
import { useInstalledPlugins } from "@/plugins/registry";
import {
groupPluginWorkspaceRowBadgeItems,
isWorkspaceRowBadgeItemVisible,
type PluginWorkspaceRowBadgeItemGroup,
} from "@/plugins/workspace-row-badge-items";
import { DEFAULT_SIDEBAR_CHECKS_DISPLAY, type SidebarChecksDisplay } from "./checks-display";
import { DEFAULT_SIDEBAR_ROW_ITEMS, type SidebarRowItem, type SidebarRowItems } from "./row-items";
@@ -33,10 +27,6 @@ export interface SidebarDisplayPreferences {
setTitleSource: (source: WorkspaceTitleSource) => void;
rowItems: SidebarRowItems;
toggleRowItem: (item: SidebarRowItem) => void;
/** Workspace row badge items contributed by installed plugins, one per plugin item across hosts. */
pluginRowBadgeItems: readonly PluginWorkspaceRowBadgeItemGroup[];
isPluginRowBadgeItemVisible: (key: string) => boolean;
togglePluginRowBadgeItem: (key: string) => void;
checksDisplay: SidebarChecksDisplay;
setChecksDisplay: (display: SidebarChecksDisplay) => void;
trailing: SidebarWorkspaceTrailing;
@@ -85,30 +75,9 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
sidebarWorkspaceTrailing,
sidebarRowItems,
sidebarChecksDisplay,
sidebarPluginRowBadgeItems,
},
updateSettings,
} = useAppSettings();
const installedPlugins = useInstalledPlugins();
const pluginRowBadgeItems = useMemo(
() => groupPluginWorkspaceRowBadgeItems(installedPlugins),
[installedPlugins],
);
const isPluginRowBadgeItemVisible = useCallback(
(key: string) => isWorkspaceRowBadgeItemVisible(sidebarPluginRowBadgeItems, key),
[sidebarPluginRowBadgeItems],
);
const togglePluginRowBadgeItem = useCallback(
(key: string) => {
void updateSettings({
sidebarPluginRowBadgeItems: {
...sidebarPluginRowBadgeItems,
[key]: !isWorkspaceRowBadgeItemVisible(sidebarPluginRowBadgeItems, key),
},
});
},
[updateSettings, sidebarPluginRowBadgeItems],
);
const setTitleSource = useCallback(
(source: WorkspaceTitleSource) => {
@@ -154,9 +123,6 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
setTitleSource,
rowItems: sidebarRowItems,
toggleRowItem,
pluginRowBadgeItems,
isPluginRowBadgeItemVisible,
togglePluginRowBadgeItem,
checksDisplay: sidebarChecksDisplay,
setChecksDisplay,
trailing: sidebarWorkspaceTrailing,
@@ -182,9 +148,6 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
setTitleSource,
sidebarRowItems,
toggleRowItem,
pluginRowBadgeItems,
isPluginRowBadgeItemVisible,
togglePluginRowBadgeItem,
sidebarChecksDisplay,
setChecksDisplay,
sidebarWorkspaceTrailing,
@@ -186,7 +186,6 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
prHint={workspace.prHint}
serviceSummary={serviceSummary}
labels={labels}
workspace={workspace}
/>
</View>
</View>
@@ -16,10 +16,6 @@ import { openExternalUrl } from "@/utils/open-external-url";
import { useSidebarMetaPreferences } from "@/components/sidebar/display-preferences/model";
import type { Theme } from "@/styles/theme";
import { PullRequestStateIcon } from "@/git/pull-request-state-icon";
import {
PluginWorkspaceRowBadgeItems,
usePluginWorkspaceRowBadgeItems,
} from "@/plugins/workspace-row-badge-items";
import { CheckIndicator } from "./check-indicator";
import type { CheckSummary, CheckSummaryState } from "./check-summary";
import { selectMetaRowItems, type MetaRowItem } from "./meta-items";
@@ -63,9 +59,6 @@ const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
* state has its own colors — a label is a name a person chose, so it carries its own identity
* color on a tint instead (see `WorkspaceLabelChip`). It sits last so the reported facts are
* read first, and it stays the same height as the rest of the line.
*
* Plugin items follow everything the row knows itself. A plugin renders its slot as it likes:
* the row adds no separator, since only the plugin knows whether it has anything to say.
*/
export function WorkspaceMetaRow({
currentBranch,
@@ -74,7 +67,6 @@ export function WorkspaceMetaRow({
prHint,
serviceSummary,
labels = EMPTY_LABELS,
workspace = null,
}: {
currentBranch: string | null;
projectName: string | null;
@@ -82,8 +74,6 @@ export function WorkspaceMetaRow({
prHint: PrHint | null;
serviceSummary: WorkspaceServiceSummary | null;
labels?: readonly WorkspaceLabelDefinition[];
/** The real workspace behind the row; previews leave it out and get no plugin items. */
workspace?: { serverId: string; workspaceId: string } | null;
}) {
const { rowItems, checksDisplay } = useSidebarMetaPreferences();
const items = selectMetaRowItems({
@@ -96,9 +86,8 @@ export function WorkspaceMetaRow({
visible: rowItems,
checksDisplay,
});
const pluginItems = usePluginWorkspaceRowBadgeItems(workspace?.serverId ?? null);
if (items.length === 0 && (!workspace || pluginItems.length === 0)) return null;
if (items.length === 0) return null;
return (
<View style={styles.row}>
@@ -108,9 +97,6 @@ export function WorkspaceMetaRow({
<MetaItemNode item={item} hostBadge={hostBadge} leading={index === 0} />
</Fragment>
))}
{workspace ? (
<PluginWorkspaceRowBadgeItems targets={pluginItems} workspaceId={workspace.workspaceId} />
) : null}
</View>
);
}
+1 -16
View File
@@ -1,4 +1,3 @@
import type { ForgeWeb } from "@getpaseo/protocol/messages";
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
import { createForgeShorthand, type ForgeShorthand } from "./shorthand";
@@ -10,27 +9,13 @@ const ForgeShorthandContext = createContext<ForgeShorthand | null>(null);
export function ForgeShorthandProvider({
forge,
remoteUrl,
forgeWeb,
children,
}: {
forge: string | null | undefined;
remoteUrl: string | null | undefined;
forgeWeb: ForgeWeb | null | undefined;
children: ReactNode;
}) {
// Keyed on the strings: the descriptor hands over a new `forgeWeb` object whenever any
// other workspace field changes, and a new context value re-renders every Markdown message.
const webHost = forgeWeb?.host;
const referenceRepository = forgeWeb?.referenceRepository;
const value = useMemo(
() =>
createForgeShorthand({
forge,
remoteUrl,
forgeWeb: webHost ? { host: webHost, referenceRepository } : null,
}),
[forge, remoteUrl, webHost, referenceRepository],
);
const value = useMemo(() => createForgeShorthand({ forge, remoteUrl }), [forge, remoteUrl]);
return <ForgeShorthandContext.Provider value={value}>{children}</ForgeShorthandContext.Provider>;
}
@@ -22,17 +22,6 @@ describe("createForgeShorthand", () => {
expect(gitlab.resolve("g/sub/q#3")).toBe("https://gitlab.com/g/sub/q/-/issues/3");
});
it("links numbers to the repository the daemon says owns them, but keeps explicit references", () => {
const fork = createForgeShorthand({
forge: "github",
remoteUrl: "git@github.com-work:fork-owner/repo.git",
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
})!;
expect(fork.resolve("#123")).toBe("https://github.com/main-owner/repo/issues/123");
expect(fork.resolve("acme/tools#4")).toBe("https://github.com/acme/tools/issues/4");
});
it("only recognizes the sigils the forge declares", () => {
expect(matches(github, "see #1 and !2")).toEqual(["#1"]);
expect(github.resolve("!2")).toBeNull();
@@ -1,4 +1,3 @@
import type { ForgeWeb } from "@getpaseo/protocol/messages";
import type { ForgeReferenceSigil } from "@/git/client-forge-module";
import { forgeFromRemoteUrl } from "@/git/forge";
import { getClientForgeLogicModule } from "@/git/forges";
@@ -27,15 +26,12 @@ const REFERENCE = new RegExp(String.raw`^(?:(${REPO}))?([#!])(${NUMBER})$`);
export function createForgeShorthand(input: {
forge: string | null | undefined;
remoteUrl: string | null | undefined;
forgeWeb?: ForgeWeb | null;
}): ForgeShorthand | null {
const remoteUrl = input.remoteUrl;
const forge = input.forge || forgeFromRemoteUrl(remoteUrl);
if (!forge || !remoteUrl) return null;
const sigils = forgeSigils(forge).filter(
(sigil) =>
buildForgeReferenceUrl(forge, { remoteUrl, forgeWeb: input.forgeWeb, sigil, number: 1 }) !==
null,
(sigil) => buildForgeReferenceUrl(forge, { remoteUrl, sigil, number: 1 }) !== null,
);
if (sigils.length === 0) return null;
return {
@@ -45,7 +41,6 @@ export function createForgeShorthand(input: {
if (!match) return null;
return buildForgeReferenceUrl(forge, {
remoteUrl,
forgeWeb: input.forgeWeb,
repo: match[1] ?? null,
sigil: match[2] as ForgeReferenceSigil,
number: Number(match[3]),
-78
View File
@@ -326,81 +326,3 @@ describe("hasForgeWebUrls", () => {
expect(hasForgeWebUrls("bitbucket")).toBe(false);
});
});
describe("links resolved by the daemon (forgeWeb)", () => {
const sshAliasRemote = "git@github.com-work:acme/repo.git";
it("links #N on the web host the daemon resolved for an SSH config alias", () => {
expect(
buildForgeReferenceUrl("github", {
remoteUrl: sshAliasRemote,
forgeWeb: { host: "github.com" },
sigil: "#",
number: 1,
}),
).toBe("https://github.com/acme/repo/issues/1");
});
it("opens the branch and a file on the resolved web host for an SSH config alias", () => {
const forgeWeb = { host: "github.com" };
expect(
buildForgeBranchTreeUrl("github", { remoteUrl: sshAliasRemote, forgeWeb, branch: "main" }),
).toBe("https://github.com/acme/repo/tree/main");
expect(
buildForgeBlobUrl("github", {
remoteUrl: sshAliasRemote,
forgeWeb,
branch: "main",
path: "src/index.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts");
});
it("links #N to the repository that owns the numbers, such as a fork's parent", () => {
expect(
buildForgeReferenceUrl("github", {
remoteUrl: "git@github.com:fork-owner/repo.git",
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
sigil: "#",
number: 123,
}),
).toBe("https://github.com/main-owner/repo/issues/123");
});
it("keeps an explicit owner/repo#N reference on the named repository", () => {
expect(
buildForgeReferenceUrl("github", {
remoteUrl: "git@github.com:fork-owner/repo.git",
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
repo: "other/tools",
sigil: "#",
number: 7,
}),
).toBe("https://github.com/other/tools/issues/7");
});
it("keeps the branch and file links on the origin repository for a fork", () => {
const input = {
remoteUrl: "git@github.com:fork-owner/repo.git",
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
branch: "feature/only-on-fork",
};
expect(buildForgeBranchTreeUrl("github", input)).toBe(
"https://github.com/fork-owner/repo/tree/feature/only-on-fork",
);
expect(buildForgeBlobUrl("github", { ...input, path: "a.ts" })).toBe(
"https://github.com/fork-owner/repo/blob/feature/only-on-fork/a.ts",
);
});
it("keeps the scheme and port of a self-hosted http remote", () => {
expect(
buildForgeReferenceUrl("gitea", {
remoteUrl: "http://forge.test:3000/team/repo.git",
forgeWeb: { host: "forge.test", referenceRepository: "upstream/repo" },
sigil: "#",
number: 12,
}),
).toBe("http://forge.test:3000/upstream/repo/issues/12");
});
});
+20 -32
View File
@@ -6,23 +6,17 @@
* format); an unknown forge has no grammar and yields null, so the action is
* simply absent rather than wrong.
*
* URL grammar lives on each client forge module. The daemon resolves the web
* host (SSH config aliases, cloud SSH hosts) and the repository that owns
* issue and change-request numbers (a fork's parent) and publishes them as
* `forgeWeb`; branch and file links stay on the origin repository.
* URL grammar lives on each client forge module. The repo identity and host
* both ride the manifest's `cloudHosts` only to canonicalize a cloud SSH alias
* (e.g. ssh.github.com -> github.com); a self-hosted host is used as-is.
*/
import {
forgeWebHost,
isForgeCloudHost,
parseGitRemoteLocation,
} from "@getpaseo/protocol/git-remote";
import type { ForgeWeb } from "@getpaseo/protocol/messages";
import { getForgeDefinition } from "@getpaseo/protocol/forge-manifest";
import { normalizeHost, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import type { ForgeReferenceSigil } from "@/git/client-forge-module";
import { getClientForgeLogicModule } from "@/git/forges";
export interface ForgeBlobUrlInput {
remoteUrl: string | null | undefined;
forgeWeb?: ForgeWeb | null;
branch: string | null | undefined;
path: string | null | undefined;
lineStart?: number;
@@ -31,17 +25,12 @@ export interface ForgeBlobUrlInput {
export interface ForgeBranchTreeUrlInput {
remoteUrl: string | null | undefined;
forgeWeb?: ForgeWeb | null;
branch: string | null | undefined;
}
export interface ForgeReferenceUrlInput {
remoteUrl: string | null | undefined;
forgeWeb?: ForgeWeb | null;
/**
* Repository path of a cross-repository reference; the repository that owns
* issue numbers (`forgeWeb.referenceRepository`, else the remote's) when absent.
*/
/** Repository path of a cross-repository reference; the remote's repository when absent. */
repo?: string | null;
sigil: ForgeReferenceSigil;
number: number;
@@ -73,6 +62,13 @@ interface ForgeWebLocation {
repo: string;
}
/**
* Web host + repo path from a remote. The host is the remote's host, except a
* cloud SSH alias (a non-first entry in the forge's `cloudHosts`, e.g.
* ssh.github.com) is canonicalized to the forge's web host (`cloudHosts[0]`).
* Self-hosted hosts are returned untouched. The repo path supports nested
* groups (e.g. GitLab subgroups) since it is the full remote path.
*/
function isValidRepoPath(path: string): boolean {
const segments = path.split("/").filter((segment) => segment.length > 0);
if (segments.length === 0) {
@@ -81,17 +77,9 @@ function isValidRepoPath(path: string): boolean {
return !segments.includes("..");
}
/**
* Web location of the origin repository. The host is the daemon's
* `forgeWeb.host`. A daemon without `forgeWeb` publishes none, so the remote's
* own host is used, with a cloud SSH alias mapped to the forge's web host.
* The repo path supports nested groups (e.g. GitLab subgroups) since it is
* the full remote path.
*/
function resolveForgeWebLocation(
forge: string,
remoteUrl: string | null | undefined,
forgeWeb: ForgeWeb | null | undefined,
): ForgeWebLocation | null {
if (!remoteUrl) {
return null;
@@ -100,9 +88,9 @@ function resolveForgeWebLocation(
if (!location || !isValidRepoPath(location.path)) {
return null;
}
// COMPAT(forgeWeb): added in v0.16.0, remove the remote-derived host after 2027-06-01 once daemon floor >= v0.16.0.
const webHost = forgeWeb?.host ?? forgeWebHost(forge, location.host);
const isCloudHost = isForgeCloudHost(forge, location.host);
const cloudHosts = (getForgeDefinition(forge)?.cloudHosts ?? []).map(normalizeHost);
const isCloudHost = cloudHosts.includes(location.host);
const webHost = isCloudHost ? cloudHosts[0] : location.host;
// Carry a non-default port only for a self-hosted http(s) origin (e.g.
// `:60443`): the web UI shares that origin. An SSH/scp remote's port is not the
// web port, and a canonicalized cloud host always serves on the default port.
@@ -151,7 +139,7 @@ export function buildForgeBranchTreeUrl(
input: ForgeBranchTreeUrlInput,
): string | null {
const grammar = getClientForgeLogicModule(forge)?.urlGrammar;
const location = resolveForgeWebLocation(forge, input.remoteUrl, input.forgeWeb);
const location = resolveForgeWebLocation(forge, input.remoteUrl);
const branch = input.branch?.trim();
if (!grammar || !location || !branch || branch === "HEAD") {
return null;
@@ -161,7 +149,7 @@ export function buildForgeBranchTreeUrl(
export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): string | null {
const grammar = getClientForgeLogicModule(forge)?.urlGrammar;
const location = resolveForgeWebLocation(forge, input.remoteUrl, input.forgeWeb);
const location = resolveForgeWebLocation(forge, input.remoteUrl);
const branch = input.branch?.trim();
const filePath = normalizeBlobPath(input.path);
if (!grammar || !location || !branch || branch === "HEAD" || !filePath) {
@@ -187,8 +175,8 @@ export function buildForgeReferenceUrl(
const referencePath = getClientForgeLogicModule(forge)?.urlGrammar?.referencePaths?.find(
(path) => path.sigil === input.sigil,
);
const location = resolveForgeWebLocation(forge, input.remoteUrl, input.forgeWeb);
const repo = input.repo ?? input.forgeWeb?.referenceRepository ?? location?.repo;
const location = resolveForgeWebLocation(forge, input.remoteUrl);
const repo = input.repo ?? location?.repo;
if (
!referencePath ||
!location ||
@@ -96,8 +96,6 @@ export interface AppSettings {
sidebarWorkspaceTrailing: SidebarWorkspaceTrailing;
sidebarRowItems: SidebarRowItems;
sidebarChecksDisplay: SidebarChecksDisplay;
/** Plugin workspace row badge items by `<pluginId>/<itemId>`; a missing key shows. */
sidebarPluginRowBadgeItems: Record<string, boolean>;
/** Top-level sidebar rows in display order; empty means the default order, all visible. */
sidebarNavItems: SidebarNavPreference[];
/** Sidebar footer items in display order; empty means the default order, all visible. */
@@ -158,7 +156,6 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
sidebarWorkspaceTrailing: "timestamp",
sidebarRowItems: DEFAULT_SIDEBAR_ROW_ITEMS,
sidebarChecksDisplay: DEFAULT_SIDEBAR_CHECKS_DISPLAY,
sidebarPluginRowBadgeItems: {},
sidebarNavItems: [],
sidebarFooterItems: [],
usage: DEFAULT_USAGE_PREFERENCES,
@@ -256,7 +253,6 @@ const StoredAppSettingsSchema = z
.enum(["iconAndText", "icon", "none"])
.optional()
.catch(DEFAULT_SIDEBAR_CHECKS_DISPLAY),
sidebarPluginRowBadgeItems: z.record(z.string(), z.boolean()).catch({}),
sidebarNavItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]),
sidebarFooterItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]),
usage: UsagePreferencesSchema,
+20
View File
@@ -3092,6 +3092,26 @@ export const ar: TranslationResources = {
custom: "نماذج مخصصة",
updated: "تم تحديث{{time}}",
},
installation: {
title: "التثبيت",
version: "الإصدار",
installed: "مثبّت",
notInstalled: "غير مثبّت",
install: "تثبيت",
checking: "جارٍ التحقق…",
failed: "تعذّر التحقق من التثبيت",
updates: "التحديثات",
upToDate: "محدّث",
updateAvailable: "يتوفر تحديث",
updateInstructions: "تعليمات التحديث",
update: "تحديث",
updating: "جارٍ التحديث…",
updated: "تم التحديث",
updateFailed: "فشل التحديث",
outputTruncated: "تم حذف المخرجات السابقة.",
updatesFailed: "تعذّر التحقق من التحديثات",
installInstructions: "تعليمات التثبيت",
},
diagnostic: {
title: "التشخيص",
button: "التشخيص",
+20
View File
@@ -3187,6 +3187,26 @@ export const en = {
custom: "Custom models",
updated: "Updated {{time}}",
},
installation: {
title: "Installation",
version: "Version",
installed: "Installed",
notInstalled: "Not installed",
install: "Install",
checking: "Checking…",
failed: "Couldn't check this installation",
updates: "Updates",
upToDate: "Up to date",
updateAvailable: "Update available",
updateInstructions: "Update instructions",
update: "Update",
updating: "Updating…",
updated: "Updated",
updateFailed: "Update failed",
outputTruncated: "Earlier output was dropped.",
updatesFailed: "Couldn't check for updates",
installInstructions: "Install instructions",
},
diagnostic: {
title: "Diagnostic",
button: "Diagnostic",
+20
View File
@@ -3121,6 +3121,26 @@ export const es: TranslationResources = {
custom: "Modelos personalizados",
updated: "{{time}}actualizado",
},
installation: {
title: "Instalación",
version: "Versión",
installed: "Instalado",
notInstalled: "No instalado",
install: "Instalar",
checking: "Comprobando…",
failed: "No se pudo comprobar la instalación",
updates: "Actualizaciones",
upToDate: "Actualizado",
updateAvailable: "Actualización disponible",
updateInstructions: "Instrucciones de actualización",
update: "Actualizar",
updating: "Actualizando…",
updated: "Actualizado",
updateFailed: "No se pudo actualizar",
outputTruncated: "Se descartó la salida anterior.",
updatesFailed: "No se pudo comprobar si hay actualizaciones",
installInstructions: "Instrucciones de instalación",
},
diagnostic: {
title: "Diagnóstico",
button: "Diagnóstico",
+20
View File
@@ -3129,6 +3129,26 @@ export const fr: TranslationResources = {
custom: "Modèles personnalisés",
updated: "Mis à jour {{time}}",
},
installation: {
title: "Installation",
version: "Version",
installed: "Installé",
notInstalled: "Non installé",
install: "Installer",
checking: "Vérification…",
failed: "Impossible de vérifier l'installation",
updates: "Mises à jour",
upToDate: "À jour",
updateAvailable: "Mise à jour disponible",
updateInstructions: "Instructions de mise à jour",
update: "Mettre à jour",
updating: "Mise à jour…",
updated: "Mis à jour",
updateFailed: "Échec de la mise à jour",
outputTruncated: "La sortie précédente a été supprimée.",
updatesFailed: "Impossible de vérifier les mises à jour",
installInstructions: "Instructions d'installation",
},
diagnostic: {
title: "Diagnostic",
button: "Diagnostic",
+20
View File
@@ -3089,6 +3089,26 @@ export const ja: TranslationResources = {
custom: "カスタムモデル",
updated: "{{time}}に更新",
},
installation: {
title: "インストール",
version: "バージョン",
installed: "インストール済み",
notInstalled: "未インストール",
install: "インストール",
checking: "確認中…",
failed: "インストールを確認できませんでした",
updates: "アップデート",
upToDate: "最新です",
updateAvailable: "アップデートがあります",
updateInstructions: "アップデート手順",
update: "アップデート",
updating: "アップデート中…",
updated: "アップデート済み",
updateFailed: "アップデートに失敗しました",
outputTruncated: "以前の出力は破棄されました。",
updatesFailed: "アップデートを確認できませんでした",
installInstructions: "インストール手順",
},
diagnostic: {
title: "診断",
button: "診断",
+20
View File
@@ -3075,6 +3075,26 @@ export const ko: TranslationResources = {
custom: "사용자 지정 모델",
updated: "{{time}} 업데이트됨",
},
installation: {
title: "설치",
version: "버전",
installed: "설치됨",
notInstalled: "설치되지 않음",
install: "설치",
checking: "확인 중…",
failed: "설치를 확인할 수 없습니다",
updates: "업데이트",
upToDate: "최신 상태",
updateAvailable: "업데이트 가능",
updateInstructions: "업데이트 안내",
update: "업데이트",
updating: "업데이트 중…",
updated: "업데이트됨",
updateFailed: "업데이트 실패",
outputTruncated: "이전 출력이 삭제되었습니다.",
updatesFailed: "업데이트를 확인할 수 없습니다",
installInstructions: "설치 안내",
},
diagnostic: {
title: "진단",
button: "진단",
+20
View File
@@ -3102,6 +3102,26 @@ export const ptBR: TranslationResources = {
custom: "Modelos personalizados",
updated: "Atualizado {{time}}",
},
installation: {
title: "Instalação",
version: "Versão",
installed: "Instalado",
notInstalled: "Não instalado",
install: "Instalar",
checking: "Verificando…",
failed: "Não foi possível verificar a instalação",
updates: "Atualizações",
upToDate: "Atualizado",
updateAvailable: "Atualização disponível",
updateInstructions: "Instruções de atualização",
update: "Atualizar",
updating: "Atualizando…",
updated: "Atualizado",
updateFailed: "Falha na atualização",
outputTruncated: "A saída anterior foi descartada.",
updatesFailed: "Não foi possível verificar atualizações",
installInstructions: "Instruções de instalação",
},
diagnostic: {
title: "Diagnóstico",
button: "Diagnóstico",
+20
View File
@@ -3113,6 +3113,26 @@ export const ru: TranslationResources = {
custom: "Пользовательские модели",
updated: "Обновлено {{time}}",
},
installation: {
title: "Установка",
version: "Версия",
installed: "Установлено",
notInstalled: "Не установлено",
install: "Установить",
checking: "Проверка…",
failed: "Не удалось проверить установку",
updates: "Обновления",
upToDate: "Актуальная версия",
updateAvailable: "Доступно обновление",
updateInstructions: "Инструкции по обновлению",
update: "Обновить",
updating: "Обновление…",
updated: "Обновлено",
updateFailed: "Не удалось обновить",
outputTruncated: "Более ранний вывод был отброшен.",
updatesFailed: "Не удалось проверить обновления",
installInstructions: "Инструкции по установке",
},
diagnostic: {
title: "Диагностика",
button: "Диагностика",
+20
View File
@@ -3021,6 +3021,26 @@ export const zhCN: TranslationResources = {
custom: "自定义 Models",
updated: "已更新 {{time}}",
},
installation: {
title: "安装",
version: "版本",
installed: "已安装",
notInstalled: "未安装",
install: "安装",
checking: "正在检查…",
failed: "无法检查安装状态",
updates: "更新",
upToDate: "已是最新",
updateAvailable: "有可用更新",
updateInstructions: "更新说明",
update: "更新",
updating: "正在更新…",
updated: "已更新",
updateFailed: "更新失败",
outputTruncated: "较早的输出已被丢弃。",
updatesFailed: "无法检查更新",
installInstructions: "安装说明",
},
diagnostic: {
title: "诊断",
button: "诊断",
@@ -4,32 +4,20 @@ export type KeyboardFocusSurface = "message-input" | "terminal";
export function createKeyboardFocusScope(hasEditableFocus: () => boolean = () => false) {
const surfaces = new Map<symbol, KeyboardFocusSurface>();
const listeners = new Set<() => void>();
const notify = () => {
for (const listener of listeners) listener();
};
return {
declare(surface: KeyboardFocusSurface) {
const token = Symbol(surface);
surfaces.set(token, surface);
notify();
return () => {
if (surfaces.delete(token)) notify();
surfaces.delete(token);
};
},
/** Pass `editableFocused` to resolve for a text control that is not a declared surface. */
resolve(commandCenterOpen: boolean, editableFocused = hasEditableFocus()): KeyboardFocusScope {
resolve(commandCenterOpen: boolean): KeyboardFocusScope {
if (commandCenterOpen) return "command-center";
let focused: KeyboardFocusScope = editableFocused ? "editable" : "other";
let focused: KeyboardFocusScope = hasEditableFocus() ? "editable" : "other";
for (const surface of surfaces.values()) focused = surface;
return focused;
},
subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}
export const focusSurfaces = createKeyboardFocusScope(hasFocusedTextInput);
@@ -10,7 +10,6 @@ export interface KeyboardSourceHandlers {
keyUp(event: KeyboardInputEvent): void;
blur(): void;
commandCenterOpen(): boolean;
subscribeCommandCenter(listener: () => void): () => void;
}
export function nativeKeyboardInput(
event: KeyboardInputEvent,
@@ -24,6 +23,5 @@ export interface KeyboardPolicyInput {
bindings: readonly ParsedShortcutBinding[];
chordState?: ChordState;
isMac: boolean;
usesDesktopBindings: boolean;
enabled: boolean;
}
@@ -366,13 +366,7 @@ describe("Electron guest source through shared resolution", () => {
},
captureFocusRestore: (id) => restored.push(id),
},
{
keyDown: pipeline.keyDown,
keyUp() {},
blur() {},
commandCenterOpen: () => false,
subscribeCommandCenter: () => () => {},
},
{ keyDown: pipeline.keyDown, keyUp() {}, blur() {}, commandCenterOpen: () => false },
);
receive({
browserId: "tab-1",
@@ -1,5 +1,5 @@
import { requireNativeModule } from "expo-modules-core";
import type { ClaimedShortcut, HardwareKeyboardEvent } from "./hardware";
import type { HardwareKeyboardEvent } from "./hardware";
interface HardwareKeyboardModule {
getHardwareKeyboardPresent(): boolean;
addListener(event: "onHardwareKeyboardPresenceChanged", handler: () => void): { remove(): void };
@@ -19,9 +19,6 @@ export function subscribeHardwareKeyboard(
export function setHardwareKeyboardOverlayKeys(keys: readonly string[]): void {
module.setOverlayKeys(keys);
}
// A focused Android EditText types nothing for Tab, Shift+Tab or Alt+Shift+[,
// so there is no character to take from it.
export function setHardwareKeyboardClaimedShortcuts(_shortcuts: readonly ClaimedShortcut[]): void {}
export function getHardwareKeyboardPresent(): boolean {
return module.getHardwareKeyboardPresent();
@@ -1,10 +1,9 @@
import { requireNativeModule } from "expo-modules-core";
import type { ClaimedShortcut, HardwareKeyboardEvent } from "./hardware";
import type { HardwareKeyboardEvent } from "./hardware";
interface HardwareKeyboardModule {
getHardwareKeyboardPresent(): boolean;
addListener(event: "onHardwareKeyboardPresenceChanged", handler: () => void): { remove(): void };
setOverlayKeys(keys: readonly string[]): void;
setClaimedShortcuts(shortcuts: readonly ClaimedShortcut[]): void;
addListener(
event: "onHardwareKeyboardShortcut",
handler: (event: HardwareKeyboardEvent) => void,
@@ -20,9 +19,6 @@ export function subscribeHardwareKeyboard(
export function setHardwareKeyboardOverlayKeys(keys: readonly string[]): void {
module.setOverlayKeys(keys);
}
export function setHardwareKeyboardClaimedShortcuts(shortcuts: readonly ClaimedShortcut[]): void {
module.setClaimedShortcuts(shortcuts);
}
export function getHardwareKeyboardPresent(): boolean {
return module.getHardwareKeyboardPresent();
@@ -3,19 +3,12 @@ export interface HardwareKeyboardEvent extends KeyboardShortcutInput {
isComposing: boolean;
phase: "down" | "up";
}
/** A shortcut the native window takes from the focused text control, by DOM `code`. */
export interface ClaimedShortcut {
code: string;
shift: boolean;
alt: boolean;
}
export function subscribeHardwareKeyboard(
_handler: (event: HardwareKeyboardEvent) => void,
): () => void {
return () => {};
}
export function setHardwareKeyboardOverlayKeys(_keys: readonly string[]): void {}
export function setHardwareKeyboardClaimedShortcuts(_shortcuts: readonly ClaimedShortcut[]): void {}
export function getHardwareKeyboardPresent(): boolean {
return false;
@@ -1,19 +1,11 @@
import type { KeyboardPolicyInput, KeyboardSourceHandlers } from "./contract";
import {
setHardwareKeyboardClaimedShortcuts,
setHardwareKeyboardOverlayKeys,
subscribeHardwareKeyboard,
} from "./hardware";
import { createHardwareKeyboardSource } from "./native";
const hardwareKeyboard = createHardwareKeyboardSource({
subscribe: subscribeHardwareKeyboard,
setOverlayKeys: setHardwareKeyboardOverlayKeys,
setClaimedShortcuts: setHardwareKeyboardClaimedShortcuts,
});
import type { KeyboardSourceHandlers } from "./contract";
import { setHardwareKeyboardOverlayKeys, subscribeHardwareKeyboard } from "./hardware";
import { attachHardwareKeyboardSource } from "./native";
export function attachKeyboardSources(handlers: KeyboardSourceHandlers): () => void {
return hardwareKeyboard.attach(handlers);
return attachHardwareKeyboardSource(
{ subscribe: subscribeHardwareKeyboard, setOverlayKeys: setHardwareKeyboardOverlayKeys },
handlers,
);
}
export function publishKeyboardPolicy(input: KeyboardPolicyInput): void {
hardwareKeyboard.publishPolicy(input);
}
export function publishKeyboardPolicy(_input: import("./contract").KeyboardPolicyInput): void {}
@@ -1,3 +1,4 @@
import { usesDesktopBindings } from "../conventions";
import { getDesktopHost } from "@/desktop/host";
import { getResidentBrowserWebview } from "@/desktop/browser/resident-webviews";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
@@ -25,7 +26,7 @@ export function attachKeyboardSources(handlers: KeyboardSourceHandlers): () => v
}
export function publishKeyboardPolicy(input: import("./contract").KeyboardPolicyInput): void {
const policy = input.enabled
? buildBrowserKeyboardPolicy(input)
? buildBrowserKeyboardPolicy({ ...input, usesDesktopBindings: usesDesktopBindings })
: { menuPrefixes: [], prefixes: [] };
void getDesktopHost()?.browser?.setShortcutPolicy?.(policy);
}
@@ -1,92 +1,31 @@
import { matchesKeyboardShortcutContext } from "../../keyboard-shortcuts";
import { focusSurfaces } from "../focus-surfaces";
import { overlays } from "../overlays";
import {
nativeKeyboardInput,
type KeyboardPolicyInput,
type KeyboardSourceHandlers,
} from "./contract";
import type { ClaimedShortcut, HardwareKeyboardEvent } from "./hardware";
import { nativeKeyboardInput, type KeyboardSourceHandlers } from "./contract";
import type { HardwareKeyboardEvent } from "./hardware";
interface HardwareKeySource {
subscribe(handler: (event: HardwareKeyboardEvent) => void): () => void;
setOverlayKeys(keys: readonly string[]): void;
setClaimedShortcuts(shortcuts: readonly ClaimedShortcut[]): void;
}
const DIGIT_CODES = Array.from({ length: 9 }, (_, index) => `Digit${index + 1}`);
/**
* The shortcuts a focused text control would otherwise type or act on.
*
* The native window observes every press but cannot wait for the pipeline to
* decide, so it has to claim these keys up front: without a claim, Shift+Tab
* cycles the agent mode and the composer also inserts a tab. Cmd and Ctrl
* combos do not type, and the platform's own editing combos stay with the text
* control through `editable: false`. Unmodified keys belong to overlays and the
* composer's Return.
*/
function claimedShortcuts(
policy: KeyboardPolicyInput | null,
commandCenterOpen: boolean,
): ClaimedShortcut[] {
if (!policy?.enabled) return [];
const context = {
isMac: policy.isMac,
usesDesktopBindings: policy.usesDesktopBindings,
commandCenterOpen,
focusScope: focusSurfaces.resolve(commandCenterOpen, true),
};
const claimed = new Map<string, ClaimedShortcut>();
for (const binding of policy.bindings) {
const combo = binding.parsedChord[0];
if (!combo || binding.preventDefault === false) continue;
if (combo.meta || combo.ctrl || combo.mod || !(combo.shift || combo.alt)) continue;
if (!matchesKeyboardShortcutContext(binding.when, context)) continue;
const shift = combo.shift === true;
const alt = combo.alt === true;
for (const code of combo.code === "Digit" ? DIGIT_CODES : [combo.code]) {
claimed.set(`${code}:${shift}:${alt}`, { code, shift, alt });
export function attachHardwareKeyboardSource(
source: HardwareKeySource,
handlers: KeyboardSourceHandlers,
): () => void {
const updateKeys = () => source.setOverlayKeys(overlays.getKeys());
updateKeys();
const unsubscribeOverlays = overlays.subscribe(updateKeys);
const unsubscribeHardware = source.subscribe((input) => {
const event = { ...input, preventDefault() {}, stopPropagation() {} };
if (input.phase === "up") {
handlers.keyUp(event);
return;
}
}
return [...claimed.values()];
}
export function createHardwareKeyboardSource(source: HardwareKeySource) {
let policy: KeyboardPolicyInput | null = null;
let updateClaims = () => {};
return {
publishPolicy(next: KeyboardPolicyInput) {
policy = next;
updateClaims();
},
attach(handlers: KeyboardSourceHandlers): () => void {
const updateKeys = () => source.setOverlayKeys(overlays.getKeys());
updateClaims = () =>
source.setClaimedShortcuts(claimedShortcuts(policy, handlers.commandCenterOpen()));
updateKeys();
updateClaims();
const unsubscribeOverlays = overlays.subscribe(updateKeys);
const unsubscribeFocus = focusSurfaces.subscribe(() => updateClaims());
const unsubscribeCommandCenter = handlers.subscribeCommandCenter(() => updateClaims());
const unsubscribeHardware = source.subscribe((input) => {
const event = { ...input, preventDefault() {}, stopPropagation() {} };
if (input.phase === "up") {
handlers.keyUp(event);
return;
}
handlers.keyDown(
nativeKeyboardInput(event, focusSurfaces.resolve(handlers.commandCenterOpen())),
);
});
return () => {
unsubscribeHardware();
unsubscribeFocus();
unsubscribeCommandCenter();
unsubscribeOverlays();
updateClaims = () => {};
source.setOverlayKeys([]);
source.setClaimedShortcuts([]);
};
},
handlers.keyDown(
nativeKeyboardInput(event, focusSurfaces.resolve(handlers.commandCenterOpen())),
);
});
return () => {
unsubscribeHardware();
unsubscribeOverlays();
source.setOverlayKeys([]);
};
}
@@ -25,7 +25,6 @@ describe("DOM source adapter", () => {
keyUp() {},
blur() {},
commandCenterOpen: () => false,
subscribeCommandCenter: () => () => {},
});
const event = new KeyboardEvent("keydown", {
key: "j",
@@ -130,7 +130,7 @@ describe("hardware source adapter", () => {
it.each([true, false])(
"delivers hardware down/up and declared focus with mac=%s",
async (isMac) => {
const { createHardwareKeyboardSource } = await import("./internal/sources/native");
const { attachHardwareKeyboardSource } = await import("./internal/sources/native");
const { focusSurfaces } = await import("./internal/focus-surfaces");
const actions: string[] = [];
const releases: string[] = [];
@@ -148,22 +148,23 @@ describe("hardware source adapter", () => {
},
overlays: createOverlayRegistry(),
});
const detach = createHardwareKeyboardSource({
subscribe(handler) {
events.add(handler);
return () => {
events.delete(handler);
};
const detach = attachHardwareKeyboardSource(
{
subscribe(handler) {
events.add(handler);
return () => {
events.delete(handler);
};
},
setOverlayKeys() {},
},
setOverlayKeys() {},
setClaimedShortcuts() {},
}).attach({
keyDown: pipeline.keyDown,
keyUp: (event) => releases.push(event.key),
blur() {},
commandCenterOpen: () => false,
subscribeCommandCenter: () => () => {},
});
{
keyDown: pipeline.keyDown,
keyUp: (event) => releases.push(event.key),
blur() {},
commandCenterOpen: () => false,
},
);
const down = {
...keyboardEvent("k", "KeyK", isMac),
isComposing: false,
@@ -179,94 +180,4 @@ describe("hardware source adapter", () => {
expect(events.size).toBe(0);
},
);
it("claims the Shift and Option shortcuts a focused text control would otherwise type", async () => {
const { createHardwareKeyboardSource } = await import("./internal/sources/native");
const { focusSurfaces } = await import("./internal/focus-surfaces");
let claimed: string[] = [];
const source = createHardwareKeyboardSource({
subscribe: () => () => {},
setOverlayKeys() {},
setClaimedShortcuts(shortcuts) {
claimed = shortcuts
.map(({ code, shift, alt }) => [alt && "Alt", shift && "Shift", code])
.map((parts) => parts.filter(Boolean).join("+"))
.sort();
},
});
source.publishPolicy({
bindings: buildEffectiveBindings({}),
isMac: true,
usesDesktopBindings: true,
enabled: true,
});
let commandCenterOpen = false;
const commandCenterListeners = new Set<() => void>();
const detach = source.attach({
keyDown() {},
keyUp() {},
blur() {},
commandCenterOpen: () => commandCenterOpen,
subscribeCommandCenter(listener) {
commandCenterListeners.add(listener);
return () => commandCenterListeners.delete(listener);
},
});
const setCommandCenterOpen = (open: boolean) => {
commandCenterOpen = open;
for (const listener of commandCenterListeners) listener();
};
// iPad tab switching uses Cmd+Shift+[ / ], which no text control types.
expect(claimed).toEqual([]);
const releaseComposer = focusSurfaces.declare("message-input");
expect(claimed).toEqual(["Shift+Tab"]);
setCommandCenterOpen(true);
expect(claimed).toEqual([]);
setCommandCenterOpen(false);
expect(claimed).toEqual(["Shift+Tab"]);
releaseComposer();
expect(claimed).toEqual([]);
source.publishPolicy({
bindings: buildEffectiveBindings({ "message-input-mode-cycle-shift-tab": "Alt+M" }),
isMac: true,
usesDesktopBindings: true,
enabled: true,
});
const releaseRebound = focusSurfaces.declare("message-input");
expect(claimed).toEqual(["Alt+KeyM"]);
releaseRebound();
// A chord's later step is only a shortcut while the chord is in progress;
// claiming it always would stop the composer typing a capital A.
source.publishPolicy({
bindings: buildEffectiveBindings({ "message-input-mode-cycle-shift-tab": "Mod+J Shift+A" }),
isMac: true,
usesDesktopBindings: true,
enabled: true,
});
const releaseChord = focusSurfaces.declare("message-input");
expect(claimed).toEqual([]);
releaseChord();
const releaseFinal = focusSurfaces.declare("message-input");
source.publishPolicy({
bindings: buildEffectiveBindings({}),
isMac: true,
usesDesktopBindings: true,
enabled: false,
});
expect(claimed).toEqual([]);
source.publishPolicy({
bindings: buildEffectiveBindings({}),
isMac: true,
usesDesktopBindings: true,
enabled: true,
});
expect(claimed).toEqual(["Shift+Tab"]);
detach();
expect(claimed).toEqual([]);
releaseFinal();
});
});
+1 -5
View File
@@ -62,7 +62,7 @@ export function useKeyboardShortcuts({
const keyboardWorkspaceSelectionRef = useRef<ActiveWorkspaceSelection | null>(null);
const publishBrowserShortcutPolicy = useCallback(
(chordState?: ChordState) => {
publishKeyboardPolicy({ bindings, chordState, isMac, usesDesktopBindings, enabled });
publishKeyboardPolicy({ bindings, chordState, isMac, enabled });
},
[bindings, enabled, isMac],
);
@@ -265,10 +265,6 @@ export function useKeyboardShortcuts({
keyUp: handleKeyUp,
blur: resetModifiers,
commandCenterOpen: () => useKeyboardShortcutsStore.getState().commandCenterOpen,
subscribeCommandCenter: (listener) =>
useKeyboardShortcutsStore.subscribe((state, previous) => {
if (state.commandCenterOpen !== previous.commandCenterOpen) listener();
}),
});
return () => {
detach();
@@ -134,7 +134,6 @@ describe("DOM source through the shared resolver", () => {
keyUp() {},
blur() {},
commandCenterOpen: () => false,
subscribeCommandCenter: () => () => {},
});
const event = new KeyboardEvent("keydown", {
key: "j",
@@ -26,7 +26,6 @@ function installation(): InstalledPlugin {
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -105,7 +105,6 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -37,7 +37,6 @@ function installed(): InstalledPlugin {
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
workspaceRowBadgeItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
@@ -47,7 +47,6 @@ function plugin(): InstalledPlugin {
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
workspaceRowBadgeItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
-41
View File
@@ -101,7 +101,6 @@ describe("evaluatePluginClientBundle", () => {
plugin.addSettingsScreen({ id: "display", title: "Display", icon: "Settings", Component }),
plugin.addSidebarHeaderItem({ id: "main", title: "Main", Component }),
plugin.addSidebarFooterItem({ id: "status", title: "Status", Component }),
plugin.addWorkspaceRowBadgeItem({ id: "status", title: "Status", icon: "Blocks", Component }),
plugin.addWorkspacePanel({ id: "panel", title: "Panel", icon: "Blocks", context: "workspace", Component }),
plugin.addCommandCenterItem({ id: "command", title: "Command", icon: "Blocks", context: "global", onSelect() {} }),
plugin.addSlashCommand({ name: "review", description: "Review", argumentHint: "", context: "workspace", onSubmit() {} }),
@@ -133,7 +132,6 @@ describe("evaluatePluginClientBundle", () => {
plugin.settingsScreens,
plugin.sidebarItems.header,
plugin.sidebarItems.footer,
plugin.workspaceRowBadgeItems,
plugin.workspacePanels,
plugin.commandCenterItems,
plugin.clientSlashCommands,
@@ -154,7 +152,6 @@ describe("evaluatePluginClientBundle", () => {
plugin.settingsScreens,
plugin.sidebarItems.header,
plugin.sidebarItems.footer,
plugin.workspaceRowBadgeItems,
plugin.workspacePanels,
plugin.commandCenterItems,
plugin.clientSlashCommands,
@@ -285,44 +282,6 @@ describe("evaluatePluginClientBundle", () => {
).toThrow("Sidebar item main has no title");
});
it("collects workspace row badge items and rejects duplicate and malformed ones", () => {
const plugin = evaluatePluginClientBundle(
"example",
bundle(`
function Item() { return null; }
plugin.addWorkspaceRowBadgeItem({ id: "status", title: " Status ", icon: "Blocks", Component: Item });
`),
);
expect(
plugin.workspaceRowBadgeItems.map(({ id, title, icon }) => ({ id, title, icon })),
).toEqual([{ id: "status", title: "Status", icon: "Blocks" }]);
const row = (fields: string) =>
bundle(`function Item() { return null; } plugin.addWorkspaceRowBadgeItem({ ${fields} });`);
expect(() =>
evaluatePluginClientBundle(
"example",
bundle(`
function Item() { return null; }
plugin.addWorkspaceRowBadgeItem({ id: "status", title: "One", icon: "Blocks", Component: Item });
plugin.addWorkspaceRowBadgeItem({ id: "status", title: "Two", icon: "Blocks", Component: Item });
`),
),
).toThrow("Duplicate workspace row badge item: status");
expect(() =>
evaluatePluginClientBundle(
"example",
row(`id: "status", title: " ", icon: "Blocks", Component: Item`),
),
).toThrow("Workspace row badge item status has no title");
expect(() =>
evaluatePluginClientBundle(
"example",
row(`id: "status", title: "Status", icon: "Blocks", Component: "Item"`),
),
).toThrow("Workspace row badge item status is not a component");
});
it("expands the addSurface and addSidebarItem aliases to a screen and a legacy header item", () => {
const plugin = evaluatePluginClientBundle(
"example",
-21
View File
@@ -37,7 +37,6 @@ import type {
PluginScreenContribution,
PluginSidebarItemContribution,
PluginWorkspacePanelContribution,
PluginWorkspaceRowBadgeItemContribution,
} from "./types";
import type { ComponentType } from "react";
import { parsePluginIcon, parseSerializablePluginIcon } from "./icons";
@@ -134,7 +133,6 @@ export function runPluginClientBundle(
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -153,7 +151,6 @@ export function runPluginClientBundle(
header: new Set(),
footer: new Set(),
};
const workspaceRowItemIds = new Set<string>();
const filePanelIds = new Set<string>();
const composerItemIds = new Set<string>();
const newWorkspaceItemIds = new Set<string>();
@@ -326,23 +323,6 @@ export function runPluginClientBundle(
addSidebarFooterItem(contribution: PluginSidebarItemContribution) {
return addSidebarItem("footer", contribution);
},
addWorkspaceRowBadgeItem(contribution: PluginWorkspaceRowBadgeItemContribution) {
const itemId = requireId(contribution.id, "workspace row badge item id");
if (workspaceRowItemIds.has(itemId))
throw new Error(`Duplicate workspace row badge item: ${itemId}`);
const title = normalizePluginText(contribution.title);
if (!title) throw new Error(`Workspace row badge item ${itemId} has no title`);
const icon = parsePluginIcon(`Workspace row badge item ${itemId}`, contribution.icon);
if (!isComponentType(contribution.Component)) {
throw new Error(`Workspace row badge item ${itemId} is not a component`);
}
workspaceRowItemIds.add(itemId);
return register(
collector.workspaceRowBadgeItems,
{ id: itemId, title, icon, Component: contribution.Component },
() => workspaceRowItemIds.delete(itemId),
);
},
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
// Titled by its id; a legacy item pointing at it lends the header its own title instead.
addSurface(surfaceId: string, Component: ComponentType<PluginSurfaceProps>) {
@@ -654,7 +634,6 @@ export function runPluginClientBundle(
sidebarViews: collector.sidebarViews,
sidebarItems: collector.sidebarItems,
legacySidebarItems: collector.legacySidebarItems,
workspaceRowBadgeItems: collector.workspaceRowBadgeItems,
filePanels: collector.filePanels,
composerItems: collector.composerItems,
newWorkspaceItems: collector.newWorkspaceItems,
@@ -46,7 +46,6 @@ function installation(invoked: string[]): InstalledPlugin {
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
+1 -1
View File
@@ -14,7 +14,7 @@ import type { InstalledPlugin } from "./types";
/** `withUnistyles` mapping that hands a host the plugin theme. */
export const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
export function renderNothing() {
function renderNothing() {
return null;
}
@@ -11,7 +11,6 @@ export {
export { ExternalLink } from "@/components/ui/external-link";
export { SidebarRow, SidebarSeparator } from "@/plugins/sidebar-items/kit";
export { WorkspaceRowBadge } from "@/plugins/workspace-row-badge-items/kit";
export { DocumentFrame } from "@/file-panels/document-frame";
-1
View File
@@ -125,7 +125,6 @@ export class PluginRegistry {
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -37,7 +37,6 @@ function installed(
footer: [{ id: "status", title: "Status", Component: () => null }],
},
legacySidebarItems,
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -51,7 +51,6 @@ function installation(
surfaces: surfaces.map((id) => ({ id, title: id, Component: () => null })),
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
workspaceRowBadgeItems: [],
legacySidebarItems: sidebarItems.map((item) => ({
...item,
title: item.id,
-1
View File
@@ -64,7 +64,6 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -27,7 +27,6 @@ function plugin(input: {
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -34,7 +34,6 @@ vi.mock("../client-runtime", () => ({
}));
vi.mock("../icons", () => ({
Icon: () => null,
PluginIconView: () => null,
pluginIconComponent: () => () => null,
}));
-5
View File
@@ -17,7 +17,6 @@ import type {
PluginSidebarContribution as SDKPluginSidebarContribution,
PluginSidebarViewContribution,
PluginSidebarItemContribution as SDKPluginSidebarItemContribution,
PluginWorkspaceRowBadgeItemContribution as SDKPluginWorkspaceRowBadgeItemContribution,
PluginScreenContribution as SDKPluginScreenContribution,
PluginSettingsScreenContribution as SDKPluginSettingsScreenContribution,
PluginTimelineRendererContribution,
@@ -34,8 +33,6 @@ type PluginClientSlashCommandContribution = SDKPluginClientSlashCommandContribut
type PluginComposerPillContribution = SDKPluginComposerPillContribution<PluginText>;
type PluginSidebarContribution = SDKPluginSidebarContribution<PluginText>;
type PluginSidebarItemContribution = SDKPluginSidebarItemContribution<PluginText>;
type PluginWorkspaceRowBadgeItemContribution =
SDKPluginWorkspaceRowBadgeItemContribution<PluginText>;
type PluginScreenContribution = SDKPluginScreenContribution<PluginText>;
type PluginSettingsScreenContribution = SDKPluginSettingsScreenContribution<PluginText>;
type WorkspacePanelInput = SDKPluginWorkspacePanelContribution<PluginText>;
@@ -59,7 +56,6 @@ export interface EvaluatedPlugin {
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
/** `addSidebarItem` registrations, so `/plugin/<id>/sidebar/<item>` routes keep resolving. */
legacySidebarItems: PluginSidebarContribution[];
workspaceRowBadgeItems: PluginWorkspaceRowBadgeItemContribution[];
filePanels: PluginFilePanelContribution[];
composerItems: PluginComposerItemContribution[];
newWorkspaceItems: PluginNewWorkspaceItemContribution[];
@@ -99,7 +95,6 @@ export type {
PluginComposerPillContribution,
PluginSidebarContribution,
PluginSidebarItemContribution,
PluginWorkspaceRowBadgeItemContribution,
PluginScreenContribution,
PluginSettingsScreenContribution,
PluginThemeContribution,
@@ -24,7 +24,6 @@ function installed(): InstalledPlugin {
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -1,96 +0,0 @@
import type { PluginTheme } from "@getpaseo/plugin";
import type { PluginWorkspaceRowBadgeItemProps } from "@getpaseo/plugin/client";
import { useMemo } from "react";
import { withUnistyles } from "react-native-unistyles";
import { useAppSettings } from "@/hooks/use-settings";
import { useInstalledPlugins } from "../registry";
import { pluginThemeMapping, renderNothing, usePluginItemHost } from "../item-host";
import { PluginEnvironmentProvider } from "../popover";
import { SurfaceErrorBoundary } from "../surface-error-boundary";
import type { InstalledPlugin, PluginWorkspaceRowBadgeItemContribution } from "../types";
import { isWorkspaceRowBadgeItemVisible, workspaceRowBadgeItemKey } from "./model";
import { WorkspaceRowBadgeTitleContext } from "./kit";
export { WorkspaceRowBadge } from "./kit";
export {
groupPluginWorkspaceRowBadgeItems,
isWorkspaceRowBadgeItemVisible,
type PluginWorkspaceRowBadgeItemGroup,
} from "./model";
interface RowBadgeItemTarget {
plugin: InstalledPlugin;
item: PluginWorkspaceRowBadgeItemContribution;
}
/** The visible plugin row items installed on this workspace's host. */
export function usePluginWorkspaceRowBadgeItems(serverId: string | null): RowBadgeItemTarget[] {
const installed = useInstalledPlugins();
const {
settings: { sidebarPluginRowBadgeItems },
} = useAppSettings();
return useMemo(
() =>
installed
.filter((plugin) => plugin.serverId === serverId)
.flatMap((plugin) => plugin.workspaceRowBadgeItems.map((item) => ({ plugin, item })))
.filter(({ plugin, item }) =>
isWorkspaceRowBadgeItemVisible(
sidebarPluginRowBadgeItems,
workspaceRowBadgeItemKey(plugin.id, item.id),
),
),
[installed, serverId, sidebarPluginRowBadgeItems],
);
}
/**
* Plugin items on a workspace's meta line, after the row's own. No wrapper views: an item that
* renders nothing must leave no gap behind it on the line.
*/
export function PluginWorkspaceRowBadgeItems({
targets,
workspaceId,
}: {
targets: readonly RowBadgeItemTarget[];
workspaceId: string;
}) {
return targets.map((target) => (
<ThemedRowBadgeItemHost
key={workspaceRowBadgeItemKey(target.plugin.id, target.item.id)}
target={target}
workspaceId={workspaceId}
uniProps={pluginThemeMapping}
/>
));
}
function RowBadgeItemHost({
target: { plugin, item },
workspaceId,
theme,
}: {
target: RowBadgeItemTarget;
workspaceId: string;
theme: PluginTheme;
}) {
const { environment, props: hostProps } = usePluginItemHost(plugin, theme);
const props = useMemo<PluginWorkspaceRowBadgeItemProps>(
() => ({ ...hostProps, workspaceId }),
[hostProps, workspaceId],
);
const title = plugin.i18n.resolve(item.title, plugin.locale);
const Item = item.Component;
return (
<SurfaceErrorBoundary installation={plugin} Surface={Item} renderError={renderNothing}>
<PluginEnvironmentProvider environment={environment}>
<WorkspaceRowBadgeTitleContext.Provider value={title}>
<Item {...props} />
</WorkspaceRowBadgeTitleContext.Provider>
</PluginEnvironmentProvider>
</SurfaceErrorBoundary>
);
}
const ThemedRowBadgeItemHost = withUnistyles(RowBadgeItemHost);
@@ -1,93 +0,0 @@
import type { WorkspaceRowBadgeProps, WorkspaceRowBadgeTone } from "@getpaseo/plugin/client/ui";
import { createContext, useContext, useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { HOST_BADGE_ICON_SIZE } from "@/hosts/host-badge";
import type { Theme } from "@/styles/theme";
import { parsePluginIcon, PluginIconView } from "../icons";
/** The item's title, the accessible name of a badge that has neither a label nor its own. */
export const WorkspaceRowBadgeTitleContext = createContext<string | undefined>(undefined);
const ThemedPluginIconView = withUnistyles(PluginIconView);
function toneColor(theme: Theme, tone: WorkspaceRowBadgeTone): string {
if (tone === "success") return theme.colors.statusSuccess;
if (tone === "warning") return theme.colors.statusWarning;
if (tone === "danger") return theme.colors.statusDanger;
if (tone === "info") return theme.colors.statusDotRunning;
return theme.colors.foregroundMuted;
}
const TONE_MAPPINGS: Record<WorkspaceRowBadgeTone, (theme: Theme) => { color: string }> = {
muted: (theme) => ({ color: toneColor(theme, "muted") }),
success: (theme) => ({ color: toneColor(theme, "success") }),
warning: (theme) => ({ color: toneColor(theme, "warning") }),
danger: (theme) => ({ color: toneColor(theme, "danger") }),
info: (theme) => ({ color: toneColor(theme, "info") }),
};
/**
* A plugin's badge on the line under a workspace title, drawn like the row's own peers: one glyph
* at the line's size and a short word, in the color of the state it reports. The plugin owns the
* slot, so the badge draws no separator.
*/
export function WorkspaceRowBadge({
icon,
label,
tone = "muted",
accessibilityLabel,
}: WorkspaceRowBadgeProps) {
const itemTitle = useContext(WorkspaceRowBadgeTitleContext);
// Parsed at render, not registration, so an invalid icon throws to the item's boundary.
const parsedIcon = useMemo(
() => (icon === undefined ? null : parsePluginIcon("Workspace row badge", icon)),
[icon],
);
if (!parsedIcon && !label) return null;
return (
<View style={styles.item} accessibilityLabel={accessibilityLabel ?? (label || itemTitle)}>
{parsedIcon ? (
<ThemedPluginIconView
icon={parsedIcon}
size={HOST_BADGE_ICON_SIZE}
uniProps={TONE_MAPPINGS[tone]}
/>
) : null}
{label ? (
<Text style={[styles.text, textToneStyle(tone)]} numberOfLines={1}>
{label}
</Text>
) : null}
</View>
);
}
// Read inside render, never into a module-scope table — see docs/unistyles.md.
function textToneStyle(tone: WorkspaceRowBadgeTone) {
if (tone === "success") return styles.success;
if (tone === "warning") return styles.warning;
if (tone === "danger") return styles.danger;
if (tone === "info") return styles.info;
return styles.muted;
}
const styles = StyleSheet.create((theme) => ({
item: {
flexDirection: "row",
alignItems: "center",
gap: 3,
minWidth: 0,
flexShrink: 1,
},
text: {
fontSize: theme.fontSize.sm,
lineHeight: 16,
flexShrink: 1,
},
muted: { color: toneColor(theme, "muted") },
success: { color: toneColor(theme, "success") },
warning: { color: toneColor(theme, "warning") },
danger: { color: toneColor(theme, "danger") },
info: { color: toneColor(theme, "info") },
}));
@@ -1,44 +0,0 @@
import { describe, expect, it } from "vitest";
import type { InstalledPlugin } from "../types";
import { groupPluginWorkspaceRowBadgeItems, isWorkspaceRowBadgeItemVisible } from "./model";
function Item() {
return null;
}
function installed(id: string, serverId: string, itemIds: string[]): InstalledPlugin {
return {
id,
serverId,
locale: "en",
i18n: { resolve: (text: string) => text },
workspaceRowBadgeItems: itemIds.map((itemId) => ({
id: itemId,
title: `${id} ${itemId}`,
icon: "Gauge",
Component: Item,
})),
} as unknown as InstalledPlugin;
}
describe("groupPluginWorkspaceRowBadgeItems", () => {
it("lists each plugin item once across hosts, in install order", () => {
const groups = groupPluginWorkspaceRowBadgeItems([
installed("status", "host-a", ["state"]),
installed("asks", "host-a", ["open"]),
installed("status", "host-b", ["state"]),
]);
expect(groups.map(({ key, title }) => ({ key, title }))).toEqual([
{ key: "status/state", title: "status state" },
{ key: "asks/open", title: "asks open" },
]);
});
});
describe("isWorkspaceRowBadgeItemVisible", () => {
it("shows items until they are switched off", () => {
expect(isWorkspaceRowBadgeItemVisible({}, "status/state")).toBe(true);
expect(isWorkspaceRowBadgeItemVisible({ "status/state": true }, "status/state")).toBe(true);
expect(isWorkspaceRowBadgeItemVisible({ "status/state": false }, "status/state")).toBe(false);
});
});
@@ -1,45 +0,0 @@
import type { PluginIcon } from "@getpaseo/plugin/client";
import type { InstalledPlugin } from "../types";
/** One workspace row badge item, coalesced across every host that contributes it. */
export interface PluginWorkspaceRowBadgeItemGroup {
/** `<pluginId>/<itemId>`: the key its Show toggle is stored under. */
key: string;
pluginId: string;
itemId: string;
title: string;
icon: PluginIcon;
}
export function workspaceRowBadgeItemKey(pluginId: string, itemId: string): string {
return `${pluginId}/${itemId}`;
}
/** An item missing from the stored record has never been toggled, so it shows. */
export function isWorkspaceRowBadgeItemVisible(
visibility: Readonly<Record<string, boolean>>,
key: string,
): boolean {
return visibility[key] !== false;
}
/** Distinct items in install order, for the Show menu. Each host's copy draws on its own rows. */
export function groupPluginWorkspaceRowBadgeItems(
plugins: readonly InstalledPlugin[],
): PluginWorkspaceRowBadgeItemGroup[] {
const groups = new Map<string, PluginWorkspaceRowBadgeItemGroup>();
for (const plugin of plugins) {
for (const item of plugin.workspaceRowBadgeItems) {
const key = workspaceRowBadgeItemKey(plugin.id, item.id);
if (groups.has(key)) continue;
groups.set(key, {
key,
pluginId: plugin.id,
itemId: item.id,
title: plugin.i18n.resolve(item.title, plugin.locale),
icon: item.icon,
});
}
}
return [...groups.values()];
}
@@ -354,21 +354,6 @@ describe("ReplicaCache", () => {
);
});
it("preserves where the workspace's forge is on the web across cache reopen", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
const baseline = directory({ workspaces: { generation: "g", afterSeq: 8 } });
const forgeWeb = { host: "github.com", referenceRepository: "main-owner/repo" };
const workspace = normalizeWorkspaceDescriptor({ ...workspacePayload(), forgeWeb });
baseline.workspaces.set(workspace.id, workspace);
commitDirectory(writer, SERVER_ID, baseline);
await writer.flush();
const restored = await createCache(storage).readDirectory(SERVER_ID);
expect(restored.workspaces.get(workspace.id)?.forgeWeb).toEqual(forgeWeb);
});
it("preserves workspace sorting timestamps across cache reopen", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
@@ -3,7 +3,6 @@ import { z } from "zod";
import {
AgentStatusSchema,
AgentTimelineItemPayloadSchema,
ForgeWebSchema,
WorkspaceGitHubRuntimePayloadSchema,
} from "@getpaseo/protocol/messages";
import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest";
@@ -336,7 +335,6 @@ const StoredWorkspaceSchema = z.strictObject({
gitRuntime: WorkspaceGitRuntimeSchema,
githubRuntime: WorkspaceGitHubRuntimePayloadSchema,
forge: z.string().optional(),
forgeWeb: ForgeWebSchema.optional(),
});
const StoredProjectSchema = z.strictObject({
@@ -770,7 +768,6 @@ function serializeWorkspace(workspace: WorkspaceDescriptor): StoredWorkspace {
gitRuntime: workspace.gitRuntime,
githubRuntime: workspace.githubRuntime,
forge: workspace.forge,
forgeWeb: workspace.forgeWeb,
};
}
@@ -3919,11 +3919,7 @@ function WorkspaceMarkdownLinks({
}: AssistantFileLinkResolverProviderProps & { workspace: WorkspaceDescriptor | null | undefined }) {
return (
<AssistantFileLinkResolverProvider {...fileLinks}>
<ForgeShorthandProvider
forge={workspace?.forge}
remoteUrl={workspace?.gitRuntime?.remoteUrl}
forgeWeb={workspace?.forgeWeb}
>
<ForgeShorthandProvider forge={workspace?.forge} remoteUrl={workspace?.gitRuntime?.remoteUrl}>
{children}
</ForgeShorthandProvider>
</AssistantFileLinkResolverProvider>
-2
View File
@@ -140,7 +140,6 @@ export interface WorkspaceDescriptor {
gitRuntime?: WorkspaceDescriptorPayload["gitRuntime"];
githubRuntime?: WorkspaceDescriptorPayload["githubRuntime"];
forge?: WorkspaceDescriptorPayload["forge"];
forgeWeb?: WorkspaceDescriptorPayload["forgeWeb"];
project?: ProjectPlacementPayload;
}
@@ -186,7 +185,6 @@ export function normalizeWorkspaceDescriptor(
gitRuntime: payload.gitRuntime,
githubRuntime: payload.githubRuntime,
forge: payload.forge,
forgeWeb: payload.forgeWeb,
project: payload.project,
};
}
+15 -38
View File
@@ -2,13 +2,7 @@ import type { TFunction } from "i18next";
import { i18n } from "@/i18n/i18next";
import { usageCopy } from "./copy";
import type { UsageDisplayAs } from "./preferences";
import type {
AvailableUsageReport,
UsageReport,
UsageReportEntry,
UsageView,
UsageWindow,
} from "./types";
import type { UsageReport, UsageReportEntry, UsageView, UsageWindow } from "./types";
export function usedPercent(window: UsageWindow): number | null {
if (window.usedPct != null) return window.usedPct;
@@ -211,34 +205,13 @@ export function resolveUsageModalHostId(choice: UsageHostChoice): string | null
return picked?.serverId ?? resolveUsageHostId(choice) ?? connected[0]?.serverId ?? null;
}
export interface UsageSnapshot {
report: AvailableUsageReport;
successfulAt: string;
retained: boolean;
}
/** The latest successful values and their freshness, independent of the latest attempt's outcome. */
export function resolveUsageSnapshot(entry: UsageReportEntry): UsageSnapshot | null {
if (entry.report.status === "available") {
// COMPAT(usageSuccessfulAt): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0.
// Old daemons expose only attempt time for available reports, never for failures.
return {
report: entry.report,
successfulAt: entry.successfulAt ?? entry.fetchedAt,
retained: false,
};
}
// COMPAT(usageLastSuccessful): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0.
// Older daemons cannot provide history; a failed attempt is never a successful update.
const retained = entry.lastSuccessful;
if (!retained) return null;
return { report: retained.report, successfulAt: retained.fetchedAt, retained: true };
}
/** Current outcome owns messages; only a successful poll owns values and freshness. */
export function resolveUsageReport(entry: UsageReportEntry, t: TFunction) {
const available = entry.report.status === "available";
const snapshot = resolveUsageSnapshot(entry);
// COMPAT(usageLastSuccessful): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0.
// Older daemons cannot provide history; a failed attempt is never a successful update.
const snapshot = available ? null : (entry.lastSuccessful ?? null);
const content = entry.report.status === "available" ? entry.report : (snapshot?.report ?? null);
const messages: string[] = [];
if (!available) {
if (entry.loginErrors)
@@ -251,12 +224,16 @@ export function resolveUsageReport(entry: UsageReportEntry, t: TFunction) {
else messages.push(usageProblemText(entry.report, t));
}
return {
windows: snapshot?.report.windows ?? [],
balances: snapshot?.report.balances ?? [],
details: snapshot?.report.details ?? [],
planLabel: snapshot?.report.planLabel ?? null,
successfulAt: snapshot?.successfulAt ?? null,
retained: snapshot?.retained ?? false,
windows: content?.windows ?? [],
balances: content?.balances ?? [],
details: content?.details ?? [],
planLabel: content?.planLabel ?? null,
// COMPAT(usageSuccessfulAt): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0.
// Old daemons expose only attempt time for available reports, never for failures.
successfulAt: available
? (entry.successfulAt ?? entry.fetchedAt)
: (snapshot?.fetchedAt ?? null),
retained: snapshot !== null,
messages,
};
}
+2 -26
View File
@@ -139,7 +139,6 @@ describe("resolvePinnedUsage", () => {
expect(sources[0]).toEqual({
key: "claude:default",
icon: "<svg/>",
hasError: false,
windows: [
{
key: "claude:default/five-hour",
@@ -258,7 +257,7 @@ describe("resolvePinnedUsage", () => {
});
});
it("shows retained windows and marks errored sources in the sidebar summary", () => {
it("keeps unavailable and error reports out of the sidebar summary even with retained data", () => {
if (claude.report.status !== "available" || codex.report.status !== "available")
throw new Error("fixtures must be available");
const expired: UsageReportEntry = {
@@ -274,30 +273,7 @@ it("shows retained windows and marks errored sources in the sidebar summary", ()
lastSuccessful: { fetchedAt: codex.fetchedAt, report: codex.report },
report: { status: "error", error: "Store deleted" },
};
const expected = [
{ key: "claude:default", hasError: false, windows: ["31%"] },
{ key: "codex:default", hasError: true, windows: ["12%"] },
];
const summarize = (pins: UsagePreferences["pins"]) =>
resolvePinnedUsage([expired, failed], preferences(pins)).map((source) => ({
key: source.key,
hasError: source.hasError,
windows: source.windows.map((window) => window.percentText),
}));
expect(summarize(null)).toEqual(expected);
expect(
summarize([
{ sourceId: "claude", windowId: "five-hour" },
{ sourceId: "codex", windowId: "weekly" },
]),
).toEqual(expected);
expect(
resolvePinnedUsage(
[{ ...failed, lastSuccessful: undefined }],
preferences([{ sourceId: "codex", windowId: "weekly" }]),
),
).toEqual([]);
expect(resolvePinnedUsage([expired, failed], preferences(null))).toEqual([]);
});
it("localizes pinned labels and meanings while preserving custom window names", async () => {
+4 -14
View File
@@ -1,7 +1,7 @@
import type { TFunction } from "i18next";
import { i18n } from "@/i18n/i18next";
import { formatPct, formatDisplayPct, formatProviderUsageLabel } from "./format";
import { displayPercent, resolveUsageSnapshot } from "./model";
import { displayPercent } from "./model";
import { effectiveUsagePins, type UsagePreferences } from "./preferences";
import { windowTone } from "./tone";
import type { UsageReportEntry, UsageTone } from "./types";
@@ -23,8 +23,6 @@ export interface PinnedUsageWindow {
export interface PinnedUsageSource {
key: string;
icon: string | null;
/** The displayed values are retained from before a failed refresh. */
hasError: boolean;
windows: PinnedUsageWindow[];
}
@@ -76,9 +74,8 @@ export function resolvePinnedUsage(
): PinnedUsageSource[] {
const pins = effectiveUsagePins(preferences, reports);
return groupBySource(reports).flatMap((entry) => {
const snapshot = resolveUsageSnapshot(entry);
if (!snapshot) return [];
const windows = snapshot.report.windows
if (entry.report.status !== "available") return [];
const windows = entry.report.windows
.filter(
(window) =>
displayPercent(window, preferences.displayAs) !== null &&
@@ -97,13 +94,6 @@ export function resolvePinnedUsage(
};
});
if (windows.length === 0) return [];
return [
{
key: entry.id,
icon: entry.icon ?? null,
hasError: entry.report.status === "error",
windows,
},
];
return [{ key: entry.id, icon: entry.icon ?? null, windows }];
});
}
+3 -4
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { displayPercent, resolveUsageSnapshot } from "./model";
import { displayPercent } from "./model";
import type { UsageReportEntry } from "./types";
/** Whether percentages read as the share used or the share left. */
@@ -55,9 +55,8 @@ export function effectiveUsagePins(
if (preferences.pins !== null) return preferences.pins;
const pins: UsagePin[] = [];
for (const entry of reports) {
const snapshot = resolveUsageSnapshot(entry);
if (!snapshot) continue;
const withPercent = snapshot.report.windows.filter(
if (entry.report.status !== "available") continue;
const withPercent = entry.report.windows.filter(
(window) => displayPercent(window, preferences.displayAs) !== null,
);
const marked = withPercent.filter((window) => window.summary);
+6 -38
View File
@@ -8,7 +8,6 @@ import {
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { TriangleAlert } from "lucide-react-native";
import {
Pressable,
Text,
@@ -16,8 +15,7 @@ import {
type LayoutChangeEvent,
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { StyleSheet } from "react-native-unistyles";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useAppVisible } from "@/hooks/use-app-visible";
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
@@ -37,9 +35,6 @@ import {
} from "./pinned";
import { UsageModal } from "./usage-modal";
const ThemedTriangleAlert = withUnistyles(TriangleAlert);
const errorIconColor = (theme: Theme) => ({ color: theme.colors.statusDanger });
/** Each summary window with data on the usage host, under its source; empty while none has. */
export function useUsageSummary(): readonly PinnedUsageSource[] {
const { t } = useTranslation();
@@ -65,14 +60,7 @@ export function UsageSidebarItem({ sources }: { sources: readonly PinnedUsageSou
const label = t(builtinSidebarNavLabelKey("usage"));
const openUsage = useOpenSidebarUsage();
if (sources.length === 0) return null;
return (
<PinnedUsageTrigger
label={label}
sources={sources}
errorLabel={t("providerUsage.refreshFailed")}
onPress={openUsage}
/>
);
return <PinnedUsageTrigger label={label} sources={sources} onPress={openUsage} />;
}
const OpenSidebarUsageContext = createContext<(() => void) | null>(null);
@@ -107,15 +95,9 @@ export function useOpenSidebarUsage(): () => void {
return openUsage;
}
function pinnedUsageLabel(
label: string,
sources: readonly PinnedUsageSource[],
errorLabel: string,
): string {
function pinnedUsageLabel(label: string, sources: readonly PinnedUsageSource[]): string {
const windows = sources.flatMap((source) => source.windows);
const summary = windows.map((window) => window.label).join(", ");
const error = sources.some((source) => source.hasError) ? `, ${errorLabel}` : "";
return `${label}: ${summary}${error}`;
return `${label}: ${windows.map((window) => window.label).join(", ")}`;
}
function triggerStyle({ hovered }: PressableStateCallbackType & { hovered?: boolean }) {
@@ -134,12 +116,10 @@ function layoutWidth(event: LayoutChangeEvent): number {
function PinnedUsageTrigger({
label,
sources,
errorLabel,
onPress,
}: {
label: string;
sources: readonly PinnedUsageSource[];
errorLabel: string;
onPress: () => void;
}) {
const [available, setAvailable] = useState<number | null>(null);
@@ -164,7 +144,7 @@ function PinnedUsageTrigger({
<Pressable
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={pinnedUsageLabel(label, sources, errorLabel)}
accessibilityLabel={pinnedUsageLabel(label, sources)}
style={triggerStyle}
testID="sidebar-usage"
>
@@ -205,17 +185,10 @@ function PinnedUsageLine({
return sources.map((source, sourceIndex) => (
<Fragment key={source.key}>
<View
style={[styles.source, sourceIndex === 0 ? null : styles.sourceGap]}
style={sourceIndex === 0 ? null : styles.sourceGap}
testID={measuring ? undefined : "sidebar-usage-source"}
>
<UsageSourceIcon svg={source.icon} size={14} />
{source.hasError ? (
<ThemedTriangleAlert
size={12}
uniProps={errorIconColor}
testID={measuring ? undefined : "sidebar-usage-error"}
/>
) : null}
</View>
{source.windows.map((window, windowIndex) => {
const leadStyle = windowIndex === 0 ? styles.iconGap : styles.windowGap;
@@ -278,11 +251,6 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
},
source: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
sourceGap: {
marginLeft: theme.spacing[3],
},
@@ -184,55 +184,6 @@ describe("planWorkspaceOpenTargets", () => {
]);
});
it("opens the branch and the active file on the web host the daemon resolved for an SSH alias", () => {
const aliasStatus = {
isGit: true,
remoteUrl: "git@github.com-work:acme/repo.git",
currentBranch: "feature/x",
forgeWeb: { host: "github.com" },
};
const open = (activeFile?: { path: string }) =>
planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
activeFile,
desktopTargets: [],
canUseDesktopBridge: false,
isLocalExecution: false,
checkoutStatus: aliasStatus,
forge: "github",
}).map((target) => (target.source === "forge" ? target.url : null));
expect(open()).toEqual(["https://github.com/acme/repo/tree/feature/x"]);
expect(open({ path: "src/app.ts" })).toEqual([
"https://github.com/acme/repo/blob/feature/x/src/app.ts",
]);
});
it("keeps the branch and file on the origin repository of a fork", () => {
const forkStatus = {
isGit: true,
remoteUrl: "git@github.com:fork-owner/repo.git",
currentBranch: "feature/only-on-fork",
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
};
const forgeUrls = (activeFile: { path: string } | undefined) =>
planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
activeFile,
desktopTargets: [],
canUseDesktopBridge: false,
isLocalExecution: false,
checkoutStatus: forkStatus,
forge: "github",
}).flatMap((target) => (target.source === "forge" ? [target.url] : []));
const urls = [...forgeUrls(undefined), ...forgeUrls({ path: "src/app.ts" })];
expect(urls).toEqual([
"https://github.com/fork-owner/repo/tree/feature/only-on-fork",
"https://github.com/fork-owner/repo/blob/feature/only-on-fork/src/app.ts",
]);
});
it("infers the forge from the remote URL when the forge input is null", () => {
const targets = planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
@@ -1,4 +1,3 @@
import type { ForgeWeb } from "@getpaseo/protocol/messages";
import { type Forge, forgeFromRemoteUrl, getForgePresentation } from "@/git/forge";
import type { DesktopOpenTarget, OpenDesktopTargetInput } from "@/workspace/desktop-open-targets";
import {
@@ -10,7 +9,6 @@ import {
interface CheckoutStatusForOpenTarget {
isGit: boolean;
remoteUrl?: string | null;
forgeWeb?: ForgeWeb | null;
currentBranch?: string | null;
}
@@ -120,7 +118,6 @@ function buildForgeWebUrl(
forge: Forge,
input: {
remoteUrl: string | null | undefined;
forgeWeb: ForgeWeb | null | undefined;
branch: string | null | undefined;
path: string | null;
lineStart?: number;
@@ -132,7 +129,6 @@ function buildForgeWebUrl(
return (
presentation.buildBlobUrl?.({
remoteUrl: input.remoteUrl,
forgeWeb: input.forgeWeb,
branch: input.branch,
path: input.path,
lineStart: input.lineStart,
@@ -143,7 +139,6 @@ function buildForgeWebUrl(
return (
presentation.buildBranchTreeUrl?.({
remoteUrl: input.remoteUrl,
forgeWeb: input.forgeWeb,
branch: input.branch,
}) ?? null
);
@@ -164,7 +159,6 @@ function planForgeOpenTarget(input: {
}
const url = buildForgeWebUrl(forge, {
remoteUrl: input.checkoutStatus.remoteUrl,
forgeWeb: input.checkoutStatus.forgeWeb,
branch: input.checkoutStatus.currentBranch,
path: input.resolvedFile?.relativePath ?? null,
lineStart: input.activeFile?.lineStart,
+72
View File
@@ -117,6 +117,8 @@ import type {
RefreshProvidersSnapshotResponseMessage,
ProviderDiagnosticResponseMessage,
ProviderUsageListResponseMessage,
ProviderInstallationInspectResponseMessage,
ProviderInstallationCheckUpdatesResponseMessage,
UsageReportEntry,
DaemonGetStatusResponse,
DaemonGetPairingOfferResponse,
@@ -629,6 +631,13 @@ type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"
type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"];
type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"];
type ProviderUsageListPayload = ProviderUsageListResponseMessage["payload"];
type ProviderInstallationInspectPayload = ProviderInstallationInspectResponseMessage["payload"];
type ProviderInstallationCheckUpdatesPayload =
ProviderInstallationCheckUpdatesResponseMessage["payload"];
type ProviderInstallationUpdatePayload = Extract<
SessionOutboundMessage,
{ type: "provider.installation.update.response" }
>["payload"];
interface UsageListReportsPayload {
requestId: string;
reports: UsageReportEntry[];
@@ -6224,6 +6233,69 @@ export class DaemonClient {
});
}
/** Install and update links plus installed state. Requires `features.providerInstallation`. */
async inspectProviderInstallation(
provider: AgentProvider,
options?: { refresh?: boolean; requestId?: string },
): Promise<ProviderInstallationInspectPayload> {
return this.sendNamespacedCorrelatedSessionRequest<"provider.installation.inspect.response">({
requestId: options?.requestId,
message: {
type: "provider.installation.inspect.request",
provider,
...(options?.refresh ? { refresh: true } : {}),
},
timeout: 60_000,
});
}
/** Whether the provider reports a newer version. Requires `features.providerInstallation`. */
async checkProviderUpdates(
provider: AgentProvider,
options?: { refresh?: boolean; requestId?: string },
): Promise<ProviderInstallationCheckUpdatesPayload> {
return this.sendNamespacedCorrelatedSessionRequest<"provider.installation.check_updates.response">(
{
requestId: options?.requestId,
message: {
type: "provider.installation.check_updates.request",
provider,
...(options?.refresh ? { refresh: true } : {}),
},
timeout: 60_000,
},
);
}
/** Starts the provider's update, or joins the running one. Follow it with `observeProviderUpdates`. */
async updateProvider(
provider: AgentProvider,
options?: { requestId?: string },
): Promise<ProviderInstallationUpdatePayload> {
return this.sendNamespacedCorrelatedSessionRequest<"provider.installation.update.response">({
requestId: options?.requestId,
message: { type: "provider.installation.update.request", provider },
timeout: 60_000,
});
}
/**
* The provider's latest update, then `provider.installation.update.progress` as updates start,
* report output and settle. Survives reconnects: each new snapshot replaces the previous one.
*/
observeProviderUpdates(
provider: AgentProvider,
options?: { signal?: AbortSignal },
): OwnedSubscription<
CorrelatedResponsePayload<"provider.installation.update.subscribe.response">
> {
return this.observe(
"provider.installation.update.subscribe.response",
{ type: "provider.installation.update.subscribe.request", provider },
options,
);
}
async listProviderUsage(options?: { requestId?: string }): Promise<ProviderUsageListPayload> {
return this.sendNamespacedCorrelatedSessionRequest({
requestId: options?.requestId,
-5
View File
@@ -306,11 +306,6 @@ export interface PaseoAgentTimelineRefetchOptions {
cursor?: FetchAgentTimelineCursor;
limit?: number;
projection?: FetchAgentTimelineProjection;
/**
* Extends a `tail` or `before` page back to the start of the oldest turn it touches, so
* `{ direction: "tail", limit: 1, wholeTurns: true }` returns the latest turn.
*/
wholeTurns?: boolean;
requestId?: string;
}
+1 -11
View File
@@ -393,7 +393,6 @@ describe("lifecycle hook SDK types", () => {
expect(
typeErrors(`
import type { PluginServerContext, PluginHooks, PluginHookPayload, PluginHookResult } from "@getpaseo/plugin/server";
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
export default function contribute(server: PluginServerContext) {
server.addHook("worktree.create.before", async ({ request }) => {
if (request.kind === "checkout-branch") {
@@ -427,17 +426,8 @@ describe("lifecycle hook SDK types", () => {
const { metadata, ...withoutMetadata } = request;
return withoutMetadata;
});
server.addHook("agent.turn.after", async (event, { paseo, signal }) => {
const { agent, outcome } = event;
server.addHook("agent.turn.after", async ({ agent, outcome, timeline }, { paseo, signal }) => {
const kind: "completed" | "failed" | "canceled" = outcome.kind;
// @ts-expect-error Turn payloads carry no history; read it from the agent's timeline.
event.timeline;
const turn = await paseo.agents
.ref(agent.id)
.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
const items: readonly AgentTimelineItem[] = turn.entries
.filter((entry) => entry.turnId === event.turnId)
.map(({ item }) => item);
// @ts-expect-error Notification payloads have no creation request.
outcome.request;
});
-19
View File
@@ -121,22 +121,6 @@ export interface PluginSidebarViewContribution<Text extends PluginText = string>
Component: ComponentType<PluginSidebarViewProps>;
}
export interface PluginWorkspaceRowBadgeItemProps extends PluginHostProps {
workspaceId: string;
}
/**
* Part of the line under a workspace's title in the sidebar, on every workspace of the plugin's host.
* Render `WorkspaceRowBadge`, or nothing when there is nothing to say about this workspace.
*/
export interface PluginWorkspaceRowBadgeItemContribution<Text extends PluginText = string> {
id: string;
/** The item's toggle in the sidebar's Show menu. */
title: Text;
icon: PluginIcon;
Component: ComponentType<PluginWorkspaceRowBadgeItemProps>;
}
/**
* A Lucide icon name, inline SVG markup, or a component. Components receive the surface's size
* and color; buttons widen the props with their host and target context.
@@ -230,9 +214,6 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl
addSidebarHeaderItem(contribution: PluginSidebarItemContribution<PluginText>): PluginCleanup;
addSidebarFooterItem(contribution: PluginSidebarItemContribution<PluginText>): PluginCleanup;
addAgentPanel(contribution: PluginAgentPanelContribution<PluginText>): PluginCleanup;
addWorkspaceRowBadgeItem(
contribution: PluginWorkspaceRowBadgeItemContribution<PluginText>,
): PluginCleanup;
addWorkspacePanel(contribution: PluginWorkspacePanelContribution<PluginText>): PluginCleanup;
addCommandCenterItem(
contribution: PluginCommandCenterItemContribution<PluginText>,
-2
View File
@@ -18,8 +18,6 @@ export type {
PluginSidebarViewProps,
PluginSidebarViewContribution,
PluginSidebarItemContribution,
PluginWorkspaceRowBadgeItemProps,
PluginWorkspaceRowBadgeItemContribution,
PluginIcon,
PluginPanelLocation,
PluginOpenPanelOptions,
-13
View File
@@ -173,19 +173,6 @@ export declare const SidebarRow: ComponentType<SidebarRowProps>;
/** A line between groups of rows. Render it from a sidebar item's `Component`. */
export declare const SidebarSeparator: ComponentType;
export type WorkspaceRowBadgeTone = "muted" | "success" | "warning" | "danger" | "info";
export interface WorkspaceRowBadgeProps {
/** A Lucide icon name, inline SVG, or a component. */
icon?: SidebarIcon;
label?: string;
/** Default "muted". */
tone?: WorkspaceRowBadgeTone;
/** Defaults to `label`, then the item's `title`. */
accessibilityLabel?: string;
}
/** One item on a sidebar workspace row's meta line. Render it from a workspace row badge item's `Component`. */
export declare const WorkspaceRowBadge: ComponentType<WorkspaceRowBadgeProps>;
/** Paseo's loading state. Fills the panel; the panel decides when to show it. */
export declare const Loading: ComponentType;
+6 -6
View File
@@ -2,6 +2,7 @@ import type { AgentToolDefinition } from "./agent-tools.js";
import type {
AgentPermissionRequest,
AgentPermissionResponse,
AgentTimelineItem,
AgentSessionConfig,
JsonValue,
} from "@getpaseo/protocol/agent-types";
@@ -103,13 +104,12 @@ export interface PluginHookEvents {
"agent.turn.after": {
agent: PluginHookAgent;
turnId: string | null;
/**
* The payload carries no history. Read the turn with
* `paseo.agents.ref(agent.id).timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true })`
* and keep the entries whose `turnId` equals this `turnId`. The result is empty when a newer turn
* started before the read, or when `turnId` is `null`.
*/
outcome: PluginTurnOutcome;
/**
* @deprecated Always empty. Fetch history on demand with
* context.paseo.agents.ref(agent.id).timeline.refetch({ direction: "tail", limit: 20 }).
*/
timeline: readonly AgentTimelineItem[];
};
"agent.permission.before": { agent: PluginHookAgent; request: AgentPermissionRequest };
"agent.permission.after": {
+77
View File
@@ -39,9 +39,86 @@ export interface ProviderRegistration {
description?: string;
/** Plugin-directory-relative path to a self-contained SVG file. */
icon?: string;
/** How users install the provider's CLI and which version they run. Every member is optional. */
installation?: ProviderInstallation;
connect(request: ProviderConnectRequest): Promise<ProviderConnection>;
}
export interface ProviderInstallation {
/** http(s) page that explains how to install the provider. Paseo opens it; it never installs. */
installUrl?: string;
/** http(s) page that explains how to update the provider. */
updateUrl?: string;
/** Report whether the provider is installed and which version. Runs only when a user asks. */
inspect?(context: ProviderInstallationContext): Promise<ProviderInspection>;
/**
* Report whether a newer version is available. The provider decides how to find out and what
* counts as newer; Paseo shows the result as written. Return `unknown` when it cannot tell.
* Runs only when a user asks, never before a session.
*/
checkForUpdates?(context: ProviderInstallationContext): Promise<ProviderUpdateCheck>;
/**
* Update the provider, usually by running its CLI's own updater. Paseo shows everything passed
* to `report` while it runs, then the returned message. A throw is a failure with its message.
* Runs only when a user asks. Paseo sets no deadline; `signal` aborts when the daemon stops.
*/
update?(context: ProviderUpdateContext): Promise<ProviderUpdateResult>;
}
export interface ProviderUpdateContext extends ProviderInstallationContext {
/** Shows output to the user as it happens, unchanged and in order. */
report(event: ProviderUpdateReport): void;
}
export const ProviderUpdateReportSchema = z
.object({ type: z.enum(["stdout", "stderr", "message"]), text: z.string() })
.strip();
export type ProviderUpdateReport = z.infer<typeof ProviderUpdateReportSchema>;
export const ProviderUpdateResultSchema = z.discriminatedUnion("status", [
/** `message` is shown as written, such as which version is now installed. */
z.object({ status: z.literal("succeeded"), message: z.string().optional() }).strip(),
z.object({ status: z.literal("failed"), message: z.string() }).strip(),
]);
export type ProviderUpdateResult = z.infer<typeof ProviderUpdateResultSchema>;
export interface ProviderInstallationContext {
/** The launch a session would use. Absent when the provider declares no `command`. */
launch?: ProviderInstallationLaunch;
/** Aborted when Paseo stops waiting for the result. */
signal: AbortSignal;
}
export interface ProviderInstallationLaunch extends ProviderLaunch {
/** False when the executable was not found; `command` is then the configured command as written. */
resolved: boolean;
}
export const ProviderInspectionSchema = z.discriminatedUnion("status", [
/** `version` is shown as written; Paseo does not parse or compare it. */
z.object({ status: z.literal("installed"), version: z.string().optional() }).strip(),
z.object({ status: z.literal("not_installed") }).strip(),
]);
export type ProviderInspection = z.infer<typeof ProviderInspectionSchema>;
export const ProviderUpdateCheckSchema = z.discriminatedUnion("status", [
z.object({ status: z.literal("current") }).strip(),
/** `versions` and `message` are shown as written; Paseo does not parse, sort or compare them. */
z
.object({
status: z.literal("available"),
versions: z.array(z.string()).optional(),
message: z.string().optional(),
})
.strip(),
z.object({ status: z.literal("unknown"), message: z.string().optional() }).strip(),
]);
export type ProviderUpdateCheck = z.infer<typeof ProviderUpdateCheckSchema>;
export type ProviderCatalogOptions =
| { scope: "global"; force?: boolean; launch?: ProviderLaunch }
| { scope: "workspace"; cwd: string; force?: boolean; launch?: ProviderLaunch };
-20
View File
@@ -108,26 +108,6 @@ export function normalizeHost(host: string): string {
return host.trim().replace(/\.+$/u, "").toLowerCase();
}
/** Whether `host` is one of the forge's public cloud hosts, SSH aliases included. */
export function isForgeCloudHost(forge: string, host: string): boolean {
const cloudHosts = (getForgeDefinition(forge)?.cloudHosts ?? []).map(normalizeHost);
return cloudHosts.includes(normalizeHost(host));
}
/**
* Web host for a remote host of `forge`. A cloud SSH alias (a non-first entry
* in the forge's `cloudHosts`, e.g. ssh.github.com) maps to the forge's web
* host (`cloudHosts[0]`); any other host, self-hosted included, is returned
* as is.
*/
export function forgeWebHost(forge: string, host: string): string {
const normalized = normalizeHost(host);
if (!isForgeCloudHost(forge, normalized)) {
return normalized;
}
return normalizeHost(getForgeDefinition(forge)?.cloudHosts?.[0] ?? normalized);
}
function normalizeRemotePath(path: string): string | null {
let normalized = path.trim().replace(/^\/+|\/+$/gu, "");
if (normalized.endsWith(".git")) normalized = normalized.slice(0, -4);
+182 -19
View File
@@ -1958,6 +1958,34 @@ export const ProviderDiagnosticRequestMessageSchema = z.object({
requestId: z.string(),
});
export const ProviderInstallationInspectRequestMessageSchema = z.object({
type: z.literal("provider.installation.inspect.request"),
provider: AgentProviderSchema,
/** Skip the daemon's short result cache and ask the provider again. */
refresh: z.boolean().optional(),
requestId: z.string(),
});
export const ProviderInstallationCheckUpdatesRequestMessageSchema = z.object({
type: z.literal("provider.installation.check_updates.request"),
provider: AgentProviderSchema,
/** Skip the daemon's update-check cache and ask the provider again. */
refresh: z.boolean().optional(),
requestId: z.string(),
});
export const ProviderInstallationUpdateRequestMessageSchema = z.object({
type: z.literal("provider.installation.update.request"),
provider: AgentProviderSchema,
requestId: z.string(),
});
export const ProviderInstallationUpdateSubscribeRequestMessageSchema = z.object({
type: z.literal("provider.installation.update.subscribe.request"),
provider: AgentProviderSchema,
requestId: z.string(),
});
export const ProviderUsageListRequestMessageSchema = z.object({
type: z.literal("provider.usage.list.request"),
requestId: z.string(),
@@ -3592,6 +3620,10 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
GetProvidersSnapshotRequestMessageSchema,
RefreshProvidersSnapshotRequestMessageSchema,
ProviderDiagnosticRequestMessageSchema,
ProviderInstallationInspectRequestMessageSchema,
ProviderInstallationCheckUpdatesRequestMessageSchema,
ProviderInstallationUpdateRequestMessageSchema,
ProviderInstallationUpdateSubscribeRequestMessageSchema,
ProviderUsageListRequestMessageSchema,
UsageListReportsRequestMessageSchema,
ResumeAgentRequestMessageSchema,
@@ -3918,6 +3950,8 @@ export const ServerInfoStatusPayloadSchema = z
// COMPAT(usageSetting): added in v0.14, remove after 2027-05-01.
usageSetting: z.boolean().optional(),
usageSources: z.boolean().optional(),
// COMPAT(providerInstallation): added in v0.17.0, remove gate after 2027-05-13.
providerInstallation: z.boolean().optional(),
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
providersSnapshotCwd: z.boolean().optional(),
// COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12.
@@ -4328,22 +4362,6 @@ export const WorkspaceScriptPayloadSchema = z.object({
terminalId: z.string().nullable().optional().default(null),
});
/**
* Where a workspace's forge is on the web, resolved by the daemon so every
* client builds the same links.
*/
export const ForgeWebSchema = z.object({
/** Web host of the forge: the SSH config alias resolved, cloud SSH hosts mapped to their web host. */
host: z.string(),
/**
* Repository path that owns issue and change-request numbers when it is not
* the origin repository, such as a fork's parent. Absent: the origin repository.
*/
referenceRepository: z.string().optional(),
});
export type ForgeWeb = z.infer<typeof ForgeWebSchema>;
const WorkspaceGitRuntimePayloadSchema = z
.object({
currentBranch: z.string().nullable().optional(),
@@ -4480,8 +4498,6 @@ export const WorkspaceDescriptorPayloadSchema = z
// until 2027-01-17; remove the consumer fallback once the supported daemon
// floor is >= v0.2.0.
forge: z.string().optional(),
// Absent from daemons older than v0.16.0; clients then derive links from the origin remote.
forgeWeb: ForgeWebSchema.optional(),
project: ProjectPlacementPayloadSchema.optional(),
// COMPAT(directorySync): sequence of this latest directory projection.
syncSeq: z.number().int().positive().optional(),
@@ -5715,7 +5731,6 @@ const CheckoutStatusCommonSchema = z.object({
// upstream branch name is not necessarily currentBranch, so composing one is wrong.
// aheadOfOrigin/behindOfOrigin are measured against exactly this ref.
upstreamRef: z.string().nullable().optional(),
forgeWeb: ForgeWebSchema.optional(),
});
const CheckoutStatusNotGitSchema = CheckoutStatusCommonSchema.extend({
@@ -6742,6 +6757,129 @@ export const ProviderDiagnosticResponseMessageSchema = z.object({
}),
});
/** Provider-owned facts; `version` is an opaque string the app shows as written. */
export const ProviderInstallationInspectionSchema = z.discriminatedUnion("status", [
z.object({ status: z.literal("installed"), version: z.string().optional() }),
z.object({ status: z.literal("not_installed") }),
z.object({ status: z.literal("error"), message: z.string() }),
]);
export const ProviderInstallationSchema = z.object({
installUrl: z.string().optional(),
updateUrl: z.string().optional(),
/** Absent when the provider does not report its installation. */
inspection: ProviderInstallationInspectionSchema.optional(),
/** True when the provider can run its own update through `provider.installation.update`. */
canUpdate: z.boolean().optional(),
});
export const ProviderInstallationInspectResponseMessageSchema = z.object({
type: z.literal("provider.installation.inspect.response"),
payload: z.object({
provider: AgentProviderSchema,
/** Null when the provider declares no installation contract. */
installation: ProviderInstallationSchema.nullable(),
error: z.string().nullable(),
requestId: z.string(),
}),
});
/**
* The provider's answer to "is a newer version available". `versions` and `message` are opaque
* strings the app shows as written. `error` means the check threw or missed its deadline.
*/
export const ProviderInstallationUpdateCheckSchema = z.discriminatedUnion("status", [
z.object({ status: z.literal("current") }),
z.object({
status: z.literal("available"),
versions: z.array(z.string()).optional(),
message: z.string().optional(),
}),
z.object({ status: z.literal("unknown"), message: z.string().optional() }),
z.object({ status: z.literal("error"), message: z.string() }),
]);
export const ProviderInstallationCheckUpdatesResponseMessageSchema = z.object({
type: z.literal("provider.installation.check_updates.response"),
payload: z.object({
provider: AgentProviderSchema,
/** Null when the provider cannot check for updates. */
updateCheck: ProviderInstallationUpdateCheckSchema.nullable(),
error: z.string().nullable(),
requestId: z.string(),
}),
});
/** One piece of updater output, shown unchanged and in order. */
export const ProviderInstallationUpdateOutputSchema = z.object({
type: z.enum(["stdout", "stderr", "message"]),
text: z.string(),
});
/** The state of one provider update. Messages are the provider's, shown as written. */
export const ProviderInstallationUpdateOperationSchema = z.discriminatedUnion("status", [
z.object({ status: z.literal("running"), id: z.string(), startedAt: z.string() }),
z.object({
status: z.literal("succeeded"),
id: z.string(),
startedAt: z.string(),
finishedAt: z.string(),
message: z.string().optional(),
}),
z.object({
status: z.literal("failed"),
id: z.string(),
startedAt: z.string(),
finishedAt: z.string(),
message: z.string(),
}),
]);
/** The latest update of a provider since the daemon started, with its retained output. */
export const ProviderInstallationUpdateSchema = z.object({
operation: ProviderInstallationUpdateOperationSchema,
output: z.array(ProviderInstallationUpdateOutputSchema),
/** True when the oldest output was dropped to stay within the daemon's output limit. */
truncated: z.boolean().optional(),
});
export const ProviderInstallationUpdateResponseMessageSchema = z.object({
type: z.literal("provider.installation.update.response"),
payload: z.object({
provider: AgentProviderSchema,
/** The update this request started, or the one already running. */
update: ProviderInstallationUpdateSchema.nullable(),
error: z.string().nullable(),
requestId: z.string(),
}),
});
export const ProviderInstallationUpdateSubscribeResponseMessageSchema = z.object({
type: z.literal("provider.installation.update.subscribe.response"),
payload: z.object({
provider: AgentProviderSchema,
subscriptionId: z.string().optional(),
/** Null when the provider has not been updated since the daemon started. */
update: ProviderInstallationUpdateSchema.nullable(),
error: z.string().nullable(),
requestId: z.string(),
}),
});
/**
* Sent to `provider.installation.update.subscribe` owners when an update starts, reports output
* or finishes. `output` holds only the entries new since the previous progress for `operation.id`.
*/
export const ProviderInstallationUpdateProgressMessageSchema = z.object({
type: z.literal("provider.installation.update.progress"),
payload: z.object({
subscriptionId: z.string().optional(),
provider: AgentProviderSchema,
operation: ProviderInstallationUpdateOperationSchema,
output: z.array(ProviderInstallationUpdateOutputSchema),
}),
});
export const ProviderUsageToneSchema = z.enum(["default", "ok", "warning", "danger"]);
export const ProviderUsageStatusSchema = z.enum(["available", "unavailable", "error"]);
@@ -7595,6 +7733,11 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ProvidersSnapshotUpdateMessageSchema,
RefreshProvidersSnapshotResponseMessageSchema,
ProviderDiagnosticResponseMessageSchema,
ProviderInstallationInspectResponseMessageSchema,
ProviderInstallationCheckUpdatesResponseMessageSchema,
ProviderInstallationUpdateResponseMessageSchema,
ProviderInstallationUpdateSubscribeResponseMessageSchema,
ProviderInstallationUpdateProgressMessageSchema,
ProviderUsageListResponseMessageSchema,
UsageListReportsUpdateMessageSchema,
UsageListReportsResponseMessageSchema,
@@ -7779,6 +7922,26 @@ export type RefreshProvidersSnapshotResponseMessage = z.infer<
export type ProviderDiagnosticResponseMessage = z.infer<
typeof ProviderDiagnosticResponseMessageSchema
>;
export type ProviderInstallation = z.infer<typeof ProviderInstallationSchema>;
export type ProviderInstallationInspection = z.infer<typeof ProviderInstallationInspectionSchema>;
export type ProviderInstallationInspectResponseMessage = z.infer<
typeof ProviderInstallationInspectResponseMessageSchema
>;
export type ProviderInstallationUpdateCheck = z.infer<typeof ProviderInstallationUpdateCheckSchema>;
export type ProviderInstallationCheckUpdatesResponseMessage = z.infer<
typeof ProviderInstallationCheckUpdatesResponseMessageSchema
>;
export type ProviderInstallationUpdateOutput = z.infer<
typeof ProviderInstallationUpdateOutputSchema
>;
export type ProviderInstallationUpdateOperation = z.infer<
typeof ProviderInstallationUpdateOperationSchema
>;
export type ProviderInstallationUpdate = z.infer<typeof ProviderInstallationUpdateSchema>;
export type ProviderInstallationUpdateProgress = Omit<
z.infer<typeof ProviderInstallationUpdateProgressMessageSchema>["payload"],
"subscriptionId"
>;
export type ProviderUsageTone = z.infer<typeof ProviderUsageToneSchema>;
export type UsageProblem = z.infer<typeof UsageProblemSchema>;
export type UsageReport = z.infer<typeof UsageReportSchema>;
@@ -1251,15 +1251,6 @@ describe("workspace message schemas", () => {
expect(
WorkspaceDescriptorPayloadSchema.parse({ ...baseWorkspace, forge: "gitlab" }).forge,
).toBe("gitlab");
// forgeWeb is optional: an old daemon omits it, a new daemon's value round-trips.
expect(WorkspaceDescriptorPayloadSchema.parse(baseWorkspace).forgeWeb).toBeUndefined();
expect(
WorkspaceDescriptorPayloadSchema.parse({
...baseWorkspace,
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
}).forgeWeb,
).toEqual({ host: "github.com", referenceRepository: "main-owner/repo" });
});
test("workspace.create.request rejects old flat backing shape and accepts new source envelope", () => {
@@ -13334,6 +13334,7 @@ test.each([
},
turnId: "hook-turn",
outcome,
timeline: [],
},
},
]);
@@ -10,6 +10,12 @@ import type {
} from "@getpaseo/protocol/agent-types";
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type {
ProviderInspection,
ProviderUpdateCheck,
ProviderUpdateReport,
ProviderUpdateResult,
} from "@getpaseo/plugin/server/provider";
import type { PaseoToolCatalog } from "./tools/types.js";
export type {
@@ -804,6 +810,24 @@ export interface AgentDraftControls {
options: ProviderOptions;
}
export interface AgentClientInstallationContext {
/** Aborted when Paseo stops waiting for the result. */
signal: AbortSignal;
}
export interface AgentClientInstallation {
installUrl?: string;
updateUrl?: string;
inspect?(context: AgentClientInstallationContext): Promise<ProviderInspection>;
checkForUpdates?(context: AgentClientInstallationContext): Promise<ProviderUpdateCheck>;
/** Runs the provider's updater. See `ProviderInstallation.update` in the plugin contract. */
update?(context: AgentClientUpdateContext): Promise<ProviderUpdateResult>;
}
export interface AgentClientUpdateContext extends AgentClientInstallationContext {
report(event: ProviderUpdateReport): void;
}
export interface AgentClient {
readonly provider: AgentProvider;
readonly capabilities: AgentCapabilityFlags;
@@ -867,6 +891,12 @@ export interface AgentClient {
*/
isAvailable(signal?: AbortSignal, options?: FetchCatalogOptions): Promise<boolean>;
getDiagnostic?(): Promise<{ diagnostic: string }>;
/**
* Installation links and the installed version. The same contract as plugin providers'
* `ProviderRegistration.installation`; built-ins resolve their own launch inside `inspect`.
* Never consulted by availability, discovery, or session creation.
*/
readonly installation?: AgentClientInstallation;
/**
* Archive a durable native session (best-effort). Runtime release belongs to AgentSession.close().
* Called when Paseo archives an agent so the provider's own UI reflects the same state.
@@ -12,6 +12,10 @@ import {
ProviderInputSchema,
ProviderStatusSchema,
type ProviderStatus,
type ProviderInspection,
type ProviderUpdateCheck,
type ProviderUpdateResult,
type ProviderInstallationLaunch,
type ProviderLaunch,
requireProviderCapabilities,
type ProviderCapability,
@@ -31,6 +35,8 @@ import {
import type {
AgentCapabilityFlags,
AgentClient,
AgentClientInstallation,
AgentClientUpdateContext,
AgentCreateSessionOptions,
AgentLaunchContext,
AgentMode,
@@ -156,7 +162,8 @@ class ProviderRuntime {
private readonly runtimeSettings?: ProviderRuntimeSettings,
) {}
private async resolveLaunch(): Promise<ProviderLaunch | undefined> {
/** The effective launch, including when its executable does not resolve. */
private async resolveEffectiveLaunch(): Promise<ProviderInstallationLaunch | undefined> {
const command = this.registration.command;
if (!command) return undefined;
const launch = await resolveProviderLaunch({
@@ -167,8 +174,45 @@ class ProviderRuntime {
const env = createProviderEnv({ runtimeSettings: this.runtimeSettings });
launch.env = env;
const availability = await checkProviderLaunchAvailable(launch);
if (!availability.resolvedPath) throw new Error(`${launch.command} not found on PATH`);
return { command: resolvePath(availability.resolvedPath), args: launch.args, env };
if (!availability.resolvedPath)
return { command: launch.command, args: launch.args, env, resolved: false };
return {
command: resolvePath(availability.resolvedPath),
args: launch.args,
env,
resolved: true,
};
}
private async resolveLaunch(): Promise<ProviderLaunch | undefined> {
const launch = await this.resolveEffectiveLaunch();
if (!launch) return undefined;
if (!launch.resolved) throw new Error(`${launch.command} not found on PATH`);
return { command: launch.command, args: launch.args, env: launch.env };
}
async inspectInstallation(signal: AbortSignal): Promise<ProviderInspection> {
const inspect = this.registration.installation?.inspect;
if (!inspect) throw new Error("Provider does not report its installation");
const launch = await this.resolveEffectiveLaunch();
signal.throwIfAborted();
return await inspect.call(this.registration.installation, { launch, signal });
}
async checkForUpdates(signal: AbortSignal): Promise<ProviderUpdateCheck> {
const checkForUpdates = this.registration.installation?.checkForUpdates;
if (!checkForUpdates) throw new Error("Provider does not check for updates");
const launch = await this.resolveEffectiveLaunch();
signal.throwIfAborted();
return await checkForUpdates.call(this.registration.installation, { launch, signal });
}
async update(context: AgentClientUpdateContext): Promise<ProviderUpdateResult> {
const update = this.registration.installation?.update;
if (!update) throw new Error("Provider does not update itself");
const launch = await this.resolveEffectiveLaunch();
context.signal.throwIfAborted();
return await update.call(this.registration.installation, { ...context, launch });
}
async status(): Promise<ProviderStatus> {
@@ -961,12 +1005,26 @@ class PluginAgentClient implements AgentClient {
private readonly pendingChildren: PendingChild[] = [];
readonly getCatalogCacheKey?: AgentClient["getCatalogCacheKey"];
readonly installation?: AgentClientInstallation;
constructor(registration: ProviderRegistration, runtimeSettings?: ProviderRuntimeSettings) {
this.provider = registration.id;
this.runtime = new ProviderRuntime(registration, runtimeSettings);
if (registration.getCatalogCacheKey)
this.getCatalogCacheKey = (options) => this.runtime.catalogCacheKey(options);
const installation = registration.installation;
if (installation)
this.installation = {
installUrl: installation.installUrl,
updateUrl: installation.updateUrl,
inspect: installation.inspect
? ({ signal }) => this.runtime.inspectInstallation(signal)
: undefined,
checkForUpdates: installation.checkForUpdates
? ({ signal }) => this.runtime.checkForUpdates(signal)
: undefined,
update: installation.update ? (context) => this.runtime.update(context) : undefined,
};
this.runtime.onSessionOpened((session, opened) => this.acceptChild(session, opened));
}
@@ -0,0 +1,850 @@
import type {
ProviderEvent,
ProviderInstallation,
ProviderInstallationContext,
ProviderRegistration,
ProviderUpdateResult,
} from "@getpaseo/plugin/server/provider";
import type {
ProviderInstallationUpdate,
ProviderInstallationUpdateProgress,
} from "@getpaseo/protocol/messages";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import type { AgentClient, AgentClientInstallation } from "../agent-sdk-types.js";
import { ProviderSnapshotManager } from "../provider-snapshot-manager.js";
import { ProviderInstallations } from "./provider-installations.js";
const OPAQUE_VERSION = " v1.2.3-beta+weird (build 7)\n";
const MISSING_COMMAND = "paseo-provider-installation-missing-binary";
const OPAQUE_VERSIONS = ["v9-nightly+abc", " 2.0 (LTS)\n"];
const OPAQUE_MESSAGE = " Restart to finish updating\n";
function pluginProvider(
installation: ProviderInstallation | undefined,
command: readonly [string, ...string[]] = [process.execPath],
): ProviderRegistration {
return {
id: "installation-plugin",
label: "Installation plugin",
command,
installation,
async connect() {
let listener: ((event: ProviderEvent) => void) | undefined;
return {
version: 1,
capabilities: [],
async send(input) {
if (input.type === "catalog")
listener?.({
type: "catalog",
requestId: input.requestId,
catalog: { models: [{ id: "model", label: "Model" }], modes: [] },
});
},
onEvent(next) {
listener = next;
return () => {
listener = undefined;
};
},
async close() {},
};
},
};
}
function builtinClient(installation: AgentClientInstallation | undefined): AgentClient {
return {
provider: "codex",
capabilities: {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
},
installation,
async createSession() {
throw new Error("not opened by this test");
},
async resumeSession() {
throw new Error("not opened by this test");
},
async fetchCatalog() {
return { models: [], modes: [] };
},
async isAvailable() {
return true;
},
};
}
function managerWith(input: { plugin?: ProviderRegistration; codex?: AgentClient }) {
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
extraClients: input.codex ? { codex: input.codex } : {},
});
if (input.plugin) manager.replacePluginProviders([input.plugin]);
return manager;
}
describe("provider installation", () => {
test("exposes links and lets the plugin report not installed when its executable is missing", async () => {
const contexts: ProviderInstallationContext[] = [];
const manager = managerWith({
plugin: pluginProvider(
{
installUrl: "https://example.com/install",
updateUrl: "https://example.com/update",
async inspect(context) {
contexts.push(context);
return context.launch?.resolved ? { status: "installed" } : { status: "not_installed" };
},
},
[MISSING_COMMAND, "serve"],
),
});
try {
await expect(manager.installations.inspect("installation-plugin")).resolves.toEqual({
installUrl: "https://example.com/install",
updateUrl: "https://example.com/update",
inspection: { status: "not_installed" },
});
expect(contexts[0]?.launch).toEqual({
command: MISSING_COMMAND,
args: ["serve"],
env: expect.objectContaining({ PATH: expect.any(String) }),
resolved: false,
});
} finally {
manager.destroy();
}
});
test.each([
{
name: "plugin",
manager: () =>
managerWith({
plugin: pluginProvider({
installUrl: "https://example.com/install",
async inspect({ launch }) {
if (!launch?.resolved || launch.command !== process.execPath)
throw new Error("Unexpected launch");
return { status: "installed", version: OPAQUE_VERSION };
},
}),
}),
provider: "installation-plugin",
},
{
name: "built-in",
manager: () =>
managerWith({
codex: builtinClient({
installUrl: "https://example.com/install",
async inspect() {
return { status: "installed", version: OPAQUE_VERSION };
},
}),
}),
provider: "codex",
},
])("returns a $name provider's version string unchanged", async ({ manager, provider }) => {
const providers = manager();
try {
await expect(providers.installations.inspect(provider)).resolves.toEqual({
installUrl: "https://example.com/install",
inspection: { status: "installed", version: OPAQUE_VERSION },
});
} finally {
providers.destroy();
}
});
test("keeps links and reports the failure when inspect throws", async () => {
const manager = managerWith({
plugin: pluginProvider({
installUrl: "https://example.com/install",
async inspect() {
throw new Error("version probe exploded");
},
}),
});
try {
await expect(manager.installations.inspect("installation-plugin")).resolves.toEqual({
installUrl: "https://example.com/install",
inspection: { status: "error", message: "version probe exploded" },
});
} finally {
manager.destroy();
}
});
test("returns null for a provider without an installation contract", async () => {
const manager = managerWith({ plugin: pluginProvider(undefined) });
try {
await expect(manager.installations.inspect("installation-plugin")).resolves.toBeNull();
await expect(manager.installations.inspect("not-a-provider")).rejects.toThrow(
"Provider not-a-provider is not configured",
);
} finally {
manager.destroy();
}
});
test("availability and discovery never wait on inspect", async () => {
let inspectCalls = 0;
const manager = managerWith({
plugin: pluginProvider({
inspect() {
inspectCalls += 1;
return new Promise(() => {});
},
}),
});
try {
void manager.installations.inspect("installation-plugin");
const entry = await manager.getProvider({ provider: "installation-plugin", wait: true });
expect(entry.status).toBe("ready");
expect(inspectCalls).toBe(1);
} finally {
manager.destroy();
}
});
test.each([
{
name: "plugin",
manager: () =>
managerWith({
plugin: pluginProvider({
async checkForUpdates({ launch }) {
if (!launch?.resolved) throw new Error("Unexpected launch");
return { status: "available", versions: OPAQUE_VERSIONS, message: OPAQUE_MESSAGE };
},
}),
}),
provider: "installation-plugin",
},
{
name: "built-in",
manager: () =>
managerWith({
codex: builtinClient({
async checkForUpdates() {
return { status: "available", versions: OPAQUE_VERSIONS, message: OPAQUE_MESSAGE };
},
}),
}),
provider: "codex",
},
])("returns a $name provider's available versions unchanged", async ({ manager, provider }) => {
const providers = manager();
try {
await expect(providers.installations.checkForUpdates(provider)).resolves.toEqual({
status: "available",
versions: OPAQUE_VERSIONS,
message: OPAQUE_MESSAGE,
});
} finally {
providers.destroy();
}
});
test.each([
{
name: "returns unknown",
checkForUpdates: async () => ({ status: "unknown" as const, message: "Offline" }),
expected: { status: "unknown", message: "Offline" },
},
{
name: "throws",
checkForUpdates: async (): Promise<never> => {
throw new Error("release feed exploded");
},
expected: { status: "error", message: "release feed exploded" },
},
{
name: "breaks the contract",
checkForUpdates: async () => ({ status: "up_to_date" }) as never,
expected: { status: "error", message: expect.stringContaining("status") },
},
])("reports an inconclusive check when the provider $name", async (input) => {
const manager = managerWith({
plugin: pluginProvider({ checkForUpdates: input.checkForUpdates }),
});
try {
await expect(manager.installations.checkForUpdates("installation-plugin")).resolves.toEqual(
input.expected,
);
} finally {
manager.destroy();
}
});
test("returns null when the provider cannot check for updates", async () => {
const manager = managerWith({
plugin: pluginProvider({ installUrl: "https://example.com/install" }),
});
try {
await expect(
manager.installations.checkForUpdates("installation-plugin"),
).resolves.toBeNull();
await expect(manager.installations.checkForUpdates("not-a-provider")).rejects.toThrow(
"Provider not-a-provider is not configured",
);
} finally {
manager.destroy();
}
});
test("availability, discovery and the installed version never wait on an update check", async () => {
let checks = 0;
const manager = managerWith({
plugin: pluginProvider({
async inspect() {
return { status: "installed", version: "1.0.0" };
},
checkForUpdates() {
checks += 1;
return new Promise(() => {});
},
}),
});
try {
void manager.installations.checkForUpdates("installation-plugin");
const entry = await manager.getProvider({ provider: "installation-plugin", wait: true });
expect(entry.status).toBe("ready");
await expect(manager.installations.inspect("installation-plugin")).resolves.toEqual({
inspection: { status: "installed", version: "1.0.0" },
});
expect(checks).toBe(1);
} finally {
manager.destroy();
}
});
});
describe("ProviderInstallations", () => {
function counting(
inspect: NonNullable<AgentClientInstallation["inspect"]>,
options: { now?: () => number; inspectTimeoutMs?: number } = {},
) {
let calls = 0;
const client = builtinClient({
inspect(context) {
calls += 1;
return inspect(context);
},
});
const installations = new ProviderInstallations({
logger: createTestLogger(),
resolveClient: (provider) => (provider === "codex" ? client : null),
...options,
});
return { installations, calls: () => calls };
}
test("shares concurrent reads, caches briefly, and refresh asks again", async () => {
let time = 0;
const { installations, calls } = counting(
async () => ({ status: "installed", version: `v${time}` }),
{ now: () => time },
);
const [first, second] = await Promise.all([
installations.inspect("codex"),
installations.inspect("codex"),
]);
expect(first).toEqual(second);
expect(calls()).toBe(1);
time = 1_000;
await expect(installations.inspect("codex")).resolves.toEqual({
inspection: { status: "installed", version: "v0" },
});
expect(calls()).toBe(1);
await expect(installations.inspect("codex", { refresh: true })).resolves.toEqual({
inspection: { status: "installed", version: "v1000" },
});
expect(calls()).toBe(2);
time = 1_000_000;
await installations.inspect("codex");
expect(calls()).toBe(3);
});
function countingUpdates(
checkForUpdates: NonNullable<AgentClientInstallation["checkForUpdates"]>,
options: { now?: () => number; updateCheckTimeoutMs?: number } = {},
) {
let calls = 0;
const client = builtinClient({
checkForUpdates(context) {
calls += 1;
return checkForUpdates(context);
},
});
const installations = new ProviderInstallations({
logger: createTestLogger(),
resolveClient: (provider) => (provider === "codex" ? client : null),
updateCheckCacheMs: 3_600_000,
inspectCacheMs: 30_000,
...options,
});
return { installations, calls: () => calls };
}
test("shares concurrent update checks, holds an answer for the long cache, and refresh asks again", async () => {
let time = 0;
const { installations, calls } = countingUpdates(
async () => ({ status: "available", versions: [`v${time}`] }),
{ now: () => time },
);
const [first, second] = await Promise.all([
installations.checkForUpdates("codex"),
installations.checkForUpdates("codex"),
]);
expect(first).toEqual(second);
expect(calls()).toBe(1);
time = 3_000_000;
await expect(installations.checkForUpdates("codex")).resolves.toEqual({
status: "available",
versions: ["v0"],
});
expect(calls()).toBe(1);
await expect(installations.checkForUpdates("codex", { refresh: true })).resolves.toEqual({
status: "available",
versions: ["v3000000"],
});
expect(calls()).toBe(2);
time = 3_000_000 + 3_600_000;
await installations.checkForUpdates("codex");
expect(calls()).toBe(3);
});
test("retries an inconclusive update check after the short cache", async () => {
let time = 0;
const { installations, calls } = countingUpdates(async () => ({ status: "unknown" }), {
now: () => time,
});
await installations.checkForUpdates("codex");
time = 29_000;
await installations.checkForUpdates("codex");
expect(calls()).toBe(1);
time = 31_000;
await installations.checkForUpdates("codex");
expect(calls()).toBe(2);
});
test("keeps update checks and inspection in separate caches", async () => {
const client = builtinClient({
async inspect() {
return { status: "installed", version: "1.0.0" };
},
async checkForUpdates() {
return { status: "current" };
},
});
const installations = new ProviderInstallations({
logger: createTestLogger(),
resolveClient: () => client,
});
await expect(installations.checkForUpdates("codex")).resolves.toEqual({ status: "current" });
await expect(installations.inspect("codex")).resolves.toEqual({
inspection: { status: "installed", version: "1.0.0" },
});
});
test("asks for updates again once the installed version changes", async () => {
let time = 0;
let installed = "1.0.0";
let checks = 0;
const client = builtinClient({
async inspect() {
// The app sends inspect and check together; the check must not answer first.
await new Promise((resolve) => setTimeout(resolve, 20));
return { status: "installed", version: installed };
},
async checkForUpdates() {
checks += 1;
return installed === "1.0.0"
? { status: "available", versions: ["2.0.0"] }
: { status: "current" };
},
});
const installations = new ProviderInstallations({
logger: createTestLogger(),
resolveClient: () => client,
inspectCacheMs: 30_000,
updateCheckCacheMs: 3_600_000,
now: () => time,
});
const together = () =>
Promise.all([installations.inspect("codex"), installations.checkForUpdates("codex")]);
await expect(together()).resolves.toEqual([
{ inspection: { status: "installed", version: "1.0.0" } },
{ status: "available", versions: ["2.0.0"] },
]);
// The CLI updated itself outside Paseo.
installed = "2.0.0";
time = 31_000;
await expect(together()).resolves.toEqual([
{ inspection: { status: "installed", version: "2.0.0" } },
{ status: "current" },
]);
expect(checks).toBe(2);
// Two clients reopening together after another change share one new check.
installed = "3.0.0";
time = 93_000;
await Promise.all([together(), together()]);
expect(checks).toBe(3);
// The same version again keeps the long-cached answer.
time = 124_000;
await together();
expect(checks).toBe(3);
});
test("an update check never starts an inspection or waits on one to ask the provider", async () => {
let inspections = 0;
let finishInspection: () => void = () => {};
const client = builtinClient({
async inspect() {
inspections += 1;
await new Promise<void>((resolve) => {
finishInspection = resolve;
});
return { status: "installed", version: "1.0.0" };
},
async checkForUpdates() {
return { status: "current" };
},
});
const installations = new ProviderInstallations({
logger: createTestLogger(),
resolveClient: () => client,
});
await expect(installations.checkForUpdates("codex")).resolves.toEqual({ status: "current" });
expect(inspections).toBe(0);
const inspection = installations.inspect("codex");
await expect(installations.checkForUpdates("codex", { refresh: true })).resolves.toEqual({
status: "current",
});
finishInspection();
await inspection;
});
test("aborts a slow update check and reports it as an error", async () => {
let aborted = false;
function waitForAbort(signal: AbortSignal) {
return new Promise<never>((_resolve, reject) => {
signal.addEventListener("abort", () => {
aborted = true;
reject(new Error("aborted"));
});
});
}
const { installations } = countingUpdates(({ signal }) => waitForAbort(signal), {
updateCheckTimeoutMs: 10,
});
await expect(installations.checkForUpdates("codex")).resolves.toEqual({
status: "error",
message: "Timed out after 10ms",
});
expect(aborted).toBe(true);
});
test("aborts a slow inspect and reports it as an error", async () => {
let aborted = false;
function waitForAbort(signal: AbortSignal) {
return new Promise<never>((_resolve, reject) => {
signal.addEventListener("abort", () => {
aborted = true;
reject(new Error("aborted"));
});
});
}
const { installations } = counting(({ signal }) => waitForAbort(signal), {
inspectTimeoutMs: 10,
});
await expect(installations.inspect("codex")).resolves.toEqual({
inspection: { status: "error", message: "Timed out after 10ms" },
});
expect(aborted).toBe(true);
});
});
describe("provider updates", () => {
function updating(
update: NonNullable<AgentClientInstallation["update"]>,
options: {
inspect?: AgentClientInstallation["inspect"];
refreshProvider?: (provider: string) => Promise<void>;
updateOutputLimit?: number;
} = {},
) {
let calls = 0;
const client = builtinClient({
updateUrl: "https://example.com/update",
inspect: options.inspect,
update(context) {
calls += 1;
return update(context);
},
});
const installations = new ProviderInstallations({
logger: createTestLogger(),
resolveClient: (provider) => (provider === "codex" ? client : null),
refreshProvider: options.refreshProvider,
updateOutputLimit: options.updateOutputLimit,
});
const progress: ProviderInstallationUpdateProgress[] = [];
installations.observeUpdates("codex", (event) => progress.push(event));
return { installations, progress, calls: () => calls };
}
function settled(installations: ProviderInstallations): Promise<ProviderInstallationUpdate> {
return new Promise((resolve) => {
const stop = installations.observeUpdates("codex", (event) => {
if (event.operation.status === "running") return;
stop();
resolve(installations.getUpdate("codex")!);
});
});
}
test("passes output and the success message through unchanged, in order", async () => {
const { installations, progress } = updating(async ({ report }) => {
report({ type: "message", text: OPAQUE_MESSAGE });
report({ type: "stdout", text: " added 1 package\r\n" });
report({ type: "stderr", text: "npm warn \u001b[33mdeprecated\u001b[0m\n" });
report({ type: "stdout", text: "" });
return { status: "succeeded", message: OPAQUE_MESSAGE };
});
const done = settled(installations);
const started = installations.update("codex");
expect(started.operation.status).toBe("running");
const update = await done;
const output = [
{ type: "message", text: OPAQUE_MESSAGE },
{ type: "stdout", text: " added 1 package\r\n" },
{ type: "stderr", text: "npm warn \u001b[33mdeprecated\u001b[0m\n" },
{ type: "stdout", text: "" },
];
expect(update).toEqual({
operation: {
status: "succeeded",
id: started.operation.id,
startedAt: expect.any(String),
finishedAt: expect.any(String),
message: OPAQUE_MESSAGE,
},
output,
});
expect(progress.map((event) => event.operation.status)).toEqual([
"running",
"running",
"running",
"running",
"running",
"succeeded",
]);
expect(progress.flatMap((event) => event.output)).toEqual(output);
});
test("reports a failure with the provider's message and keeps its output", async () => {
const { installations } = updating(async ({ report }) => {
report({ type: "stdout", text: "Updating Codex via `npm install -g @openai/codex`...\n" });
report({ type: "stderr", text: "npm error code EACCES\n" });
return { status: "failed", message: OPAQUE_MESSAGE };
});
const done = settled(installations);
installations.update("codex");
await expect(done).resolves.toEqual({
operation: expect.objectContaining({ status: "failed", message: OPAQUE_MESSAGE }),
output: [
{ type: "stdout", text: "Updating Codex via `npm install -g @openai/codex`...\n" },
{ type: "stderr", text: "npm error code EACCES\n" },
],
});
});
test("turns a throw into a failure with its message and keeps earlier output", async () => {
const { installations } = updating(async ({ report }) => {
report({ type: "stdout", text: "step 1\n" });
throw new Error(OPAQUE_MESSAGE);
});
const done = settled(installations);
installations.update("codex");
await expect(done).resolves.toEqual({
operation: expect.objectContaining({ status: "failed", message: OPAQUE_MESSAGE }),
output: [{ type: "stdout", text: "step 1\n" }],
});
});
test("a second request while an update runs joins it instead of starting another", async () => {
let finish!: () => void;
const { installations, calls } = updating(async ({ report }) => {
report({ type: "stdout", text: "working\n" });
await new Promise<void>((resolve) => {
finish = resolve;
});
return { status: "succeeded" };
});
const first = installations.update("codex");
const second = installations.update("codex");
expect(second.operation.id).toBe(first.operation.id);
expect(second.output).toEqual([{ type: "stdout", text: "working\n" }]);
expect(calls()).toBe(1);
const done = settled(installations);
finish();
await expect(done).resolves.toMatchObject({ operation: { status: "succeeded" } });
const third = installations.update("codex");
expect(third.operation.id).not.toBe(first.operation.id);
expect(calls()).toBe(2);
});
test("after success the next read asks the provider again and discovery refreshes", async () => {
let version = "1.0.0";
const refreshed: string[] = [];
const { installations } = updating(
async () => {
version = "2.0.0";
return { status: "succeeded" };
},
{
inspect: async () => ({ status: "installed", version }),
refreshProvider: async (provider) => {
refreshed.push(provider);
},
},
);
await expect(installations.inspect("codex")).resolves.toMatchObject({
inspection: { version: "1.0.0" },
});
const done = settled(installations);
installations.update("codex");
await done;
await expect(installations.inspect("codex")).resolves.toMatchObject({
inspection: { version: "2.0.0" },
});
expect(refreshed).toEqual(["codex"]);
});
test("a failed refresh after success still reports success; a failure keeps the caches", async () => {
let version = "1.0.0";
let result: "succeeded" | "failed" = "failed";
const { installations } = updating(
async () => {
version = "2.0.0";
return result === "succeeded" ? { status: result } : { status: result, message: "no" };
},
{
inspect: async () => ({ status: "installed", version }),
refreshProvider: async () => {
throw new Error("discovery failed");
},
},
);
await installations.inspect("codex");
let done = settled(installations);
installations.update("codex");
await done;
await expect(installations.inspect("codex")).resolves.toMatchObject({
inspection: { version: "1.0.0" },
});
result = "succeeded";
done = settled(installations);
installations.update("codex");
await expect(done).resolves.toMatchObject({ operation: { status: "succeeded" } });
});
test("retains the most recent output within the limit and marks the drop", async () => {
const { installations, progress } = updating(
async ({ report }) => {
report({ type: "stdout", text: "aaaa" });
report({ type: "stdout", text: "bbbb" });
report({ type: "stderr", text: "cccc" });
report({ type: "stdout", text: "0123456789XYZ" });
return { status: "succeeded" };
},
{ updateOutputLimit: 10 },
);
const done = settled(installations);
installations.update("codex");
const update = await done;
expect(update.output).toEqual([{ type: "stdout", text: "3456789XYZ" }]);
expect(update.truncated).toBe(true);
expect(progress.flatMap((event) => event.output).at(-1)).toEqual({
type: "stdout",
text: "3456789XYZ",
});
const partial = updating(
async ({ report }) => {
report({ type: "stdout", text: "aaaa" });
report({ type: "stdout", text: "bbbb" });
report({ type: "stderr", text: "cccc" });
return { status: "succeeded" };
},
{ updateOutputLimit: 10 },
);
const partialDone = settled(partial.installations);
partial.installations.update("codex");
await expect(partialDone).resolves.toMatchObject({
output: [
{ type: "stdout", text: "bbbb" },
{ type: "stderr", text: "cccc" },
],
truncated: true,
});
});
test("advertises the update only when the provider declares one", async () => {
const { installations } = updating(async () => ({ status: "succeeded" }));
await expect(installations.inspect("codex")).resolves.toEqual({
updateUrl: "https://example.com/update",
canUpdate: true,
});
const plain = new ProviderInstallations({
logger: createTestLogger(),
resolveClient: () => builtinClient({ updateUrl: "https://example.com/update" }),
});
await expect(plain.inspect("codex")).resolves.toEqual({
updateUrl: "https://example.com/update",
});
expect(() => plain.update("codex")).toThrow("Provider codex cannot update itself");
expect(plain.getUpdate("codex")).toBeNull();
});
test("close aborts a running update, which settles with the provider's answer", async () => {
function failOnAbort(signal: AbortSignal): Promise<ProviderUpdateResult> {
return new Promise((resolve) => {
const stopped = () =>
resolve({ status: "failed", message: `stopped: ${String(signal.reason)}` });
signal.addEventListener("abort", stopped);
});
}
const { installations } = updating(({ signal }) => failOnAbort(signal));
const done = settled(installations);
installations.update("codex");
installations.close();
await expect(done).resolves.toMatchObject({
operation: { status: "failed", message: "stopped: Error: The daemon is stopping" },
});
});
});
@@ -0,0 +1,373 @@
import { randomUUID } from "node:crypto";
import type { Logger } from "pino";
import type {
ProviderInstallation,
ProviderInstallationInspection,
ProviderInstallationUpdate,
ProviderInstallationUpdateCheck,
ProviderInstallationUpdateProgress,
} from "@getpaseo/protocol/messages";
import {
ProviderInspectionSchema,
ProviderUpdateCheckSchema,
ProviderUpdateReportSchema,
ProviderUpdateResultSchema,
type ProviderUpdateResult,
} from "@getpaseo/plugin/server/provider";
import type {
AgentClient,
AgentClientInstallationContext,
AgentClientUpdateContext,
AgentProvider,
} from "../agent-sdk-types.js";
import { ProviderUpdateOperation } from "./provider-update-operation.js";
const DEFAULT_INSPECT_TIMEOUT_MS = 20_000;
const DEFAULT_INSPECT_CACHE_MS = 30_000;
// Below the plugin RPC timeout, so a slow plugin check reports this deadline.
const DEFAULT_UPDATE_CHECK_TIMEOUT_MS = 25_000;
const DEFAULT_UPDATE_CHECK_CACHE_MS = 60 * 60_000;
/** Characters of updater output kept per update; older output is dropped first. */
export const DEFAULT_UPDATE_OUTPUT_LIMIT = 64 * 1024;
export interface ProviderInstallationsOptions {
logger: Logger;
/** The current client for a configured provider, or null when the provider is unknown. */
resolveClient(provider: AgentProvider): AgentClient | null;
inspectTimeoutMs?: number;
inspectCacheMs?: number;
updateCheckTimeoutMs?: number;
/** How long a conclusive update check is reused. Failed checks use `inspectCacheMs`. */
updateCheckCacheMs?: number;
/** Rediscovers the provider after a successful update. A failure is logged, not reported. */
refreshProvider?(provider: AgentProvider): Promise<void>;
updateOutputLimit?: number;
now?: () => number;
}
export type ProviderUpdateListener = (progress: ProviderInstallationUpdateProgress) => void;
export interface InspectProviderInstallationOptions {
/** Skip the settled-result cache. Joins a call that is already running. */
refresh?: boolean;
}
/** A wire result: the provider's answer, or the `failed` value when the call threw or timed out. */
interface ProviderCallResult {
status: string;
}
interface ProviderCall<T extends ProviderCallResult> {
name: string;
/** Validates the provider's answer; throws when it breaks the contract. */
parse(value: unknown): T;
timeoutMs: number;
/** How long a settled result is reused. */
cacheMs(result: T): number;
run(context: AgentClientInstallationContext): Promise<unknown>;
failed(message: string): T;
}
interface CachedRead<T> {
promise: Promise<T>;
settledAt: number | null;
cacheMs: number;
}
/**
* Calls provider installation callbacks for every provider, built-in or plugin, through
* `AgentClient.installation`. Owns deadlines, in-flight sharing and result caching. Results are
* keyed by client, so a provider configuration change, which replaces the client, starts fresh.
* Inspection and update checks are cached separately so the installed version never waits on an
* update check.
*/
export class ProviderInstallations {
private readonly logger: Logger;
private readonly resolveClient: ProviderInstallationsOptions["resolveClient"];
private readonly inspectTimeoutMs: number;
private readonly inspectCacheMs: number;
private readonly updateCheckTimeoutMs: number;
private readonly updateCheckCacheMs: number;
private readonly refreshProvider: ProviderInstallationsOptions["refreshProvider"];
private readonly updateOutputLimit: number;
private readonly now: () => number;
private readonly updates = new Map<AgentProvider, ProviderUpdateOperation>();
private readonly updateListeners = new Map<AgentProvider, Set<ProviderUpdateListener>>();
private readonly runningUpdates = new Set<AbortController>();
private readonly inspections = new WeakMap<
AgentClient,
CachedRead<ProviderInstallationInspection>
>();
private readonly updateChecks = new WeakMap<
AgentClient,
CachedRead<ProviderInstallationUpdateCheck>
>();
/** The installed version each cached update check answered for, compared as an opaque string. */
private readonly checkedVersions = new WeakMap<
CachedRead<ProviderInstallationUpdateCheck>,
string | null
>();
constructor(options: ProviderInstallationsOptions) {
this.logger = options.logger;
this.resolveClient = options.resolveClient;
this.inspectTimeoutMs = options.inspectTimeoutMs ?? DEFAULT_INSPECT_TIMEOUT_MS;
this.inspectCacheMs = options.inspectCacheMs ?? DEFAULT_INSPECT_CACHE_MS;
this.updateCheckTimeoutMs = options.updateCheckTimeoutMs ?? DEFAULT_UPDATE_CHECK_TIMEOUT_MS;
this.updateCheckCacheMs = options.updateCheckCacheMs ?? DEFAULT_UPDATE_CHECK_CACHE_MS;
this.refreshProvider = options.refreshProvider;
this.updateOutputLimit = options.updateOutputLimit ?? DEFAULT_UPDATE_OUTPUT_LIMIT;
this.now = options.now ?? Date.now;
}
/** Links and installed state, or null when the provider declares no installation contract. */
async inspect(
provider: AgentProvider,
options: InspectProviderInstallationOptions = {},
): Promise<ProviderInstallation | null> {
const client = this.requireClient(provider);
const installation = client.installation;
if (!installation) return null;
const result: ProviderInstallation = {};
if (installation.installUrl) result.installUrl = installation.installUrl;
if (installation.updateUrl) result.updateUrl = installation.updateUrl;
if (installation.update) result.canUpdate = true;
const inspect = installation.inspect;
if (inspect) {
result.inspection = await this.read(provider, client, this.inspections, options, {
name: "inspect",
parse: (value) => ProviderInspectionSchema.parse(value),
timeoutMs: this.inspectTimeoutMs,
cacheMs: () => this.inspectCacheMs,
run: (context) => inspect.call(installation, context),
failed: (message) => ({ status: "error" as const, message }),
});
}
return result;
}
/**
* Whether a newer version is available, as the provider reports it, or null when the provider
* cannot check. A check that throws or misses its deadline is reported as `error`.
*/
async checkForUpdates(
provider: AgentProvider,
options: InspectProviderInstallationOptions = {},
): Promise<ProviderInstallationUpdateCheck | null> {
const client = this.requireClient(provider);
const installation = client.installation;
const checkForUpdates = installation?.checkForUpdates;
if (!installation || !checkForUpdates) return null;
// A cached check answers only for the version it was computed against, so a CLI updated
// outside Paseo is not offered the update it already has. The version comes from an
// inspection that is cached or already running; a check never starts one or waits for one
// when it asks the provider itself.
const knownVersion = this.knownInstalledVersion(client);
const cached = this.updateChecks.get(client);
if (cached && knownVersion && !options.refresh && this.isFresh(cached)) {
const version = await knownVersion;
// Another request may already have replaced the stale entry; share its check.
if (
version !== undefined &&
this.checkedVersions.get(cached) !== version &&
this.updateChecks.get(client) === cached
)
this.updateChecks.delete(client);
}
const check = this.read(provider, client, this.updateChecks, options, {
name: "update check",
parse: (value) => ProviderUpdateCheckSchema.parse(value),
timeoutMs: this.updateCheckTimeoutMs,
// Inconclusive answers are retried soon; a conclusive one holds for the long cache.
cacheMs: (result) =>
result.status === "current" || result.status === "available"
? this.updateCheckCacheMs
: this.inspectCacheMs,
run: (context) => checkForUpdates.call(installation, context),
failed: (message) => ({ status: "error" as const, message }),
});
const entry = this.updateChecks.get(client);
if (entry && knownVersion && !this.checkedVersions.has(entry))
void this.recordCheckedVersion(entry, knownVersion);
return check;
}
/**
* Starts the provider's update, or returns the one already running. The update runs until the
* provider settles it, independent of any client; only `close` aborts it. Throws when the
* provider declares no `update`.
*/
update(provider: AgentProvider): ProviderInstallationUpdate {
const running = this.updates.get(provider);
if (running?.running) return running.snapshot();
const client = this.requireClient(provider);
const installation = client.installation;
const update = installation?.update;
if (!installation || !update) throw new Error(`Provider ${provider} cannot update itself`);
const operation = new ProviderUpdateOperation({
id: randomUUID(),
provider,
startedAt: this.timestamp(),
outputLimit: this.updateOutputLimit,
publish: (progress) => {
for (const listener of this.updateListeners.get(provider) ?? []) listener(progress);
},
});
this.updates.set(provider, operation);
void this.runUpdate(provider, client, operation, (context) =>
update.call(installation, context),
);
return operation.snapshot();
}
/** The latest update of the provider since the daemon started, or null. */
getUpdate(provider: AgentProvider): ProviderInstallationUpdate | null {
return this.updates.get(provider)?.snapshot() ?? null;
}
/** Calls `listener` when an update of the provider starts, reports output or settles. */
observeUpdates(provider: AgentProvider, listener: ProviderUpdateListener): () => void {
let listeners = this.updateListeners.get(provider);
if (!listeners) {
listeners = new Set();
this.updateListeners.set(provider, listeners);
}
listeners.add(listener);
return () => {
listeners.delete(listener);
if (listeners.size === 0) this.updateListeners.delete(provider);
};
}
/** Aborts running updates. Each settles as failed with the provider's answer to the abort. */
close(): void {
for (const controller of this.runningUpdates)
controller.abort(new Error("The daemon is stopping"));
}
/**
* The installed version from an inspection that is fresh or still running, or undefined when
* there is none. Resolves to undefined when the inspection failed, which says nothing about
* the version.
*/
private knownInstalledVersion(
client: AgentClient,
): Promise<string | null | undefined> | undefined {
const read = this.inspections.get(client);
if (!read || (read.settledAt !== null && !this.isFresh(read))) return undefined;
return read.promise.then((inspection) => {
if (inspection.status === "error") return undefined;
return inspection.status === "installed" ? (inspection.version ?? null) : null;
});
}
private async recordCheckedVersion(
entry: CachedRead<ProviderInstallationUpdateCheck>,
knownVersion: Promise<string | null | undefined>,
): Promise<void> {
const version = await knownVersion;
if (version !== undefined && !this.checkedVersions.has(entry))
this.checkedVersions.set(entry, version);
}
private async runUpdate(
provider: AgentProvider,
client: AgentClient,
operation: ProviderUpdateOperation,
run: (context: AgentClientUpdateContext) => Promise<unknown>,
): Promise<void> {
const controller = new AbortController();
this.runningUpdates.add(controller);
let result: ProviderUpdateResult;
try {
const answer = await run({
signal: controller.signal,
report: (event) => {
const parsed = ProviderUpdateReportSchema.safeParse(event);
if (parsed.success) operation.append(parsed.data);
else this.logger.warn({ provider }, "Ignored invalid provider update output");
},
});
result = ProviderUpdateResultSchema.parse(answer);
} catch (error) {
this.logger.warn({ err: error, provider }, "Provider update failed");
result = {
status: "failed",
message: error instanceof Error ? error.message : String(error),
};
} finally {
this.runningUpdates.delete(controller);
}
if (result.status === "succeeded") {
// The installed version and update state are now stale; read them again on next request.
this.inspections.delete(client);
this.updateChecks.delete(client);
}
operation.settle(result, this.timestamp());
if (result.status === "succeeded" && this.refreshProvider) {
await this.refreshProvider(provider).catch((error: unknown) => {
this.logger.warn({ err: error, provider }, "Provider refresh after update failed");
});
}
}
private timestamp(): string {
return new Date(this.now()).toISOString();
}
private requireClient(provider: AgentProvider): AgentClient {
const client = this.resolveClient(provider);
if (!client) throw new Error(`Provider ${provider} is not configured`);
return client;
}
private read<T extends ProviderCallResult>(
provider: AgentProvider,
client: AgentClient,
cache: WeakMap<AgentClient, CachedRead<T>>,
options: InspectProviderInstallationOptions,
call: ProviderCall<T>,
): Promise<T> {
const existing = cache.get(client);
if (existing && (existing.settledAt === null || (!options.refresh && this.isFresh(existing)))) {
return existing.promise;
}
// invoke settles asynchronously, after `read` is initialized.
const promise = this.invoke(provider, call).then((result) => {
read.settledAt = this.now();
read.cacheMs = call.cacheMs(result);
return result;
});
const read: CachedRead<T> = { promise, settledAt: null, cacheMs: 0 };
cache.set(client, read);
return read.promise;
}
private isFresh(read: CachedRead<unknown>): boolean {
return read.settledAt !== null && this.now() - read.settledAt < read.cacheMs;
}
private async invoke<T extends ProviderCallResult>(
provider: AgentProvider,
call: ProviderCall<T>,
): Promise<T> {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => {
const error = new Error(`Timed out after ${call.timeoutMs}ms`);
reject(error);
controller.abort(error);
}, call.timeoutMs);
});
try {
const result = await Promise.race([call.run({ signal: controller.signal }), deadline]);
return call.parse(result);
} catch (error) {
this.logger.warn({ err: error, provider }, `Provider installation ${call.name} failed`);
return call.failed(error instanceof Error ? error.message : String(error));
} finally {
clearTimeout(timer);
}
}
}
@@ -0,0 +1,80 @@
import type {
ProviderInstallationUpdate,
ProviderInstallationUpdateOperation,
ProviderInstallationUpdateOutput,
ProviderInstallationUpdateProgress,
} from "@getpaseo/protocol/messages";
import type { ProviderUpdateResult } from "@getpaseo/plugin/server/provider";
interface ProviderUpdateOperationOptions {
id: string;
provider: string;
startedAt: string;
/** Characters of output text retained; the oldest output is dropped beyond it. */
outputLimit: number;
publish(progress: ProviderInstallationUpdateProgress): void;
}
/**
* One provider update: its state and the output it reported, bounded to `outputLimit`
* characters. Output reported after the update settles is ignored.
*/
export class ProviderUpdateOperation {
private state: ProviderInstallationUpdateOperation;
private readonly output: ProviderInstallationUpdateOutput[] = [];
private outputLength = 0;
private truncated = false;
constructor(private readonly options: ProviderUpdateOperationOptions) {
this.state = { status: "running", id: options.id, startedAt: options.startedAt };
this.publish([]);
}
get running(): boolean {
return this.state.status === "running";
}
append(entry: ProviderInstallationUpdateOutput): void {
if (!this.running) return;
const limit = this.options.outputLimit;
const kept =
entry.text.length > limit ? { type: entry.type, text: entry.text.slice(-limit) } : entry;
if (kept !== entry) this.truncated = true;
this.output.push(kept);
this.outputLength += kept.text.length;
while (this.outputLength > limit) {
const dropped = this.output.shift()!;
this.outputLength -= dropped.text.length;
this.truncated = true;
}
this.publish([kept]);
}
settle(result: ProviderUpdateResult, finishedAt: string): void {
if (!this.running) return;
const { id, startedAt } = this.state;
this.state =
result.status === "succeeded"
? {
status: "succeeded",
id,
startedAt,
finishedAt,
...(result.message === undefined ? {} : { message: result.message }),
}
: { status: "failed", id, startedAt, finishedAt, message: result.message };
this.publish([]);
}
snapshot(): ProviderInstallationUpdate {
return {
operation: this.state,
output: [...this.output],
...(this.truncated ? { truncated: true } : {}),
};
}
private publish(output: ProviderInstallationUpdateOutput[]): void {
this.options.publish({ provider: this.options.provider, operation: this.state, output });
}
}
@@ -623,6 +623,7 @@ function wrapClientProvider(
getCatalogCacheKey: inner.getCatalogCacheKey?.bind(inner),
isAvailable: (signal, options) => inner.isAvailable(signal, options),
getDiagnostic: inner.getDiagnostic?.bind(inner),
installation: inner.installation,
};
}
@@ -51,6 +51,7 @@ import {
} from "./agent-configuration-validator.js";
import type { ProviderRegistration } from "@getpaseo/plugin/server/provider";
import { PluginAgentClientRegistry } from "./plugin-provider.js";
import { ProviderInstallations } from "./provider-installation/provider-installations.js";
const DEFAULT_REFRESH_TIMEOUT_MS = 120_000;
const MAX_REFRESH_TIMEOUT_MS = 2_147_483_647;
@@ -253,6 +254,8 @@ export class ProviderSnapshotManager {
private providerClients: Record<AgentProvider, AgentClient>;
private readonly ownedClients = new Set<AgentClient>();
private readonly pluginProviders: PluginAgentClientRegistry;
/** Installation links and versions; separate from availability and discovery. */
readonly installations: ProviderInstallations;
private pluginProvidersSettled = false;
constructor(options: ProviderSnapshotManagerOptions) {
@@ -281,6 +284,14 @@ export class ProviderSnapshotManager {
...this.extraClients,
} as Record<AgentProvider, AgentClient>;
for (const client of Object.values(this.providerClients)) this.ownedClients.add(client);
this.installations = new ProviderInstallations({
logger: options.logger.child({ module: "provider-installations" }),
resolveClient: (provider) => {
const definition = this.generation.definitions[provider];
return definition ? this.ensureClient(provider, definition) : null;
},
refreshProvider: (provider) => this.refreshSettingsSnapshot({ providers: [provider] }),
});
}
getSnapshot(cwd?: string): ProviderSnapshot {
@@ -688,6 +699,7 @@ export class ProviderSnapshotManager {
async shutdown(): Promise<void> {
this.destroyed = true;
this.installations.close();
for (const state of this.generation.providerStates.values()) state.discoveryLimit.clearQueue();
// Materialize a client per enabled provider so provider-owned resources
// (background processes, sockets, etc.) get a chance to release even when
@@ -698,6 +710,7 @@ export class ProviderSnapshotManager {
destroy(): void {
this.destroyed = true;
this.installations.close();
for (const state of this.generation.providerStates.values()) state.discoveryLimit.clearQueue();
this.catalogs.clear();
this.targets.clear();
@@ -92,6 +92,15 @@ import {
} from "./rewind.js";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
import { claudeConfigDir, claudeProjectDirSync, claudeTranscriptPath } from "./project-dir.js";
import {
compareWithLatestRelease,
fetchHomebrewCaskVersion,
fetchReleaseText,
homebrewCaskOf,
resolveExecutablePath,
type LatestReleaseLookup,
} from "../release-check.js";
import { runUpdateCommand, updatedForNewAgents } from "../update-command.js";
import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js";
import {
isProviderImageMarkdown,
@@ -130,6 +139,7 @@ import {
type AgentTimelineItem,
type AgentUsage,
type AgentRuntimeInfo,
type AgentClientInstallation,
type FetchCatalogOptions,
type ImportableProviderSession,
type ImportProviderSessionContext,
@@ -476,6 +486,8 @@ interface ClaudeAgentClientOptions {
queryFactory?: ClaudeQueryFactory;
resolveBinary?: () => Promise<string>;
resolveVersion?: (signal?: AbortSignal) => Promise<string>;
/** Where update checks learn the newest release; defaults to Claude Code's release channel. */
lookupLatestRelease?: LatestReleaseLookup;
rewindSdk?: ClaudeRewindSdk;
}
@@ -1608,6 +1620,44 @@ export class ClaudeAgentClient implements AgentClient {
private readonly discoverModels: typeof discoverClaudeModels;
readonly provider = "claude" as const;
readonly capabilities = CLAUDE_CAPABILITIES;
readonly installation: AgentClientInstallation = {
installUrl: CLAUDE_INSTALL_URL,
updateUrl: CLAUDE_UPDATE_URL,
inspect: async ({ signal }) => {
const probe = await readClaudeCodeVersion(this.runtimeSettings, signal);
if (probe.status === "not_installed") return probe;
return probe.version
? { status: "installed", version: probe.version }
: { status: "installed" };
},
checkForUpdates: async ({ signal }) => {
const probe = await readClaudeCodeVersion(this.runtimeSettings, signal);
if (probe.status === "not_installed")
return { status: "unknown", message: "Claude Code is not installed" };
if (!probe.version)
return { status: "unknown", message: "The configured command does not report its version" };
const latest = await this.lookupLatestRelease({
executablePath: probe.executablePath,
signal,
});
return compareWithLatestRelease({ installed: probe.version, latest });
},
// `claude update` updates native installs itself and package-manager installs through the
// package manager.
update: async (context) => {
const installed = await resolveInstalledClaude(this.runtimeSettings);
if (!installed) return { status: "failed", message: "Claude Code is not installed" };
return runUpdateCommand(
{
command: installed.executable,
args: [...installed.args, "update"],
env: createProviderEnvSpec({ runtimeSettings: this.runtimeSettings }),
successMessage: updatedForNewAgents("Claude Code"),
},
context,
);
},
};
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
private readonly logger: Logger;
@@ -1615,6 +1665,7 @@ export class ClaudeAgentClient implements AgentClient {
private readonly queryFactory?: ClaudeQueryFactory;
private readonly resolveBinary: () => Promise<string>;
private readonly resolveVersion: (signal?: AbortSignal) => Promise<string>;
private readonly lookupLatestRelease: LatestReleaseLookup;
private readonly rewindSdk: ClaudeRewindSdk;
private readonly scheduleImportDescriptors = createImportDescriptorScheduler(
(candidate: ClaudeSessionCandidate, signal) => this.readImportDescriptor(candidate, signal),
@@ -1636,6 +1687,13 @@ export class ClaudeAgentClient implements AgentClient {
this.resolveVersion =
options.resolveVersion ??
((signal) => resolveClaudeCodeVersion(this.runtimeSettings, signal));
this.lookupLatestRelease =
options.lookupLatestRelease ??
((input) =>
lookupLatestClaudeRelease({
...input,
configDir: claudeConfigDir(this.buildProviderEnv()),
}));
this.rewindSdk = options.rewindSdk ?? realClaudeRewindSdk;
}
@@ -1923,20 +1981,85 @@ async function resolveClaudeBinary(runtimeSettings?: ProviderRuntimeSettings): P
);
}
const CLAUDE_INSTALL_URL = "https://code.claude.com/docs/en/setup#install-claude-code";
const CLAUDE_UPDATE_URL = "https://code.claude.com/docs/en/setup#update-claude-code";
type ClaudeCodeVersionRead =
| { status: "not_installed" }
/** `version` is null when the configured command is a wrapper that does not report Claude Code's version. */
| { status: "installed"; version: string | null; executablePath: string };
const CLAUDE_UPDATE_CHANNELS = new Set(["latest", "stable", "rc"]);
/** The `autoUpdatesChannel` Claude Code updates from, `latest` unless settings pick another. */
async function readClaudeUpdateChannel(configDir: string): Promise<string> {
try {
const settings: unknown = JSON.parse(
await promises.readFile(path.join(configDir, "settings.json"), "utf8"),
);
const channel =
settings && typeof settings === "object"
? (settings as Record<string, unknown>).autoUpdatesChannel
: undefined;
return typeof channel === "string" && CLAUDE_UPDATE_CHANNELS.has(channel) ? channel : "latest";
} catch {
return "latest";
}
}
/**
* The release Claude Code's own updater offers: the Homebrew cask for cask installs, otherwise
* the version published on the configured update channel.
*/
async function lookupLatestClaudeRelease(input: {
executablePath: string;
configDir: string;
signal: AbortSignal;
}): Promise<string> {
const cask = homebrewCaskOf(input.executablePath);
if (cask) return fetchHomebrewCaskVersion(cask, input.signal);
const channel = await readClaudeUpdateChannel(input.configDir);
return fetchReleaseText(
`https://downloads.claude.ai/claude-code-releases/${channel}`,
input.signal,
);
}
export async function resolveClaudeCodeVersion(
runtimeSettings?: ProviderRuntimeSettings,
signal?: AbortSignal,
): Promise<string> {
const read = await readClaudeCodeVersion(runtimeSettings, signal);
if (read.status === "not_installed") {
throw new Error("Claude binary not found while resolving Claude Code version");
}
if (!read.version) {
throw new Error("Unable to parse Claude Code version from --version output");
}
return read.version;
}
/** The executable and arguments a session would launch, or null when Claude Code is missing. */
async function resolveInstalledClaude(
runtimeSettings: ProviderRuntimeSettings | undefined,
): Promise<{ executable: string; args: string[] } | null> {
const launch = await resolveProviderLaunch({
commandConfig: runtimeSettings?.command,
defaultBinary: "claude",
});
const availability = await checkProviderLaunchAvailable(launch);
if (!availability.available) {
throw new Error("Claude binary not found while resolving Claude Code version");
}
const executable = availability.resolvedPath ?? launch.command;
const { stdout, stderr } = await execCommand(executable, [...launch.args, "--version"], {
if (!availability.available) return null;
return { executable: availability.resolvedPath ?? launch.command, args: [...launch.args] };
}
async function readClaudeCodeVersion(
runtimeSettings: ProviderRuntimeSettings | undefined,
signal: AbortSignal | undefined,
): Promise<ClaudeCodeVersionRead> {
const installed = await resolveInstalledClaude(runtimeSettings);
if (!installed) return { status: "not_installed" };
const { executable } = installed;
const { stdout, stderr } = await execCommand(executable, [...installed.args, "--version"], {
...createProviderEnvSpec({ runtimeSettings }),
timeout: 5_000,
signal,
@@ -1948,10 +2071,11 @@ export async function resolveClaudeCodeVersion(
const version = /\d+\.\d+\.\d+\s+\(Claude Code\)/i.test(rawVersionOutput)
? parseClaudeCodeVersion(rawVersionOutput)
: null;
if (!version) {
throw new Error("Unable to parse Claude Code version from --version output");
}
return version.join(".");
return {
status: "installed",
version: version ? version.join(".") : null,
executablePath: await resolveExecutablePath(executable),
};
}
async function resolveClaudeAuth(
@@ -0,0 +1,113 @@
import type { ProviderUpdateCheck } from "@getpaseo/plugin/server/provider";
import type { AgentClientInstallation } from "../agent-sdk-types.js";
import {
checkProviderLaunchAvailable,
createProviderEnvSpec,
resolveProviderLaunch,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import { readCommandVersionOutput } from "./diagnostic-utils.js";
import { runUpdateCommand } from "./update-command.js";
/** The provider's CLI as configured, found on the daemon's PATH. */
export interface InstalledCli {
/** The resolved executable. */
command: string;
/** Arguments the configuration puts before any subcommand. */
args: string[];
env: Record<string, string> | undefined;
}
export interface CliInstallationOptions {
/** The product name used in messages, such as "Copilot". */
name: string;
defaultBinary: string;
runtimeSettings: ProviderRuntimeSettings | undefined;
installUrl: string;
updateUrl?: string;
/** The version shown for the CLI's `--version` output. Defaults to its first line. */
versionLabel?(output: string): string | undefined;
/** Asks the source the CLI's own updater asks. Left out when that source is not known. */
checkForUpdates?(input: {
cli: InstalledCli;
version: string | undefined;
signal: AbortSignal;
}): Promise<ProviderUpdateCheck>;
/** The CLI's own update command. Left out when the CLI cannot update itself. */
update?: { args: string[]; successMessage: string };
}
function firstLine(output: string): string | undefined {
return output.split("\n")[0]?.trim() || undefined;
}
/**
* The installation contract for a provider whose CLI reports its version with `--version` and,
* where it can, updates itself with a subcommand.
*/
export function createCliInstallation(options: CliInstallationOptions): AgentClientInstallation {
const { name, runtimeSettings } = options;
const versionLabel = options.versionLabel ?? firstLine;
async function resolveCli(): Promise<InstalledCli | null> {
const launch = await resolveProviderLaunch({
commandConfig: runtimeSettings?.command,
defaultBinary: options.defaultBinary,
});
const availability = await checkProviderLaunchAvailable(launch);
if (!availability.available) return null;
return {
command: availability.resolvedPath ?? launch.command,
args: [...launch.args],
env: runtimeSettings?.env,
};
}
async function readVersion(cli: InstalledCli, signal: AbortSignal): Promise<string | undefined> {
const output = await readCommandVersionOutput({
command: cli.command,
args: [...cli.args, "--version"],
env: cli.env,
signal,
});
return versionLabel(output);
}
const installation: AgentClientInstallation = {
installUrl: options.installUrl,
async inspect({ signal }) {
const cli = await resolveCli();
if (!cli) return { status: "not_installed" };
const version = await readVersion(cli, signal);
return version ? { status: "installed", version } : { status: "installed" };
},
};
if (options.updateUrl) installation.updateUrl = options.updateUrl;
const checkForUpdates = options.checkForUpdates;
if (checkForUpdates) {
installation.checkForUpdates = async ({ signal }) => {
const cli = await resolveCli();
if (!cli) return { status: "unknown", message: `${name} is not installed` };
return checkForUpdates({ cli, version: await readVersion(cli, signal), signal });
};
}
const update = options.update;
if (update) {
installation.update = async (context) => {
const cli = await resolveCli();
if (!cli) return { status: "failed", message: `${name} is not installed` };
return runUpdateCommand(
{
command: cli.command,
args: [...cli.args, ...update.args],
env: createProviderEnvSpec({ runtimeSettings }),
successMessage: update.successMessage,
},
context,
);
};
}
return installation;
}
@@ -24,6 +24,7 @@ import {
type AgentRunOptions,
type AgentRunResult,
type AgentRuntimeInfo,
type AgentClientInstallation,
type AgentSession,
type AgentSessionConfig,
type SteerActiveTurnOptions,
@@ -68,6 +69,7 @@ import { normalizeCodexFilePath } from "./codex/tool-call-detail-parser.js";
import {
checkProviderLaunchAvailable,
createProviderEnv,
createProviderEnvSpec,
resolveProviderLaunch,
type ProviderRuntimeSettings,
type ResolvedProviderLaunch,
@@ -115,8 +117,18 @@ import {
formatProviderDiagnosticError,
buildBinaryDiagnosticRows,
buildCommandResolutionDiagnosticRows,
readCommandVersionOutput,
resolveBinaryVersion,
} from "./diagnostic-utils.js";
import {
compareWithLatestRelease,
fetchHomebrewCaskVersion,
fetchReleaseField,
homebrewCaskOf,
resolveExecutablePath,
type LatestReleaseLookup,
} from "./release-check.js";
import { runUpdateCommand, updatedForNewAgents } from "./update-command.js";
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
import {
MODE_APPLIES_NEXT_TURN_NOTICE,
@@ -320,6 +332,8 @@ interface CodexAppServerProcessPort {
interface CodexAppServerAgentDeps {
appServerProcess?: CodexAppServerProcessPort;
environment?: Record<string, string>;
/** Where update checks learn the newest release; defaults to Codex's own release sources. */
lookupLatestRelease?: LatestReleaseLookup;
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
customProvider?: {
id: string;
@@ -7728,9 +7742,69 @@ export class CodexAppServerAgentSession implements AgentSession {
}
}
const CODEX_INSTALL_URL = "https://developers.openai.com/codex/cli";
/** `codex --version` prints `codex-cli <version>`; report the version part. */
function readCodexVersionLabel(output: string): string | undefined {
const line = output.split("\n")[0]?.trim() ?? "";
return line.replace(/^codex-cli\s+/i, "") || undefined;
}
type InstalledCodex =
| { status: "not_installed" }
| { status: "installed"; version: string | undefined; executablePath: string };
/**
* The release Codex's own updater offers: the Homebrew cask for cask installs, which lags
* upstream, otherwise the latest GitHub release, tagged `rust-v<version>`.
*/
const lookupLatestCodexRelease: LatestReleaseLookup = async ({ executablePath, signal }) => {
const cask = homebrewCaskOf(executablePath);
if (cask) return fetchHomebrewCaskVersion(cask, signal);
const tag = await fetchReleaseField({
url: "https://api.github.com/repos/openai/codex/releases/latest",
field: "tag_name",
signal,
});
return tag.replace(/^rust-v/, "");
};
export class CodexAppServerAgentClient implements AgentClient {
readonly provider = CODEX_PROVIDER;
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
readonly installation: AgentClientInstallation = {
installUrl: CODEX_INSTALL_URL,
updateUrl: CODEX_INSTALL_URL,
inspect: async ({ signal }) => {
const installed = await this.readInstalledCodex(signal);
return installed.status === "installed"
? { status: "installed", version: installed.version }
: installed;
},
checkForUpdates: async ({ signal }) => {
const installed = await this.readInstalledCodex(signal);
if (installed.status === "not_installed")
return { status: "unknown", message: "Codex is not installed" };
const lookup = this.deps.lookupLatestRelease ?? lookupLatestCodexRelease;
const latest = await lookup({ executablePath: installed.executablePath, signal });
return compareWithLatestRelease({ installed: installed.version, latest });
},
// `codex update` picks the updater for the install method: npm, bun, pnpm, Homebrew or the
// standalone installer, and exits non-zero with a reason when it cannot tell.
update: async (context) => {
const command = await this.resolveInstalledCommand();
if (!command) return { status: "failed", message: "Codex is not installed" };
return runUpdateCommand(
{
command: command.command,
args: [...command.args, "update"],
env: createProviderEnvSpec({ runtimeSettings: this.runtimeSettings }),
successMessage: updatedForNewAgents("Codex"),
},
context,
);
},
};
private goalsEnabledPromise: Promise<boolean> | null = null;
private autoReviewEnabledPromise: Promise<boolean> | null = null;
@@ -7740,6 +7814,40 @@ export class CodexAppServerAgentClient implements AgentClient {
private readonly deps: CodexAppServerAgentDeps = {},
) {}
private async readInstalledCodex(signal: AbortSignal): Promise<InstalledCodex> {
const installed = await this.resolveInstalledCommand();
if (!installed) return { status: "not_installed" };
const output = await readCommandVersionOutput({
command: installed.command,
args: [...installed.args, "--version"],
env: this.runtimeSettings?.env,
signal,
});
return {
status: "installed",
version: readCodexVersionLabel(output),
executablePath: await resolveExecutablePath(installed.executable),
};
}
/** The command a session would launch, or null when Codex is not installed. */
private async resolveInstalledCommand(): Promise<{
command: string;
args: string[];
executable: string;
} | null> {
const launch = await resolveCodexLaunch(this.runtimeSettings);
const availability = await checkCodexLaunchAvailable(launch);
if (!availability.available) return null;
const command =
launch.source === "override" ? launch.command : (availability.resolvedPath ?? launch.command);
return {
command,
args: [...launch.args],
executable: availability.resolvedPath ?? command,
};
}
private sessionDeps(launchEnv: Record<string, string> | undefined): CodexAppServerAgentDeps {
return {
...this.deps,

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