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
62 changed files with 5588 additions and 85 deletions
+12
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,
+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
@@ -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" }));
}
@@ -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 } : {}) };
}
+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: "诊断",
+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,
+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 };
+182
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.
@@ -6723,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"]);
@@ -7576,6 +7733,11 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ProvidersSnapshotUpdateMessageSchema,
RefreshProvidersSnapshotResponseMessageSchema,
ProviderDiagnosticResponseMessageSchema,
ProviderInstallationInspectResponseMessageSchema,
ProviderInstallationCheckUpdatesResponseMessageSchema,
ProviderInstallationUpdateResponseMessageSchema,
ProviderInstallationUpdateSubscribeResponseMessageSchema,
ProviderInstallationUpdateProgressMessageSchema,
ProviderUsageListResponseMessageSchema,
UsageListReportsUpdateMessageSchema,
UsageListReportsResponseMessageSchema,
@@ -7760,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>;
@@ -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,
@@ -1,7 +1,11 @@
import type { Logger } from "pino";
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
import type {
AgentCapabilityFlags,
AgentClientInstallation,
AgentMode,
} from "../agent-sdk-types.js";
import {
checkProviderLaunchAvailable,
resolveProviderLaunch,
@@ -21,6 +25,14 @@ import {
buildBinaryDiagnosticRows,
buildCommandResolutionDiagnosticRows,
} from "./diagnostic-utils.js";
import { createCliInstallation } from "./cli-installation.js";
import { updatedForNewAgents } from "./update-command.js";
const COPILOT_INSTALL_URL =
"https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli";
// The command reference documents `copilot update`.
const COPILOT_UPDATE_URL =
"https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-command-reference";
const COPILOT_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
@@ -78,7 +90,15 @@ interface CopilotACPAgentClientOptions {
runtimeSettings?: ProviderRuntimeSettings;
}
/** `copilot --version` prints `GitHub Copilot CLI <version>.` and a hint; report the version. */
function readCopilotVersionLabel(output: string): string | undefined {
const line = output.split("\n")[0]?.trim() ?? "";
return line.replace(/^GitHub Copilot CLI\s+/i, "").replace(/\.$/, "") || undefined;
}
export class CopilotACPAgentClient extends ACPAgentClient {
readonly installation: AgentClientInstallation;
constructor(options: CopilotACPAgentClientOptions) {
super({
provider: "copilot",
@@ -94,6 +114,16 @@ export class CopilotACPAgentClient extends ACPAgentClient {
beforeModeWriter: beforeCopilotModeWriter,
capabilities: COPILOT_CAPABILITIES,
});
// Copilot's updater lives in its native binary, so there is no known release source to check.
this.installation = createCliInstallation({
name: "Copilot",
defaultBinary: "copilot",
runtimeSettings: options.runtimeSettings,
installUrl: COPILOT_INSTALL_URL,
updateUrl: COPILOT_UPDATE_URL,
versionLabel: readCopilotVersionLabel,
update: { args: ["update"], successMessage: updatedForNewAgents("Copilot") },
});
}
override async isAvailable(): Promise<boolean> {
@@ -388,13 +388,21 @@ export async function buildCommandResolutionDiagnosticRows(
];
}
/** Run a version command and return its trimmed output, stdout first. Throws when it fails. */
export async function readCommandVersionOutput(
invocation: BinaryDiagnosticVersionCommand & { signal?: AbortSignal },
): Promise<string> {
const { stdout, stderr } = await execCommand(invocation.command, invocation.args, {
...createProviderEnvSpec({ runtimeSettings: { env: invocation.env } }),
timeout: 5_000,
signal: invocation.signal,
});
return stdout.trim() || stderr.trim();
}
async function resolveCommandVersion(invocation: BinaryDiagnosticVersionCommand): Promise<string> {
try {
const { stdout, stderr } = await execCommand(invocation.command, invocation.args, {
...createProviderEnvSpec({ runtimeSettings: { env: invocation.env } }),
timeout: 5_000,
});
return stdout.trim() || stderr.trim() || "unknown";
return (await readCommandVersionOutput(invocation)) || "unknown";
} catch (error) {
return `error: ${toDiagnosticErrorMessage(error)}`;
}
@@ -12,6 +12,7 @@ import {
type AgentClient,
type AgentDraftControls,
type AgentOptionControl,
type AgentClientInstallation,
type AgentLaunchContext,
type AgentMetadata,
type AgentMode,
@@ -80,6 +81,7 @@ import { getUserMessageText } from "./message-history.js";
import { mapOmpSystemNoticeToNotification } from "./system-notice.js";
import { materializeProviderImage } from "../provider-image-output.js";
import { OmpCliRuntime } from "./cli-runtime.js";
import { createOmpInstallation } from "./installation.js";
import { listOmpImportableSessions, readOmpImportSessionConfig } from "./session-descriptor.js";
import type { OmpRuntime, OmpRuntimeSession, OmpStartSessionInput } from "./runtime.js";
import { OmpMcpBridge, type OmpBridgedToolIdentity } from "./mcp-bridge.js";
@@ -2293,6 +2295,7 @@ export class OmpAgentSession implements AgentSession {
export class OmpAgentClient implements AgentClient {
readonly provider: AgentProvider = OMP_PROVIDER;
readonly capabilities: AgentCapabilityFlags = withOmpCapabilities();
readonly installation: AgentClientInstallation;
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
@@ -2314,6 +2317,7 @@ export class OmpAgentClient implements AgentClient {
);
this.logger = options.logger;
this.runtimeSettings = runtimeSettings;
this.installation = createOmpInstallation(runtimeSettings);
this.providerIdleScheduler = options.providerIdleScheduler;
this.noTurnScheduler = options.noTurnScheduler;
this.usagePollScheduler = options.usagePollScheduler;
@@ -0,0 +1,62 @@
import { stripVTControlCharacters } from "node:util";
import type { ProviderUpdateCheck } from "@getpaseo/plugin/server/provider";
import { execCommand } from "../../../../utils/spawn.js";
import type { AgentClientInstallation } from "../../agent-sdk-types.js";
import {
createProviderEnvSpec,
type ProviderRuntimeSettings,
} from "../../provider-launch-config.js";
import { createCliInstallation, type InstalledCli } from "../cli-installation.js";
import { updatedForNewAgents } from "../update-command.js";
const OMP_INSTALL_URL = "https://github.com/can1357/oh-my-pi#install";
/**
* Reads the answer of `omp update --check`, which asks the release source `omp update` installs
* from. Anything else it prints, such as an install that another tool keeps up to date, is
* reported as is.
*/
export function readOmpUpdateCheck(output: string): ProviderUpdateCheck {
const text = stripVTControlCharacters(output).trim();
const available = /New version available:\s*(\S+)/.exec(text)?.[1];
if (available) return { status: "available", versions: [available] };
if (/Already up to date/i.test(text)) return { status: "current" };
return { status: "unknown", message: text || "omp update --check printed nothing" };
}
async function checkOmpForUpdates(input: {
cli: InstalledCli;
runtimeSettings: ProviderRuntimeSettings | undefined;
signal: AbortSignal;
}): Promise<ProviderUpdateCheck> {
const { stdout, stderr } = await execCommand(
input.cli.command,
[...input.cli.args, "update", "--check"],
{
...createProviderEnvSpec({ runtimeSettings: input.runtimeSettings }),
timeout: 20_000,
signal: input.signal,
},
);
return readOmpUpdateCheck(`${stdout}\n${stderr}`);
}
export function createOmpInstallation(
runtimeSettings: ProviderRuntimeSettings | undefined,
): AgentClientInstallation {
return createCliInstallation({
name: "Oh My Pi",
defaultBinary: "omp",
runtimeSettings,
installUrl: OMP_INSTALL_URL,
updateUrl: OMP_INSTALL_URL,
// `omp --version` prints `omp/18.6.1`.
versionLabel: (output) =>
output
.split("\n")[0]
?.trim()
.replace(/^omp\//, "") || undefined,
checkForUpdates: ({ cli, signal }) => checkOmpForUpdates({ cli, runtimeSettings, signal }),
update: { args: ["update"], successMessage: updatedForNewAgents("Oh My Pi") },
});
}
@@ -1,6 +1,7 @@
import type { Logger } from "pino";
import type {
AgentClient,
AgentClientInstallation,
AgentSessionConfig,
AgentLaunchContext,
AgentCreateSessionOptions,
@@ -22,6 +23,13 @@ import type { OpenCodeV2AgentClient } from "./v2/agent.js";
import { resolveExternalOpenCodeServer } from "./v2/external-server.js";
import { withOpenCodeRuntimeNotice } from "./runtime-notice.js";
import { selectOpenCodeService } from "./v2/service-selection.js";
import { createCliInstallation } from "../cli-installation.js";
const OPENCODE_INSTALL_URL = "https://opencode.ai/docs/";
// Agents share a long-lived OpenCode server, and this client keeps the protocol it chose for the
// version it found. Neither changes until the daemon restarts.
const OPENCODE_UPDATED =
"OpenCode is updated. Restart the Paseo daemon to use the new version; agents keep the running OpenCode server until then.";
// Keep the minimum aligned with the SDK and binary exercised by CI.
const MINIMUM_V2: readonly [number, number] = [0, 10];
@@ -59,6 +67,7 @@ export class OpenCodeRuntimeClient implements AgentClient {
private selected: Promise<OpenCodeAgentClient | OpenCodeV2AgentClient> | null = null;
private legacySelected = false;
private readonly legacy: OpenCodeAgentClient;
readonly installation: AgentClientInstallation;
constructor(
private readonly logger: Logger,
private readonly settings?: ProviderRuntimeSettings,
@@ -68,6 +77,20 @@ export class OpenCodeRuntimeClient implements AgentClient {
this.capabilities = this.legacy.capabilities;
this.resolveCreateConfig = this.legacy.resolveCreateConfig;
this.isCreateConfigUnattended = this.legacy.isCreateConfigUnattended;
// OpenCode's update source depends on its channel and install method, which only the binary
// knows, so it has no update check here.
this.installation = createCliInstallation({
name: "OpenCode",
defaultBinary: "opencode",
runtimeSettings: settings,
installUrl: OPENCODE_INSTALL_URL,
versionLabel: (output) =>
output
.split("\n")[0]
?.trim()
.replace(/^opencode\s+/i, "") || undefined,
update: { args: ["upgrade"], successMessage: OPENCODE_UPDATED },
});
}
private selectService() {
return selectOpenCodeService({
@@ -19,6 +19,7 @@ import {
type AgentPermissionRequest,
type AgentPermissionResponse,
type AgentProviderNotice,
type AgentClientInstallation,
type AgentPersistenceHandle,
type AgentPromptInput,
type AgentProvider,
@@ -43,6 +44,13 @@ import {
type ProviderRefreshContext,
} from "../../agent-sdk-types.js";
import { importSessionFromPersistence } from "../../provider-session-import.js";
import { createCliInstallation } from "../cli-installation.js";
import {
compareWithLatestRelease,
fetchReleaseField,
type LatestReleaseLookup,
} from "../release-check.js";
import { updatedForNewAgents } from "../update-command.js";
import { runProviderRefreshActivity } from "../../provider-refresh-deadline.js";
import { runProviderTurn } from "../provider-runner.js";
import {
@@ -198,8 +206,15 @@ export interface PiRpcAgentClientOptions {
runtimeSettings?: ProviderRuntimeSettings;
runtime?: PiRuntime;
usagePollScheduler?: PiUsagePollScheduler;
lookupLatestRelease?: LatestReleaseLookup;
}
const PI_INSTALL_URL = "https://pi.dev/docs/latest/quickstart";
/** The release `pi update` installs, from the endpoint Pi's own version check reads. */
const lookupLatestPiRelease: LatestReleaseLookup = ({ signal }) =>
fetchReleaseField({ url: "https://pi.dev/api/latest-version", field: "version", signal });
interface PiPromptPayload {
text: string;
images?: PiImageContent[];
@@ -2602,6 +2617,7 @@ export class PiRpcAgentSession implements AgentSession {
export class PiRpcAgentClient implements AgentClient {
readonly provider: AgentProvider;
readonly capabilities: AgentCapabilityFlags;
readonly installation: AgentClientInstallation;
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
@@ -2615,6 +2631,21 @@ export class PiRpcAgentClient implements AgentClient {
this.runtimeSettings = options.runtimeSettings;
this.runtime = options.runtime;
this.usagePollScheduler = options.usagePollScheduler;
const lookup = options.lookupLatestRelease ?? lookupLatestPiRelease;
this.installation = createCliInstallation({
name: "Pi",
defaultBinary: PI_BINARY_COMMAND,
runtimeSettings: options.runtimeSettings,
installUrl: PI_INSTALL_URL,
updateUrl: PI_INSTALL_URL,
checkForUpdates: async ({ cli, version, signal }) =>
compareWithLatestRelease({
installed: version,
latest: await lookup({ executablePath: cli.command, signal }),
}),
// `--self` updates Pi itself and leaves installed Pi packages alone.
update: { args: ["update", "--self"], successMessage: updatedForNewAgents("Pi") },
});
}
async createSession(
@@ -0,0 +1,355 @@
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import type { AgentClient } from "../agent-sdk-types.js";
import { ClaudeAgentClient } from "./claude/agent.js";
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
import { CopilotACPAgentClient } from "./copilot-acp-agent.js";
import { OmpAgentClient } from "./omp/agent.js";
import { OpenCodeRuntimeClient } from "./opencode/runtime-client.js";
import { PiRpcAgentClient } from "./pi/agent.js";
import type { LatestReleaseLookup } from "./release-check.js";
import type { ProviderUpdateReport } from "@getpaseo/plugin/server/provider";
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
});
/** A command that prints `banner` for `--version`, the way the real CLI does. */
async function versionCommand(banner: string): Promise<[string, string]> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-update-check-"));
roots.push(root);
const script = path.join(root, "cli.mjs");
await fs.writeFile(script, `console.log(${JSON.stringify(banner)});\n`);
return [process.execPath, script];
}
/**
* A command that, for `update`, writes `stdout` and `stderr` and exits with `exitCode`. Its
* arguments are recorded so the test can see which updater ran.
*/
async function updaterCommand(input: {
stdout: string;
stderr: string;
exitCode: number;
}): Promise<{ argv: [string, string]; recordedArgs: () => Promise<string[]> }> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-updater-"));
roots.push(root);
const script = path.join(root, "cli.mjs");
const record = path.join(root, "args.json");
await fs.writeFile(
script,
[
`import { writeFileSync } from "node:fs";`,
`writeFileSync(${JSON.stringify(record)}, JSON.stringify(process.argv.slice(2)));`,
`process.stdout.write(${JSON.stringify(input.stdout)});`,
`process.stderr.write(${JSON.stringify(input.stderr)});`,
`process.exitCode = ${input.exitCode};`,
].join("\n"),
);
return {
argv: [process.execPath, script],
recordedArgs: async () => JSON.parse(await fs.readFile(record, "utf8")) as string[],
};
}
async function runUpdate(client: AgentClient) {
const output: ProviderUpdateReport[] = [];
const result = await client.installation?.update?.({
signal: new AbortController().signal,
report: (event) => output.push(event),
});
return { result, output };
}
function releases(latest: string) {
const lookups: string[] = [];
const lookup: LatestReleaseLookup = async ({ executablePath }) => {
lookups.push(executablePath);
return latest;
};
return { lookup, lookups };
}
function check(client: AgentClient) {
return client.installation?.checkForUpdates?.({ signal: new AbortController().signal });
}
const providers = [
{
name: "Codex",
banner: (version: string) => `codex-cli ${version}`,
client: (argv: [string, string], lookupLatestRelease: LatestReleaseLookup) =>
new CodexAppServerAgentClient(
createTestLogger(),
{ command: { mode: "replace", argv } },
{ lookupLatestRelease },
),
},
{
name: "Claude Code",
banner: (version: string) => `${version} (Claude Code)`,
client: (argv: [string, string], lookupLatestRelease: LatestReleaseLookup) =>
new ClaudeAgentClient({
logger: createTestLogger(),
runtimeSettings: { command: { mode: "replace", argv } },
lookupLatestRelease,
}),
},
];
describe.each(providers)("$name update", ({ client }) => {
test("runs the CLI's own update command and passes its output through", async () => {
const updater = await updaterCommand({
stdout: "Updating via `npm install -g`...\nadded 1 package\n",
stderr: "npm warn deprecated\n",
exitCode: 0,
});
const { result, output } = await runUpdate(client(updater.argv, releases("9.9.9").lookup));
expect(await updater.recordedArgs()).toEqual(["update"]);
expect(result).toEqual({ status: "succeeded", message: expect.stringContaining("updated") });
expect(output[0]).toEqual({
type: "message",
text: `Running ${updater.argv.join(" ")} update\n`,
});
expect(
output
.filter((event) => event.type === "stdout")
.map((event) => event.text)
.join(""),
).toBe("Updating via `npm install -g`...\nadded 1 package\n");
expect(
output
.filter((event) => event.type === "stderr")
.map((event) => event.text)
.join(""),
).toBe("npm warn deprecated\n");
});
test("fails with the exit code and keeps the updater's output", async () => {
const updater = await updaterCommand({
stdout: "",
stderr: "Could not detect the installation method\n",
exitCode: 3,
});
const { result, output } = await runUpdate(client(updater.argv, releases("9.9.9").lookup));
expect(result).toEqual({
status: "failed",
message: `${updater.argv.join(" ")} update exited with code 3`,
});
expect(output).toContainEqual({
type: "stderr",
text: "Could not detect the installation method\n",
});
});
});
describe.each(providers)("$name update check", ({ banner, client }) => {
test("reports the newer release as available, version as published", async () => {
const argv = await versionCommand(banner("0.165.0"));
const { lookup, lookups } = releases("0.168.0");
await expect(check(client(argv, lookup))).resolves.toEqual({
status: "available",
versions: ["0.168.0"],
});
expect(lookups).toEqual([await fs.realpath(process.execPath)]);
});
test("reports current when the installed release is the latest or newer", async () => {
const argv = await versionCommand(banner("2.1.323"));
await expect(check(client(argv, releases("2.1.323").lookup))).resolves.toEqual({
status: "current",
});
await expect(check(client(argv, releases("2.1.311").lookup))).resolves.toEqual({
status: "current",
});
});
test("is inconclusive when the release channel answers something unreadable", async () => {
const argv = await versionCommand(banner("2.1.323"));
await expect(check(client(argv, releases("<html>").lookup))).resolves.toEqual({
status: "unknown",
message: "Unrecognized release <html>",
});
});
});
describe("Codex prerelease update check", () => {
const codex = providers[0]!;
const client = codex.client;
const banner = codex.banner;
test("orders a prerelease before the release with the same numbers", async () => {
const prerelease = await versionCommand(banner("0.200.0-alpha.1"));
await expect(check(client(prerelease, releases("0.200.0").lookup))).resolves.toEqual({
status: "available",
versions: ["0.200.0"],
});
const release = await versionCommand(banner("0.200.0"));
await expect(check(client(release, releases("0.200.0-alpha.1").lookup))).resolves.toEqual({
status: "current",
});
});
test("is inconclusive between two prereleases of the same release", async () => {
const argv = await versionCommand(banner("0.200.0-alpha.1"));
await expect(check(client(argv, releases("0.200.0-alpha.2").lookup))).resolves.toEqual({
status: "unknown",
message: "Cannot compare 0.200.0-alpha.1 with 0.200.0-alpha.2",
});
});
});
test("Claude Code is inconclusive when a wrapper command hides its version", async () => {
const argv = await versionCommand("cux 0.4.1");
const { lookup, lookups } = releases("2.1.323");
await expect(check(providers[1]!.client(argv, lookup))).resolves.toEqual({
status: "unknown",
message: "The configured command does not report its version",
});
expect(lookups).toEqual([]);
});
/**
* A command that answers `--version` with `banner` and any other invocation with `rest`, the way
* a CLI answers both its version and its update check.
*/
async function cliCommand(input: { banner: string; rest: string }): Promise<[string, string]> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-cli-"));
roots.push(root);
const script = path.join(root, "cli.mjs");
await fs.writeFile(
script,
[
`const args = process.argv.slice(2);`,
`console.log(args.at(-1) === "--version" ? ${JSON.stringify(input.banner)} : ${JSON.stringify(input.rest)});`,
].join("\n"),
);
return [process.execPath, script];
}
function inspect(client: AgentClient) {
return client.installation?.inspect?.({ signal: new AbortController().signal });
}
const selfUpdatingClis = [
{
name: "Copilot",
banner: "GitHub Copilot CLI 1.0.110.\nRun 'copilot update' to check for updates.",
version: "1.0.110",
updateArgs: ["update"],
successMessage: expect.stringContaining("New agents use the new version"),
client: (argv: [string, string]) =>
new CopilotACPAgentClient({
logger: createTestLogger(),
runtimeSettings: { command: { mode: "replace", argv } },
}),
},
{
name: "OpenCode",
banner: "1.19.5",
version: "1.19.5",
updateArgs: ["upgrade"],
// OpenCode runs one server for every agent, which keeps the old version until restarted.
successMessage: expect.stringContaining("Restart the Paseo daemon"),
client: (argv: [string, string]) =>
new OpenCodeRuntimeClient(createTestLogger(), { command: { mode: "replace", argv } }),
},
{
name: "Pi",
banner: "1.3.0",
version: "1.3.0",
updateArgs: ["update", "--self"],
successMessage: expect.stringContaining("New agents use the new version"),
client: (argv: [string, string]) =>
new PiRpcAgentClient({
logger: createTestLogger(),
runtimeSettings: { command: { mode: "replace", argv } },
}),
},
{
name: "Oh My Pi",
banner: "omp/18.6.1",
version: "18.6.1",
updateArgs: ["update"],
successMessage: expect.stringContaining("New agents use the new version"),
client: (argv: [string, string]) =>
new OmpAgentClient({
logger: createTestLogger(),
runtimeSettings: { command: { mode: "replace", argv } },
}),
},
];
describe.each(selfUpdatingClis)("$name installation", (cli) => {
test("reports the version the CLI prints", async () => {
const argv = await versionCommand(cli.banner);
await expect(inspect(cli.client(argv))).resolves.toEqual({
status: "installed",
version: cli.version,
});
});
test("updates with the CLI's own update command", async () => {
const updater = await updaterCommand({ stdout: "done\n", stderr: "", exitCode: 0 });
const { result } = await runUpdate(cli.client(updater.argv));
expect(await updater.recordedArgs()).toEqual(cli.updateArgs);
expect(result).toEqual({ status: "succeeded", message: cli.successMessage });
});
test("is not installed when the command is missing", async () => {
const client = cli.client(["/nonexistent/paseo-cli", "--flag"]);
await expect(inspect(client)).resolves.toEqual({ status: "not_installed" });
await expect(runUpdate(client)).resolves.toMatchObject({
result: { status: "failed", message: `${cli.name} is not installed` },
});
});
});
test("Pi checks the release its own update check reads", async () => {
const argv = await versionCommand("1.3.0");
const { lookup, lookups } = releases("1.5.2");
const client = new PiRpcAgentClient({
logger: createTestLogger(),
runtimeSettings: { command: { mode: "replace", argv } },
lookupLatestRelease: lookup,
});
await expect(check(client)).resolves.toEqual({ status: "available", versions: ["1.5.2"] });
expect(lookups).toEqual([await fs.realpath(process.execPath)]);
});
describe("Oh My Pi update check", () => {
async function ompCheck(answer: string) {
const argv = await cliCommand({ banner: "omp/18.6.1", rest: answer });
return check(
new OmpAgentClient({
logger: createTestLogger(),
runtimeSettings: { command: { mode: "replace", argv } },
}),
);
}
test("reads the answer of `omp update --check`", async () => {
await expect(ompCheck("\u001b[32mNew version available: 18.7.0\u001b[0m")).resolves.toEqual({
status: "available",
versions: ["18.7.0"],
});
await expect(ompCheck("Already up to date")).resolves.toEqual({ status: "current" });
});
test("passes through an install that another tool keeps up to date", async () => {
await expect(ompCheck("omp is kept up to date by mise")).resolves.toEqual({
status: "unknown",
message: "omp is kept up to date by mise",
});
});
});
test("Copilot and OpenCode declare no update check", () => {
const argv: [string, string] = [process.execPath, "cli.mjs"];
expect(selfUpdatingClis[0]!.client(argv).installation.checkForUpdates).toBeUndefined();
expect(selfUpdatingClis[1]!.client(argv).installation.checkForUpdates).toBeUndefined();
});
@@ -0,0 +1,108 @@
import { realpath } from "node:fs/promises";
import type { ProviderUpdateCheck } from "@getpaseo/plugin/server/provider";
/**
* Returns the newest version a provider's release channel offers for the installed executable.
* Built-in providers inject this so tests can answer without the network.
*/
export type LatestReleaseLookup = (input: {
/** The executable the provider launches, symlinks resolved. */
executablePath: string;
signal: AbortSignal;
}) => Promise<string>;
interface ReleaseVersion {
/** The version as written, such as `0.200.0-alpha.1`. */
text: string;
numbers: [number, number, number];
/** True for a prerelease such as `-alpha.1`, which comes before the release itself. */
prerelease: boolean;
}
function parseReleaseVersion(value: string): ReleaseVersion | null {
const match = value.match(/\b(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?/);
if (!match) return null;
return {
text: match[0],
numbers: [Number(match[1]), Number(match[2]), Number(match[3])],
prerelease: match[4] !== undefined,
};
}
/** Newer, older or the same release; `null` between two prereleases of one release. */
function compareReleases(latest: ReleaseVersion, installed: ReleaseVersion): number | null {
for (let index = 0; index < 3; index += 1) {
const difference = latest.numbers[index] - installed.numbers[index];
if (difference !== 0) return difference;
}
if (latest.prerelease && installed.prerelease) return latest.text === installed.text ? 0 : null;
if (installed.prerelease) return 1;
if (latest.prerelease) return -1;
return 0;
}
/**
* Compares `major.minor.patch` releases, the scheme Claude Code, Codex and Pi publish. A
* prerelease comes before the release with the same numbers.
*/
export function compareWithLatestRelease(input: {
installed: string | null | undefined;
latest: string;
}): ProviderUpdateCheck {
const installed = input.installed ? parseReleaseVersion(input.installed) : null;
const latest = parseReleaseVersion(input.latest);
if (!installed) return { status: "unknown", message: "The installed version is not readable" };
if (!latest) return { status: "unknown", message: `Unrecognized release ${input.latest}` };
const order = compareReleases(latest, installed);
if (order === null)
return { status: "unknown", message: `Cannot compare ${installed.text} with ${latest.text}` };
return order > 0
? { status: "available", versions: [input.latest.trim()] }
: { status: "current" };
}
/** Resolves symlinks so Homebrew and npm installs show their real location. */
export async function resolveExecutablePath(executablePath: string): Promise<string> {
try {
return await realpath(executablePath);
} catch {
return executablePath;
}
}
/** The cask name when the executable lives in a Homebrew Caskroom, such as `codex`. */
export function homebrewCaskOf(executablePath: string): string | null {
return executablePath.match(/\/Caskroom\/([^/]+)\//)?.[1] ?? null;
}
async function fetchRelease(url: string, signal: AbortSignal): Promise<Response> {
const response = await fetch(url, { signal, headers: { "User-Agent": "paseo" } });
if (!response.ok) throw new Error(`${url} returned ${response.status}`);
return response;
}
export async function fetchReleaseText(url: string, signal: AbortSignal): Promise<string> {
return (await (await fetchRelease(url, signal)).text()).trim();
}
/** Reads a string field from a JSON release document. */
export async function fetchReleaseField(input: {
url: string;
field: string;
signal: AbortSignal;
}): Promise<string> {
const body: unknown = await (await fetchRelease(input.url, input.signal)).json();
const value =
body && typeof body === "object" ? (body as Record<string, unknown>)[input.field] : undefined;
if (typeof value !== "string") throw new Error(`${input.url} has no ${input.field}`);
return value;
}
/** The latest Homebrew cask version, which can lag the upstream release. */
export function fetchHomebrewCaskVersion(cask: string, signal: AbortSignal): Promise<string> {
return fetchReleaseField({
url: `https://formulae.brew.sh/api/cask/${encodeURIComponent(cask)}.json`,
field: "version",
signal,
});
}
@@ -0,0 +1,52 @@
import type { ProviderUpdateResult } from "@getpaseo/plugin/server/provider";
import { spawnProcess } from "../../../utils/spawn.js";
import type { AgentClientUpdateContext } from "../agent-sdk-types.js";
import type { ProviderEnvSpec } from "../provider-launch-config.js";
export interface UpdateCommand {
command: string;
args: string[];
env: ProviderEnvSpec;
/** Shown when the command exits 0. */
successMessage: string;
}
/** The success message for a CLI that Paseo starts once per agent. */
export function updatedForNewAgents(name: string): string {
return `${name} is updated. New agents use the new version; running agents keep theirs until restarted.`;
}
/**
* Runs a CLI's own updater and passes its output through unchanged. Exit code 0 is success;
* anything else is a failure. Aborting the signal kills the command.
*/
export function runUpdateCommand(
update: UpdateCommand,
context: AgentClientUpdateContext,
): Promise<ProviderUpdateResult> {
const commandLine = [update.command, ...update.args].join(" ");
context.report({ type: "message", text: `Running ${commandLine}\n` });
return new Promise((resolve, reject) => {
const child = spawnProcess(update.command, update.args, {
...update.env,
stdio: ["ignore", "pipe", "pipe"],
signal: context.signal,
});
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (text: string) => context.report({ type: "stdout", text }));
child.stderr?.on("data", (text: string) => context.report({ type: "stderr", text }));
child.once("error", reject);
child.once("close", (code, signal) => {
if (code === 0) resolve({ status: "succeeded", message: update.successMessage });
else
resolve({
status: "failed",
message:
code === null
? `${commandLine} stopped by ${signal}`
: `${commandLine} exited with code ${code}`,
});
});
});
}
@@ -162,6 +162,11 @@ const INBOUND_PERMISSION = {
"project.remove.request": "workspace.manage",
"project.rename.request": "workspace.manage",
project_icon_request: "workspace.read",
"provider.installation.inspect.request": "daemon.read",
"provider.installation.check_updates.request": "daemon.read",
// Runs the provider's updater on the host.
"provider.installation.update.request": "daemon.manage",
"provider.installation.update.subscribe.request": "daemon.read",
"provider.usage.list.request": "daemon.read",
"usage.list_reports.request": "daemon.read",
provider_diagnostic_request: "daemon.read",
@@ -414,6 +419,11 @@ const OUTBOUND_PERMISSION = {
"project.rename.response": "workspace.manage",
"project.update": "workspace.read",
project_icon_response: "workspace.read",
"provider.installation.inspect.response": "daemon.read",
"provider.installation.check_updates.response": "daemon.read",
"provider.installation.update.response": "daemon.manage",
"provider.installation.update.subscribe.response": "daemon.read",
"provider.installation.update.progress": "daemon.read",
"provider.usage.list.response": "daemon.read",
"usage.list_reports.update": "daemon.read",
"usage.list_reports.response": "daemon.read",
@@ -9,6 +9,7 @@ import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { execFileSync } from "node:child_process";
import { createDaemonTestContext } from "../test-utils/index.js";
import { createTestAgentClient } from "../test-utils/fake-agent-client.js";
function isBinaryInstalled(binary: string): boolean {
try {
@@ -333,3 +334,188 @@ test("bundled Hoplite requires a config entry and removal survives restart", asy
await rm(daemon.staticDir, { recursive: true, force: true });
}
}, 60000);
test("provider installation reaches the client: version unchanged, Install link when missing", async () => {
const version = " codex-cli 0.200.0-alpha+local (build 7)\n";
const codex = Object.assign(createTestAgentClient("codex"), {
installation: {
installUrl: "https://example.com/codex/install",
async inspect() {
return { status: "installed" as const, version };
},
},
});
const daemon = await createTestPaseoDaemon({
agentClients: { codex },
mcpEnabled: false,
builtinPlugins: new BuiltinPluginLoader(undefined, ["muse-provider"]),
providerOverrides: { muse: { command: ["paseo-e2e-missing-muse"] } },
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
expect(client.getLastServerInfoMessage()?.features?.providerInstallation).toBe(true);
await expect(client.inspectProviderInstallation("codex")).resolves.toMatchObject({
provider: "codex",
error: null,
installation: {
installUrl: "https://example.com/codex/install",
inspection: { status: "installed", version },
},
});
await expect(client.inspectProviderInstallation("muse")).resolves.toMatchObject({
provider: "muse",
error: null,
installation: {
installUrl: "https://paseo.sh/docs/muse-code",
inspection: { status: "not_installed" },
},
});
} finally {
await client.close();
await daemon.close();
}
}, 60000);
test("provider update checks reach the client: versions unchanged, failures inconclusive", async () => {
const versions = [" 0.300.0-beta+x\n", "nightly (unstable)"];
const message = " Restart Codex to finish\n";
let codexChecks = 0;
const codex = Object.assign(createTestAgentClient("codex"), {
installation: {
async checkForUpdates() {
codexChecks += 1;
return { status: "available" as const, versions, message };
},
},
});
const claude = Object.assign(createTestAgentClient("claude"), {
installation: {
async checkForUpdates(): Promise<never> {
throw new Error("release channel unreachable");
},
},
});
const daemon = await createTestPaseoDaemon({
agentClients: { codex, claude },
mcpEnabled: false,
builtinPlugins: new BuiltinPluginLoader(undefined, ["muse-provider"]),
providerOverrides: { muse: { command: ["paseo-e2e-missing-muse"] } },
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await expect(client.checkProviderUpdates("codex")).resolves.toMatchObject({
provider: "codex",
error: null,
updateCheck: { status: "available", versions, message },
});
await client.checkProviderUpdates("codex");
expect(codexChecks).toBe(1);
await client.checkProviderUpdates("codex", { refresh: true });
expect(codexChecks).toBe(2);
await expect(client.checkProviderUpdates("claude")).resolves.toMatchObject({
error: null,
updateCheck: { status: "error", message: "release channel unreachable" },
});
await expect(client.checkProviderUpdates("muse")).resolves.toMatchObject({
error: null,
updateCheck: { status: "unknown", message: "Muse Code is not installed" },
});
} finally {
await client.close();
await daemon.close();
}
}, 60000);
test("a provider update outlives the client that started it and a new client reads it", async () => {
let version = "1.0.0";
let updates = 0;
let finish!: () => void;
const finished = new Promise<void>((resolve) => {
finish = resolve;
});
const codex = Object.assign(createTestAgentClient("codex"), {
installation: {
async inspect() {
return { status: "installed" as const, version };
},
async update({ report }: { report: (event: { type: "stdout"; text: string }) => void }) {
updates += 1;
report({ type: "stdout", text: " step 1\r\n" });
await finished;
report({ type: "stdout", text: " step 2\n" });
version = "2.0.0";
return { status: "succeeded" as const, message: " Restart agents \n" };
},
},
});
const daemon = await createTestPaseoDaemon({
agentClients: { codex },
mcpEnabled: false,
builtinPlugins: new BuiltinPluginLoader(undefined, []),
});
const first = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const second = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await first.connect();
await expect(first.inspectProviderInstallation("codex")).resolves.toMatchObject({
installation: { canUpdate: true, inspection: { version: "1.0.0" } },
});
const firstObservation = first.observeProviderUpdates("codex");
const firstOutput: string[] = [];
firstObservation.subscribe({
snapshot: () => {},
update: (message) => {
if (message.type === "provider.installation.update.progress")
firstOutput.push(...message.payload.output.map((entry) => entry.text));
},
});
await expect(firstObservation.ready).resolves.toMatchObject({ update: null });
const started = await first.updateProvider("codex");
expect(started).toMatchObject({ error: null, update: { operation: { status: "running" } } });
await expect.poll(() => firstOutput).toEqual([" step 1\r\n"]);
await first.close();
await second.connect();
const joined = await second.updateProvider("codex");
expect(joined.update?.operation.id).toBe(started.update?.operation.id);
expect(updates).toBe(1);
const observation = second.observeProviderUpdates("codex");
let latest: { status: string; message?: string } | null = null;
const output: string[] = [];
observation.subscribe({
snapshot: (snapshot) => {
latest = snapshot.update?.operation ?? null;
output.splice(0, output.length, ...(snapshot.update?.output ?? []).map((e) => e.text));
},
update: (message) => {
if (message.type !== "provider.installation.update.progress") return;
latest = message.payload.operation;
output.push(...message.payload.output.map((entry) => entry.text));
},
});
await expect(observation.ready).resolves.toMatchObject({
update: {
operation: { status: "running" },
output: [{ type: "stdout", text: " step 1\r\n" }],
},
});
finish();
await expect
.poll(() => latest)
.toMatchObject({ status: "succeeded", message: " Restart agents \n" });
expect(output).toEqual([" step 1\r\n", " step 2\n"]);
await expect(second.inspectProviderInstallation("codex")).resolves.toMatchObject({
installation: { inspection: { version: "2.0.0" } },
});
await observation.release();
} finally {
finish();
await first.close().catch(() => undefined);
await second.close();
await daemon.close();
}
}, 60000);
@@ -308,6 +308,173 @@ describe("PluginService", () => {
},
);
it.each(["subprocess", "builtin"] as const)(
"carries a provider update's output and result through the %s plugin runtime",
async (runtime) => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const directory = await createPlugin(
"provider-update",
`export default function contribute(server) {
server.registerProvider({ id: "plugin-agent", label: "Plugin agent", command: ["agent"],
installation: {
async update({ launch, report }) {
report({ type: "message", text: "Running " + launch.command + " update\\n" });
report({ type: "stdout", text: " added 1 package\\r\\n" });
report({ type: "bogus", text: "dropped" });
report({ type: "stderr", text: "warn \\u001b[33mold\\u001b[0m\\n" });
if (launch.env.MODE === "throw") throw new Error(" updater crashed \\n");
if (launch.env.MODE === "fail") return { status: "failed", message: " exit 3 \\n" };
return { status: "succeeded", message: " Restart agents \\n" };
},
},
async connect() { throw new Error("not connected by this test"); },
});
return () => {};
}`,
);
const store = createStore(home);
const builtinRoot = path.join(home, "builtins");
if (runtime === "builtin") {
await mkdir(builtinRoot);
await cp(directory, path.join(builtinRoot, "provider-update"), { recursive: true });
}
const builtinPlugins = new BuiltinPluginLoader(
builtinRoot,
runtime === "builtin" ? ["provider-update"] : [],
);
const service = bindTestSessionHost(
new PluginService(pino({ level: "silent" }), store, "0.4.0", { builtinPlugins }),
);
await service.start();
try {
if (runtime === "subprocess") await service.installDirectory({ path: directory });
const [registration] = service.getProviderRegistrations();
const update = registration!.installation!.update!;
const run = async (env: Record<string, string>) => {
const output: unknown[] = [];
const result = await update({
launch: { command: "/bin/agent", args: [], env, resolved: true },
signal: new AbortController().signal,
report: (event) => output.push(event),
}).catch((error: Error) => ({ thrown: error.message }));
return { result, output };
};
const output = [
{ type: "message", text: "Running /bin/agent update\n" },
{ type: "stdout", text: " added 1 package\r\n" },
{ type: "stderr", text: "warn \u001b[33mold\u001b[0m\n" },
];
await expect(run({})).resolves.toEqual({
result: { status: "succeeded", message: " Restart agents \n" },
output,
});
await expect(run({ MODE: "fail" })).resolves.toEqual({
result: { status: "failed", message: " exit 3 \n" },
output,
});
await expect(run({ MODE: "throw" })).resolves.toEqual({
result: { thrown: " updater crashed \n" },
output,
});
} finally {
await service.stopAllPlugins();
}
},
);
it.each(["subprocess", "builtin"] as const)(
"carries provider installation links, inspect and update checks through the %s plugin runtime",
async (runtime) => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const directory = await createPlugin(
"provider-installation",
`export default function contribute(server) {
server.registerProvider({ id: "plugin-agent", label: "Plugin agent", command: ["agent"],
installation: {
installUrl: "https://example.com/install",
updateUrl: "https://example.com/update",
async inspect({ launch, signal }) {
if (launch.env.HANG === "yes")
return await new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(new Error("aborted"))));
if (!launch.resolved) return { status: "not_installed" };
return { status: "installed", version: launch.command + " " + launch.env.VERSION };
},
async checkForUpdates({ launch, signal }) {
if (launch.env.HANG === "yes")
return await new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(new Error("aborted"))));
if (launch.env.FEED === "down") throw new Error("feed down");
return { status: "available", versions: [launch.env.VERSION, "nightly"], message: launch.env.NOTE };
},
},
async connect() { throw new Error("not connected by this test"); },
});
return () => {};
}`,
);
const store = createStore(home);
const builtinRoot = path.join(home, "builtins");
if (runtime === "builtin") {
await mkdir(builtinRoot);
await cp(directory, path.join(builtinRoot, "provider-installation"), { recursive: true });
}
const builtinPlugins = new BuiltinPluginLoader(
builtinRoot,
runtime === "builtin" ? ["provider-installation"] : [],
);
const service = bindTestSessionHost(
new PluginService(pino({ level: "silent" }), store, "0.4.0", { builtinPlugins }),
);
await service.start();
try {
if (runtime === "subprocess") await service.installDirectory({ path: directory });
const [registration] = service.getProviderRegistrations();
const installation = registration!.installation!;
expect(installation).toMatchObject({
installUrl: "https://example.com/install",
updateUrl: "https://example.com/update",
});
const signal = new AbortController().signal;
const launch = { command: "/bin/agent", args: [], env: { VERSION: " 1.0 (beta)\n" } };
await expect(
installation.inspect!({ launch: { ...launch, resolved: true }, signal }),
).resolves.toEqual({ status: "installed", version: "/bin/agent 1.0 (beta)\n" });
await expect(
installation.inspect!({ launch: { ...launch, resolved: false }, signal }),
).resolves.toEqual({ status: "not_installed" });
await expect(
installation.checkForUpdates!({
launch: { ...launch, env: { ...launch.env, NOTE: " Restart \n" }, resolved: true },
signal,
}),
).resolves.toEqual({
status: "available",
versions: [" 1.0 (beta)\n", "nightly"],
message: " Restart \n",
});
await expect(
installation.checkForUpdates!({
launch: { ...launch, env: { FEED: "down" }, resolved: true },
signal,
}),
).rejects.toThrow("feed down");
for (const call of [installation.inspect!, installation.checkForUpdates!]) {
const abort = new AbortController();
const hanging = call({
launch: { ...launch, env: { HANG: "yes" }, resolved: true },
signal: abort.signal,
});
abort.abort(new Error("stop waiting"));
await expect(hanging).rejects.toThrow("stop waiting");
}
} finally {
await service.stopAllPlugins();
}
},
);
it("resolves a provider icon path to sanitized inline SVG", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
+43 -2
View File
@@ -3,7 +3,7 @@ import type { PluginLifecycle } from "./lifecycle/index.js";
import path from "node:path";
import { stat, rm } from "node:fs/promises";
import type pino from "pino";
import type { ProviderRegistration } from "@getpaseo/plugin/server/provider";
import type { ProviderInstallation, ProviderRegistration } from "@getpaseo/plugin/server/provider";
import {
PluginIdSchema,
type PluginLogEntry,
@@ -27,7 +27,10 @@ import { runPluginBuild } from "./preparation.js";
import { PluginRuntime } from "./runtime.js";
import type { PluginRuntimeSubscription } from "./runtime-subscription.js";
import { BuiltinPluginLoader, type BuiltinPlugin } from "./builtin/index.js";
import type { PluginProviderMetadata } from "./plugin-process-protocol.js";
import type {
PluginProviderInstallationMetadata,
PluginProviderMetadata,
} from "./plugin-process-protocol.js";
import { readPluginProviderIcon } from "./provider-icon.js";
import {
type AgentUsageLookup,
@@ -59,6 +62,9 @@ interface PluginRuntimePort {
connectProvider: PluginRuntime["connectProvider"];
getProviderCatalogCacheKey?: PluginRuntime["getProviderCatalogCacheKey"];
getProviderStatus?: PluginRuntime["getProviderStatus"];
inspectProviderInstallation?: PluginRuntime["inspectProviderInstallation"];
checkProviderUpdates?: PluginRuntime["checkProviderUpdates"];
updateProvider?: PluginRuntime["updateProvider"];
validatePlugin?(path: string): Promise<void>;
startPlugin(pluginId: string, path: string, canPublish: () => boolean): Promise<void>;
startBuiltinPlugin?(plugin: BuiltinPlugin): Promise<void>;
@@ -718,6 +724,9 @@ export class PluginService {
return this.runtime.getProviderCatalogCacheKey(pluginId, provider.id, options);
}
: undefined,
installation: provider.installation
? this.providerInstallation(pluginId, provider.id, provider.installation)
: undefined,
icon: provider.iconPath
? await readPluginProviderIcon(pluginDirectory, provider.iconPath)
: undefined,
@@ -737,6 +746,38 @@ export class PluginService {
}
}
private providerInstallation(
pluginId: string,
providerId: string,
metadata: PluginProviderInstallationMetadata,
): ProviderInstallation {
return {
installUrl: metadata.installUrl,
updateUrl: metadata.updateUrl,
inspect: metadata.hasInspect
? (context) => {
if (!this.runtime.inspectProviderInstallation)
throw new Error("Plugin runtime cannot inspect provider installations");
return this.runtime.inspectProviderInstallation(pluginId, providerId, context);
}
: undefined,
checkForUpdates: metadata.hasCheckForUpdates
? (context) => {
if (!this.runtime.checkProviderUpdates)
throw new Error("Plugin runtime cannot check provider updates");
return this.runtime.checkProviderUpdates(pluginId, providerId, context);
}
: undefined,
update: metadata.hasUpdate
? (context) => {
if (!this.runtime.updateProvider)
throw new Error("Plugin runtime cannot update providers");
return this.runtime.updateProvider(pluginId, providerId, context);
}
: undefined,
};
}
private removeProviderRegistrations(pluginId: string): void {
const ids = this.providerIdsByPlugin.get(pluginId);
if (!ids) return;
@@ -6,9 +6,12 @@ import type {
ProviderCatalogOptions,
ProviderEvent,
ProviderInput,
ProviderInstallationLaunch,
ProviderStatusRequest,
ProviderUpdateReport,
} from "@getpaseo/plugin/server/provider";
import {
ProviderUpdateReportSchema,
ProviderEventSchema,
ProviderInputSchema,
ProviderLaunchSchema,
@@ -19,6 +22,7 @@ export interface PluginProviderMetadata {
requiresConfiguration?: boolean;
hasCatalogCacheKey?: boolean;
hasStatus?: boolean;
installation?: PluginProviderInstallationMetadata;
command?: readonly [string, ...string[]];
id: string;
label: string;
@@ -26,6 +30,14 @@ export interface PluginProviderMetadata {
iconPath?: string;
}
export interface PluginProviderInstallationMetadata {
installUrl?: string;
updateUrl?: string;
hasInspect: boolean;
hasCheckForUpdates: boolean;
hasUpdate?: boolean;
}
export interface PluginUsageSourceMetadata {
id: string;
label: string;
@@ -42,6 +54,16 @@ export type PluginProcessRequest =
providerId: string;
request: ProviderStatusRequest;
}
| {
type:
| "provider.installation.inspect"
| "provider.installation.check_updates"
| "provider.installation.update";
requestId: string;
providerId: string;
request: { launch?: ProviderInstallationLaunch };
}
| { type: "provider.installation.cancel"; requestId: string }
| {
type: "initialize";
pluginId: string;
@@ -99,6 +121,7 @@ export type PluginProcessMessage =
}
| { type: "result"; requestId: string; output: unknown }
| { type: "error"; requestId: string; error: string }
| { type: "provider.installation.report"; requestId: string; report: ProviderUpdateReport }
| { type: "fatal"; error: string }
| { type: "subscription.event"; subscriptionId: string; event: unknown }
| { type: "subscription.closed"; subscriptionId: string; error?: string }
@@ -132,6 +155,16 @@ const providerMetadataSchema = z
requiresConfiguration: z.boolean().optional(),
hasCatalogCacheKey: z.boolean().optional(),
hasStatus: z.boolean().optional(),
installation: z
.object({
installUrl: z.string().optional(),
updateUrl: z.string().optional(),
hasInspect: z.boolean(),
hasCheckForUpdates: z.boolean(),
hasUpdate: z.boolean().optional(),
})
.strict()
.optional(),
command: z.tuple([z.string().min(1)], z.string()).optional(),
})
.strict();
@@ -187,6 +220,23 @@ export const PluginProcessRequestSchema: z.ZodType<PluginProcessRequest> = z.dis
request: z.object({ launch: ProviderLaunchSchema.optional() }).strict(),
})
.strict(),
z
.object({
type: z.enum([
"provider.installation.inspect",
"provider.installation.check_updates",
"provider.installation.update",
]),
requestId: z.string().min(1),
providerId: z.string().min(1),
request: z
.object({
launch: ProviderLaunchSchema.extend({ resolved: z.boolean() }).strict().optional(),
})
.strict(),
})
.strict(),
z.object({ type: z.literal("provider.installation.cancel"), requestId: z.string() }).strict(),
z
.object({
type: z.literal("initialize"),
@@ -308,6 +358,13 @@ export const PluginProcessMessageSchema: z.ZodType<PluginProcessMessage> = z.dis
z
.object({ type: z.literal("error"), requestId: z.string().min(1), error: z.string() })
.strict(),
z
.object({
type: z.literal("provider.installation.report"),
requestId: z.string().min(1),
report: ProviderUpdateReportSchema,
})
.strict(),
z.object({ type: z.literal("fatal"), error: z.string() }).strict(),
z
.object({
@@ -29,6 +29,10 @@ import {
type ProviderConnection,
type ProviderRegistration,
ProviderStatusSchema,
ProviderInspectionSchema,
ProviderUpdateCheckSchema,
ProviderUpdateReportSchema,
ProviderUpdateResultSchema,
} from "@getpaseo/plugin/server/provider";
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -42,6 +46,35 @@ function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function isHttpUrl(value: string): boolean {
try {
const { protocol } = new URL(value);
return protocol === "https:" || protocol === "http:";
} catch {
return false;
}
}
function validateProviderInstallation(
id: string,
installation: ProviderRegistration["installation"],
): void {
if (installation === undefined) return;
for (const url of [installation.installUrl, installation.updateUrl]) {
if (url !== undefined && (typeof url !== "string" || !isHttpUrl(url)))
throw new Error(`Plugin provider ${id} installation links must be http(s) URLs`);
}
if (installation.inspect !== undefined && typeof installation.inspect !== "function")
throw new Error(`Invalid installation inspect callback for plugin provider ${id}`);
if (
installation.checkForUpdates !== undefined &&
typeof installation.checkForUpdates !== "function"
)
throw new Error(`Invalid installation checkForUpdates callback for plugin provider ${id}`);
if (installation.update !== undefined && typeof installation.update !== "function")
throw new Error(`Invalid installation update callback for plugin provider ${id}`);
}
export interface PluginWorkerChannel {
send(message: PluginProcessMessage, callback?: () => void): void;
onMessage(handler: (message: PluginProcessRequest) => void): () => void;
@@ -87,6 +120,7 @@ export function createPluginWorker(options: {
{ connection: ProviderConnection; unsubscribe: () => void; closing?: Promise<void> }
>();
const pendingProviderConnections = new Map<string, { tombstoned: boolean }>();
const installationRequests = new Map<string, AbortController>();
let cleanup: (() => void | Promise<void>) | null = null;
let daemonClient: DaemonClient | null = null;
let paseo: PaseoApi | null = null;
@@ -167,6 +201,7 @@ export function createPluginWorker(options: {
}
if (provider.status !== undefined && typeof provider.status !== "function")
throw new Error(`Invalid status callback for plugin provider ${id}`);
validateProviderInstallation(id, provider.installation);
if (
provider.command !== undefined &&
(provider.command.length === 0 ||
@@ -202,10 +237,77 @@ export function createPluginWorker(options: {
requiresConfiguration: provider.requiresConfiguration,
hasCatalogCacheKey: provider.getCatalogCacheKey !== undefined,
hasStatus: provider.status !== undefined,
installation: provider.installation
? {
installUrl: provider.installation.installUrl,
updateUrl: provider.installation.updateUrl,
hasInspect: provider.installation.inspect !== undefined,
hasCheckForUpdates: provider.installation.checkForUpdates !== undefined,
hasUpdate: provider.installation.update !== undefined,
}
: undefined,
command: provider.command,
};
}
/** Handles installation requests; returns false for any other message. */
function handleInstallationMessage(message: PluginProcessRequest): boolean {
if (message.type === "provider.installation.cancel") {
installationRequests
.get(message.requestId)
?.abort(new Error("Installation request canceled"));
return true;
}
if (
message.type !== "provider.installation.inspect" &&
message.type !== "provider.installation.check_updates" &&
message.type !== "provider.installation.update"
)
return false;
const controller = new AbortController();
installationRequests.set(message.requestId, controller);
const context = { launch: message.request.launch, signal: controller.signal };
void (async () => {
const installation = providers.get(message.providerId)?.installation;
if (message.type === "provider.installation.update") {
if (!installation?.update)
throw new Error(`Provider has no update capability: ${message.providerId}`);
let settled = false;
const report = (event: unknown) => {
const parsed = ProviderUpdateReportSchema.safeParse(event);
if (settled || !parsed.success) return;
send({
type: "provider.installation.report",
requestId: message.requestId,
report: parsed.data,
});
};
try {
return ProviderUpdateResultSchema.parse(
await installation.update({ ...context, report }),
);
} finally {
settled = true;
}
}
if (message.type === "provider.installation.inspect") {
if (!installation?.inspect)
throw new Error(`Provider has no inspect capability: ${message.providerId}`);
return ProviderInspectionSchema.parse(await installation.inspect(context));
}
if (!installation?.checkForUpdates)
throw new Error(`Provider has no checkForUpdates capability: ${message.providerId}`);
return ProviderUpdateCheckSchema.parse(await installation.checkForUpdates(context));
})()
.then(
(output) => send({ type: "result", requestId: message.requestId, output }),
(error) =>
send({ type: "error", requestId: message.requestId, error: describeError(error) }),
)
.finally(() => installationRequests.delete(message.requestId));
return true;
}
async function connectProvider(
message: Extract<PluginProcessRequest, { type: "provider.connect" }>,
): Promise<void> {
@@ -367,6 +469,8 @@ export function createPluginWorker(options: {
agentTools.close();
subscriptions.releaseAll();
for (const pending of pendingProviderConnections.values()) pending.tombstoned = true;
for (const request of installationRequests.values())
request.abort(new Error("Plugin is stopping"));
const currentCleanup = cleanup;
cleanup = null;
try {
@@ -402,6 +506,9 @@ export function createPluginWorker(options: {
function rejectWhileStopping(message: PluginProcessRequest): void {
if (
message.type === "provider.status" ||
message.type === "provider.installation.inspect" ||
message.type === "provider.installation.check_updates" ||
message.type === "provider.installation.update" ||
message.type === "provider.catalog_key" ||
message.type === "usage.fetch" ||
message.type === "usage.discover" ||
@@ -470,6 +577,47 @@ export function createPluginWorker(options: {
else send({ type: "error", requestId: message.requestId, error: "Plugin API unavailable" });
}
/** Handles a provider's status, catalogue key and installation requests; false for others. */
function handleProviderQueryMessage(message: PluginProcessRequest): message is Extract<
PluginProcessRequest,
{
type:
| "provider.status"
| "provider.catalog_key"
| "provider.installation.inspect"
| "provider.installation.check_updates"
| "provider.installation.update"
| "provider.installation.cancel";
}
> {
if (message.type === "provider.status") {
void (async () => {
const provider = providers.get(message.providerId);
if (!provider || !provider.status)
throw new Error(`Provider has no status capability: ${message.providerId}`);
const output = ProviderStatusSchema.parse(await provider.status(message.request));
send({ type: "result", requestId: message.requestId, output });
})().catch((error) =>
send({ type: "error", requestId: message.requestId, error: describeError(error) }),
);
return true;
}
if (message.type === "provider.catalog_key") {
void (async () => {
const provider = providers.get(message.providerId);
if (!provider) throw new Error(`Unknown provider: ${message.providerId}`);
const output = await provider.getCatalogCacheKey?.(message.options);
if (output !== undefined && typeof output !== "string")
throw new Error("Invalid catalogue key");
send({ type: "result", requestId: message.requestId, output });
})().catch((error) =>
send({ type: "error", requestId: message.requestId, error: describeError(error) }),
);
return true;
}
return handleInstallationMessage(message);
}
function handleMessage(rawMessage: unknown): void {
const parsed = PluginProcessRequestSchema.safeParse(rawMessage);
if (!parsed.success) {
@@ -512,31 +660,7 @@ export function createPluginWorker(options: {
return;
}
if (handleToolMessage(message)) return;
if (message.type === "provider.status") {
void (async () => {
const provider = providers.get(message.providerId);
if (!provider || !provider.status)
throw new Error(`Provider has no status capability: ${message.providerId}`);
const output = ProviderStatusSchema.parse(await provider.status(message.request));
send({ type: "result", requestId: message.requestId, output });
})().catch((error) =>
send({ type: "error", requestId: message.requestId, error: describeError(error) }),
);
return;
}
if (message.type === "provider.catalog_key") {
void (async () => {
const provider = providers.get(message.providerId);
if (!provider) throw new Error(`Unknown provider: ${message.providerId}`);
const output = await provider.getCatalogCacheKey?.(message.options);
if (output !== undefined && typeof output !== "string")
throw new Error("Invalid catalogue key");
send({ type: "result", requestId: message.requestId, output });
})().catch((error) =>
send({ type: "error", requestId: message.requestId, error: describeError(error) }),
);
return;
}
if (handleProviderQueryMessage(message)) return;
if (message.type === "usage.fetch" || message.type === "usage.discover") {
handleUsageRequest(message);
return;
+129 -21
View File
@@ -4,7 +4,16 @@ import type { PluginWireBeforeRequests, PluginBeforeResults } from "./lifecycle/
import type { SessionOpenWireRequest, AttachedAgentTool } from "./agent-tools.js";
import type { UsageScope } from "@getpaseo/plugin/server/usage";
import {
ProviderInspectionSchema,
ProviderUpdateCheckSchema,
ProviderUpdateResultSchema,
ProviderStatusSchema,
type ProviderInspection,
type ProviderUpdateCheck,
type ProviderUpdateContext,
type ProviderUpdateReport,
type ProviderUpdateResult,
type ProviderInstallationContext,
type ProviderStatus,
type ProviderStatusRequest,
} from "@getpaseo/plugin/server/provider";
@@ -68,7 +77,15 @@ interface PluginChild {
interface PendingInvocation {
resolve: (output: unknown) => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout>;
/** Absent for requests without a deadline. */
timeout?: ReturnType<typeof setTimeout>;
report?: (report: ProviderUpdateReport) => void;
}
interface PluginRequestOptions {
signal?: AbortSignal;
/** Receives `provider.installation.report` messages for the request. */
report?: (report: ProviderUpdateReport) => void;
}
interface LoadedPlugin {
@@ -267,6 +284,21 @@ function jsonTransportValue<Value>(value: Value): Value {
return JSON.parse(encoded) as Value;
}
function isInstallationRequest(message: PluginProcessRequest): boolean {
return (
message.type === "provider.installation.inspect" ||
message.type === "provider.installation.check_updates" ||
message.type === "provider.installation.update"
);
}
function requestTimeoutMs(message: PluginProcessRequest): number | null {
if (message.type === "tool.call") return TOOL_CALL_TIMEOUT_MS;
// An updater runs as long as it needs; daemon shutdown aborts it.
if (message.type === "provider.installation.update") return null;
return REQUEST_TIMEOUT_MS;
}
function send(child: PluginChild, message: PluginProcessRequest): Promise<void> {
return new Promise((resolve, reject) => {
child.send(message, (error) => {
@@ -438,6 +470,69 @@ export class PluginRuntime {
);
}
async inspectProviderInstallation(
pluginId: string,
providerId: string,
context: ProviderInstallationContext,
): Promise<ProviderInspection> {
const loaded = this.plugins.get(pluginId);
if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`);
return ProviderInspectionSchema.parse(
await this.request(
loaded,
{
type: "provider.installation.inspect",
requestId: randomUUID(),
providerId,
request: { launch: context.launch },
},
{ signal: context.signal },
),
);
}
async checkProviderUpdates(
pluginId: string,
providerId: string,
context: ProviderInstallationContext,
): Promise<ProviderUpdateCheck> {
const loaded = this.plugins.get(pluginId);
if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`);
return ProviderUpdateCheckSchema.parse(
await this.request(
loaded,
{
type: "provider.installation.check_updates",
requestId: randomUUID(),
providerId,
request: { launch: context.launch },
},
{ signal: context.signal },
),
);
}
async updateProvider(
pluginId: string,
providerId: string,
context: ProviderUpdateContext,
): Promise<ProviderUpdateResult> {
const loaded = this.plugins.get(pluginId);
if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`);
return ProviderUpdateResultSchema.parse(
await this.request(
loaded,
{
type: "provider.installation.update",
requestId: randomUUID(),
providerId,
request: { launch: context.launch },
},
{ signal: context.signal, report: (report) => context.report(report) },
),
);
}
async connectProvider(
pluginId: string,
providerId: string,
@@ -732,7 +827,7 @@ export class PluginRuntime {
return (await this.request(
loaded,
{ type: "tool.call", requestId, agent, name, input },
signal,
{ signal },
)) as AgentToolResult;
} catch (error) {
if (signal?.aborted) throw error;
@@ -743,8 +838,9 @@ export class PluginRuntime {
private request(
loaded: LoadedPlugin,
message: Extract<PluginProcessRequest, { requestId: string }>,
signal?: AbortSignal,
options: PluginRequestOptions = {},
): Promise<unknown> {
const { signal, report } = options;
const child = loaded.child;
const pluginId = loaded.id;
if (!child) throw new Error(`Plugin ${pluginId} has no server entry`);
@@ -756,27 +852,34 @@ export class PluginRuntime {
loaded.pending.delete(requestId);
if (message.type === "tool.call")
void send(child, { type: "tool.cancel", requestId }).catch(() => {});
if (isInstallationRequest(message))
void send(child, { type: "provider.installation.cancel", requestId }).catch(() => {});
signal?.removeEventListener("abort", cancel);
reject(signal?.reason ?? new Error("Tool call canceled"));
};
const timeout = setTimeout(
() => {
loaded.pending.delete(requestId);
signal?.removeEventListener("abort", cancel);
if (message.type === "tool.call")
void send(child, { type: "tool.cancel", requestId }).catch(() => {});
if (message.type === "hook") {
void send(child, { type: "hook.cancel", requestId }).catch(() => {});
}
// The handler may still start after the daemon gave up on it.
if (message.type === "subscribe") {
const { subscriptionId } = message;
void send(child, { type: "unsubscribe", subscriptionId }).catch(() => {});
}
reject(new Error(`Plugin RPC timed out: ${pluginId}.${message.type}`));
},
message.type === "tool.call" ? TOOL_CALL_TIMEOUT_MS : REQUEST_TIMEOUT_MS,
);
const timeoutMs = requestTimeoutMs(message);
const timeout =
timeoutMs === null
? undefined
: setTimeout(() => {
loaded.pending.delete(requestId);
signal?.removeEventListener("abort", cancel);
if (message.type === "tool.call")
void send(child, { type: "tool.cancel", requestId }).catch(() => {});
if (message.type === "hook") {
void send(child, { type: "hook.cancel", requestId }).catch(() => {});
}
// The handler may still start after the daemon gave up on it.
if (message.type === "subscribe") {
const { subscriptionId } = message;
void send(child, { type: "unsubscribe", subscriptionId }).catch(() => {});
}
if (isInstallationRequest(message))
void send(child, { type: "provider.installation.cancel", requestId }).catch(
() => {},
);
reject(new Error(`Plugin RPC timed out: ${pluginId}.${message.type}`));
}, timeoutMs);
signal?.addEventListener("abort", cancel, { once: true });
loaded.pending.set(requestId, {
resolve: (value) => {
@@ -788,6 +891,7 @@ export class PluginRuntime {
reject(error);
},
timeout,
report,
});
void send(child, message).catch((error) => {
clearTimeout(timeout);
@@ -1055,6 +1159,10 @@ export class PluginRuntime {
this.dependencies.onSettingsChanged?.(loaded.id, message.settingsId);
return;
}
if (message.type === "provider.installation.report") {
loaded.pending.get(message.requestId)?.report?.(message.report);
return;
}
if (message.type.startsWith("provider.")) {
this.handleProviderMessage(loaded, message);
return;
+35
View File
@@ -2585,12 +2585,41 @@ export class Session {
});
}
private async subscribeProviderUpdates(
request: Extract<
SessionInboundMessage,
{ type: "provider.installation.update.subscribe.request" }
>,
): Promise<void> {
if (!this.delivery.isModern(this.delivery.currentSource))
throw new Error("Provider update observation requires owned subscriptions");
const installations = this.providerSnapshotManager.installations;
let stop: (() => void) | undefined;
const owner = this.delivery.begin("provider-update", undefined, () => stop?.());
// Register and read in one synchronous turn so no progress falls between them.
stop = installations.observeUpdates(request.provider, (progress) =>
owner.emit({ type: "provider.installation.update.progress", payload: progress }),
);
this.emit({
type: "provider.installation.update.subscribe.response",
payload: {
requestId: request.requestId,
subscriptionId: owner.responseId,
provider: request.provider,
update: installations.getUpdate(request.provider),
error: null,
},
});
}
private dispatchSubscriptionMessage(
msg: SessionInboundMessage,
source?: object,
): Promise<void> | undefined {
if (msg.type === "workspace.setup.subscribe.request") return this.subscribeWorkspaceSetup(msg);
if (msg.type === "browser.host.register.request") return this.registerBrowserHost(msg);
if (msg.type === "provider.installation.update.subscribe.request")
return this.subscribeProviderUpdates(msg);
if (msg.type === "browser.automation.execute.response") {
if (source)
this.browserToolsBroker?.receiveResponse(
@@ -3462,6 +3491,12 @@ export class Session {
return this.providerCatalogSession.handleRefreshProvidersSnapshotRequest(msg);
case "provider_diagnostic_request":
return this.providerCatalogSession.handleProviderDiagnosticRequest(msg);
case "provider.installation.inspect.request":
return this.providerCatalogSession.handleInspectProviderInstallationRequest(msg);
case "provider.installation.check_updates.request":
return this.providerCatalogSession.handleCheckProviderUpdatesRequest(msg);
case "provider.installation.update.request":
return this.providerCatalogSession.handleUpdateProviderRequest(msg);
case "provider.usage.list.request":
return this.usageSession.handleLegacyList(msg);
case "usage.list_reports.request":
@@ -517,6 +517,77 @@ export class ProviderCatalogSession {
});
}
}
async handleInspectProviderInstallationRequest(
msg: Extract<SessionInboundMessage, { type: "provider.installation.inspect.request" }>,
): Promise<void> {
const response = { provider: msg.provider, requestId: msg.requestId };
try {
const installation = await this.providerSnapshotManager.installations.inspect(msg.provider, {
refresh: msg.refresh,
});
this.host.emit({
type: "provider.installation.inspect.response",
payload: { ...response, installation, error: null },
});
} catch (error) {
this.host.emit({
type: "provider.installation.inspect.response",
payload: {
...response,
installation: null,
error: error instanceof Error ? error.message : String(error),
},
});
}
}
async handleUpdateProviderRequest(
msg: Extract<SessionInboundMessage, { type: "provider.installation.update.request" }>,
): Promise<void> {
const response = { provider: msg.provider, requestId: msg.requestId };
try {
const update = this.providerSnapshotManager.installations.update(msg.provider);
this.host.emit({
type: "provider.installation.update.response",
payload: { ...response, update, error: null },
});
} catch (error) {
this.host.emit({
type: "provider.installation.update.response",
payload: {
...response,
update: null,
error: error instanceof Error ? error.message : String(error),
},
});
}
}
async handleCheckProviderUpdatesRequest(
msg: Extract<SessionInboundMessage, { type: "provider.installation.check_updates.request" }>,
): Promise<void> {
const response = { provider: msg.provider, requestId: msg.requestId };
try {
const updateCheck = await this.providerSnapshotManager.installations.checkForUpdates(
msg.provider,
{ refresh: msg.refresh },
);
this.host.emit({
type: "provider.installation.check_updates.response",
payload: { ...response, updateCheck, error: null },
});
} catch (error) {
this.host.emit({
type: "provider.installation.check_updates.response",
payload: {
...response,
updateCheck: null,
error: error instanceof Error ? error.message : String(error),
},
});
}
}
}
function resolveCatalogRequestCwd(cwd?: string | null): string | undefined {
@@ -2030,6 +2030,7 @@ export class VoiceAssistantWebSocketServer {
relayProtectedSecurity: true,
usageSetting: true,
usageSources: true,
providerInstallation: true,
ownedSubscriptions: true,
providerOptionControls: true,
entityMetadata: true,
@@ -789,3 +789,30 @@ it.runIf(process.platform === "win32")(
});
},
);
describe("installation", () => {
const signal = new AbortController().signal;
it("reports the version agy prints and links its install docs", async () => {
const installation = provider.installation!;
expect(installation.installUrl).toBe("https://antigravity.google/docs/getting-started?tab=cli");
expect(installation.update).toBeUndefined();
const launch = {
command: process.execPath,
args: [fake],
env: { AGY_TEST_VERSION: "1.2.14" },
resolved: true,
};
await expect(installation.inspect!({ launch, signal })).resolves.toEqual({
status: "installed",
version: "1.2.14",
});
});
it("is not installed when the executable does not resolve", async () => {
const launch = { command: "agy", args: [], env: {}, resolved: false };
await expect(provider.installation!.inspect!({ launch, signal })).resolves.toEqual({
status: "not_installed",
});
});
});
@@ -1,4 +1,5 @@
import { createHash } from "node:crypto";
import { execCommand } from "@getpaseo/plugin/server";
import {
negotiateProviderCapabilities,
requireProviderCapabilities,
@@ -6,6 +7,7 @@ import {
type ProviderConnection,
type ProviderEvent,
type ProviderInput,
type ProviderInstallationLaunch,
type ProviderLaunch,
type ProviderRegistration,
} from "@getpaseo/plugin/server/provider";
@@ -20,6 +22,21 @@ const capabilities = [
"session.persistence",
] as const;
/** The release `agy --version` reports, or its first line when it names no release. */
async function readAgyVersion(
launch: ProviderInstallationLaunch,
signal: AbortSignal,
): Promise<string | undefined> {
const { stdout } = await execCommand(launch.command, [...launch.args, "--version"], {
env: launch.env,
timeout: 5000,
maxBuffer: 8192,
signal,
});
const firstLine = stdout.trim().split("\n")[0]?.trim();
return /\b\d+\.\d+\.\d+\S*/.exec(stdout)?.[0] ?? (firstLine || undefined);
}
export function createAntigravityProvider(): ProviderRegistration {
return {
id: "antigravity",
@@ -27,6 +44,15 @@ export function createAntigravityProvider(): ProviderRegistration {
icon: "icon.svg",
command: ["agy"],
description: "Antigravity CLI on your host",
// agy documents no update command and no release source, so there is nothing to check or run.
installation: {
installUrl: "https://antigravity.google/docs/getting-started?tab=cli",
async inspect({ launch, signal }) {
if (!launch?.resolved) return { status: "not_installed" };
const version = await readAgyVersion(launch, signal);
return version ? { status: "installed", version } : { status: "installed" };
},
},
async status({ launch }) {
if (!launch)
return {
@@ -0,0 +1,94 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { ProviderInstallationLaunch } from "@getpaseo/plugin/server/provider";
import { createHopliteInstallation } from "./installation.js";
const temporary: string[] = [];
const signal = new AbortController().signal;
afterEach(async () => {
await Promise.all(temporary.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
/** A `hoplite` that records its arguments and prints `banner` for `--version`. */
async function fakeHoplite(banner: string) {
const dir = await mkdtemp(path.join(os.tmpdir(), "paseo-hoplite-"));
temporary.push(dir);
const script = path.join(dir, "hoplite.mjs");
const record = path.join(dir, "args.json");
await writeFile(
script,
[
`import { writeFileSync } from "node:fs";`,
`writeFileSync(${JSON.stringify(record)}, JSON.stringify(process.argv.slice(2)));`,
`console.log(${JSON.stringify(banner)});`,
].join("\n"),
);
// Sessions launch `hoplite acp` plus any configured arguments.
const launch: ProviderInstallationLaunch = {
command: process.execPath,
args: [script, "acp", "--verbose"],
env: {},
resolved: true,
};
return { launch, args: async () => JSON.parse(await readFile(record, "utf8")) as string[] };
}
function latest(version: string) {
return async () => version;
}
describe("Hoplite installation", () => {
it("reads the version from the launch without `acp`", async () => {
const hoplite = await fakeHoplite("3.4.0");
const installation = createHopliteInstallation(latest("3.5.1"));
await expect(installation.inspect!({ launch: hoplite.launch, signal })).resolves.toEqual({
status: "installed",
version: "3.4.0",
});
expect(await hoplite.args()).toEqual(["--version"]);
});
it("compares the installed version with the release the installer reads", async () => {
const hoplite = await fakeHoplite("3.4.0");
const check = (release: string) =>
createHopliteInstallation(latest(release)).checkForUpdates!({
launch: hoplite.launch,
signal,
});
await expect(check("3.5.1")).resolves.toEqual({ status: "available", versions: ["3.5.1"] });
await expect(check("3.4.0")).resolves.toEqual({ status: "current" });
await expect(check("3.3.9")).resolves.toEqual({ status: "current" });
await expect(check("<html>")).resolves.toEqual({
status: "unknown",
message: "Unrecognized release <html>",
});
});
it("does not check when the user turned Hoplite's update check off", async () => {
const hoplite = await fakeHoplite("3.4.0");
const launch = { ...hoplite.launch, env: { HOPLITE_NO_UPDATE_CHECK: "1" } };
await expect(
createHopliteInstallation(latest("3.5.1")).checkForUpdates!({ launch, signal }),
).resolves.toEqual({
status: "unknown",
message: "HOPLITE_NO_UPDATE_CHECK turns update checks off",
});
});
it("links the upgrade docs and runs no updater, since the CLI does not update itself", () => {
const installation = createHopliteInstallation();
expect(installation.updateUrl).toBe("https://hoplite.sh/docs/cli#upgrade");
expect(installation.update).toBeUndefined();
});
it("is not installed when the executable does not resolve", async () => {
const launch = { command: "hoplite", args: ["acp"], env: {}, resolved: false };
const installation = createHopliteInstallation(latest("3.5.1"));
await expect(installation.inspect!({ launch, signal })).resolves.toEqual({
status: "not_installed",
});
});
});
@@ -0,0 +1,84 @@
import { execCommand } from "@getpaseo/plugin/server";
import type {
ProviderInstallation,
ProviderInstallationLaunch,
ProviderUpdateCheck,
} from "@getpaseo/plugin/server/provider";
/** The file Hoplite's installer and its own update check read for the current release. */
const LATEST_RELEASE_URL = "https://hoplite.sh/downloads/latest.json";
export type LatestHopliteRelease = (signal: AbortSignal) => Promise<string>;
type Release = [number, number, number];
function parseRelease(text: string): Release | null {
const match = /\b(\d+)\.(\d+)\.(\d+)\b/.exec(text);
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
}
function isNewer(latest: Release, installed: Release): boolean {
for (let index = 0; index < 3; index += 1) {
if (latest[index] !== installed[index]) return latest[index] > installed[index];
}
return false;
}
async function fetchLatestRelease(signal: AbortSignal): Promise<string> {
const response = await fetch(LATEST_RELEASE_URL, { signal });
if (!response.ok) throw new Error(`${LATEST_RELEASE_URL} returned ${response.status}`);
const body: unknown = await response.json();
const version =
body && typeof body === "object" ? (body as Record<string, unknown>).version : undefined;
if (typeof version !== "string") throw new Error(`${LATEST_RELEASE_URL} has no version`);
return version;
}
/**
* Sessions run `hoplite acp` followed by any configured arguments. The version comes from the
* part of the launch before `acp`, so `npx hoplite acp` reads `npx hoplite --version`.
*/
async function readVersion(
launch: ProviderInstallationLaunch,
signal: AbortSignal,
): Promise<string | undefined> {
const acp = launch.args.indexOf("acp");
const args = acp === -1 ? launch.args : launch.args.slice(0, acp);
const { stdout } = await execCommand(launch.command, [...args, "--version"], {
env: launch.env,
timeout: 5000,
maxBuffer: 8192,
signal,
});
const firstLine = stdout.trim().split("\n")[0]?.trim();
return /\b\d+\.\d+\.\d+\S*/.exec(stdout)?.[0] ?? (firstLine || undefined);
}
export function createHopliteInstallation(
latestRelease: LatestHopliteRelease = fetchLatestRelease,
): ProviderInstallation {
return {
installUrl: "https://hoplite.sh/docs/cli#install",
// The CLI does not update itself; its docs list the command for each install method.
updateUrl: "https://hoplite.sh/docs/cli#upgrade",
async inspect({ launch, signal }) {
if (!launch?.resolved) return { status: "not_installed" };
const version = await readVersion(launch, signal);
return version ? { status: "installed", version } : { status: "installed" };
},
async checkForUpdates({ launch, signal }): Promise<ProviderUpdateCheck> {
if (!launch?.resolved) return { status: "unknown", message: "Hoplite is not installed" };
if (launch.env.HOPLITE_NO_UPDATE_CHECK === "1")
return { status: "unknown", message: "HOPLITE_NO_UPDATE_CHECK turns update checks off" };
const installed = parseRelease((await readVersion(launch, signal)) ?? "");
if (!installed)
return { status: "unknown", message: "The installed version is not readable" };
const latestText = (await latestRelease(signal)).trim();
const latest = parseRelease(latestText);
if (!latest) return { status: "unknown", message: `Unrecognized release ${latestText}` };
return isNewer(latest, installed)
? { status: "available", versions: [latestText] }
: { status: "current" };
},
};
}
@@ -1,5 +1,6 @@
import { runAcpProvider } from "@getpaseo/plugin/server/acp";
import type { ProviderRegistration } from "@getpaseo/plugin/server/provider";
import { createHopliteInstallation } from "./installation.js";
import { runPhaseTransformer } from "./run-phase.js";
export function createHopliteProvider(): ProviderRegistration {
@@ -14,5 +15,6 @@ export function createHopliteProvider(): ProviderRegistration {
acpOptions: { workspaceOnlyCatalog: true },
transformers: [runPhaseTransformer],
}),
installation: createHopliteInstallation(),
};
}
+66 -16
View File
@@ -17,6 +17,11 @@ import { MuseError, actionableError } from "./errors.js";
import { Sessions } from "./sessions.js";
import { Session } from "./session.js";
import { accountSchema } from "./wire.js";
import {
checkMuseForUpdates,
readMuseChannelRelease,
type ReadMuseChannelRelease,
} from "./updates.js";
const capabilities = [
"prompt.message",
@@ -29,12 +34,42 @@ const capabilities = [
"session.list",
] as const;
export function createMuseProvider(usage: Usage): ProviderRegistration {
export interface MuseProviderOptions {
/** Reads a Muse update channel; defaults to the channel the `muse` launcher updates from. */
readChannelRelease?: ReadMuseChannelRelease;
}
export function createMuseProvider(
usage: Usage,
museOptions: MuseProviderOptions = {},
): ProviderRegistration {
return {
id: "muse",
label: "Muse Code",
icon: "icon.svg",
command: ["muse"],
installation: {
installUrl: "https://paseo.sh/docs/muse-code",
async inspect({ launch, signal }) {
if (!launch?.resolved) return { status: "not_installed" };
const version = await readVersion(launch, signal);
// Report the build the update check compares, so a new build of the same x.y.z reads as
// a different version and Paseo drops the update check it cached for the old one.
return version
? { status: "installed", version: version.build ?? version.text }
: { status: "installed" };
},
async checkForUpdates({ launch, signal }) {
if (!launch?.resolved) return { status: "unknown", message: "Muse Code is not installed" };
const version = await readVersion(launch, signal);
return checkMuseForUpdates({
installedBuild: version?.build ?? null,
env: launch.env,
readChannelRelease: museOptions.readChannelRelease ?? readMuseChannelRelease,
signal,
});
},
},
async getCatalogCacheKey(options) {
return launchKey(requireLaunch(options.launch));
},
@@ -60,24 +95,39 @@ function requireLaunch(launch: ProviderLaunch | undefined): ProviderLaunch {
if (!launch) throw new MuseError("missingLaunch", "Muse requires a daemon-resolved executable");
return launch;
}
interface MuseVersion {
text: string;
major: number;
minor: number;
/** The build in parentheses, as in `Muse Code 1.4.18 (1.4.18-R8587.1)`. */
build: string | null;
}
/** The `x.y.z` that `muse --version` prints, or null when the output has none. */
async function readVersion(
launch: ProviderLaunch,
signal?: AbortSignal,
): Promise<MuseVersion | null> {
const { stdout } = await execCommand(launch.command, [...launch.args, "--version"], {
env: launch.env,
timeout: 3000,
maxBuffer: 8192,
signal,
});
const match = /\b(\d+)\.(\d+)\.(\d+)\b/.exec(stdout);
if (!match) return null;
const build = /\(([^)\s]+)\)/.exec(stdout)?.[1] ?? null;
return { text: match[0], major: Number(match[1]), minor: Number(match[2]), build };
}
async function status(launch: ProviderLaunch): Promise<ProviderStatus> {
let host: MspConnection | undefined;
try {
const { stdout: versionText } = await execCommand(
launch.command,
[...launch.args, "--version"],
{
env: launch.env,
timeout: 3000,
maxBuffer: 8192,
},
);
const match = /\b(\d+)\.(\d+)\.(\d+)\b/.exec(versionText);
if (!match) return { available: false, diagnostic: "Muse returned an unrecognized version." };
const major = Number(match[1]);
const minor = Number(match[2]);
if (major < 1 || (major === 1 && minor < 3))
return { available: false, diagnostic: `Update Muse Code: found ${match[0]}, need ≥1.3.0` };
const version = await readVersion(launch);
if (!version) return { available: false, diagnostic: "Muse returned an unrecognized version." };
if (version.major < 1 || (version.major === 1 && version.minor < 3))
return {
available: false,
diagnostic: `Update Muse Code: found ${version.text}, need ≥1.3.0`,
};
// Leave room for the version probe, account read and cleanup within the status RPC deadline.
host = new MspConnection({ launch, timeoutMs: 3000, startupTimeoutMs: 20000 });
await host.initialize();
+52
View File
@@ -0,0 +1,52 @@
import type { ProviderUpdateCheck } from "@getpaseo/plugin/server/provider";
import { z } from "zod";
/** What a Muse update channel publishes; the launcher installs `version` when it differs. */
export interface MuseChannelRelease {
version: string;
notificationText?: string;
}
export type ReadMuseChannelRelease = (input: {
channel: string;
signal: AbortSignal;
}) => Promise<MuseChannelRelease>;
const DEFAULT_CHANNEL = "muse-stable";
const channelManifestSchema = z.object({
version: z.string().min(1),
notification_text: z.string().optional(),
});
/** Reads the channel manifest the `muse` launcher updates from. */
export const readMuseChannelRelease: ReadMuseChannelRelease = async ({ channel, signal }) => {
const url = `https://api.meta.ai/muse-code/channels/${encodeURIComponent(channel)}`;
const response = await fetch(url, { signal });
if (!response.ok) throw new Error(`Muse update channel returned ${response.status}`);
const manifest = channelManifestSchema.parse(await response.json());
return { version: manifest.version, notificationText: manifest.notification_text };
};
/**
* Compares the installed build with the build the launcher's channel publishes. The launcher
* replaces any build that differs from the channel's, so a different build is an update.
*/
export async function checkMuseForUpdates(input: {
/** The build `muse --version` prints in parentheses, such as `1.4.18-R8587.1`. */
installedBuild: string | null;
env: Record<string, string> | undefined;
readChannelRelease: ReadMuseChannelRelease;
signal: AbortSignal;
}): Promise<ProviderUpdateCheck> {
if (!input.installedBuild)
return { status: "unknown", message: "This Muse Code does not report its build" };
const channel = input.env?.MUSE_CHANNEL || DEFAULT_CHANNEL;
const release = await input.readChannelRelease({ channel, signal: input.signal });
if (release.version === input.installedBuild) return { status: "current" };
return {
status: "available",
versions: [release.version],
...(release.notificationText ? { message: release.notificationText } : {}),
};
}
@@ -19,6 +19,7 @@ import {
import type { UsageSourceRegistration } from "@getpaseo/plugin/server";
import { execCommand } from "@getpaseo/plugin/server";
import contribute from "../index.server.js";
import { createMuseProvider } from "../server/provider.js";
const connections: ProviderConnection[] = [];
const roots: string[] = [];
@@ -161,6 +162,75 @@ test("registration exposes Muse with a daemon-resolved command", () => {
icon: "icon.svg",
});
});
test("installation links to the Muse guide and reports the installed version", async () => {
const provider = registration();
const launch = {
command: process.execPath,
args: [fileURLToPath(new URL("./fake-muse.mjs", import.meta.url))],
env: { MUSE_TEST_VERSION: "1.7.2" },
};
const signal = new AbortController().signal;
expect(provider.installation?.installUrl).toBe("https://paseo.sh/docs/muse-code");
await expect(
provider.installation?.inspect?.({ launch: { ...launch, resolved: true }, signal }),
).resolves.toEqual({ status: "installed", version: "1.7.2" });
await expect(
provider.installation?.inspect?.({
launch: { ...launch, command: "muse", resolved: false },
signal,
}),
).resolves.toEqual({ status: "not_installed" });
});
test("installation reports the build its update check compares", async () => {
const provider = registration();
const launch = {
command: process.execPath,
args: [fileURLToPath(new URL("./fake-muse.mjs", import.meta.url))],
env: { MUSE_TEST_VERSION: "1.4.18 (1.4.18-R8600.1)" },
resolved: true,
};
await expect(
provider.installation?.inspect?.({ launch, signal: new AbortController().signal }),
).resolves.toEqual({ status: "installed", version: "1.4.18-R8600.1" });
});
test("update check compares the installed build with the launcher's channel", async () => {
const channels: string[] = [];
const provider = createMuseProvider(new MuseUsage(), {
async readChannelRelease({ channel }) {
channels.push(channel);
return channel === "muse-beta"
? { version: "1.5.0-R9000.2", notificationText: "Faster startup" }
: { version: "1.4.18-R8587.1", notificationText: "" };
},
});
const signal = new AbortController().signal;
const check = (env: Record<string, string>) =>
provider.installation?.checkForUpdates?.({
launch: {
command: process.execPath,
args: [fileURLToPath(new URL("./fake-muse.mjs", import.meta.url))],
env,
resolved: true,
},
signal,
});
await expect(check({ MUSE_TEST_VERSION: "1.4.18 (1.4.18-R8587.1)" })).resolves.toEqual({
status: "current",
});
await expect(
check({ MUSE_TEST_VERSION: "1.4.18 (1.4.18-R8587.1)", MUSE_CHANNEL: "muse-beta" }),
).resolves.toEqual({
status: "available",
versions: ["1.5.0-R9000.2"],
message: "Faster startup",
});
await expect(check({ MUSE_TEST_VERSION: "1.4.1" })).resolves.toEqual({
status: "unknown",
message: "This Muse Code does not report its build",
});
expect(channels).toEqual(["muse-stable", "muse-beta"]);
});
test("catalog uses model effort variants, modes, and bounded cached discovery", async () => {
const h = await harness("catalog-controls");
await h.send({ type: "catalog", requestId: "catalog-1" });
+117
View File
@@ -427,6 +427,123 @@ export default function contribute(client: PluginClientContext) {
The same renderer can display items emitted by a provider, a client timeline transformer, or a
daemon timeline append. A renderer does not require a provider implementation.
## Report installation and updates
Add `installation` to your registration and the provider's settings show an **Installation**
section. Users see whether your CLI is installed and which version, whether an update is
available, and an **Update** button that runs your updater and shows its output. Every member is
optional, so declare only what your CLI supports:
| Member | Users see | Declare it when |
| ----------------- | ----------------------------------------------------------------------- | ------------------------------------------------------ |
| `installUrl` | **Install instructions**, and **Install** while not installed | Users install a CLI |
| `updateUrl` | **Update instructions** when a check finds an update or cannot tell | Your docs say how to update |
| `inspect` | **Version**, or **Not installed** | The CLI reports its version |
| `checkForUpdates` | **Up to date**, **Update available**, or **Couldn't check for updates** | You can ask the release source your CLI's updater uses |
| `update` | **Update** while an update is available, then the updater's output | The CLI updates itself with a command |
This `example-agent` prints its version, checks for a release with
`self-update --check --json`, and updates with `self-update`:
```ts
import { execCommand, spawnProcess, terminateProcess } from "@getpaseo/plugin/server";
import type {
ProviderInstallation,
ProviderInstallationLaunch,
} from "@getpaseo/plugin/server/provider";
async function run(launch: ProviderInstallationLaunch, args: string[], signal: AbortSignal) {
const { stdout } = await execCommand(launch.command, [...launch.args, ...args], {
env: launch.env,
timeout: 20_000,
signal,
});
return stdout.trim();
}
export const installation: ProviderInstallation = {
installUrl: "https://example.com/docs/install",
updateUrl: "https://example.com/docs/update",
async inspect({ launch, signal }) {
if (!launch?.resolved) return { status: "not_installed" };
// `example-agent --version` prints `1.4.2`.
return { status: "installed", version: await run(launch, ["--version"], signal) };
},
async checkForUpdates({ launch, signal }) {
if (!launch?.resolved) return { status: "unknown", message: "example-agent is not installed" };
// Asks the release source `example-agent self-update` installs from.
const answer = JSON.parse(await run(launch, ["self-update", "--check", "--json"], signal)) as {
latest: string;
updateAvailable: boolean;
};
return answer.updateAvailable
? { status: "available", versions: [answer.latest] }
: { status: "current" };
},
async update({ launch, signal, report }) {
if (!launch?.resolved) return { status: "failed", message: "example-agent is not installed" };
const child = spawnProcess(launch.command, [...launch.args, "self-update"], {
env: launch.env,
stdio: "pipe",
});
child.stdin.end();
child.stdout.on("data", (chunk: Buffer) => report({ type: "stdout", text: chunk.toString() }));
child.stderr.on("data", (chunk: Buffer) => report({ type: "stderr", text: chunk.toString() }));
const stop = () => void terminateProcess(child);
signal.addEventListener("abort", stop, { once: true });
const code = await new Promise<number | null>((resolve, reject) => {
child.once("error", reject);
child.once("close", resolve);
}).finally(() => signal.removeEventListener("abort", stop));
if (code !== 0) return { status: "failed", message: `self-update exited with code ${code}` };
return { status: "succeeded", message: "New sessions use the new version." };
},
};
```
Add it to the registration next to `command`:
```ts
export function createProvider(): ProviderRegistration {
return {
id: "example-agent",
label: "Example Agent",
command: ["example-agent"],
installation,
async connect(request) {
// ...
},
};
}
```
Each callback receives the launch a session would use. When `launch.resolved` is `false`, Paseo did
not find the executable and `command` is the configured name, so report `not_installed` without
running it. `launch` is absent when the registration declares no `command`. For an ACP provider,
spread `runAcpProvider()` into the registration and add `installation` beside it. Run version and
update probes with the launch's arguments before your session subcommand, such as `hoplite
--version` for a provider that runs `hoplite acp`.
Paseo treats versions as text. It shows `version` and `versions` as written and never compares
them, so your check decides what counts as newer. Ask the source your CLI's own updater asks, and
leave `checkForUpdates` out when you cannot. A check that returns `unknown`, throws, or misses its
deadline shows **Couldn't check for updates** with your message and never shows **Up to date**.
`update` runs only when a user selects **Update**, and Paseo runs one update per provider at a
time. Pass the updater's output to `report` unchanged; Paseo keeps the last 65,536 characters. Paseo adds no
verification of its own, so the update does what your command does in a terminal. Return
`succeeded` with a message that says when the new version takes effect, or `failed` with the reason.
Without `update`, an available update shows **Update instructions** when you declare `updateUrl`.
Paseo calls `inspect` when the settings open and keeps the answer for 30 seconds. It calls
`checkForUpdates` after `inspect` reports the CLI installed and keeps a `current` or `available`
answer for an hour. `inspect` must answer within 20 seconds and `checkForUpdates` within 25.
`update` has no deadline. `signal` aborts when Paseo stops waiting or the daemon stops.
See the [`installation` reference](/docs/plugins/reference#provider-installation) for every field.
## Adapt an ACP agent
Use the ACP shim when the agent already speaks ACP:
+97
View File
@@ -351,6 +351,103 @@ export default function contribute(client: PluginClientContext) {
同一个渲染器可以显示由 Provider、客户端时间线转换器或 daemon 时间线追加发出的条目。渲染器并不需要 Provider 实现。
## 报告安装与更新 {#report-installation-and-updates}
在注册中添加 `installation`,Provider 的设置中就会出现 **安装** 部分。用户可以看到你的 CLI 是否已安装、是哪个版本、是否有可用更新,以及一个 **更新** 按钮,它会运行你的更新程序并显示其输出。每个成员都是可选的,只声明你的 CLI 支持的部分:
| 成员 | 用户看到的内容 | 何时声明 |
| ----------------- | ------------------------------------------------- | --------------------------------------- |
| `installUrl` | **安装说明**,未安装时还有 **安装** | 用户需要安装 CLI |
| `updateUrl` | 检查发现更新或无法判断时显示 **更新说明** | 你的文档说明了如何更新 |
| `inspect` | **版本**,或 **未安装** | CLI 会报告自己的版本 |
| `checkForUpdates` | **已是最新**、**有可用更新** 或 **无法检查更新** | 你能询问 CLI 自带更新程序所用的发布来源 |
| `update` | 有可用更新时显示 **更新**,随后显示更新程序的输出 | CLI 能用一条命令更新自己 |
这个 `example-agent` 会打印自己的版本,用 `self-update --check --json` 检查发布,并用 `self-update` 更新:
```ts
import { execCommand, spawnProcess, terminateProcess } from "@getpaseo/plugin/server";
import type {
ProviderInstallation,
ProviderInstallationLaunch,
} from "@getpaseo/plugin/server/provider";
async function run(launch: ProviderInstallationLaunch, args: string[], signal: AbortSignal) {
const { stdout } = await execCommand(launch.command, [...launch.args, ...args], {
env: launch.env,
timeout: 20_000,
signal,
});
return stdout.trim();
}
export const installation: ProviderInstallation = {
installUrl: "https://example.com/docs/install",
updateUrl: "https://example.com/docs/update",
async inspect({ launch, signal }) {
if (!launch?.resolved) return { status: "not_installed" };
// `example-agent --version` prints `1.4.2`.
return { status: "installed", version: await run(launch, ["--version"], signal) };
},
async checkForUpdates({ launch, signal }) {
if (!launch?.resolved) return { status: "unknown", message: "example-agent is not installed" };
// Asks the release source `example-agent self-update` installs from.
const answer = JSON.parse(await run(launch, ["self-update", "--check", "--json"], signal)) as {
latest: string;
updateAvailable: boolean;
};
return answer.updateAvailable
? { status: "available", versions: [answer.latest] }
: { status: "current" };
},
async update({ launch, signal, report }) {
if (!launch?.resolved) return { status: "failed", message: "example-agent is not installed" };
const child = spawnProcess(launch.command, [...launch.args, "self-update"], {
env: launch.env,
stdio: "pipe",
});
child.stdin.end();
child.stdout.on("data", (chunk: Buffer) => report({ type: "stdout", text: chunk.toString() }));
child.stderr.on("data", (chunk: Buffer) => report({ type: "stderr", text: chunk.toString() }));
const stop = () => void terminateProcess(child);
signal.addEventListener("abort", stop, { once: true });
const code = await new Promise<number | null>((resolve, reject) => {
child.once("error", reject);
child.once("close", resolve);
}).finally(() => signal.removeEventListener("abort", stop));
if (code !== 0) return { status: "failed", message: `self-update exited with code ${code}` };
return { status: "succeeded", message: "New sessions use the new version." };
},
};
```
把它添加到注册中 `command` 的旁边:
```ts
export function createProvider(): ProviderRegistration {
return {
id: "example-agent",
label: "Example Agent",
command: ["example-agent"],
installation,
async connect(request) {
// ...
},
};
}
```
每个回调都会收到会话将使用的启动配置。当 `launch.resolved` 为 `false` 时,Paseo 没有找到可执行文件,`command` 是配置中写的名称,因此直接报告 `not_installed`,不要运行它。注册没有声明 `command` 时,`launch` 不存在。对于 ACP Provider,把 `runAcpProvider()` 展开到注册中,并在旁边添加 `installation`。版本和更新探测使用会话子命令之前的启动参数,例如对运行 `hoplite acp` 的 Provider 使用 `hoplite --version`。
Paseo 把版本当作文本。它按原样显示 `version` 和 `versions`,从不比较它们,因此由你的检查决定什么算更新。询问 CLI 自带更新程序所询问的来源;做不到时就不要声明 `checkForUpdates`。返回 `unknown`、抛出异常或超过期限的检查会显示 **无法检查更新** 和你的消息,绝不会显示 **已是最新**。
`update` 只在用户选择 **更新** 时运行,并且每个 Provider 同一时间只运行一个更新。把更新程序的输出原样传给 `report`;Paseo 保留最后 65,536 个字符。Paseo 不会额外做任何校验,所以更新的效果与在终端中运行你的命令相同。返回 `succeeded` 并用消息说明新版本何时生效,或返回 `failed` 并说明原因。没有 `update` 时,如果你声明了 `updateUrl`,可用更新会显示 **更新说明**。
Paseo 在设置打开时调用 `inspect`,并把结果保留 30 秒。它在 `inspect` 报告 CLI 已安装后调用 `checkForUpdates`,并把 `current` 或 `available` 结果保留一小时。`inspect` 必须在 20 秒内回答,`checkForUpdates` 必须在 25 秒内回答。`update` 没有期限。当 Paseo 停止等待或 daemon 停止时,`signal` 会中止。每个字段见 [`installation` 参考](/docs/plugins/reference#provider-installation)。
## 适配 ACP Agent {#adapt-an-acp-agent}
当 Agent 已经支持 ACP 时,使用 ACP 适配层:
+56
View File
@@ -440,6 +440,62 @@ It must resolve inside that directory to a regular file that follows the
[SVG icon rules](#svg-rules). Paseo reads and validates the file when the plugin starts; the string
is never inline SVG or a URL.
#### Provider installation
**Requires Paseo 0.17.** Older versions ignore `installation`.
`ProviderRegistration.installation` fills the **Installation** section of the provider's settings.
Every member is optional. Paseo hides the section when you declare neither `inspect` nor
`installUrl`. [Report installation and updates](/docs/plugins/providers#report-installation-and-updates)
has a complete example. Import the types from `@getpaseo/plugin/server/provider`.
```ts
interface ProviderInstallation {
installUrl?: string;
updateUrl?: string;
inspect?(context: ProviderInstallationContext): Promise<ProviderInspection>;
checkForUpdates?(context: ProviderInstallationContext): Promise<ProviderUpdateCheck>;
update?(context: ProviderUpdateContext): Promise<ProviderUpdateResult>;
}
```
| Member | Called | Deadline | Answer kept |
| ----------------- | ------------------------------------------------------------ | -------- | ------------------------------------------------ |
| `inspect` | When provider settings open | 20 s | 30 s |
| `checkForUpdates` | After `inspect` answers `installed` | 25 s | 1 hour for `current` or `available`, else 30 s |
| `update` | When the user selects **Update**, one at a time per provider | None | Paseo reads both answers again after `succeeded` |
**Refresh** in the provider's settings skips the kept answers. Paseo never calls these members
before a session or on a schedule. A deadline or a throw becomes an error that shows the message.
`installUrl` and `updateUrl` are `http` or `https` pages that Paseo opens in the browser. The
plugin fails to start when either is another kind of URL.
The context:
| Field | Type | Meaning |
| -------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `launch` | `ProviderInstallationLaunch \| undefined` | The launch a session would use, with `resolved`. Absent when the registration declares no `command`. |
| `signal` | `AbortSignal` | Aborts when Paseo stops waiting or the daemon stops. |
| `report` | `(event: ProviderUpdateReport) => void` | `update` only. Shows `{ type: "stdout" \| "stderr" \| "message", text }` to the user in order. |
`ProviderInstallationLaunch` is `{ command, args, env, resolved }`. With `resolved: true`, `command`
is the executable's path. With `resolved: false`, Paseo did not find the executable and `command`
is the configured name.
Answers:
| Type | Values |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `ProviderInspection` | `{ status: "installed", version?: string }` or `{ status: "not_installed" }` |
| `ProviderUpdateCheck` | `{ status: "current" }`, `{ status: "available", versions?: string[], message?: string }`, or `{ status: "unknown", message?: string }` |
| `ProviderUpdateResult` | `{ status: "succeeded", message?: string }` or `{ status: "failed", message: string }` |
Paseo shows `version`, `versions` and `message` as written and does not parse, sort or compare
them. It reuses a `current` or `available` answer for up to an hour while `inspect` reports the
same `version`, so include in `version` everything `checkForUpdates` compares. It keeps the last 65,536 characters of `report` output. An answer that does not match these
shapes is an error. **Update** appears only when `checkForUpdates` answers `available` and you
declare `update`; without `update`, Paseo offers `updateUrl` instead.
### Usage sources
**Requires Paseo 0.11.** Register a source from your server entry with
+44
View File
@@ -367,6 +367,50 @@ Paseo 刷新 Agent 的方式是关闭它当前的 Provider 会话,再用当前
`ProviderRegistration.icon` 是相对于插件目录的文件路径,例如 `icon.svg`。它必须解析到该目录内一个遵循 [SVG 图标规则](#svg-rules)的普通文件。Paseo 在插件启动时读取并校验该文件;这个字符串从不是内联 SVG 或 URL。
#### Provider 安装 {#provider-installation}
**需要 Paseo 0.17**。较旧的版本会忽略 `installation`。`ProviderRegistration.installation` 填充 Provider 设置中的 **安装** 部分。每个成员都是可选的。`inspect` 和 `installUrl` 都没有声明时,Paseo 会隐藏这个部分。完整示例见[报告安装与更新](/docs/plugins/providers#report-installation-and-updates)。类型从 `@getpaseo/plugin/server/provider` 导入。
```ts
interface ProviderInstallation {
installUrl?: string;
updateUrl?: string;
inspect?(context: ProviderInstallationContext): Promise<ProviderInspection>;
checkForUpdates?(context: ProviderInstallationContext): Promise<ProviderUpdateCheck>;
update?(context: ProviderUpdateContext): Promise<ProviderUpdateResult>;
}
```
| 成员 | 调用时机 | 期限 | 结果保留时间 |
| ----------------- | ------------------------------------------------ | ----- | ----------------------------------------------- |
| `inspect` | 打开 Provider 设置时 | 20 秒 | 30 秒 |
| `checkForUpdates` | `inspect` 回答 `installed` 之后 | 25 秒 | `current` 或 `available` 为一小时,其他为 30 秒 |
| `update` | 用户选择 **更新** 时,每个 Provider 同一时间一个 | 无 | `succeeded` 之后 Paseo 重新读取两个结果 |
Provider 设置中的 **刷新** 会跳过保留的结果。Paseo 从不在会话开始前或按计划调用这些成员。超过期限或抛出异常会变成显示该消息的错误。
`installUrl` 和 `updateUrl` 是 Paseo 在浏览器中打开的 `http` 或 `https` 页面。其中任何一个是其他类型的 URL 时,插件无法启动。
上下文:
| 字段 | 类型 | 含义 |
| -------- | ----------------------------------------- | --------------------------------------------------------------------------------------- |
| `launch` | `ProviderInstallationLaunch \| undefined` | 会话将使用的启动配置,带有 `resolved`。注册没有声明 `command` 时不存在。 |
| `signal` | `AbortSignal` | 当 Paseo 停止等待或 daemon 停止时中止。 |
| `report` | `(event: ProviderUpdateReport) => void` | 仅用于 `update`。按顺序向用户显示 `{ type: "stdout" \| "stderr" \| "message", text }`。 |
`ProviderInstallationLaunch` 是 `{ command, args, env, resolved }`。`resolved: true` 时,`command` 是可执行文件的路径。`resolved: false` 时,Paseo 没有找到可执行文件,`command` 是配置中写的名称。
结果:
| 类型 | 取值 |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `ProviderInspection` | `{ status: "installed", version?: string }` 或 `{ status: "not_installed" }` |
| `ProviderUpdateCheck` | `{ status: "current" }`、`{ status: "available", versions?: string[], message?: string }` 或 `{ status: "unknown", message?: string }` |
| `ProviderUpdateResult` | `{ status: "succeeded", message?: string }` 或 `{ status: "failed", message: string }` |
Paseo 按原样显示 `version`、`versions` 和 `message`,不解析、排序或比较它们。只要 `inspect` 报告的 `version` 不变,Paseo 会在最长一小时内复用 `current` 或 `available` 的结果,所以 `version` 要包含 `checkForUpdates` 比较的全部内容。它保留 `report` 输出的最后 65,536 个字符。不符合这些结构的结果是错误。只有当 `checkForUpdates` 回答 `available` 且你声明了 `update` 时才会出现 **更新**;没有 `update` 时,Paseo 改为提供 `updateUrl`。
### 用量来源 {#usage-sources}
**需要 Paseo 0.11**。在服务端入口中用 `server.registerUsageSource()` 注册一个来源。从 `@getpaseo/plugin/server/usage` 导入它的契约和辅助函数。