mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-13 20:05:11 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d07893d845 | ||
|
|
985ff9dcd4 | ||
|
|
5ef83ac70a | ||
|
|
48c2067a1d | ||
|
|
0b00e79e17 | ||
|
|
c2f1cb46ae | ||
|
|
254c7a0831 | ||
|
|
45136b1f6f | ||
|
|
5359250c47 | ||
|
|
148b58a8bf | ||
|
|
fff89a3c17 | ||
|
|
5b0cb13f02 | ||
|
|
ae45e182bd | ||
|
|
59d2fe6958 | ||
|
|
af763060c5 | ||
|
|
046c206577 | ||
|
|
17e313e19a |
@@ -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,
|
||||
|
||||
@@ -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 } : {}) };
|
||||
}
|
||||
@@ -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: "التشخيص",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "診断",
|
||||
|
||||
@@ -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: "진단",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Диагностика",
|
||||
|
||||
@@ -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: "诊断",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
+850
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 适配层:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` 导入它的契约和辅助函数。
|
||||
|
||||
Reference in New Issue
Block a user