mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-22 01:02:41 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e94a5731b | ||
|
|
f13bb2136c | ||
|
|
2323ee6844 | ||
|
|
1e00f452ea | ||
|
|
a162e1075f | ||
|
|
8f1091da64 | ||
|
|
4dd0e50e78 | ||
|
|
dc785bb29a | ||
|
|
93bfb34e21 | ||
|
|
2d85fe4e04 | ||
|
|
1463396ebc | ||
|
|
d4ea77b910 | ||
|
|
f715221de8 | ||
|
|
aedba5ec27 | ||
|
|
bdc58fb07c | ||
|
|
3f1a04304d | ||
|
|
50cf244e86 | ||
|
|
aaeefdc00b | ||
|
|
c5deaa0516 | ||
|
|
72c180aaf7 | ||
|
|
66b2567be3 | ||
|
|
cb2b569bbd | ||
|
|
2ecf3ba783 | ||
|
|
4515061f88 | ||
|
|
3c478e5967 | ||
|
|
05637a7f05 | ||
|
|
13d21f60f1 | ||
|
|
fc3b2c4eb7 | ||
|
|
a9fef4879a | ||
|
|
a292b402cf | ||
|
|
64c9f263e8 | ||
|
|
48059aeae3 | ||
|
|
7fd469ae19 | ||
|
|
56d5680742 | ||
|
|
06bde1e57a | ||
|
|
396a2bc0b2 |
@@ -27,6 +27,8 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- Enabled the plugin registry by default: `paseo plugin add owner/slug` installs a reviewed artifact even when a local directory matches; use explicit local paths (including `.`, `..`, and Windows drive or UNC paths), and use `github:owner/repository` for direct GitHub installs. Registry connection, missing-ID, and `--ref` errors include recovery steps.
|
||||
|
||||
- Changed the context window details to show usage for the account the agent runs under, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes ([#5465](https://github.com/getpaseo/paseo/pull/5465))
|
||||
- Changed the context window details on desktop to a hover card with Refresh ([#5975](https://github.com/getpaseo/paseo/pull/5975))
|
||||
- Changed the context window meter to show before an agent's first turn ([#6089](https://github.com/getpaseo/paseo/pull/6089))
|
||||
|
||||
@@ -39,8 +39,11 @@
|
||||
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
|
||||
</p>
|
||||
|
||||
Run agents in parallel on your own machines. Ship from your phone or your desk.
|
||||
Paseo is a desktop, mobile, web, and CLI app for coding agents. Open the desktop app and work: agents, editor, terminals, diffs, pull requests, and a browser in one window. Run many agents at once, each in its own worktree, on one machine or several. The mobile app is the full app, native on iOS and Android.
|
||||
|
||||
- **Parallel agents:** Run many agents at once, each in its own worktree.
|
||||
- **Built-in orchestration:** Agents in Paseo can create worktrees, launch other agents, and talk to them, across providers.
|
||||
- **Full IDE:** Edit files, review diffs, open pull requests, and run terminals, in split panes you arrange how you want.
|
||||
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
|
||||
- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, and Muse Code through the same interface. Pick the right model for each job.
|
||||
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
|
||||
@@ -49,11 +52,18 @@ Run agents in parallel on your own machines. Ship from your phone or your desk.
|
||||
|
||||
## Plugins
|
||||
|
||||
Add themes, workspace panels, commands, settings screens, and coding-agent providers with trusted
|
||||
TypeScript plugins. Install from npm, Git, or a local directory with `paseo plugin install <source>`.
|
||||
Plugins run on the daemon and show up in every client you connect, with the same UI on desktop, web,
|
||||
iOS, and Android. Write a plugin once and it is on your phone.
|
||||
|
||||
Start with the [plugin quickstart](https://paseo.sh/docs/plugins). Plugins run with access to your daemon
|
||||
machine and inside connected clients; install only code you trust.
|
||||
- **UI:** screens, sidebar items, workspace panels, Command Center items, slash commands, composer pills, attachment sources, timeline items, themes.
|
||||
- **Agent lifecycle:** change configuration, environment, and MCP servers, answer permissions, follow up when a turn ends.
|
||||
- **Providers:** add a coding agent as a provider.
|
||||
|
||||
Install from the registry with `paseo plugin add owner/slug`, or from Git or a local directory.
|
||||
|
||||
**[Browse plugins](https://paseo.sh/plugins)** · **[Plugin docs](https://paseo.sh/docs/plugins)**
|
||||
|
||||
Plugins run with access to your daemon machine and inside connected clients; install only code you trust.
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -77,16 +87,16 @@ Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub r
|
||||
|
||||
To connect from your phone, open **Settings → your host → Pair Device**.
|
||||
|
||||
### CLI / headless
|
||||
### Server
|
||||
|
||||
Install the CLI and start Paseo:
|
||||
For a server, a VM, or any machine without the desktop app. Install the CLI and start the daemon:
|
||||
|
||||
```bash
|
||||
npm install -g @getpaseo/cli
|
||||
paseo
|
||||
```
|
||||
|
||||
Paseo starts locally, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. This path is useful for servers and remote machines.
|
||||
Paseo starts, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. The desktop, mobile, and web apps connect to this daemon like any other host.
|
||||
|
||||
For full setup and configuration, see:
|
||||
|
||||
@@ -197,7 +207,7 @@ npm run typecheck
|
||||
|
||||
## Sponsors
|
||||
|
||||
Paseo is built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage.
|
||||
Paseo is an independent project used by tens of thousands of developers daily, built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage.
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
|
||||
|
||||
+17
-12
@@ -117,15 +117,19 @@ the bundled integration; an entry with `extends` shadows it with a custom provid
|
||||
|
||||
## Install from a registry
|
||||
|
||||
Registry installs are off by default. Set `pluginRegistryEnabled: true` in daemon config or
|
||||
`PASEO_PLUGIN_REGISTRY_ENABLED=1`, then restart the daemon. While they are off, bare
|
||||
`owner/repo` is GitHub shorthand and the daemon never contacts a registry.
|
||||
|
||||
With registry installs on, `paseo plugin install owner/slug` installs the registry's reviewed
|
||||
artifact, and GitHub shorthand requires `github:`. Registry installs
|
||||
`paseo plugin add owner/slug` installs the registry's reviewed artifact by default.
|
||||
Browse published plugins at [paseo.sh/plugins](https://paseo.sh/plugins).
|
||||
Use `git:owner/repository` or a full Git URL for a Git source. Registry installs
|
||||
keep the registry URL and ID, so update checks use its approved pin. Explicit version/ref
|
||||
selection is unavailable for registry installs; install an explicit source to select your own.
|
||||
|
||||
Registry overviews are untrusted author content, even before installation. Render them with
|
||||
`@getpaseo/protocol/plugin-overview`: no raw HTML, HTTPS-only links and images, no relative
|
||||
URLs, and isolated external links. The website adapter and corpus tests live in
|
||||
`packages/website/src/plugins/overview.tsx` and `overview.test.tsx`; the reusable corpus is
|
||||
`packages/protocol/tests/fixtures/plugin-overview.json`. A future app overview must share
|
||||
this policy. Installed-plugin descriptions in the app remain plain text.
|
||||
|
||||
Use `host/owner/slug` for a registry at `https://host`, or set
|
||||
`PASEO_PLUGIN_REGISTRY` to change the default base (including a path prefix).
|
||||
Private registry credentials live in daemon config under
|
||||
@@ -138,13 +142,13 @@ owns static hosting, record shapes, pins, and advisory install counts.
|
||||
|
||||
## Install a Git source
|
||||
|
||||
GitHub repositories use `owner/repository` or `github:owner/repository`. Other hosts use a Git
|
||||
URL. An existing directory still wins over source resolution.
|
||||
GitHub repositories use `git:owner/repository` or `github:owner/repository`. Other hosts use a Git
|
||||
URL.
|
||||
|
||||
```bash
|
||||
paseo plugin install owner/repository
|
||||
paseo plugin install git:owner/repository
|
||||
paseo plugin install https://gitlab.com/group/repository.git
|
||||
paseo plugin install owner/monorepo:plugins/review
|
||||
paseo plugin install git:owner/monorepo:plugins/review
|
||||
paseo plugin install github:owner/repository --ref main
|
||||
```
|
||||
|
||||
@@ -155,8 +159,9 @@ and ask for approval. `ls` reports the installed commit without contacting the r
|
||||
## Managed source ownership
|
||||
|
||||
The [public source reference](../public-docs/plugins/reference.md#plugin-sources) owns identifier
|
||||
syntax and npm prerequisites; the [publishing guide](../public-docs/plugins/publishing.md) owns distribution. Both clients send the source unchanged
|
||||
through `installPluginSource`; only the daemon resolves host paths and acquires sources.
|
||||
syntax and npm prerequisites; the [publishing guide](../public-docs/plugins/publishing.md) owns distribution. The CLI resolves directory sources before
|
||||
calling `installPluginSource`; see the public source reference for CLI and app path semantics.
|
||||
Only the daemon reads plugin files and acquires sources.
|
||||
|
||||
`ManagedPluginSources` owns acquisition and offline source description. Config stores the active
|
||||
directory; sources.json stores managed kind and the Git acquisition remote. The remote is needed
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-mQOyGIvwVt45hldUnYuZTKJg4mLPm7d8jXXNlSQnExE=
|
||||
sha256-zL3+gbScK8eMkjQUtKDX4UJTXt4BMeqMSyeWB017MA4=
|
||||
|
||||
Generated
+1
@@ -40174,6 +40174,7 @@
|
||||
"@types/react": "^19.1.6",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"parse5": "^7.3.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.3",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import overviewCorpus from "../../../protocol/tests/fixtures/plugin-overview.json";
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
@@ -557,7 +558,7 @@ async function retryGitInstallAndInspectRows(
|
||||
|
||||
await installPlugin(page, missingSource);
|
||||
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
|
||||
"Plugin source is neither an existing directory nor a Git URL",
|
||||
`Plugin directory does not exist: ${missingSource}`,
|
||||
);
|
||||
await expect(page.getByLabel("Plugin source")).toHaveValue(missingSource);
|
||||
await capturePluginInstallForm(page, testInfo, "compact-error");
|
||||
@@ -655,3 +656,27 @@ async function reloadAndRemoveNpmPlugin(page: Page) {
|
||||
await expect(page.getByText("Removed npm-review", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("npm-review running")).toHaveCount(0);
|
||||
}
|
||||
|
||||
test("renders installed-plugin descriptions as plain text", async ({ page, pluginEnvironment }) => {
|
||||
const description = overviewCorpus.map(({ markdown }) => markdown).join("\n\n");
|
||||
await installPluginWithUntrustedDescription(pluginEnvironment, description);
|
||||
await gotoAppShell(page);
|
||||
await openPluginSettings(page);
|
||||
const text = page.getByText(description, { exact: true });
|
||||
await expect(text).toBeVisible();
|
||||
// This field must remain a text node, with no Markdown links, images, or HTML children.
|
||||
expect(await text.evaluate((element) => element.childElementCount)).toBe(0);
|
||||
});
|
||||
|
||||
async function installPluginWithUntrustedDescription(
|
||||
environment: PluginEnvironment,
|
||||
description: string,
|
||||
) {
|
||||
const directory = await createDirectoryPlugin(
|
||||
environment.directory,
|
||||
"plain-text-plugin",
|
||||
description,
|
||||
"Plain text plugin",
|
||||
);
|
||||
await environment.client.installPluginSource({ source: directory });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { getStateLabel } from "@/git/pull-request-panel/data";
|
||||
import { en } from "@/i18n/resources/en";
|
||||
import { openPullRequestPanel } from "./workspace-tabs";
|
||||
|
||||
export async function openPrPane(page: Page): Promise<void> {
|
||||
@@ -14,7 +14,7 @@ export async function expectPrPaneState(
|
||||
page: Page,
|
||||
state: "open" | "merged" | "closed" | "draft",
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId("pr-pane-state")).toHaveText(getStateLabel(state), {
|
||||
await expect(page.getByTestId("pr-pane-state")).toHaveText(en.workspace.git.pr.states[state], {
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -244,13 +244,16 @@ const FOOTER_GEOMETRY_TOLERANCE = 0.01;
|
||||
* Settings together at the end.
|
||||
*/
|
||||
export async function expectFooterIconRow(page: Page): Promise<void> {
|
||||
const boxes = (
|
||||
await Promise.all(
|
||||
FOOTER_ICON_TEST_IDS.map((testID) =>
|
||||
page.locator(`[data-testid="${testID}"]:visible`).first().boundingBox(),
|
||||
),
|
||||
)
|
||||
).map((box) => box!);
|
||||
// Read one browser-frame snapshot: the compact drawer can translate between separate RPCs.
|
||||
const boxes = await page
|
||||
.locator(FOOTER_ICON_TEST_IDS.map((testID) => `[data-testid="${testID}"]:visible`).join(","))
|
||||
.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const { x, y, width, height } = element.getBoundingClientRect();
|
||||
return { testID: element.getAttribute("data-testid"), x, y, width, height };
|
||||
}),
|
||||
);
|
||||
expect(boxes.map((box) => box.testID)).toEqual(FOOTER_ICON_TEST_IDS);
|
||||
const [first] = boxes;
|
||||
for (const box of boxes) {
|
||||
expect(Math.abs(box.y + box.height / 2 - first!.y - first!.height / 2)).toBeLessThan(2);
|
||||
|
||||
@@ -9,12 +9,13 @@ import {
|
||||
isPipelineActiveStatus,
|
||||
mapPipelineStatus,
|
||||
} from "@/git/forges/gitlab";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { IDENTITY_COLOR_NAMES, identityColor } from "@/styles/identity-colors";
|
||||
import {
|
||||
deriveAvatarColor,
|
||||
formatAge,
|
||||
getActivityVerb,
|
||||
getStateLabel,
|
||||
getActivityVerbKey,
|
||||
getStateLabelKey,
|
||||
mapPrPaneData,
|
||||
} from "./data";
|
||||
|
||||
@@ -779,37 +780,34 @@ describe("formatAge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStateLabel", () => {
|
||||
const english = i18n.getFixedT("en");
|
||||
const french = i18n.getFixedT("fr");
|
||||
|
||||
describe("getStateLabelKey", () => {
|
||||
it.each([
|
||||
["open", "Open"],
|
||||
["draft", "Draft"],
|
||||
["merged", "Merged"],
|
||||
["closed", "Closed"],
|
||||
] as const)("maps %s → %s", (state, expected) => {
|
||||
expect(getStateLabel(state)).toBe(expected);
|
||||
["open", "Open", "Ouverte"],
|
||||
["draft", "Draft", "Brouillon"],
|
||||
["merged", "Merged", "Fusionnée"],
|
||||
["closed", "Closed", "Fermée"],
|
||||
] as const)("labels %s as %s, and %s in French", (state, englishLabel, frenchLabel) => {
|
||||
expect(english(getStateLabelKey(state))).toBe(englishLabel);
|
||||
expect(french(getStateLabelKey(state))).toBe(frenchLabel);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActivityVerb", () => {
|
||||
it("returns Commented for comment kind", () => {
|
||||
expect(getActivityVerb({ kind: "comment" })).toBe("Commented");
|
||||
});
|
||||
|
||||
it("returns Approved for approved review", () => {
|
||||
expect(getActivityVerb({ kind: "review", reviewState: "approved" })).toBe("Approved");
|
||||
});
|
||||
|
||||
it("returns Requested changes for changes_requested review", () => {
|
||||
expect(getActivityVerb({ kind: "review", reviewState: "changes_requested" })).toBe(
|
||||
describe("getActivityVerbKey", () => {
|
||||
it.each([
|
||||
[{ kind: "comment" }, "Commented", "A commenté"],
|
||||
[{ kind: "review", reviewState: "approved" }, "Approved", "A approuvé"],
|
||||
[
|
||||
{ kind: "review", reviewState: "changes_requested" },
|
||||
"Requested changes",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns Reviewed for a commented review with body (generic case)", () => {
|
||||
expect(getActivityVerb({ kind: "review", reviewState: "commented" })).toBe("Reviewed");
|
||||
});
|
||||
|
||||
it("returns Reviewed when reviewState is undefined", () => {
|
||||
expect(getActivityVerb({ kind: "review" })).toBe("Reviewed");
|
||||
"A demandé des modifications",
|
||||
],
|
||||
[{ kind: "review", reviewState: "commented" }, "Reviewed", "A relu"],
|
||||
[{ kind: "review" }, "Reviewed", "A relu"],
|
||||
] as const)("labels %o as %s, and %s in French", (item, englishVerb, frenchVerb) => {
|
||||
expect(english(getActivityVerbKey(item))).toBe(englishVerb);
|
||||
expect(french(getActivityVerbKey(item))).toBe(frenchVerb);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -318,16 +318,18 @@ function parsePullRequestNumber(url: string): number | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function getStateLabel(state: PrState): string {
|
||||
if (state === "draft") return "Draft";
|
||||
if (state === "merged") return "Merged";
|
||||
if (state === "closed") return "Closed";
|
||||
return "Open";
|
||||
export function getStateLabelKey(state: PrState) {
|
||||
if (state === "draft") return "workspace.git.pr.states.draft";
|
||||
if (state === "merged") return "workspace.git.pr.states.merged";
|
||||
if (state === "closed") return "workspace.git.pr.states.closed";
|
||||
return "workspace.git.pr.states.open";
|
||||
}
|
||||
|
||||
export function getActivityVerb(item: Pick<PrPaneActivity, "kind" | "reviewState">): string {
|
||||
if (item.kind === "comment") return "Commented";
|
||||
if (item.reviewState === "approved") return "Approved";
|
||||
if (item.reviewState === "changes_requested") return "Requested changes";
|
||||
return "Reviewed";
|
||||
export function getActivityVerbKey(item: Pick<PrPaneActivity, "kind" | "reviewState">) {
|
||||
if (item.kind === "comment") return "workspace.git.pr.activity.commented";
|
||||
if (item.reviewState === "approved") return "workspace.git.pr.activity.approved";
|
||||
if (item.reviewState === "changes_requested") {
|
||||
return "workspace.git.pr.activity.requestedChanges";
|
||||
}
|
||||
return "workspace.git.pr.activity.reviewed";
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ import {
|
||||
canAddPullRequestActivityToChat,
|
||||
} from "./context-attachment";
|
||||
import { ChecksSection, getCheckIdentity } from "./checks-section";
|
||||
import { getActivityVerb, getStateLabel } from "./data";
|
||||
import { getActivityVerbKey, getStateLabelKey } from "./data";
|
||||
import type { PrPaneActivity, PrPaneCheck, PrPaneData, PrState } from "./data";
|
||||
import type { ForgeSpecificStatusFacts } from "@/git/merge-capability";
|
||||
import { CheckPresentationIcon } from "@/git/check-presentation.view";
|
||||
@@ -538,7 +538,7 @@ export function PullRequestPane({
|
||||
<View style={styles.metaLine}>
|
||||
<StateIcon size={14} uniProps={statePresentation.iconColor} />
|
||||
<Text style={stateLabelStyle(data.state)} testID="pr-pane-state">
|
||||
{getStateLabel(data.state)}
|
||||
{t(getStateLabelKey(data.state))}
|
||||
</Text>
|
||||
{nativeHeaderMeta}
|
||||
{repoIdentity ? (
|
||||
@@ -760,7 +760,8 @@ function ActivityAvatar({ activity, size }: { activity: PrPaneActivity; size: nu
|
||||
}
|
||||
|
||||
function ActivityVerb({ activity }: { activity: PrPaneActivity }) {
|
||||
const verb = getActivityVerb(activity).toLowerCase();
|
||||
const { t } = useTranslation();
|
||||
const verb = t(getActivityVerbKey(activity)).toLowerCase();
|
||||
if (activity.kind === "review" && activity.reviewState === "approved") {
|
||||
return (
|
||||
<View style={styles.verbGroup}>
|
||||
@@ -1065,8 +1066,12 @@ function ThreadBlock({
|
||||
? formatPullRequestThreadPath(thread.location)
|
||||
: t("workspace.git.pr.thread.discussion")}
|
||||
</Text>
|
||||
{thread.isResolved ? <StatusBadge label="Resolved" variant="success" /> : null}
|
||||
{thread.location?.isOutdated ? <StatusBadge label="Outdated" /> : null}
|
||||
{thread.isResolved ? (
|
||||
<StatusBadge label={t("workspace.git.pr.thread.resolved")} variant="success" />
|
||||
) : null}
|
||||
{thread.location?.isOutdated ? (
|
||||
<StatusBadge label={t("workspace.git.pr.thread.outdated")} />
|
||||
) : null}
|
||||
<View style={styles.headerTrailing}>
|
||||
{collapsed ? (
|
||||
<View style={styles.threadCount}>
|
||||
|
||||
@@ -76,6 +76,7 @@ const untranslatedLocalFallbacks = [
|
||||
|
||||
const pullRequestPanelSources = [
|
||||
"git/pull-request-panel/pane.tsx",
|
||||
"git/pull-request-panel/data.ts",
|
||||
"git/pull-request-panel/checks-section.tsx",
|
||||
"components/sidebar/sidebar-status-list.tsx",
|
||||
] as const;
|
||||
@@ -87,6 +88,16 @@ const untranslatedPullRequestPanelLabels = [
|
||||
"Adding...",
|
||||
"Comment actions",
|
||||
"Thread actions",
|
||||
"Commented",
|
||||
"Approved",
|
||||
"Requested changes",
|
||||
"Reviewed",
|
||||
"Draft",
|
||||
"Merged",
|
||||
"Closed",
|
||||
"Open",
|
||||
"Resolved",
|
||||
"Outdated",
|
||||
] as const;
|
||||
|
||||
function findUntranslatedPullRequestPanelLabels(): string[] {
|
||||
@@ -244,6 +255,14 @@ describe("translation resources", () => {
|
||||
expect(pr.actions.addingToChat).not.toBe(englishPr.actions.addingToChat);
|
||||
expect(pr.accessibility.commentActions).not.toBe(englishPr.accessibility.commentActions);
|
||||
expect(pr.accessibility.threadActions).not.toBe(englishPr.accessibility.threadActions);
|
||||
for (const verb of Object.keys(englishPr.activity) as (keyof typeof englishPr.activity)[]) {
|
||||
expect(pr.activity[verb]).not.toBe(englishPr.activity[verb]);
|
||||
}
|
||||
for (const state of Object.keys(englishPr.states) as (keyof typeof englishPr.states)[]) {
|
||||
expect(pr.states[state]).not.toBe(englishPr.states[state]);
|
||||
}
|
||||
expect(pr.thread.resolved).not.toBe(englishPr.thread.resolved);
|
||||
expect(pr.thread.outdated).not.toBe(englishPr.thread.outdated);
|
||||
expect(resource.sidebar.statusGroupAccessibility).not.toBe(
|
||||
en.sidebar.statusGroupAccessibility,
|
||||
);
|
||||
|
||||
@@ -1070,6 +1070,8 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
thread: {
|
||||
discussion: "سلسلة المناقشة",
|
||||
resolved: "تم الحل",
|
||||
outdated: "قديم",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "غير قادر على تحميل حالة طلب السحب",
|
||||
|
||||
@@ -1078,6 +1078,8 @@ export const en = {
|
||||
},
|
||||
thread: {
|
||||
discussion: "Discussion thread",
|
||||
resolved: "Resolved",
|
||||
outdated: "Outdated",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "Unable to load pull request status",
|
||||
@@ -2160,7 +2162,7 @@ export const en = {
|
||||
globalTitle: "Enable plugins",
|
||||
globalHint: "Global switch for every configured plugin",
|
||||
sourceLabel: "Plugin source",
|
||||
sourcePlaceholder: "Directory, Git URL, or npm package",
|
||||
sourcePlaceholder: "owner/slug, directory, Git URL, or npm package",
|
||||
docs: "Docs",
|
||||
install: "Install plugin",
|
||||
installing: "Installing…",
|
||||
|
||||
@@ -1093,16 +1093,18 @@ export const es: TranslationResources = {
|
||||
open: "Abierto",
|
||||
},
|
||||
activity: {
|
||||
commented: "Comentado",
|
||||
approved: "Aprobado",
|
||||
requestedChanges: "Cambios solicitados",
|
||||
reviewed: "Revisado",
|
||||
commented: "Comentó",
|
||||
approved: "Aprobó",
|
||||
requestedChanges: "Solicitó cambios",
|
||||
reviewed: "Revisó",
|
||||
},
|
||||
time: {
|
||||
justNow: "En este momento",
|
||||
},
|
||||
thread: {
|
||||
discussion: "Hilo de discusión",
|
||||
resolved: "Resuelto",
|
||||
outdated: "Desactualizado",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "No se puede cargar el estado de la solicitud de extracción",
|
||||
|
||||
@@ -1094,6 +1094,8 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
thread: {
|
||||
discussion: "Fil de discussion",
|
||||
resolved: "Résolu",
|
||||
outdated: "Obsolète",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "Impossible de charger le statut de la pull request",
|
||||
|
||||
@@ -1083,6 +1083,8 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
thread: {
|
||||
discussion: "ディスカッションスレッド",
|
||||
resolved: "解決済み",
|
||||
outdated: "古い",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "プルリクエストのステータスを読み込めません",
|
||||
|
||||
@@ -1077,6 +1077,8 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
thread: {
|
||||
discussion: "토론 스레드",
|
||||
resolved: "해결됨",
|
||||
outdated: "오래됨",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "풀 리퀘스트 상태를 불러올 수 없습니다",
|
||||
|
||||
@@ -27,7 +27,7 @@ export const pluginSettings = {
|
||||
globalTitle: "تفعيل الإضافات",
|
||||
globalHint: "مفتاح عام لكل الإضافات المضبوطة",
|
||||
sourceLabel: "مصدر الإضافة",
|
||||
sourcePlaceholder: "مجلد أو رابط Git أو حزمة npm",
|
||||
sourcePlaceholder: "owner/slug, مجلد أو رابط Git أو حزمة npm",
|
||||
docs: "الوثائق",
|
||||
install: "تثبيت الإضافة",
|
||||
installing: "جارٍ التثبيت…",
|
||||
@@ -82,7 +82,7 @@ export const pluginSettings = {
|
||||
globalTitle: "Activar plugins",
|
||||
globalHint: "Interruptor global para todos los plugins configurados",
|
||||
sourceLabel: "Fuente del plugin",
|
||||
sourcePlaceholder: "Directorio, URL de Git o paquete npm",
|
||||
sourcePlaceholder: "owner/slug, Directorio, URL de Git o paquete npm",
|
||||
docs: "Documentación",
|
||||
install: "Instalar plugin",
|
||||
installing: "Instalando…",
|
||||
@@ -137,7 +137,7 @@ export const pluginSettings = {
|
||||
globalTitle: "Activer les plugins",
|
||||
globalHint: "Interrupteur global pour tous les plugins configurés",
|
||||
sourceLabel: "Source du plugin",
|
||||
sourcePlaceholder: "Dossier, URL Git ou paquet npm",
|
||||
sourcePlaceholder: "owner/slug, Dossier, URL Git ou paquet npm",
|
||||
docs: "Documentation",
|
||||
install: "Installer le plugin",
|
||||
installing: "Installation…",
|
||||
@@ -192,7 +192,7 @@ export const pluginSettings = {
|
||||
globalTitle: "プラグインを有効化",
|
||||
globalHint: "設定済みプラグインすべての共通スイッチ",
|
||||
sourceLabel: "プラグインソース",
|
||||
sourcePlaceholder: "ディレクトリ、Git URL、npm パッケージ",
|
||||
sourcePlaceholder: "owner/slug, ディレクトリ、Git URL、npm パッケージ",
|
||||
docs: "ドキュメント",
|
||||
install: "プラグインをインストール",
|
||||
installing: "インストール中…",
|
||||
@@ -246,7 +246,7 @@ export const pluginSettings = {
|
||||
globalTitle: "플러그인 활성화",
|
||||
globalHint: "설정된 모든 플러그인의 전역 스위치",
|
||||
sourceLabel: "플러그인 소스",
|
||||
sourcePlaceholder: "디렉터리, Git URL 또는 npm 패키지",
|
||||
sourcePlaceholder: "owner/slug, 디렉터리, Git URL 또는 npm 패키지",
|
||||
docs: "문서",
|
||||
install: "플러그인 설치",
|
||||
installing: "설치 중…",
|
||||
@@ -301,7 +301,7 @@ export const pluginSettings = {
|
||||
globalTitle: "Ativar plugins",
|
||||
globalHint: "Controle global para todos os plugins configurados",
|
||||
sourceLabel: "Fonte do plugin",
|
||||
sourcePlaceholder: "Diretório, URL Git ou pacote npm",
|
||||
sourcePlaceholder: "owner/slug, Diretório, URL Git ou pacote npm",
|
||||
docs: "Documentação",
|
||||
install: "Instalar plugin",
|
||||
installing: "Instalando…",
|
||||
@@ -356,7 +356,7 @@ export const pluginSettings = {
|
||||
globalTitle: "Включить плагины",
|
||||
globalHint: "Общий переключатель для всех настроенных плагинов",
|
||||
sourceLabel: "Источник плагина",
|
||||
sourcePlaceholder: "Каталог, URL Git или пакет npm",
|
||||
sourcePlaceholder: "owner/slug, Каталог, URL Git или пакет npm",
|
||||
docs: "Документация",
|
||||
install: "Установить плагин",
|
||||
installing: "Установка…",
|
||||
@@ -410,7 +410,7 @@ export const pluginSettings = {
|
||||
globalTitle: "启用插件",
|
||||
globalHint: "所有已配置插件的全局开关",
|
||||
sourceLabel: "插件来源",
|
||||
sourcePlaceholder: "目录、Git URL 或 npm 包",
|
||||
sourcePlaceholder: "owner/slug, 目录、Git URL 或 npm 包",
|
||||
docs: "文档",
|
||||
install: "安装插件",
|
||||
installing: "正在安装…",
|
||||
|
||||
@@ -1084,15 +1084,17 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
activity: {
|
||||
commented: "Comentou",
|
||||
approved: "Aprovado",
|
||||
approved: "Aprovou",
|
||||
requestedChanges: "Solicitou alterações",
|
||||
reviewed: "Revisado",
|
||||
reviewed: "Revisou",
|
||||
},
|
||||
time: {
|
||||
justNow: "agora mesmo",
|
||||
},
|
||||
thread: {
|
||||
discussion: "Tópico de discussão",
|
||||
resolved: "Resolvido",
|
||||
outdated: "Desatualizado",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "Não foi possível carregar o status da pull request",
|
||||
|
||||
@@ -1087,6 +1087,8 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
thread: {
|
||||
discussion: "Ветка обсуждения",
|
||||
resolved: "Решено",
|
||||
outdated: "Устарело",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "Не удалось загрузить статус PR",
|
||||
|
||||
@@ -1046,22 +1046,24 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
states: {
|
||||
draft: "Draft",
|
||||
merged: "已 merge",
|
||||
draft: "草稿",
|
||||
merged: "已合并",
|
||||
closed: "已关闭",
|
||||
open: "Open",
|
||||
open: "开放",
|
||||
},
|
||||
activity: {
|
||||
commented: "已评论",
|
||||
approved: "已批准",
|
||||
requestedChanges: "请求修改",
|
||||
reviewed: "已 review",
|
||||
reviewed: "已审查",
|
||||
},
|
||||
time: {
|
||||
justNow: "刚刚",
|
||||
},
|
||||
thread: {
|
||||
discussion: "讨论主题",
|
||||
resolved: "已解决",
|
||||
outdated: "已过时",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "无法加载 Pull Request 状态",
|
||||
|
||||
@@ -358,14 +358,21 @@ test.skipIf(process.platform === "win32").each([["start"], ["daemon", "run"]])(
|
||||
stdio: "ignore",
|
||||
});
|
||||
const exited = new Promise((resolve) => child!.once("exit", resolve));
|
||||
// The lock file exists before its contents identify the supervisor.
|
||||
let supervisorPid: number | undefined;
|
||||
await expect
|
||||
.poll(async () => existsSync(path.join(home, "paseo.pid")), { timeout: 10_000 })
|
||||
.toBe(true);
|
||||
const lock = JSON.parse(await readFile(path.join(home, "paseo.pid"), "utf8"));
|
||||
.poll(
|
||||
async () => {
|
||||
supervisorPid = (await readDaemonInstance(home))?.pid;
|
||||
return supervisorPid;
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBeTypeOf("number");
|
||||
await f.ok(["status", "--home", home]);
|
||||
child.kill("SIGINT");
|
||||
await exited;
|
||||
expect(() => process.kill(lock.pid, 0)).toThrow();
|
||||
expect(() => process.kill(supervisorPid!, 0)).toThrow();
|
||||
expect((await f.ok(["status", "--home", home])).localDaemon).toBe("stopped");
|
||||
} finally {
|
||||
child?.kill("SIGTERM");
|
||||
|
||||
@@ -255,12 +255,12 @@ describe("plugin management commands", () => {
|
||||
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
const command = createPluginCommand();
|
||||
|
||||
await command.parseAsync(["install", "owner/monorepo", "--path", "plugins/review"], {
|
||||
await command.parseAsync(["install", "git:owner/monorepo", "--path", "plugins/review"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(installPluginSource).toHaveBeenCalledWith({
|
||||
source: "owner/monorepo:plugins/review",
|
||||
source: "git:owner/monorepo:plugins/review",
|
||||
});
|
||||
stderr.mockRestore();
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import path from "node:path";
|
||||
import { expandUserPath } from "../../classify.js";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { reviewPluginUpdates, type UpdateOutcome } from "./update.js";
|
||||
import { Command } from "commander";
|
||||
import type { PluginListItem, PluginLogEntry } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
formatPluginSourceReference,
|
||||
parsePluginSourceReference,
|
||||
formatPluginIdentity,
|
||||
} from "@getpaseo/protocol/plugin-source-reference";
|
||||
import type { CommandOptions, ListResult, OutputSchema, SingleResult } from "../../output/index.js";
|
||||
@@ -125,7 +128,18 @@ export async function runPluginInstallCommand(
|
||||
process.stderr.write(
|
||||
"Trusting plugin code: server code and preparation commands run unsandboxed on the daemon host; client code runs inside Paseo. Dependencies and future updates are part of the codebase you trust.\n",
|
||||
);
|
||||
const sourceReference = formatPluginSourceReference(source, options.path);
|
||||
const reference = parsePluginSourceReference(source);
|
||||
let resolvedSource = source;
|
||||
if (reference.kind === "directory") {
|
||||
const directory = expandUserPath(reference.source);
|
||||
// An absolute path can target a daemon on a different operating system.
|
||||
const absoluteDirectory =
|
||||
path.posix.isAbsolute(directory) || path.win32.isAbsolute(directory)
|
||||
? directory
|
||||
: path.resolve(directory);
|
||||
resolvedSource = formatPluginSourceReference(absoluteDirectory, reference.pluginPath);
|
||||
}
|
||||
const sourceReference = formatPluginSourceReference(resolvedSource, options.path);
|
||||
const data = await withPluginSourceClient(options.daemonTarget, (client) =>
|
||||
client.installPluginSource({
|
||||
source: sourceReference,
|
||||
@@ -215,10 +229,12 @@ export function createPluginCommand(): Command {
|
||||
plugin
|
||||
.command("install")
|
||||
.alias("add")
|
||||
.description("Trust and install a plugin from a directory, Git repository, or npm package")
|
||||
.description(
|
||||
"Trust and install a plugin from a registry, directory, Git repository, or npm package",
|
||||
)
|
||||
.argument(
|
||||
"<source>",
|
||||
"Host directory, GitHub owner/repo, git: or npm: source, optionally followed by :plugin/path",
|
||||
"Registry owner/slug or host/owner/slug, host directory, explicit git:owner/repo or Git URL, or npm: source",
|
||||
)
|
||||
.option("--id <id>", "Runtime plugin ID (defaults to paseo-plugin.json id)")
|
||||
.option("--ref <ref>", "Git branch, tag, or commit")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { exec, execFile } from "node:child_process";
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
@@ -9,6 +9,7 @@ import { scaffoldPluginDirectory } from "./scaffold.js";
|
||||
|
||||
const directories: string[] = [];
|
||||
const execFileAsync = promisify(execFile);
|
||||
const execAsync = promisify(exec);
|
||||
const tscPath = createRequire(import.meta.url).resolve("typescript/bin/tsc");
|
||||
|
||||
async function typecheckPlugin(directory: string): Promise<void> {
|
||||
@@ -28,6 +29,47 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("plugin scaffold", () => {
|
||||
it("creates an author overview beside the manifest", async () => {
|
||||
const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-"));
|
||||
directories.push(parent);
|
||||
const directory = path.join(parent, "hello-plugin");
|
||||
await scaffoldPluginDirectory(directory);
|
||||
|
||||
const overview = await readFile(path.join(directory, "OVERVIEW.md"), "utf8");
|
||||
const comments = overview.match(/<!--[\s\S]*?-->/g) ?? [];
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(overview.replace(/<!--[\s\S]*?-->/g, "").trim()).toBe("");
|
||||
expect(overview).toContain("required to list");
|
||||
expect(overview).toContain("README");
|
||||
expect(overview).toContain("choosing a plugin in Paseo");
|
||||
expect(overview).toContain("Length follows complexity");
|
||||
expect(overview).toContain("1. Describe what your plugin is and does");
|
||||
expect(overview).toContain("2. Explain how it works only when it is not obvious");
|
||||
expect(overview).toContain("3. Explain setup when needed");
|
||||
expect(overview).toContain("4. Explain capabilities and settings");
|
||||
expect(overview).toContain("permissions");
|
||||
expect(overview).toContain("known limits");
|
||||
expect(overview).not.toContain("This plugin entry was imported");
|
||||
const packageJson = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8"));
|
||||
expect(packageJson.files).toContain("OVERVIEW.md");
|
||||
expect(await readdir(directory)).toContain("paseo-plugin.json");
|
||||
});
|
||||
|
||||
it("includes the author overview in the packed npm artifact", async () => {
|
||||
const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-"));
|
||||
directories.push(parent);
|
||||
const directory = path.join(parent, "packed-plugin");
|
||||
await scaffoldPluginDirectory(directory);
|
||||
|
||||
const { stdout } = await execAsync("npm pack --dry-run --ignore-scripts --json", {
|
||||
cwd: directory,
|
||||
});
|
||||
const [artifact] = JSON.parse(stdout) as { files: { path: string }[] }[];
|
||||
expect(artifact.files.map((file) => file.path)).toEqual(
|
||||
expect.arrayContaining(["OVERVIEW.md", "paseo-plugin.json"]),
|
||||
);
|
||||
});
|
||||
|
||||
it(
|
||||
"creates a standalone split-runtime project that typechecks",
|
||||
{ timeout: 15_000 },
|
||||
@@ -51,6 +93,8 @@ describe("plugin scaffold", () => {
|
||||
expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual(
|
||||
{
|
||||
id: "hello-plugin",
|
||||
$comment: expect.stringContaining('"icon": "assets/icon.png"'),
|
||||
media: [],
|
||||
requirements: { paseo: `>=${cliPackageJson.version}` },
|
||||
},
|
||||
);
|
||||
@@ -60,6 +104,7 @@ describe("plugin scaffold", () => {
|
||||
version: "0.0.0",
|
||||
files: [
|
||||
"paseo-plugin.json",
|
||||
"OVERVIEW.md",
|
||||
"index.client.ts",
|
||||
"index.client.tsx",
|
||||
"index.server.ts",
|
||||
|
||||
@@ -3,6 +3,34 @@ import path from "node:path";
|
||||
import { PluginIdSchema } from "@getpaseo/protocol/messages";
|
||||
import { resolveCliVersion } from "../../version.js";
|
||||
|
||||
const OVERVIEW = `<!--
|
||||
OVERVIEW.md is required to list your plugin in the registry. Keep it beside
|
||||
paseo-plugin.json in your repository at the pinned commit and in your npm package.
|
||||
Replace this comment with useful facts before publishing.
|
||||
|
||||
This overview is for people choosing a plugin in Paseo, on the plugin page under
|
||||
its install command. README assumes a GitHub audience and includes technical
|
||||
details, installation instructions, and badges. Long, AI-generated READMEs leave
|
||||
people reading past that material to understand what the plugin does.
|
||||
|
||||
Write these parts in order, including only what helps someone decide to install:
|
||||
|
||||
1. Describe what your plugin is and does in plain terms first.
|
||||
2. Explain how it works only when it is not obvious.
|
||||
3. Explain setup when needed: settings, accounts, tokens, providers, tools, or
|
||||
other plugins. Include applicable daemon version and operating system
|
||||
requirements. Setup guidance is allowed; installation instructions are not.
|
||||
4. Explain capabilities and settings worth understanding, what each option does,
|
||||
what the plugin reads or sends and where, permissions, and known limits.
|
||||
|
||||
Length follows complexity; a theme needs one paragraph. Choose headings only
|
||||
when they help. Use sentence case and plain factual language, with no em dashes.
|
||||
Omit installation commands, badges, changelog, contributing or license sections,
|
||||
marketing, and unsupported claims. Avoid implementation filler such as empty
|
||||
cleanup functions, theme-token field lists, or lists of absent features.
|
||||
-->
|
||||
`;
|
||||
|
||||
const TSCONFIG = {
|
||||
compilerOptions: {
|
||||
target: "ES2020",
|
||||
@@ -155,6 +183,7 @@ export async function scaffoldPluginDirectory(
|
||||
version: "0.0.0",
|
||||
files: [
|
||||
"paseo-plugin.json",
|
||||
"OVERVIEW.md",
|
||||
"index.client.ts",
|
||||
"index.client.tsx",
|
||||
"index.server.ts",
|
||||
@@ -177,8 +206,19 @@ export async function scaffoldPluginDirectory(
|
||||
const files = new Map<string, string>([
|
||||
[
|
||||
"paseo-plugin.json",
|
||||
`${JSON.stringify({ id, requirements: { paseo: `>=${version}` } }, null, 2)}\n`,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
id,
|
||||
$comment:
|
||||
'Add "name": "My plugin" to set a display name. Add "icon": "assets/icon.png" for a PNG inside the package. Add image or video paths (relative to this manifest) or HTTPS URLs to media. Include local assets in package.json files.',
|
||||
media: [],
|
||||
requirements: { paseo: `>=${version}` },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
],
|
||||
["OVERVIEW.md", OVERVIEW],
|
||||
["package.json", `${JSON.stringify(packageJson, null, 2)}\n`],
|
||||
["tsconfig.json", `${JSON.stringify(TSCONFIG, null, 2)}\n`],
|
||||
["index.client.tsx", CLIENT_ENTRY],
|
||||
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
} from "../../../../scripts/test-support/npm-registry.mjs";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { once } from "node:events";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import { connectToDaemon } from "../../src/utils/client.ts";
|
||||
import { createE2ETestContext } from "../helpers/test-daemon.ts";
|
||||
|
||||
@@ -45,6 +47,81 @@ async function main(): Promise<void> {
|
||||
);
|
||||
await writeFile(path.join(directory, "index.server.ts"), pluginSource);
|
||||
|
||||
// Observe real CLI requests while forwarding them to the isolated daemon.
|
||||
const proxy = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
||||
const sources: string[] = [];
|
||||
proxy.on("connection", (socket, request) => {
|
||||
const upstream = new WebSocket(`ws://127.0.0.1:${context.port}${request.url}`, {
|
||||
headers: { authorization: request.headers.authorization ?? "" },
|
||||
});
|
||||
const ready = once(upstream, "open");
|
||||
socket.on("message", async (data, isBinary) => {
|
||||
const message = JSON.parse(data.toString());
|
||||
if (
|
||||
message.type === "session" &&
|
||||
message.message.type === "plugin.source.install.request"
|
||||
) {
|
||||
sources.push(message.message.source);
|
||||
}
|
||||
await ready;
|
||||
upstream.send(data, { binary: isBinary });
|
||||
});
|
||||
upstream.on("message", (data, isBinary) => socket.send(data, { binary: isBinary }));
|
||||
socket.on("close", () => upstream.close());
|
||||
upstream.on("close", () => socket.close());
|
||||
});
|
||||
await once(proxy, "listening");
|
||||
const address = proxy.address();
|
||||
assert.ok(address && typeof address !== "string");
|
||||
const sibling = path.join(context.workDir, "x");
|
||||
const caller = path.join(context.workDir, "caller");
|
||||
await mkdir(caller);
|
||||
await mkdir(path.join(sibling, "sub"), { recursive: true });
|
||||
for (const target of [caller, path.join(sibling, "sub"), context.paseoHome]) {
|
||||
await writeFile(
|
||||
path.join(target, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "relative-cli",
|
||||
requirements: { paseo: `>=${resolveCliVersion()}` },
|
||||
}),
|
||||
);
|
||||
await writeFile(path.join(target, "index.server.ts"), pluginSource);
|
||||
}
|
||||
assert.notEqual(caller, process.cwd()); // The daemon inherits the test runner's cwd.
|
||||
try {
|
||||
for (const [source, expectedSource, expectedDirectory, extraArgs] of [
|
||||
[".", caller, caller, []],
|
||||
["../x:sub", `${sibling}:sub`, path.join(sibling, "sub"), []],
|
||||
["../x", `${sibling}:sub`, path.join(sibling, "sub"), ["--path", "sub"]],
|
||||
["~", context.paseoHome, context.paseoHome, []],
|
||||
["~/.:.", `${context.paseoHome}:.`, context.paseoHome, []],
|
||||
] as const) {
|
||||
sources.length = 0;
|
||||
const result = await context.paseo(
|
||||
["plugin", "add", source, ...extraArgs, "--host", `127.0.0.1:${address.port}`, "--json"],
|
||||
{ cwd: caller },
|
||||
);
|
||||
assert.deepEqual(sources, [expectedSource]);
|
||||
assert.equal(result.exitCode, 0, result.stderr);
|
||||
assert.equal(JSON.parse(result.stdout).path, expectedDirectory);
|
||||
const removed = await context.paseo(["plugin", "remove", "relative-cli", "--json"]);
|
||||
assert.equal(removed.exitCode, 0, removed.stderr);
|
||||
}
|
||||
// An absolute path belongs to the daemon's platform, which can differ from the CLI's.
|
||||
const windowsSource = `C:\\${path.basename(context.workDir)}\\missing:sub`;
|
||||
sources.length = 0;
|
||||
const missing = await context.paseo(
|
||||
["plugin", "add", windowsSource, "--host", `127.0.0.1:${address.port}`, "--json"],
|
||||
{ cwd: caller },
|
||||
);
|
||||
assert.deepEqual(sources, [windowsSource]);
|
||||
assert.equal(missing.exitCode, 1);
|
||||
assert.match(missing.stderr, /Plugin directory does not exist/);
|
||||
} finally {
|
||||
for (const socket of proxy.clients) socket.close();
|
||||
await promisify(proxy.close.bind(proxy))();
|
||||
}
|
||||
|
||||
const install = await context.paseo(["plugin", "install", directory, "--json"]);
|
||||
assert.equal(install.exitCode, 0, install.stderr);
|
||||
assert.equal(JSON.parse(install.stdout).id, "cli-e2e");
|
||||
|
||||
@@ -144,6 +144,19 @@ const title = "Hello";
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an Astro expression open through nested template strings", () => {
|
||||
const code = "<p>{`a ${`}`} b`}</p>";
|
||||
|
||||
const tokens = highlightCode(code, "Nested.astro").flat();
|
||||
|
||||
expect(tokens).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ text: "`}`", style: "string" },
|
||||
{ text: " b`", style: "string" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("highlights Vue SFCs across script, template, and style", () => {
|
||||
const code = [
|
||||
'<script lang="ts">',
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { parser as cssParser } from "@lezer/css";
|
||||
import { parser as htmlParser } from "@lezer/html";
|
||||
import { parser as jsParser } from "@lezer/javascript";
|
||||
import { findClosingBrace } from "../js-scanner.js";
|
||||
|
||||
interface Range {
|
||||
from: number;
|
||||
@@ -23,88 +24,11 @@ interface Range {
|
||||
const jsxParser = jsParser.configure({ dialect: "ts jsx" });
|
||||
const typescriptParser = jsParser.configure({ dialect: "ts" });
|
||||
|
||||
function isSpace(code: number): boolean {
|
||||
return code === 32 || code === 9 || code === 10 || code === 13;
|
||||
}
|
||||
|
||||
function isRegexStart(text: string, position: number): boolean {
|
||||
let previous = position - 1;
|
||||
while (previous >= 0 && isSpace(text.charCodeAt(previous))) previous--;
|
||||
if (previous < 0) return true;
|
||||
|
||||
const character = text[previous];
|
||||
if (/[)\]}<"'`\d]/.test(character)) return false;
|
||||
const code = text.charCodeAt(previous);
|
||||
if (code === 62) return previous > 0 && text.charCodeAt(previous - 1) === 61;
|
||||
if (!/[A-Za-z_$]/.test(character)) return true;
|
||||
|
||||
let start = previous;
|
||||
while (start >= 0 && /[A-Za-z0-9_$]/.test(text[start])) start--;
|
||||
const keyword = text.slice(start + 1, previous + 1);
|
||||
return /^(return|typeof|instanceof|in|of|new|void|delete|yield|await|case|do|else|throw|extends|assert|with)$/.test(
|
||||
keyword,
|
||||
);
|
||||
}
|
||||
|
||||
function skipQuotedText(text: string, opening: number): number {
|
||||
const quote = text.charCodeAt(opening);
|
||||
for (let position = opening + 1; position < text.length; position++) {
|
||||
const code = text.charCodeAt(position);
|
||||
if (code === 92) position++;
|
||||
else if (code === quote) return position;
|
||||
}
|
||||
return text.length - 1;
|
||||
}
|
||||
|
||||
function skipLineComment(text: string, opening: number): number {
|
||||
const newline = text.indexOf("\n", opening + 2);
|
||||
return newline >= 0 ? newline : text.length - 1;
|
||||
}
|
||||
|
||||
function skipBlockComment(text: string, opening: number): number {
|
||||
const closing = text.indexOf("*/", opening + 2);
|
||||
return closing >= 0 ? closing + 1 : text.length - 1;
|
||||
}
|
||||
|
||||
function skipRegex(text: string, opening: number): number {
|
||||
let isInCharacterClass = false;
|
||||
for (let position = opening + 1; position < text.length; position++) {
|
||||
const code = text.charCodeAt(position);
|
||||
if (code === 10 || code === 13) return position;
|
||||
if (code === 92) position++;
|
||||
else if (isInCharacterClass && code === 93) isInCharacterClass = false;
|
||||
else if (!isInCharacterClass && code === 91) isInCharacterClass = true;
|
||||
else if (!isInCharacterClass && code === 47) return position;
|
||||
}
|
||||
return text.length - 1;
|
||||
}
|
||||
|
||||
function findClosingBrace(text: string, opening: number): number {
|
||||
let depth = 0;
|
||||
for (let position = opening; position < text.length; position++) {
|
||||
const code = text.charCodeAt(position);
|
||||
if (code === 47 && text.charCodeAt(position + 1) === 47) {
|
||||
position = skipLineComment(text, position);
|
||||
} else if (code === 47 && text.charCodeAt(position + 1) === 42) {
|
||||
position = skipBlockComment(text, position);
|
||||
} else if (code === 47 && isRegexStart(text, position)) {
|
||||
position = skipRegex(text, position);
|
||||
} else if (code === 34 || code === 39 || code === 96) {
|
||||
position = skipQuotedText(text, position);
|
||||
} else if (code === 123) {
|
||||
depth++;
|
||||
} else if (code === 125 && --depth === 0) {
|
||||
return position;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findExpressions(text: string): Range[] {
|
||||
const ranges: Range[] = [];
|
||||
for (let position = 0; position < text.length; position++) {
|
||||
if (text.charCodeAt(position) !== 123) continue;
|
||||
const closing = findClosingBrace(text, position);
|
||||
const closing = findClosingBrace(text, position + 1);
|
||||
if (closing < 0) break;
|
||||
ranges.push({ from: position, to: closing });
|
||||
position = closing;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Scans JavaScript embedded in a template (Astro `{ }` expressions, Vue `{{ }}`
|
||||
// interpolations) for the brace that closes it, without parsing the code.
|
||||
|
||||
function isSpace(code: number): boolean {
|
||||
return code === 32 || code === 9 || code === 10 || code === 13;
|
||||
}
|
||||
|
||||
function isRegexStart(text: string, position: number): boolean {
|
||||
let previous = position - 1;
|
||||
while (previous >= 0 && isSpace(text.charCodeAt(previous))) previous--;
|
||||
if (previous < 0) return true;
|
||||
|
||||
const character = text[previous];
|
||||
if (/[)\]}<"'`\d]/.test(character)) return false;
|
||||
const code = text.charCodeAt(previous);
|
||||
if (code === 62) return previous > 0 && text.charCodeAt(previous - 1) === 61;
|
||||
if (!/[A-Za-z_$]/.test(character)) return true;
|
||||
|
||||
let start = previous;
|
||||
while (start >= 0 && /[A-Za-z0-9_$]/.test(text[start])) start--;
|
||||
const keyword = text.slice(start + 1, previous + 1);
|
||||
return /^(return|typeof|instanceof|in|of|new|void|delete|yield|await|case|do|else|throw|extends|assert|with)$/.test(
|
||||
keyword,
|
||||
);
|
||||
}
|
||||
|
||||
// Template strings nested deeper than this are treated as running to the end.
|
||||
const MAX_TEMPLATE_NESTING = 32;
|
||||
|
||||
function skipQuotedText(text: string, opening: number, nesting = 0): number {
|
||||
const quote = text.charCodeAt(opening);
|
||||
for (let position = opening + 1; position < text.length; position++) {
|
||||
const code = text.charCodeAt(position);
|
||||
if (code === 92) position++;
|
||||
else if (code === quote) return position;
|
||||
else if (quote === 96 && code === 36 && text.charCodeAt(position + 1) === 123) {
|
||||
position = skipTemplateSubstitution(text, position + 2, nesting);
|
||||
}
|
||||
}
|
||||
return text.length - 1;
|
||||
}
|
||||
|
||||
function skipTemplateSubstitution(text: string, start: number, nesting: number): number {
|
||||
if (nesting >= MAX_TEMPLATE_NESTING) return text.length - 1;
|
||||
const closing = findClosingBrace(text, start, () => true, nesting + 1);
|
||||
return closing >= 0 ? closing : text.length - 1;
|
||||
}
|
||||
|
||||
function skipLineComment(text: string, opening: number): number {
|
||||
const newline = text.indexOf("\n", opening + 2);
|
||||
return newline >= 0 ? newline : text.length - 1;
|
||||
}
|
||||
|
||||
function skipBlockComment(text: string, opening: number): number {
|
||||
const closing = text.indexOf("*/", opening + 2);
|
||||
return closing >= 0 ? closing + 1 : text.length - 1;
|
||||
}
|
||||
|
||||
function skipRegex(text: string, opening: number): number {
|
||||
let isInCharacterClass = false;
|
||||
for (let position = opening + 1; position < text.length; position++) {
|
||||
const code = text.charCodeAt(position);
|
||||
if (code === 10 || code === 13) return position;
|
||||
if (code === 92) position++;
|
||||
else if (isInCharacterClass && code === 93) isInCharacterClass = false;
|
||||
else if (!isInCharacterClass && code === 91) isInCharacterClass = true;
|
||||
else if (!isInCharacterClass && code === 47) return position;
|
||||
}
|
||||
return text.length - 1;
|
||||
}
|
||||
|
||||
// Finds the first `}` outside nested braces, strings, comments, and regexes
|
||||
// for which `isEnd` holds.
|
||||
export function findClosingBrace(
|
||||
text: string,
|
||||
start: number,
|
||||
isEnd: (position: number) => boolean = () => true,
|
||||
nesting = 0,
|
||||
): number {
|
||||
let depth = 0;
|
||||
for (let position = start; position < text.length; position++) {
|
||||
const code = text.charCodeAt(position);
|
||||
if (code === 47 && text.charCodeAt(position + 1) === 47) {
|
||||
position = skipLineComment(text, position);
|
||||
} else if (code === 47 && text.charCodeAt(position + 1) === 42) {
|
||||
position = skipBlockComment(text, position);
|
||||
} else if (code === 47 && isRegexStart(text, position)) {
|
||||
position = skipRegex(text, position);
|
||||
} else if (code === 34 || code === 39 || code === 96) {
|
||||
position = skipQuotedText(text, position, nesting);
|
||||
} else if (code === 123) {
|
||||
depth++;
|
||||
} else if (code === 125 && depth === 0) {
|
||||
if (isEnd(position)) return position;
|
||||
} else if (code === 125) {
|
||||
depth--;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { parser as cssParser } from "@lezer/css";
|
||||
import { parser as htmlParser } from "@lezer/html";
|
||||
import { parser as jsParser } from "@lezer/javascript";
|
||||
import { findClosingBrace } from "../js-scanner.js";
|
||||
|
||||
interface Range {
|
||||
from: number;
|
||||
@@ -17,104 +18,6 @@ interface Range {
|
||||
const typescriptParser = jsParser.configure({ dialect: "ts" });
|
||||
const expressionParser = jsParser.configure({ top: "SingleExpression" });
|
||||
|
||||
function isSpace(code: number): boolean {
|
||||
return code === 32 || code === 9 || code === 10 || code === 13;
|
||||
}
|
||||
|
||||
function isRegexStart(text: string, position: number): boolean {
|
||||
let previous = position - 1;
|
||||
while (previous >= 0 && isSpace(text.charCodeAt(previous))) previous--;
|
||||
if (previous < 0) return true;
|
||||
|
||||
const character = text[previous];
|
||||
if (/[)\]}<"'`\d]/.test(character)) return false;
|
||||
const code = text.charCodeAt(previous);
|
||||
if (code === 62) return previous > 0 && text.charCodeAt(previous - 1) === 61;
|
||||
if (!/[A-Za-z_$]/.test(character)) return true;
|
||||
|
||||
let start = previous;
|
||||
while (start >= 0 && /[A-Za-z0-9_$]/.test(text[start])) start--;
|
||||
const keyword = text.slice(start + 1, previous + 1);
|
||||
return /^(return|typeof|instanceof|in|of|new|void|delete|yield|await|case|do|else|throw|extends|assert|with)$/.test(
|
||||
keyword,
|
||||
);
|
||||
}
|
||||
|
||||
// Template strings nested deeper than this are treated as running to the end.
|
||||
const MAX_TEMPLATE_NESTING = 32;
|
||||
|
||||
function skipQuotedText(text: string, opening: number, nesting = 0): number {
|
||||
const quote = text.charCodeAt(opening);
|
||||
for (let position = opening + 1; position < text.length; position++) {
|
||||
const code = text.charCodeAt(position);
|
||||
if (code === 92) position++;
|
||||
else if (code === quote) return position;
|
||||
else if (quote === 96 && code === 36 && text.charCodeAt(position + 1) === 123) {
|
||||
position = skipTemplateSubstitution(text, position + 2, nesting);
|
||||
}
|
||||
}
|
||||
return text.length - 1;
|
||||
}
|
||||
|
||||
function skipTemplateSubstitution(text: string, start: number, nesting: number): number {
|
||||
if (nesting >= MAX_TEMPLATE_NESTING) return text.length - 1;
|
||||
const closing = findClosingBrace(text, start, () => true, nesting + 1);
|
||||
return closing >= 0 ? closing : text.length - 1;
|
||||
}
|
||||
|
||||
function skipLineComment(text: string, opening: number): number {
|
||||
const newline = text.indexOf("\n", opening + 2);
|
||||
return newline >= 0 ? newline : text.length - 1;
|
||||
}
|
||||
|
||||
function skipBlockComment(text: string, opening: number): number {
|
||||
const closing = text.indexOf("*/", opening + 2);
|
||||
return closing >= 0 ? closing + 1 : text.length - 1;
|
||||
}
|
||||
|
||||
function skipRegex(text: string, opening: number): number {
|
||||
let isInCharacterClass = false;
|
||||
for (let position = opening + 1; position < text.length; position++) {
|
||||
const code = text.charCodeAt(position);
|
||||
if (code === 10 || code === 13) return position;
|
||||
if (code === 92) position++;
|
||||
else if (isInCharacterClass && code === 93) isInCharacterClass = false;
|
||||
else if (!isInCharacterClass && code === 91) isInCharacterClass = true;
|
||||
else if (!isInCharacterClass && code === 47) return position;
|
||||
}
|
||||
return text.length - 1;
|
||||
}
|
||||
|
||||
// Finds the first `}` outside nested braces, strings, comments, and regexes
|
||||
// for which `isEnd` holds.
|
||||
function findClosingBrace(
|
||||
text: string,
|
||||
start: number,
|
||||
isEnd: (position: number) => boolean,
|
||||
nesting = 0,
|
||||
): number {
|
||||
let depth = 0;
|
||||
for (let position = start; position < text.length; position++) {
|
||||
const code = text.charCodeAt(position);
|
||||
if (code === 47 && text.charCodeAt(position + 1) === 47) {
|
||||
position = skipLineComment(text, position);
|
||||
} else if (code === 47 && text.charCodeAt(position + 1) === 42) {
|
||||
position = skipBlockComment(text, position);
|
||||
} else if (code === 47 && isRegexStart(text, position)) {
|
||||
position = skipRegex(text, position);
|
||||
} else if (code === 34 || code === 39 || code === 96) {
|
||||
position = skipQuotedText(text, position, nesting);
|
||||
} else if (code === 123) {
|
||||
depth++;
|
||||
} else if (code === 125 && depth === 0) {
|
||||
if (isEnd(position)) return position;
|
||||
} else if (code === 125) {
|
||||
depth--;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findInterpolationEnd(text: string, start: number): number {
|
||||
return findClosingBrace(text, start, (position) => text.charCodeAt(position + 1) === 125);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,27 @@ describe("plugin protocol compatibility", () => {
|
||||
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
|
||||
},
|
||||
);
|
||||
it("preserves manifest metadata in plugin list responses", () => {
|
||||
const message = {
|
||||
type: "plugin.list.response",
|
||||
payload: {
|
||||
requestId: "metadata",
|
||||
plugins: [
|
||||
{
|
||||
id: "review",
|
||||
name: "Review tools",
|
||||
icon: "assets/icon.png",
|
||||
media: ["screenshot.png", "https://example.com/demo.mp4"],
|
||||
path: "/plugins/review",
|
||||
enabled: false,
|
||||
status: "disabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
|
||||
});
|
||||
|
||||
it("parses plugin timeline append messages and advertises the capability", () => {
|
||||
expect(
|
||||
SessionInboundMessageSchema.parse({
|
||||
|
||||
@@ -6632,6 +6632,9 @@ export const PluginNpmInstallationSchema = z.object({
|
||||
|
||||
export const PluginListItemSchema = z.object({
|
||||
id: PluginIdSchema,
|
||||
name: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
media: z.array(z.string()).optional(),
|
||||
description: z.string().optional(),
|
||||
path: z.string(),
|
||||
enabled: z.boolean(),
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pluginOverviewUrl } from "./plugin-overview.js";
|
||||
|
||||
describe("plugin overview destinations", () => {
|
||||
it.each([
|
||||
undefined,
|
||||
"",
|
||||
"./docs",
|
||||
"../docs",
|
||||
"/login",
|
||||
"//example.com",
|
||||
"#section",
|
||||
"?login",
|
||||
"http://example.com",
|
||||
"mailto:hello@example.com",
|
||||
"ftp://example.com",
|
||||
"javascript:alert(1)",
|
||||
"data:image/png;base64,AAAA",
|
||||
"vbscript:msgbox(1)",
|
||||
"javascript:alert(1)",
|
||||
"javascript%3Aalert(1)",
|
||||
"javascript%253Aalert(1)",
|
||||
"java\tscript:alert(1)",
|
||||
" https://example.com",
|
||||
"https://example.com\n",
|
||||
"https:example.com",
|
||||
"https:///example.com",
|
||||
"https://paseo.sh@evil.example",
|
||||
"https://example.com\\@evil.example",
|
||||
"https://",
|
||||
])("leaves %s as text", (url) => {
|
||||
expect(pluginOverviewUrl(url)).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"https://example.com/path?q=a&b=c#section",
|
||||
"HTTPS://example.com/path",
|
||||
"https://example.com/%3Cscript%3E",
|
||||
])("allows %s without decoding it again", (url) => {
|
||||
expect(pluginOverviewUrl(url)).toBe(url);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/** Presentation contract for author-controlled registry content, independent of the UI runtime. */
|
||||
export const pluginOverviewPolicy = {
|
||||
rawHtml: false,
|
||||
elements: [
|
||||
"p",
|
||||
"br",
|
||||
"hr",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"blockquote",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"strong",
|
||||
"em",
|
||||
"del",
|
||||
"pre",
|
||||
"code",
|
||||
"a",
|
||||
"img",
|
||||
"table",
|
||||
"thead",
|
||||
"tbody",
|
||||
"tr",
|
||||
"th",
|
||||
"td",
|
||||
],
|
||||
link: { rel: "noopener noreferrer nofollow", target: "_blank" },
|
||||
} as const;
|
||||
|
||||
/** Validate decoded Markdown destinations; never resolve against the reader's page or repository. */
|
||||
export function pluginOverviewUrl(value: string | undefined): string | undefined {
|
||||
// URL parsers discard controls and normalize backslashes; reject those ambiguous spellings.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (!value || !/^https:\/\/[^/?#]/i.test(value) || /[\u0000-\u0020\u007f\\]/.test(value))
|
||||
return undefined;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "https:" || !url.hostname || url.username || url.password)
|
||||
return undefined;
|
||||
return value;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pluginMediaKind } from "./plugin-registry.js";
|
||||
|
||||
describe("plugin media kind", () => {
|
||||
it.each([
|
||||
["https://cdn.example.com/a/preview.png", "image"],
|
||||
["https://cdn.example.com/a/preview.JPG", "image"],
|
||||
["https://cdn.example.com/a/preview.jpeg", "image"],
|
||||
["https://cdn.example.com/a/preview.webp", "image"],
|
||||
["https://cdn.example.com/a/preview.gif", "image"],
|
||||
["https://cdn.example.com/a/demo.mp4", "video"],
|
||||
["https://cdn.example.com/a/demo.WebM", "video"],
|
||||
["https://cdn.example.com/a/demo.mp4?raw=true#t=2", "video"],
|
||||
["https://cdn.example.com/mp4/preview.png?format=mp4", "image"],
|
||||
] as const)("reads %s as %s from its path extension", (url, kind) => {
|
||||
expect(pluginMediaKind(url)).toBe(kind);
|
||||
});
|
||||
});
|
||||
@@ -39,13 +39,21 @@ export const PublishedPluginSchema = z.object({
|
||||
repository: z.object({ url: z.string().url(), commit: z.string().optional() }),
|
||||
artifact: PluginRegistryArtifactSchema,
|
||||
icon: z.string().url().optional(),
|
||||
screenshots: z.array(z.string().url()),
|
||||
/** Images and videos in display order; see `pluginMediaKind`. */
|
||||
media: z.array(z.string().url()),
|
||||
submittedAt: z.string(),
|
||||
reviewedAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
publishedAt: z.string(),
|
||||
installs: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
const VIDEO_EXTENSIONS = new Set(["mp4", "webm"]);
|
||||
|
||||
/** Whether a registry media entry is a video or an image, from its path extension. */
|
||||
export function pluginMediaKind(url: string): "image" | "video" {
|
||||
const extension = new URL(url).pathname.split(".").pop()?.toLowerCase() ?? "";
|
||||
return VIDEO_EXTENSIONS.has(extension) ? "video" : "image";
|
||||
}
|
||||
export const PublishedPluginDetailSchema = PublishedPluginSchema.extend({ readme: z.string() });
|
||||
export const PluginRegistryIndexSchema = z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
@@ -54,6 +62,8 @@ export const PluginRegistryIndexSchema = z.object({
|
||||
z.object({ slug: z.string(), label: z.string(), description: z.string().optional() }),
|
||||
),
|
||||
plugins: z.array(PublishedPluginSchema),
|
||||
/** Plugin IDs the maintainer features, in display order. An ID may name a plugin that is not listed. */
|
||||
featured: z.array(z.string()),
|
||||
generatedAt: z.string(),
|
||||
});
|
||||
export type PluginRegistryIdentity = z.infer<typeof PluginRegistryIdentitySchema>;
|
||||
|
||||
@@ -3,6 +3,35 @@ import { describe, expect, it } from "vitest";
|
||||
import { parsePluginSourceReference } from "./plugin-source-reference.js";
|
||||
|
||||
describe("plugin source references", () => {
|
||||
it.each([
|
||||
".",
|
||||
"..",
|
||||
"./owner/slug",
|
||||
"../owner/slug",
|
||||
"/owner/slug",
|
||||
"~/owner/slug",
|
||||
"~",
|
||||
"C:\\",
|
||||
"C:/",
|
||||
"D:\\plugins\\repository",
|
||||
"D:/plugins/repository",
|
||||
"\\\\server\\share",
|
||||
"\\\\server\\share\\plugins\\review",
|
||||
".\\plugins\\review",
|
||||
"..\\plugins\\review",
|
||||
])("recognizes the explicit directory %s by shape", (source) => {
|
||||
expect(parsePluginSourceReference(source)).toEqual({
|
||||
kind: "directory",
|
||||
source,
|
||||
pluginPath: undefined,
|
||||
});
|
||||
expect(parsePluginSourceReference(`${source}:plugins/review`)).toEqual({
|
||||
kind: "directory",
|
||||
source,
|
||||
pluginPath: "plugins/review",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["npm:review@1.2.0:nested", "npm:review@1.2.0", "nested"],
|
||||
["npm:review@^1.2.0", "npm:review@^1.2.0", undefined],
|
||||
@@ -16,6 +45,7 @@ describe("plugin source references", () => {
|
||||
["git:file:///repo:plugins/main", "git:file:///repo", "plugins/main"],
|
||||
["plugins.example.com:8443/owner/slug", "plugins.example.com:8443/owner/slug", undefined],
|
||||
["owner/repository", "owner/repository", undefined],
|
||||
["plugins/foo", "plugins/foo", undefined],
|
||||
["owner/repository:plugins/review", "owner/repository", "plugins/review"],
|
||||
[
|
||||
"https://example.test:8443/owner/repository.git",
|
||||
@@ -39,10 +69,8 @@ describe("plugin source references", () => {
|
||||
"file:///D:/plugins/repository",
|
||||
"plugins/review",
|
||||
],
|
||||
["D:\\plugins\\repository", "D:\\plugins\\repository", undefined],
|
||||
["D:\\plugins\\repository:plugins/review", "D:\\plugins\\repository", "plugins/review"],
|
||||
])("parses %s", (reference, source, pluginPath) => {
|
||||
expect(parsePluginSourceReference(reference)).toEqual({ source, pluginPath });
|
||||
expect(parsePluginSourceReference(reference)).toEqual({ kind: "managed", source, pluginPath });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import { parsePluginRegistryReference } from "./plugin-registry.js";
|
||||
import type { PluginInstallation } from "./messages.js";
|
||||
export interface PluginSourceReference {
|
||||
kind: "directory" | "managed";
|
||||
source: string;
|
||||
pluginPath: string | undefined;
|
||||
}
|
||||
|
||||
export function parsePluginSourceReference(reference: string): PluginSourceReference {
|
||||
if (parsePluginRegistryReference(reference)) return { source: reference, pluginPath: undefined };
|
||||
const prefix = /^(npm:|github:|git:(?!\/\/))/.exec(reference)?.[0];
|
||||
const source = prefix ? reference.slice(prefix.length) : reference;
|
||||
const parsed = splitPluginPath(source, { scp: prefix !== "npm:" });
|
||||
return { ...parsed, source: `${prefix ?? ""}${parsed.source}` };
|
||||
if (!prefix && /^(\.\.?(?:[/\\]|$)|\/|~|[A-Za-z]:[/\\]|\\\\)/.test(parsed.source)) {
|
||||
return { kind: "directory", ...parsed };
|
||||
}
|
||||
if (parsePluginRegistryReference(reference))
|
||||
return { kind: "managed", source: reference, pluginPath: undefined };
|
||||
return { kind: "managed", ...parsed, source: `${prefix ?? ""}${parsed.source}` };
|
||||
}
|
||||
|
||||
function splitPluginPath(reference: string, options: { scp: boolean }): PluginSourceReference {
|
||||
function splitPluginPath(
|
||||
reference: string,
|
||||
options: { scp: boolean },
|
||||
): Pick<PluginSourceReference, "source" | "pluginPath"> {
|
||||
const separator = reference.lastIndexOf(":");
|
||||
if (separator === -1) return { source: reference, pluginPath: undefined };
|
||||
|
||||
|
||||
+1202
File diff suppressed because it is too large
Load Diff
@@ -309,18 +309,30 @@ export async function sendPromptToAgent(
|
||||
const unarchive = params.unarchive ?? true;
|
||||
|
||||
const record = await params.agentStorage.get(params.agentId);
|
||||
let archivedAtToRestore: string | null = null;
|
||||
if (record?.archivedAt) {
|
||||
if (!unarchive) {
|
||||
return { disposition: "turn_started" };
|
||||
}
|
||||
await unarchiveAgentState(params.agentStorage, params.agentManager, params.agentId);
|
||||
if (await unarchiveAgentState(params.agentStorage, params.agentManager, params.agentId)) {
|
||||
archivedAtToRestore = record.archivedAt;
|
||||
}
|
||||
}
|
||||
|
||||
await ensureAgentLoaded(params.agentId, {
|
||||
agentManager: params.agentManager,
|
||||
agentStorage: params.agentStorage,
|
||||
logger: params.logger,
|
||||
});
|
||||
try {
|
||||
await ensureAgentLoaded(params.agentId, {
|
||||
agentManager: params.agentManager,
|
||||
agentStorage: params.agentStorage,
|
||||
logger: params.logger,
|
||||
});
|
||||
} catch (error) {
|
||||
// A send that could not load the agent leaves it where it was: still archived.
|
||||
// Concurrent sends share this load, so none of them holds a live session.
|
||||
if (archivedAtToRestore) {
|
||||
await params.agentManager.archiveSnapshot(params.agentId, archivedAtToRestore);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (params.sessionMode) {
|
||||
await params.agentManager.setAgentMode(params.agentId, params.sessionMode);
|
||||
|
||||
@@ -628,6 +628,11 @@ export interface AgentCreateSessionOptions {
|
||||
* Defaults to true. Providers that cannot honor false should no-op.
|
||||
*/
|
||||
persistSession?: boolean;
|
||||
/**
|
||||
* Model ids added by provider configuration (`models` / `additionalModels`).
|
||||
* Providers that validate against runtime-advertised models must accept these.
|
||||
*/
|
||||
configuredModelIds?: readonly string[];
|
||||
}
|
||||
|
||||
/** What a resumed session is for: driving the agent, or reading what it already did. */
|
||||
@@ -637,6 +642,8 @@ export type AgentResumePurpose = "interactive" | "history";
|
||||
export interface AgentResumeSessionOptions {
|
||||
/** Defaults to interactive. History loading may be read-only for archived native sessions. */
|
||||
purpose?: AgentResumePurpose;
|
||||
/** See AgentCreateSessionOptions.configuredModelIds. */
|
||||
configuredModelIds?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -723,6 +723,57 @@ describe("AgentStreamCoalescer", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps a known tool call detail when a later update in the window has none", () => {
|
||||
const { coalescer, flushes } = createHarness();
|
||||
primeLeadingEdge(coalescer, flushes);
|
||||
|
||||
coalescer.handle(
|
||||
"agent-1",
|
||||
timeline({
|
||||
type: "tool_call",
|
||||
callId: "agent-call",
|
||||
name: "Task",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "general-purpose",
|
||||
description: "List files",
|
||||
log: "",
|
||||
actions: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
coalescer.handle(
|
||||
"agent-1",
|
||||
timeline({
|
||||
type: "tool_call",
|
||||
callId: "agent-call",
|
||||
name: "Agent",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "unknown", input: { description: "List files" }, output: null },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(flushes.map((flush) => flush.item)).toEqual([
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: "agent-call",
|
||||
name: "Agent",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "general-purpose",
|
||||
description: "List files",
|
||||
log: "",
|
||||
actions: [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserves mixed text and tool call arrival order within a flush", async () => {
|
||||
const { coalescer, flushes } = createHarness();
|
||||
primeLeadingEdge(coalescer, flushes);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentProvider, AgentStreamEvent, AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import { mergeToolCallItems } from "./timeline-projection.js";
|
||||
|
||||
export const AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS = 60;
|
||||
|
||||
@@ -183,9 +184,16 @@ export class AgentStreamCoalescer {
|
||||
}
|
||||
|
||||
const existingIndex = buffer.toolCallEntryIndexes.get(event.item.callId);
|
||||
const existing = existingIndex !== undefined ? buffer.entries[existingIndex] : undefined;
|
||||
// Merge rather than replace, so an update that carries less (an unknown detail after a
|
||||
// labeled one) cannot erase what the timeline would have kept had both been flushed.
|
||||
let item = event.item;
|
||||
if (existing?.kind === "tool_call") {
|
||||
item = mergeToolCallItems(existing.item, event.item);
|
||||
}
|
||||
const entry: PendingToolCallEntry = {
|
||||
kind: "tool_call",
|
||||
item: event.item,
|
||||
item,
|
||||
provider: event.provider,
|
||||
...(event.turnId !== undefined ? { turnId: event.turnId } : {}),
|
||||
};
|
||||
|
||||
@@ -492,6 +492,7 @@ function wrapClientProvider(
|
||||
const listFeatures = inner.listFeatures?.bind(inner);
|
||||
const archiveNativeSession = inner.archiveNativeSession?.bind(inner);
|
||||
const unarchiveNativeSession = inner.unarchiveNativeSession?.bind(inner);
|
||||
const configuredModelIds = [...profileModels, ...additionalModels].map((model) => model.id);
|
||||
|
||||
return {
|
||||
provider,
|
||||
@@ -509,7 +510,7 @@ function wrapClientProvider(
|
||||
providerOptions: mergeProviderOptions(providerOptions, config.providerOptions),
|
||||
},
|
||||
launchContext,
|
||||
options,
|
||||
{ ...options, configuredModelIds },
|
||||
),
|
||||
),
|
||||
resumeSession: async (handle, overrides, launchContext, options) =>
|
||||
@@ -526,7 +527,7 @@ function wrapClientProvider(
|
||||
providerOptions: mergeProviderOptions(providerOptions, overrides?.providerOptions),
|
||||
},
|
||||
launchContext,
|
||||
options,
|
||||
{ ...options, configuredModelIds },
|
||||
),
|
||||
),
|
||||
fetchCatalog: async (options, context) => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createRequire } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
AgentSideConnection,
|
||||
ClientSideConnection,
|
||||
@@ -54,8 +54,10 @@ import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentPersistenceHandle,
|
||||
AgentSession,
|
||||
ProviderRefreshContext,
|
||||
} from "../agent-sdk-types.js";
|
||||
import { buildProviderRegistry } from "../provider-registry.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { buildStringCommandShellInvocation } from "../../../utils/string-command-shell.js";
|
||||
import { asInternals } from "../../test-utils/class-mocks.js";
|
||||
@@ -4370,3 +4372,151 @@ describe("ACPAgentSession close() with an unresponsive provider", () => {
|
||||
expect(isProcessAlive(pid)).toBe(false);
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
// An ACP agent that advertises two models (or none, with ACP_ADVERTISE_MODELS=none) but,
|
||||
// like Cline, runs any model id it is given.
|
||||
const PERMISSIVE_MODEL_ACP_AGENT = `
|
||||
import { Readable, Writable } from "node:stream";
|
||||
const { AgentSideConnection, PROTOCOL_VERSION, ndJsonStream } = await import(process.env.ACP_SDK_URL);
|
||||
let currentModelId = "advertised-default";
|
||||
const modelState = () =>
|
||||
process.env.ACP_ADVERTISE_MODELS === "none"
|
||||
? undefined
|
||||
: {
|
||||
currentModelId,
|
||||
availableModels: [
|
||||
{ modelId: "advertised-default", name: "Advertised default" },
|
||||
{ modelId: "advertised-other", name: "Advertised other" },
|
||||
],
|
||||
};
|
||||
new AgentSideConnection(
|
||||
() => ({
|
||||
async initialize() {
|
||||
return { protocolVersion: PROTOCOL_VERSION, agentCapabilities: { loadSession: true } };
|
||||
},
|
||||
async newSession() {
|
||||
return { sessionId: "permissive-session", models: modelState() };
|
||||
},
|
||||
async loadSession() {
|
||||
return { models: modelState() };
|
||||
},
|
||||
async unstable_setSessionModel({ modelId }) {
|
||||
currentModelId = modelId;
|
||||
return {};
|
||||
},
|
||||
async authenticate() {},
|
||||
async cancel() {},
|
||||
async prompt() {
|
||||
return { stopReason: "end_turn" };
|
||||
},
|
||||
}),
|
||||
ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)),
|
||||
);
|
||||
`;
|
||||
|
||||
describe("custom ACP provider with configured models", () => {
|
||||
let dir: string;
|
||||
let agentScript: string;
|
||||
let session: AgentSession | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(path.join(tmpdir(), "paseo-acp-configured-models-"));
|
||||
agentScript = path.join(dir, "agent.mjs");
|
||||
await writeFile(agentScript, PERMISSIVE_MODEL_ACP_AGENT);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await session?.close();
|
||||
session = null;
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function buildRegistry(
|
||||
override: {
|
||||
models?: Array<{ id: string; label: string }>;
|
||||
additionalModels?: Array<{ id: string; label: string }>;
|
||||
},
|
||||
agentEnv: Record<string, string> = {},
|
||||
) {
|
||||
return buildProviderRegistry(createTestLogger(), {
|
||||
providerOverrides: {
|
||||
permissive: {
|
||||
extends: "acp",
|
||||
label: "Permissive",
|
||||
command: [process.execPath, agentScript],
|
||||
env: {
|
||||
ACP_SDK_URL: pathToFileURL(
|
||||
createRequire(import.meta.url).resolve("@agentclientprotocol/sdk"),
|
||||
).href,
|
||||
...agentEnv,
|
||||
},
|
||||
...override,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createSessionOnModel(
|
||||
registry: ReturnType<typeof buildRegistry>,
|
||||
model: string,
|
||||
): Promise<AgentSession> {
|
||||
const client = registry.permissive.createClient(createTestLogger());
|
||||
session = await client.createSession({ provider: "permissive", cwd: dir, model });
|
||||
return session;
|
||||
}
|
||||
|
||||
test("runs on a model added through additionalModels that the agent does not advertise", async () => {
|
||||
const registry = buildRegistry({
|
||||
additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }],
|
||||
});
|
||||
|
||||
const created = await createSessionOnModel(registry, "stealth/pixel-canary");
|
||||
|
||||
expect((await created.getRuntimeInfo()).model).toBe("stealth/pixel-canary");
|
||||
});
|
||||
|
||||
test("runs on a model from a replacement models list that the agent does not advertise", async () => {
|
||||
const registry = buildRegistry({
|
||||
models: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }],
|
||||
});
|
||||
|
||||
const created = await createSessionOnModel(registry, "stealth/pixel-canary");
|
||||
|
||||
expect((await created.getRuntimeInfo()).model).toBe("stealth/pixel-canary");
|
||||
});
|
||||
|
||||
test("runs on a model from additionalModels when the agent advertises no models", async () => {
|
||||
const registry = buildRegistry(
|
||||
{ additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }] },
|
||||
{ ACP_ADVERTISE_MODELS: "none" },
|
||||
);
|
||||
|
||||
const created = await createSessionOnModel(registry, "stealth/pixel-canary");
|
||||
|
||||
expect((await created.getRuntimeInfo()).model).toBe("stealth/pixel-canary");
|
||||
});
|
||||
|
||||
test("resumes on a model added through additionalModels that the agent does not advertise", async () => {
|
||||
const registry = buildRegistry({
|
||||
additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }],
|
||||
});
|
||||
const client = registry.permissive.createClient(createTestLogger());
|
||||
|
||||
session = await client.resumeSession(
|
||||
{ provider: "permissive", sessionId: "permissive-session", metadata: { cwd: dir } },
|
||||
{ model: "stealth/pixel-canary" },
|
||||
);
|
||||
|
||||
expect((await session.getRuntimeInfo()).model).toBe("stealth/pixel-canary");
|
||||
});
|
||||
|
||||
test("keeps the agent's model when the requested model is neither advertised nor configured", async () => {
|
||||
const registry = buildRegistry({
|
||||
additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }],
|
||||
});
|
||||
|
||||
const created = await createSessionOnModel(registry, "stale/removed-model");
|
||||
|
||||
expect((await created.getRuntimeInfo()).model).toBe("advertised-default");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,6 +76,9 @@ import {
|
||||
type AgentPersistenceHandle,
|
||||
type AgentPromptContentBlock,
|
||||
type AgentPromptInput,
|
||||
type AgentResumePurpose,
|
||||
type AgentCreateSessionOptions,
|
||||
type AgentResumeSessionOptions,
|
||||
type AgentRunOptions,
|
||||
type AgentRunResult,
|
||||
type AgentRuntimeInfo,
|
||||
@@ -148,6 +151,18 @@ function rejectOnSpawnError(child: ChildProcess, stderrChunks: string[]): Promis
|
||||
});
|
||||
}
|
||||
|
||||
async function isDirectory(target: string): Promise<boolean> {
|
||||
try {
|
||||
return (await fs.stat(target)).isDirectory();
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT" || code === "ENOTDIR") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -497,6 +512,8 @@ interface ACPAgentSessionOptions {
|
||||
capabilities: AgentCapabilityFlags;
|
||||
extensionCommandsParser?: ACPExtensionCommandsParser;
|
||||
handle?: AgentPersistenceHandle;
|
||||
resumePurpose?: AgentResumePurpose;
|
||||
configuredModelIds?: readonly string[];
|
||||
agentId?: string;
|
||||
launchEnv?: Record<string, string>;
|
||||
waitForInitialCommands?: boolean;
|
||||
@@ -964,6 +981,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
async createSession(
|
||||
config: AgentSessionConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
options?: AgentCreateSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
this.assertProvider(config);
|
||||
const providerOptions = ACPProviderOptionsSchema.parse(config.providerOptions ?? {});
|
||||
@@ -991,6 +1009,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
supportsMcpServers:
|
||||
providerOptions.supportsMcpServers ?? this.capabilities.supportsMcpServers,
|
||||
},
|
||||
configuredModelIds: options?.configuredModelIds,
|
||||
agentId: launchContext?.agentId,
|
||||
launchEnv: launchContext?.env,
|
||||
extensionCommandsParser: this.extensionCommandsParser,
|
||||
@@ -1006,6 +1025,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
options?: AgentResumeSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
if (handle.provider !== this.provider) {
|
||||
throw new Error(`Cannot resume ${handle.provider} handle with ${this.provider} provider`);
|
||||
@@ -1047,6 +1067,8 @@ export class ACPAgentClient implements AgentClient {
|
||||
providerOptions.supportsMcpServers ?? this.capabilities.supportsMcpServers,
|
||||
},
|
||||
handle,
|
||||
resumePurpose: options?.purpose,
|
||||
configuredModelIds: options?.configuredModelIds,
|
||||
agentId: launchContext?.agentId,
|
||||
launchEnv: launchContext?.env,
|
||||
extensionCommandsParser: this.extensionCommandsParser,
|
||||
@@ -1675,6 +1697,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
configOptions: SessionConfigOption[],
|
||||
) => SessionConfigOption[];
|
||||
private readonly configFeatureOptions: ACPConfigFeatureOption[];
|
||||
private readonly configuredModelIds: ReadonlySet<string>;
|
||||
private readonly clientCapabilities?: ACPClientCapabilities;
|
||||
private readonly clientCapabilityMeta?: ACPClientCapabilityMeta;
|
||||
private readonly modeIdTransformer?: (modeId: string) => string | null;
|
||||
@@ -1700,6 +1723,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
private readonly terminalEntries = new Map<string, TerminalEntry>();
|
||||
private readonly persistedHistory: AgentTimelineItem[] = [];
|
||||
private readonly initialHandle?: AgentPersistenceHandle;
|
||||
private readonly resumePurpose: AgentResumePurpose;
|
||||
|
||||
private readonly config: AgentSessionConfig;
|
||||
private child: ChildProcessWithoutNullStreams | null = null;
|
||||
@@ -1742,6 +1766,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
this.sessionResponseTransformer = options.sessionResponseTransformer;
|
||||
this.configOptionsTransformer = options.configOptionsTransformer;
|
||||
this.configFeatureOptions = options.configFeatureOptions ?? [];
|
||||
this.configuredModelIds = new Set(options.configuredModelIds);
|
||||
this.clientCapabilities = options.clientCapabilities;
|
||||
this.clientCapabilityMeta = options.clientCapabilityMeta;
|
||||
this.modeIdTransformer = options.modeIdTransformer;
|
||||
@@ -1753,6 +1778,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
this.agentId = options.agentId;
|
||||
this.launchEnv = options.launchEnv;
|
||||
this.initialHandle = options.handle;
|
||||
this.resumePurpose = options.resumePurpose ?? "interactive";
|
||||
this.config = { ...config, provider: options.provider };
|
||||
this.currentMode = config.modeId ?? null;
|
||||
this.currentModel = config.model ?? null;
|
||||
@@ -2175,12 +2201,20 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.setModelWithSelection({ modelId, selection: this.resolveModelSelection(modelId) });
|
||||
}
|
||||
|
||||
private resolveModelSelection(modelId: string): ACPModelSelection {
|
||||
const selection = resolveACPModelSelection({
|
||||
modelId,
|
||||
availableModels: this.availableModels,
|
||||
configOptions: this.configOptions,
|
||||
});
|
||||
await this.setModelWithSelection({ modelId, selection });
|
||||
if (selection.availableModel || !this.configuredModelIds.has(modelId)) {
|
||||
return selection;
|
||||
}
|
||||
// A model the user added in provider config is applied even when the agent does not advertise it.
|
||||
return { ...selection, availableModel: { modelId, name: modelId } };
|
||||
}
|
||||
|
||||
private async setModelWithSelection({
|
||||
@@ -2194,21 +2228,20 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
throw new Error("ACP session not initialized");
|
||||
}
|
||||
|
||||
if (selection.hasAvailableModels) {
|
||||
if (!selection.availableModel) {
|
||||
this.warnInvalidSelection(
|
||||
modelId,
|
||||
`is not a valid ${this.provider} model. Available options: ${this.availableModels
|
||||
?.map((model) => model.modelId)
|
||||
.join(", ")}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof this.connection.unstable_setSessionModel !== "function") {
|
||||
throw new Error(this.modelSelectionUnavailableMessage());
|
||||
}
|
||||
if (selection.hasAvailableModels && !selection.availableModel) {
|
||||
this.warnInvalidSelection(
|
||||
modelId,
|
||||
`is not a valid ${this.provider} model. Available options: ${this.availableModels
|
||||
?.map((model) => model.modelId)
|
||||
.join(", ")}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
selection.availableModel &&
|
||||
typeof this.connection.unstable_setSessionModel === "function"
|
||||
) {
|
||||
try {
|
||||
await this.connection.unstable_setSessionModel({
|
||||
sessionId: this.sessionId,
|
||||
@@ -2770,6 +2803,16 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Loading an archived agent's history runs nothing in its working directory, which
|
||||
// may have been removed with its worktree. session/load still names the original
|
||||
// directory; only the process starts from the home directory.
|
||||
private async resolveProcessCwd(): Promise<string> {
|
||||
if (this.resumePurpose !== "history" || (await isDirectory(this.config.cwd))) {
|
||||
return this.config.cwd;
|
||||
}
|
||||
return homedir();
|
||||
}
|
||||
|
||||
private async spawnProcess(): Promise<SpawnedACPProcess> {
|
||||
const prefix = await resolveProviderLaunch({
|
||||
commandConfig: this.runtimeSettings?.command,
|
||||
@@ -2783,7 +2826,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
const command = prefix.command;
|
||||
const args = [...prefix.args, ...this.defaultCommand.slice(1)];
|
||||
const child = spawnProcess(command, args, {
|
||||
cwd: this.config.cwd,
|
||||
cwd: await this.resolveProcessCwd(),
|
||||
...createProviderEnvSpec({
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
overlays: [this.launchEnv],
|
||||
@@ -2893,13 +2936,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
const configuredModelId = this.config.model;
|
||||
let switchedModel = false;
|
||||
if (configuredModelId && configuredModelId !== this.currentModel) {
|
||||
const selection = resolveACPModelSelection({
|
||||
modelId: configuredModelId,
|
||||
availableModels: this.availableModels,
|
||||
configOptions: this.configOptions,
|
||||
});
|
||||
try {
|
||||
await this.setModelWithSelection({ modelId: configuredModelId, selection });
|
||||
await this.setModelWithSelection({
|
||||
modelId: configuredModelId,
|
||||
selection: this.resolveModelSelection(configuredModelId),
|
||||
});
|
||||
switchedModel = true;
|
||||
} catch (error) {
|
||||
if (!this.isModelSelectionUnavailableError(error)) {
|
||||
|
||||
@@ -327,6 +327,57 @@ test("fails an auto mode turn when Claude Code uses Vertex", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("switches an auto mode session to another mode when Claude Code uses Bedrock", async () => {
|
||||
const previousBedrock = process.env.CLAUDE_CODE_USE_BEDROCK;
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined })));
|
||||
sdkQueryFactory.mockImplementation(() => queryMock);
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
queryFactory: sdkQueryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
modeId: "auto",
|
||||
});
|
||||
|
||||
try {
|
||||
await session.setMode("acceptEdits");
|
||||
|
||||
expect(await session.getCurrentMode()).toBe("acceptEdits");
|
||||
expect(sdkQueryFactory).toHaveBeenCalledTimes(1);
|
||||
expect(sdkQueryFactory.mock.calls[0]?.[0]?.options?.permissionMode).toBe("acceptEdits");
|
||||
} finally {
|
||||
restoreEnvValue("CLAUDE_CODE_USE_BEDROCK", previousBedrock);
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("does not keep a query started in a mode that Claude Code rejected", async () => {
|
||||
const launchedModes: Array<string | undefined> = [];
|
||||
sdkQueryFactory.mockImplementation(({ options }: { options: { permissionMode?: string } }) => {
|
||||
launchedModes.push(options.permissionMode);
|
||||
const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined })));
|
||||
if (launchedModes.length === 1) {
|
||||
queryMock.setPermissionMode.mockRejectedValue(new Error("bypass refused"));
|
||||
}
|
||||
return queryMock;
|
||||
});
|
||||
const session = await createSession();
|
||||
|
||||
try {
|
||||
await expect(session.setMode("bypassPermissions")).rejects.toThrow("bypass refused");
|
||||
await session.listCommands();
|
||||
|
||||
expect(await session.getCurrentMode()).toBe("default");
|
||||
expect(launchedModes).toEqual(["bypassPermissions", "default"]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("logs redacted query summary and never leaks sentinel secrets", async () => {
|
||||
const envSecret = "PASEO_ENV_SENTINEL_SECRET";
|
||||
const runtimeSecret = "PASEO_RUNTIME_SENTINEL_SECRET";
|
||||
@@ -1037,11 +1088,11 @@ test("preserves bypass capability across query restarts triggered by thinking ch
|
||||
|
||||
expect(capturedOptions).toHaveLength(2);
|
||||
expect(capturedOptions[0]).toMatchObject({
|
||||
permissionMode: "default",
|
||||
permissionMode: "bypassPermissions",
|
||||
allowDangerouslySkipPermissions: true,
|
||||
});
|
||||
expect(capturedOptions[1]).toMatchObject({
|
||||
permissionMode: "acceptEdits",
|
||||
permissionMode: "bypassPermissions",
|
||||
allowDangerouslySkipPermissions: true,
|
||||
effort: "high",
|
||||
});
|
||||
|
||||
@@ -712,4 +712,56 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
|
||||
expect(timeline.length).toBeGreaterThan(0);
|
||||
expect(timeline[0]).toMatchObject({ id: TOOL_USE_ID });
|
||||
});
|
||||
|
||||
test("replays a SubagentHandback report as the subagent's final message", async () => {
|
||||
const handbackId = "toolu_handback";
|
||||
const report = "## Verdict\n\n- **Coherent**";
|
||||
writeSession({
|
||||
parentLines: [taskToolUse(), taskToolResult()],
|
||||
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
|
||||
sidechainLines: [
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
isSidechain: true,
|
||||
agentId: AGENT_ID,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: handbackId,
|
||||
name: "SubagentHandback",
|
||||
input: { message: report },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
isSidechain: true,
|
||||
agentId: AGENT_ID,
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: handbackId,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: '{"success":true,"message":"Report delivered to your caller."}',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const items = (await replayDescriptors()).flatMap((event) =>
|
||||
event.event.type === "timeline" ? [event.event.item] : [],
|
||||
);
|
||||
expect(items).toEqual([{ type: "assistant_message", text: report }]);
|
||||
});
|
||||
});
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import pino from "pino";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import type { AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js";
|
||||
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
|
||||
const ROOT_PROMPT = `Use Claude Code's native Agent tool exactly once, never Paseo tools, with this complete task:
|
||||
|
||||
Do not use any tools. Reply with exactly this markdown report and nothing else:
|
||||
## HANDBACK_HEADING
|
||||
- **HANDBACK_BOLD** item
|
||||
|
||||
Then reply exactly ROOT_DONE.`;
|
||||
|
||||
function subagentItems(events: AgentStreamEvent[]): AgentTimelineItem[] {
|
||||
return events.flatMap((event) =>
|
||||
event.type === "provider_subagent" && event.event.type === "timeline" ? [event.event.item] : [],
|
||||
);
|
||||
}
|
||||
|
||||
function expectHandbackAsReport(items: AgentTimelineItem[]): void {
|
||||
expect(items.filter((item) => item.type === "tool_call")).toEqual([]);
|
||||
expect(
|
||||
items.flatMap((item) => (item.type === "assistant_message" ? [item.text] : [])).join(""),
|
||||
).toContain("## HANDBACK_HEADING\n- **HANDBACK_BOLD** item");
|
||||
}
|
||||
|
||||
test("an auto-mode Claude subagent's handback report shows as its final message", async () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-claude-subagent-handback-"));
|
||||
const client = new ClaudeAgentClient({ logger: pino({ level: "warn" }) });
|
||||
const session = await client.createSession({ provider: "claude", cwd, modeId: "auto" });
|
||||
try {
|
||||
const events = await collectSessionTurnEvents(session, ROOT_PROMPT);
|
||||
expectHandbackAsReport(subagentItems(events));
|
||||
|
||||
const persistence = session.describePersistence();
|
||||
expect(persistence).not.toBeNull();
|
||||
await session.close();
|
||||
const restored = await client.resumeSession(persistence!, { cwd });
|
||||
try {
|
||||
const replayed: AgentStreamEvent[] = [];
|
||||
for await (const event of restored.streamHistory()) replayed.push(event);
|
||||
expectHandbackAsReport(subagentItems(replayed));
|
||||
} finally {
|
||||
await restored.close();
|
||||
}
|
||||
} finally {
|
||||
await session.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 240_000);
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
} from "./model-manifest.js";
|
||||
import { parsePartialJsonObject } from "./partial-json.js";
|
||||
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
|
||||
import { readClaudeSubagentHandback } from "./subagent-handback.js";
|
||||
import { ClaudeTaskState } from "./task-state.js";
|
||||
import {
|
||||
ClaudeTaskProtocolSource,
|
||||
@@ -2084,6 +2085,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private toolUseCache = new Map<string, ToolUseCacheEntry>();
|
||||
private toolUseIndexToId = new Map<number, string>();
|
||||
private toolUseInputBuffers = new Map<string, string>();
|
||||
private subagentHandbackIds = new Set<string>();
|
||||
private pendingPermissions = new Map<string, PendingPermission>();
|
||||
private activeForegroundTurnId: string | null = null;
|
||||
private autonomousTurn: AutonomousTurnState | null = null;
|
||||
@@ -2439,8 +2441,15 @@ class ClaudeAgentSession implements AgentSession {
|
||||
const normalized = isPermissionMode(modeId) ? modeId : "default";
|
||||
assertClaudeModeCanRun(normalized, this.harnessEnvironment);
|
||||
const previousMode = this.currentMode;
|
||||
const activeQuery = await this.ensureQuery();
|
||||
await activeQuery.setPermissionMode(normalized);
|
||||
const launchesQuery = !this.query || this.queryRestartNeeded;
|
||||
const activeQuery = await this.ensureQuery(normalized);
|
||||
try {
|
||||
await activeQuery.setPermissionMode(normalized);
|
||||
} catch (error) {
|
||||
// The query was launched in the rejected mode; relaunch in the current mode next time.
|
||||
if (launchesQuery) this.queryRestartNeeded = true;
|
||||
throw error;
|
||||
}
|
||||
if (normalized === "plan") {
|
||||
if (previousMode !== "plan") {
|
||||
this.planResumeMode = previousMode;
|
||||
@@ -3126,7 +3135,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return { kind: "fresh-session" };
|
||||
}
|
||||
|
||||
private async ensureQuery(): Promise<Query> {
|
||||
private async ensureQuery(launchMode: PermissionMode = this.currentMode): Promise<Query> {
|
||||
if (this.query && !this.queryRestartNeeded) {
|
||||
return this.query;
|
||||
}
|
||||
@@ -3170,7 +3179,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.persistence = null;
|
||||
|
||||
const input = createAsyncMessageInput<SDKUserMessage>();
|
||||
const options = await this.buildOptions();
|
||||
const options = await this.buildOptions(launchMode);
|
||||
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
|
||||
this.input = input;
|
||||
this.query = claudeQuery(
|
||||
@@ -3296,7 +3305,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
});
|
||||
}
|
||||
|
||||
private async buildOptions(): Promise<ClaudeOptions> {
|
||||
private async buildOptions(permissionMode: PermissionMode): Promise<ClaudeOptions> {
|
||||
const { thinking, effort, ultracode } = this.resolveThinkingConfig();
|
||||
const appendedSystemPrompt = this.buildAppendedSystemPrompt();
|
||||
const providerOptions = applyClaudeToolPolicy(
|
||||
@@ -3305,7 +3314,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
);
|
||||
const settingsOptions = this.buildSettingsOptions(providerOptions, { ultracode });
|
||||
const sdkEnv = this.harnessEnvironment;
|
||||
assertClaudeModeCanRun(this.currentMode, sdkEnv);
|
||||
assertClaudeModeCanRun(permissionMode, sdkEnv);
|
||||
|
||||
const claudeBinary = await this.resolveBinary();
|
||||
this.logger.debug(
|
||||
@@ -3328,7 +3337,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
const base: ClaudeOptions = {
|
||||
cwd: this.config.cwd,
|
||||
includePartialMessages: true,
|
||||
permissionMode: this.currentMode,
|
||||
permissionMode,
|
||||
// Dynamic mode switching can recreate the underlying Claude query. Keep the
|
||||
// bypass launch capability available so later setPermissionMode("bypassPermissions")
|
||||
// calls do not fail after a model/thinking/rewind-driven restart.
|
||||
@@ -5184,7 +5193,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
// User SDK entries can arrive as multiple text blocks, but Paseo treats them as one message.
|
||||
const userTextParts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (!isClaudeContentChunk(block)) {
|
||||
if (!isClaudeContentChunk(block) || this.mapSubagentHandbackBlock(block, items)) {
|
||||
continue;
|
||||
}
|
||||
this.mapBlockToTimeline(block, {
|
||||
@@ -5272,6 +5281,24 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
// A subagent's handback call is its final message, and the call's result is only an
|
||||
// acknowledgement, so neither becomes a tool call.
|
||||
private mapSubagentHandbackBlock(block: ClaudeContentChunk, items: AgentTimelineItem[]): boolean {
|
||||
if (typeof block.tool_use_id === "string" && this.subagentHandbackIds.has(block.tool_use_id)) {
|
||||
this.subagentHandbackIds.delete(block.tool_use_id);
|
||||
return true;
|
||||
}
|
||||
const handback = readClaudeSubagentHandback(block);
|
||||
if (!handback) {
|
||||
return false;
|
||||
}
|
||||
this.subagentHandbackIds.add(handback.callId);
|
||||
if (handback.report) {
|
||||
items.push({ type: "assistant_message", text: handback.report });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private handleToolUseStart(block: ClaudeContentChunk, items: AgentTimelineItem[]): void {
|
||||
const entry = this.upsertToolUseEntry(block);
|
||||
if (!entry) {
|
||||
|
||||
@@ -35,4 +35,64 @@ describe("ClaudeSidechainTracker", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a SubagentHandback report as the subagent's final message", () => {
|
||||
const tracker = new ClaudeSidechainTracker({ getToolInput: () => null });
|
||||
const report = " indented code\n\n## Verdict\n\n- **Coherent**";
|
||||
const frames = [
|
||||
{
|
||||
type: "stream_event",
|
||||
parent_tool_use_id: "task-1",
|
||||
event: {
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: "handback-1",
|
||||
name: "SubagentHandback",
|
||||
input: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
parent_tool_use_id: "task-1",
|
||||
message: {
|
||||
id: "msg-1",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "handback-1",
|
||||
name: "SubagentHandback",
|
||||
input: { message: report },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
parent_tool_use_id: "task-1",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "handback-1",
|
||||
tool_name: "SubagentHandback",
|
||||
content: [{ type: "text", text: '{"success":true}' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const childItems = frames
|
||||
.flatMap((frame) => tracker.handleMessage(frame as unknown as SDKMessage, "task-1"))
|
||||
.flatMap((event) =>
|
||||
event.type === "provider_subagent" && event.event.type === "timeline"
|
||||
? [event.event.item]
|
||||
: [],
|
||||
);
|
||||
|
||||
expect(childItems).toEqual([{ type: "assistant_message", text: report, messageId: "msg-1" }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
mapClaudeRunningToolCall,
|
||||
} from "./tool-call-mapper.js";
|
||||
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
|
||||
import {
|
||||
isClaudeSubagentHandbackToolName,
|
||||
readClaudeSubagentHandback,
|
||||
} from "./subagent-handback.js";
|
||||
|
||||
import type { AgentMetadata, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js";
|
||||
|
||||
@@ -91,7 +95,7 @@ export class ClaudeSidechainTracker {
|
||||
const contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId);
|
||||
const actionCandidates = this.extractSubAgentActionCandidates(message);
|
||||
const childTimelineItems = [
|
||||
...this.extractSubAgentTimelineItems(message),
|
||||
...this.extractSubAgentTimelineItems(message, state),
|
||||
...this.extractSubAgentToolResults(message, state),
|
||||
];
|
||||
let actionUpdated = false;
|
||||
@@ -233,7 +237,10 @@ export class ClaudeSidechainTracker {
|
||||
this.activeSidechains.clear();
|
||||
}
|
||||
|
||||
private extractSubAgentTimelineItems(message: SDKMessage): AgentTimelineItem[] {
|
||||
private extractSubAgentTimelineItems(
|
||||
message: SDKMessage,
|
||||
state: SubAgentActivityState,
|
||||
): AgentTimelineItem[] {
|
||||
if (message.type !== "assistant" || !Array.isArray(message.message?.content)) {
|
||||
return [];
|
||||
}
|
||||
@@ -253,6 +260,18 @@ export class ClaudeSidechainTracker {
|
||||
} else if (block.type === "thinking") {
|
||||
const text = readTrimmedString(block.thinking);
|
||||
if (text) items.push({ type: "reasoning", text });
|
||||
} else {
|
||||
const handback = readClaudeSubagentHandback(block);
|
||||
if (!handback) continue;
|
||||
// The handback's result is only an acknowledgement, never a tool card.
|
||||
state.completedActionKeys.add(handback.callId);
|
||||
if (handback.report) {
|
||||
items.push({
|
||||
type: "assistant_message",
|
||||
text: handback.report,
|
||||
...(messageId ? { messageId } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
@@ -391,6 +410,12 @@ export class ClaudeSidechainTracker {
|
||||
}
|
||||
|
||||
private extractSubAgentActionCandidates(message: SDKMessage): SubAgentActionCandidate[] {
|
||||
return this.extractToolActionCandidates(message).filter(
|
||||
(action) => !isClaudeSubagentHandbackToolName(action.toolName),
|
||||
);
|
||||
}
|
||||
|
||||
private extractToolActionCandidates(message: SDKMessage): SubAgentActionCandidate[] {
|
||||
if (message.type === "assistant") {
|
||||
return this.extractAssistantMessageActions(message);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Since Claude Code 2.1.271, a subagent in auto mode delivers its report to its caller through a
|
||||
* `SubagentHandback` tool call instead of its final text. The report is the subagent's final
|
||||
* message, so Paseo shows it as one and hides the call and its acknowledgement.
|
||||
*/
|
||||
const SUBAGENT_HANDBACK_TOOL_NAME = "SubagentHandback";
|
||||
|
||||
export interface ClaudeSubagentHandback {
|
||||
callId: string;
|
||||
/** Null while the call is still streaming its input. */
|
||||
report: string | null;
|
||||
}
|
||||
|
||||
export function isClaudeSubagentHandbackToolName(name: unknown): boolean {
|
||||
return name === SUBAGENT_HANDBACK_TOOL_NAME;
|
||||
}
|
||||
|
||||
export function readClaudeSubagentHandback(block: {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}): ClaudeSubagentHandback | null {
|
||||
if (block.type !== "tool_use" || !isClaudeSubagentHandbackToolName(block.name)) return null;
|
||||
if (typeof block.id !== "string" || block.id.length === 0) return null;
|
||||
const input = block.input as { message?: unknown } | null | undefined;
|
||||
const report = typeof input?.message === "string" ? input.message : "";
|
||||
return { callId: block.id, report: report.trim().length > 0 ? report : null };
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import { GenericACPAgentClient } from "./generic-acp-agent.js";
|
||||
|
||||
describe("GenericACPAgentClient archived history", () => {
|
||||
test("replays an archived session whose working directory was removed", async () => {
|
||||
await withFakeACPAgent(async ({ command, removedCwd, loadRequestsPath }) => {
|
||||
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
|
||||
|
||||
const session = await client.resumeSession(
|
||||
{ provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } },
|
||||
undefined,
|
||||
undefined,
|
||||
{ purpose: "history" },
|
||||
);
|
||||
try {
|
||||
expect(await collectTimelineText(session.streamHistory())).toEqual([
|
||||
"user_message:What is in the README?",
|
||||
"assistant_message:It describes the project.",
|
||||
]);
|
||||
const loadRequests = JSON.parse(await readFile(loadRequestsPath, "utf8"));
|
||||
expect(loadRequests).toEqual([{ sessionId: "archived-session", cwd: removedCwd }]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("replays an archived session whose working directory path is now a file", async () => {
|
||||
await withFakeACPAgent(async ({ command, removedCwd }) => {
|
||||
await writeFile(removedCwd, "not a directory", "utf8");
|
||||
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
|
||||
|
||||
const session = await client.resumeSession(
|
||||
{ provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } },
|
||||
undefined,
|
||||
undefined,
|
||||
{ purpose: "history" },
|
||||
);
|
||||
try {
|
||||
expect(await collectTimelineText(session.streamHistory())).toEqual([
|
||||
"user_message:What is in the README?",
|
||||
"assistant_message:It describes the project.",
|
||||
]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("still requires the working directory to resume a session that will run", async () => {
|
||||
await withFakeACPAgent(async ({ command, removedCwd }) => {
|
||||
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
|
||||
|
||||
await expect(
|
||||
client.resumeSession(
|
||||
{ provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } },
|
||||
undefined,
|
||||
undefined,
|
||||
{ purpose: "interactive" },
|
||||
),
|
||||
).rejects.toThrow("ENOENT");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function collectTimelineText(events: AsyncGenerator<AgentStreamEvent>): Promise<string[]> {
|
||||
const text: string[] = [];
|
||||
for await (const event of events) {
|
||||
if (event.type !== "timeline") {
|
||||
continue;
|
||||
}
|
||||
if (event.item.type === "user_message" || event.item.type === "assistant_message") {
|
||||
text.push(`${event.item.type}:${event.item.text}`);
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
async function withFakeACPAgent(
|
||||
run: (input: {
|
||||
command: [string, ...string[]];
|
||||
removedCwd: string;
|
||||
loadRequestsPath: string;
|
||||
}) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-archived-history-"));
|
||||
try {
|
||||
const scriptPath = path.join(testDir, "fake-acp-agent.cjs");
|
||||
const loadRequestsPath = path.join(testDir, "load-requests.json");
|
||||
await writeFile(scriptPath, fakeACPAgentScript, "utf8");
|
||||
await run({
|
||||
command: [process.execPath, scriptPath, loadRequestsPath],
|
||||
removedCwd: path.join(testDir, "removed-worktree"),
|
||||
loadRequestsPath,
|
||||
});
|
||||
} finally {
|
||||
await rm(testDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const fakeACPAgentScript = `
|
||||
const fs = require("node:fs");
|
||||
const readline = require("node:readline");
|
||||
|
||||
const loadRequestsPath = process.argv[2];
|
||||
const loadRequests = [];
|
||||
const rl = readline.createInterface({ input: process.stdin });
|
||||
|
||||
function write(message) {
|
||||
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n");
|
||||
}
|
||||
|
||||
function replay(sessionId, sessionUpdate, text) {
|
||||
write({
|
||||
method: "session/update",
|
||||
params: { sessionId, update: { sessionUpdate, content: { type: "text", text } } },
|
||||
});
|
||||
}
|
||||
|
||||
rl.on("line", (line) => {
|
||||
const message = JSON.parse(line);
|
||||
if (message.method === "initialize") {
|
||||
write({
|
||||
id: message.id,
|
||||
result: {
|
||||
protocolVersion: message.params?.protocolVersion ?? 1,
|
||||
agentCapabilities: { loadSession: true, sessionCapabilities: { close: {} } },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.method === "session/load") {
|
||||
const { sessionId, cwd } = message.params;
|
||||
loadRequests.push({ sessionId, cwd });
|
||||
fs.writeFileSync(loadRequestsPath, JSON.stringify(loadRequests));
|
||||
replay(sessionId, "user_message_chunk", "What is in the README?");
|
||||
replay(sessionId, "agent_message_chunk", "It describes the project.");
|
||||
write({ id: message.id, result: {} });
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.id !== undefined) {
|
||||
write({ id: message.id, result: {} });
|
||||
}
|
||||
});
|
||||
`;
|
||||
@@ -459,7 +459,9 @@ describe("OMP agent client and session", () => {
|
||||
|
||||
test("fails a turn when the provider idle gate passes its deadline", async () => {
|
||||
const scheduler = new ManualIdleScheduler();
|
||||
const omp = new OmpHarness({ providerIdleScheduler: scheduler, providerIdleDeadlineMs: 1 });
|
||||
// Long enough that the gate's first check, made right after the turn ends, cannot already
|
||||
// be past it on a slow runner.
|
||||
const omp = new OmpHarness({ providerIdleScheduler: scheduler, providerIdleDeadlineMs: 50 });
|
||||
await omp.start();
|
||||
const { completion } = await omp.startPromptUntilProviderIdle("first", "first done", {
|
||||
isStreaming: true,
|
||||
@@ -473,7 +475,7 @@ describe("OMP agent client and session", () => {
|
||||
args: { command: "sleep 30" },
|
||||
});
|
||||
expect(omp.runningToolCallIds()).toEqual(["tool-at-deadline"]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
scheduler.retry();
|
||||
await expect(completion).rejects.toThrow(/provider idle/i);
|
||||
expect(omp.runningToolCallIds()).toEqual([]);
|
||||
|
||||
@@ -87,7 +87,8 @@ function mergeToolCallDetail(existing: ToolCallDetail, incoming: ToolCallDetail)
|
||||
return incoming;
|
||||
}
|
||||
|
||||
function mergeToolCallItems(
|
||||
/** Fold a later update for the same tool call into the one it follows. */
|
||||
export function mergeToolCallItems(
|
||||
existing: Extract<AgentTimelineItem, { type: "tool_call" }>,
|
||||
incoming: Extract<AgentTimelineItem, { type: "tool_call" }>,
|
||||
): Extract<AgentTimelineItem, { type: "tool_call" }> {
|
||||
|
||||
@@ -126,6 +126,7 @@ import { createPaseoWorktree as createRegisteredPaseoWorktree } from "./paseo-wo
|
||||
import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js";
|
||||
import { createPaseoWorktreeWorkflow } from "./worktree-session.js";
|
||||
import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||
import { formatAttachmentContentDisposition } from "./file-download/content-disposition.js";
|
||||
import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js";
|
||||
import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js";
|
||||
import type { RequestedSpeechProviders } from "./speech/speech-types.js";
|
||||
@@ -414,7 +415,6 @@ export interface PaseoDaemonConfig {
|
||||
plugins?: Record<string, PluginSource>;
|
||||
pluginRegistries?: PluginRegistries;
|
||||
pluginRegistryUrl?: string;
|
||||
pluginRegistryEnabled?: boolean;
|
||||
staticDir: string;
|
||||
mcpDebug: boolean;
|
||||
isDev?: boolean;
|
||||
@@ -625,7 +625,6 @@ export async function createPaseoDaemon(
|
||||
usageSession: (id) => agentManager.usageSession(id),
|
||||
},
|
||||
managedSources: new ManagedPluginSources(config.paseoHome, {
|
||||
enabled: config.pluginRegistryEnabled ?? false,
|
||||
registries: config.pluginRegistries,
|
||||
defaultUrl: config.pluginRegistryUrl,
|
||||
}),
|
||||
@@ -838,9 +837,8 @@ export async function createPaseoDaemon(
|
||||
return;
|
||||
}
|
||||
|
||||
const safeFileName = entry.fileName.replace(/["\r\n]/g, "_");
|
||||
res.setHeader("Content-Type", entry.mimeType);
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${safeFileName}"`);
|
||||
res.setHeader("Content-Disposition", formatAttachmentContentDisposition(entry.fileName));
|
||||
res.setHeader("Content-Length", fileStats.size.toString());
|
||||
|
||||
const stream = fileHandle.createReadStream();
|
||||
|
||||
@@ -30,7 +30,6 @@ export const DAEMON_SETTING_ENV_KEYS = [
|
||||
"PASEO_LOG_ROTATE_SIZE",
|
||||
"PASEO_PASSWORD",
|
||||
"PASEO_PLUGIN_REGISTRY",
|
||||
"PASEO_PLUGIN_REGISTRY_ENABLED",
|
||||
"PASEO_RELAY_ENABLED",
|
||||
"PASEO_RELAY_ENDPOINT",
|
||||
"PASEO_RELAY_PUBLIC_ENDPOINT",
|
||||
|
||||
@@ -211,15 +211,6 @@ test("loads private plugin registry settings through the configuration boundary"
|
||||
});
|
||||
expect(config.pluginRegistryUrl).toBe("https://plugins.example.test/internal");
|
||||
expect(config.pluginRegistries).toEqual(pluginRegistries);
|
||||
expect(config.pluginRegistryEnabled).toBe(false);
|
||||
await writeFile(
|
||||
path.join(home, "config.json"),
|
||||
JSON.stringify({ pluginRegistries, pluginRegistryEnabled: true }),
|
||||
);
|
||||
expect(loadConfig(home, { env: {} }).pluginRegistryEnabled).toBe(true);
|
||||
expect(
|
||||
loadConfig(home, { env: { PASEO_PLUGIN_REGISTRY_ENABLED: "false" } }).pluginRegistryEnabled,
|
||||
).toBe(false);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -628,10 +628,6 @@ export function resolveConfigFromPersisted(
|
||||
plugins: persisted.plugins,
|
||||
pluginRegistries: persisted.pluginRegistries,
|
||||
pluginRegistryUrl: env.PASEO_PLUGIN_REGISTRY,
|
||||
pluginRegistryEnabled:
|
||||
parseBooleanEnv(env.PASEO_PLUGIN_REGISTRY_ENABLED) ??
|
||||
persisted.pluginRegistryEnabled ??
|
||||
false,
|
||||
mcpDebug: env.MCP_DEBUG === "1",
|
||||
isDev: resolvePaseoNodeEnv(env) === "development",
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
|
||||
@@ -831,6 +831,29 @@ test("send_agent_message auto-unarchives archived agents", async () => {
|
||||
}
|
||||
}, 180000);
|
||||
|
||||
test("send_agent_message leaves an archived agent archived when its directory is gone", async () => {
|
||||
const cwd = tmpCwd();
|
||||
try {
|
||||
const created = await ctx.client.createAgent({
|
||||
config: {
|
||||
...getFullAccessConfig("codex"),
|
||||
cwd,
|
||||
},
|
||||
});
|
||||
const archived = await ctx.client.archiveAgent(created.id);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
|
||||
await expect(ctx.client.sendMessage(created.id, "hello")).rejects.toThrow(
|
||||
"Working directory does not exist",
|
||||
);
|
||||
|
||||
const afterSend = await ctx.client.fetchAgent({ agentId: created.id });
|
||||
expect(afterSend?.agent.archivedAt).toBe(archived.archivedAt);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test("refresh_agent auto-unarchives archived agents", async () => {
|
||||
const cwd = tmpCwd();
|
||||
try {
|
||||
|
||||
@@ -24,42 +24,77 @@ describe("daemon E2E", () => {
|
||||
}, 60000);
|
||||
|
||||
describe("file download tokens", () => {
|
||||
test("issues token over WS and downloads via HTTP", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const filePath = path.join(cwd, "download.txt");
|
||||
const fileContents = "download test payload";
|
||||
writeFileSync(filePath, fileContents, "utf-8");
|
||||
test.each([
|
||||
{ fileName: "download.txt", disposition: 'attachment; filename="download.txt"' },
|
||||
{
|
||||
fileName: "café.txt",
|
||||
disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt",
|
||||
},
|
||||
{
|
||||
fileName: "中文报告 (最终版).txt",
|
||||
disposition:
|
||||
"attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt",
|
||||
},
|
||||
{
|
||||
fileName: "報告書.md",
|
||||
disposition:
|
||||
"attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md",
|
||||
},
|
||||
{
|
||||
fileName: "보고서.txt",
|
||||
disposition:
|
||||
"attachment; filename=\"???.txt\"; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.txt",
|
||||
},
|
||||
{
|
||||
fileName: "report-📄.txt",
|
||||
disposition:
|
||||
"attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt",
|
||||
},
|
||||
])(
|
||||
"issues token over WS and downloads $fileName via HTTP",
|
||||
async ({ fileName, disposition }) => {
|
||||
const cwd = tmpCwd();
|
||||
try {
|
||||
const filePath = path.join(cwd, fileName);
|
||||
const fileContents = "download test payload";
|
||||
writeFileSync(filePath, fileContents, "utf-8");
|
||||
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Download Token Test Agent",
|
||||
});
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Download Token Test Agent",
|
||||
});
|
||||
|
||||
expect(agent.id).toBeTruthy();
|
||||
expect(agent.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
);
|
||||
|
||||
const tokenResponse = await ctx.client.requestDownloadToken(cwd, "download.txt");
|
||||
const tokenResponse = await ctx.client.requestDownloadToken(cwd, fileName);
|
||||
|
||||
expect(tokenResponse.error).toBeNull();
|
||||
expect(tokenResponse.token).toBeTruthy();
|
||||
expect(tokenResponse.fileName).toBe("download.txt");
|
||||
expect(tokenResponse.error).toBeNull();
|
||||
expect(tokenResponse.token).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
);
|
||||
expect(tokenResponse.fileName).toBe(fileName);
|
||||
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`,
|
||||
);
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe(tokenResponse.mimeType);
|
||||
const disposition = response.headers.get("content-disposition") ?? "";
|
||||
expect(disposition).toContain("download.txt");
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe(tokenResponse.mimeType);
|
||||
expect(response.headers.get("content-disposition")).toBe(disposition);
|
||||
|
||||
const body = await response.text();
|
||||
expect(body).toBe(fileContents);
|
||||
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 60000);
|
||||
const body = await response.text();
|
||||
expect(body).toBe(fileContents);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
60000,
|
||||
);
|
||||
|
||||
test("rejects invalid token", async () => {
|
||||
const response = await fetch(
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import { mkdtempSync, readdirSync, realpathSync, rmSync } from "node:fs";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import {
|
||||
canRunRealProvider,
|
||||
getRealProviderRuntimeSettings,
|
||||
} from "./real-provider-test-config.js";
|
||||
|
||||
const PROVIDER = "paseo-catalog-drift";
|
||||
const KEPT_MODEL = "kept-model";
|
||||
const RETIRED_MODEL = "retired-model";
|
||||
|
||||
function model(id: string, name: string) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
family: "gemini-flash",
|
||||
attachment: true,
|
||||
reasoning: false,
|
||||
tool_call: true,
|
||||
temperature: true,
|
||||
release_date: "2025-06-17",
|
||||
last_updated: "2025-06-17",
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
open_weights: false,
|
||||
limit: { context: 1_048_576, output: 65_535 },
|
||||
cost: { input: 0.1, output: 0.4 },
|
||||
};
|
||||
}
|
||||
|
||||
function catalog(modelIds: string[]) {
|
||||
return {
|
||||
[PROVIDER]: {
|
||||
id: PROVIDER,
|
||||
env: ["PASEO_CATALOG_DRIFT_KEY"],
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
api: "http://127.0.0.1:9/v1",
|
||||
name: "Catalog drift",
|
||||
doc: "http://127.0.0.1:9",
|
||||
models: Object.fromEntries(modelIds.map((id) => [id, model(id, id)])),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Stands in for models.dev, the catalog OpenCode refreshes on its own schedule.
|
||||
async function startCatalogSource() {
|
||||
let body = JSON.stringify(catalog([KEPT_MODEL, RETIRED_MODEL]));
|
||||
let fetches = 0;
|
||||
const server: Server = createServer((request, response) => {
|
||||
if (request.url !== "/api.json") {
|
||||
response.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
fetches += 1;
|
||||
response.writeHead(200, { "content-type": "application/json" }).end(body);
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
fetches: () => fetches,
|
||||
retire: (id: string) => {
|
||||
body = JSON.stringify(
|
||||
catalog([KEPT_MODEL, RETIRED_MODEL].filter((entry) => entry !== id))
|
||||
);
|
||||
},
|
||||
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
|
||||
};
|
||||
}
|
||||
|
||||
describe("daemon E2E (real opencode) - model catalog drift", () => {
|
||||
let canRun = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
canRun = await canRunRealProvider("opencode");
|
||||
});
|
||||
|
||||
beforeEach((context) => {
|
||||
if (!canRun) context.skip();
|
||||
});
|
||||
|
||||
test("models listed for OpenCode follow OpenCode's catalog after it changes", async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const source = await startCatalogSource();
|
||||
const settings = getRealProviderRuntimeSettings("opencode");
|
||||
const cacheHome = settings.env!.XDG_CACHE_HOME!;
|
||||
const provider = new OpenCodeAgentClient(logger, {
|
||||
...settings,
|
||||
env: {
|
||||
...settings.env,
|
||||
OPENCODE_MODELS_URL: source.url,
|
||||
PASEO_CATALOG_DRIFT_KEY: "test",
|
||||
},
|
||||
});
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { opencode: provider },
|
||||
logger,
|
||||
});
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
});
|
||||
const cwd = realpathSync(
|
||||
mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-catalog-"))
|
||||
);
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
|
||||
const before = await client.listProviderModels("opencode");
|
||||
expect(before.models.map((entry) => entry.id)).toContain(
|
||||
`${PROVIDER}/${RETIRED_MODEL}`
|
||||
);
|
||||
|
||||
// The catalog drops a model and OpenCode's cached copy is renewed, as when
|
||||
// any other OpenCode process refreshes it while Paseo sits idle. The next
|
||||
// OpenCode server Paseo starts loads that catalog.
|
||||
source.retire(RETIRED_MODEL);
|
||||
const cacheDir = path.join(cacheHome, "opencode");
|
||||
for (const file of readdirSync(cacheDir).filter((name) =>
|
||||
name.startsWith("models-")
|
||||
))
|
||||
rmSync(path.join(cacheDir, file), { force: true });
|
||||
|
||||
const fetchesBefore = source.fetches();
|
||||
const agent = await client.createAgent({
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
title: "catalog drift",
|
||||
model: `${PROVIDER}/${KEPT_MODEL}`,
|
||||
modeId: "build",
|
||||
});
|
||||
expect(source.fetches()).toBeGreaterThan(fetchesBefore);
|
||||
|
||||
let switchError: unknown = null;
|
||||
try {
|
||||
await client.setAgentModel(agent.id, `${PROVIDER}/${RETIRED_MODEL}`);
|
||||
} catch (error) {
|
||||
switchError = error;
|
||||
}
|
||||
|
||||
const after = await client.listProviderModels("opencode");
|
||||
const listed = after.models.map((entry) => entry.id);
|
||||
// Every model offered in the picker must be one OpenCode accepts.
|
||||
expect({
|
||||
listed: listed.includes(`${PROVIDER}/${RETIRED_MODEL}`),
|
||||
switchError,
|
||||
}).toEqual({
|
||||
listed: false,
|
||||
switchError: expect.anything(),
|
||||
});
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close().catch(() => undefined);
|
||||
await source.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { existsSync } from "node:fs";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir, uptime } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { readDaemonInstance, stopDaemonInstance } from "./daemon-instance.js";
|
||||
import { acquirePidLock, getPidLockInfo, isLocked, type PidLockInfo } from "./pid-lock.js";
|
||||
@@ -94,3 +94,42 @@ describe("daemon instance identity across a reboot", () => {
|
||||
await expect(readFile(join(paseoHome, "paseo.pid"), "utf-8")).rejects.toThrow(/ENOENT/);
|
||||
});
|
||||
});
|
||||
|
||||
// Linux keeps a boot's id while a VM is paused for a host sleep, though the VM's wall clock
|
||||
// runs ahead of its uptime once it resumes. A sleep longer than the time between boot and
|
||||
// the supervisor's start puts the wall-clock boot instant after the lock's startedAt.
|
||||
describe.runIf(process.platform === "linux")("daemon instance identity on Linux", () => {
|
||||
let paseoHome: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
paseoHome = await mkdtemp(join(tmpdir(), "paseo-daemon-instance-linux-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("a supervisor still holds the lock after its paused VM resumes", async () => {
|
||||
await acquirePidLock(paseoHome, null, { ownerPid: process.pid });
|
||||
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(Date.now() + uptime() * 1000 + 12 * 60 * 60_000);
|
||||
|
||||
expect(await readDaemonInstance(paseoHome)).toMatchObject({ pid: process.pid });
|
||||
expect(await isLocked(paseoHome)).toMatchObject({ locked: true });
|
||||
await expect(
|
||||
acquirePidLock(paseoHome, null, { ownerPid: process.pid + 10_000 }),
|
||||
).rejects.toThrow("Another Paseo daemon is already running");
|
||||
});
|
||||
|
||||
test("a lock written during another boot has no running owner", async () => {
|
||||
await writeLock(paseoHome, {
|
||||
...lockFor(process.pid, new Date()),
|
||||
bootId: "00000000-0000-0000-0000-000000000000",
|
||||
});
|
||||
|
||||
expect(await readDaemonInstance(paseoHome)).toBeNull();
|
||||
expect(await isLocked(paseoHome)).toMatchObject({ locked: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { formatAttachmentContentDisposition } from "./content-disposition.js";
|
||||
|
||||
describe("formatAttachmentContentDisposition", () => {
|
||||
test.each([
|
||||
{ fileName: "report.txt", disposition: 'attachment; filename="report.txt"' },
|
||||
{
|
||||
fileName: "café.txt",
|
||||
disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt",
|
||||
},
|
||||
{
|
||||
fileName: "Résumé Ångström.pdf",
|
||||
disposition:
|
||||
"attachment; filename=\"R?sum? ?ngstr?m.pdf\"; filename*=UTF-8''R%C3%A9sum%C3%A9%20%C3%85ngstr%C3%B6m.pdf",
|
||||
},
|
||||
{
|
||||
fileName: "中文报告 (最终版).txt",
|
||||
disposition:
|
||||
"attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt",
|
||||
},
|
||||
{
|
||||
fileName: "report-📄.txt",
|
||||
disposition:
|
||||
"attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt",
|
||||
},
|
||||
{
|
||||
fileName: 'say "hi" \\ bye.txt',
|
||||
disposition: 'attachment; filename="say \\"hi\\" \\\\ bye.txt"',
|
||||
},
|
||||
{
|
||||
fileName: "100%25 done.txt",
|
||||
disposition: "attachment; filename=\"100%25 done.txt\"; filename*=UTF-8''100%2525%20done.txt",
|
||||
},
|
||||
{
|
||||
fileName: "l'été (1)*.txt",
|
||||
disposition:
|
||||
"attachment; filename=\"l'?t? (1)*.txt\"; filename*=UTF-8''l%27%C3%A9t%C3%A9%20%281%29%2A.txt",
|
||||
},
|
||||
])("$fileName", ({ fileName, disposition }) => {
|
||||
expect(formatAttachmentContentDisposition(fileName)).toBe(disposition);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
const NON_PRINTABLE_ASCII = /[^\x20-\x7e]/g;
|
||||
const PERCENT_ESCAPE = /%[0-9A-Fa-f]{2}/;
|
||||
const QUOTED_STRING_SPECIALS = /[\\"]/g;
|
||||
const NON_ATTR_CHARS_LEFT_BY_URI_ENCODING = /['()*]/g;
|
||||
|
||||
/**
|
||||
* Content-Disposition for a file download (RFC 6266).
|
||||
*
|
||||
* Any name that is not plain ASCII also gets `filename*=UTF-8''…`, because
|
||||
* browsers do not read raw Latin-1 bytes in `filename` and fall back to a name
|
||||
* taken from the URL. A name with a percent escape gets it too, so browsers do
|
||||
* not decode the escape in `filename`.
|
||||
*/
|
||||
export function formatAttachmentContentDisposition(fileName: string): string {
|
||||
const asciiFileName = fileName.replace(NON_PRINTABLE_ASCII, "?");
|
||||
const disposition = `attachment; filename=${quote(asciiFileName)}`;
|
||||
if (asciiFileName === fileName && !PERCENT_ESCAPE.test(fileName)) {
|
||||
return disposition;
|
||||
}
|
||||
return `${disposition}; filename*=UTF-8''${encodeExtendedValue(fileName)}`;
|
||||
}
|
||||
|
||||
function quote(value: string): string {
|
||||
return `"${value.replace(QUOTED_STRING_SPECIALS, "\\$&")}"`;
|
||||
}
|
||||
|
||||
function encodeExtendedValue(value: string): string {
|
||||
return encodeURIComponent(value).replace(
|
||||
NON_ATTR_CHARS_LEFT_BY_URI_ENCODING,
|
||||
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
);
|
||||
}
|
||||
@@ -308,7 +308,6 @@ export const PersistedConfigSchema = z
|
||||
|
||||
providers: ProvidersSchema.optional(),
|
||||
pluginRegistries: PluginRegistriesSchema.optional(),
|
||||
pluginRegistryEnabled: z.boolean().optional(),
|
||||
pluginsEnabled: z.boolean().optional(),
|
||||
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
|
||||
worktrees: WorktreesConfigSchema.optional(),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { open, readFile, stat, unlink, utimes } from "node:fs/promises";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import { ensurePrivateDirectory } from "./private-files.js";
|
||||
@@ -15,6 +16,7 @@ export const pidLockInfoSchema = z.object({
|
||||
serverId: z.string().nullable().optional(),
|
||||
desktopManaged: z.boolean().optional(),
|
||||
heartbeat: z.literal(true).optional(),
|
||||
bootId: z.string().optional(),
|
||||
});
|
||||
|
||||
export interface PidLockInfo extends z.infer<typeof pidLockInfoSchema> {}
|
||||
@@ -62,16 +64,38 @@ function precedesThisBoot(startedAt: string): boolean {
|
||||
return stamped < Date.now() - uptime() * 1000 - BOOT_INSTANT_TOLERANCE_MS;
|
||||
}
|
||||
|
||||
let cachedBootId: string | null | undefined;
|
||||
|
||||
// Linux names each boot. The wall-clock boot instant is not enough there: a VM paused while
|
||||
// its host sleeps keeps its uptime, so after it resumes the instant lands after locks its
|
||||
// running supervisor wrote.
|
||||
function currentBootId(): string | null {
|
||||
if (cachedBootId === undefined) {
|
||||
try {
|
||||
cachedBootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf-8").trim() || null;
|
||||
} catch {
|
||||
cachedBootId = null;
|
||||
}
|
||||
}
|
||||
return cachedBootId;
|
||||
}
|
||||
|
||||
function writtenDuringThisBoot(lock: PidLockInfo): boolean {
|
||||
const thisBoot = currentBootId();
|
||||
if (lock.bootId && thisBoot) return lock.bootId === thisBoot;
|
||||
return !precedesThisBoot(lock.startedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the process that wrote this lock is still running.
|
||||
*
|
||||
* A PID alone does not identify the supervisor: the operating system hands the number to
|
||||
* something else once the supervisor is gone, and a reboot reassigns it freely. A process
|
||||
* cannot predate the boot it runs under, so a lock stamped before this boot is abandoned
|
||||
* cannot predate the boot it runs under, so a lock written during another boot is abandoned
|
||||
* however alive its PID looks.
|
||||
*/
|
||||
export function isPidLockOwnerRunning(lock: PidLockInfo): boolean {
|
||||
if (precedesThisBoot(lock.startedAt)) return false;
|
||||
if (!writtenDuringThisBoot(lock)) return false;
|
||||
return isPidRunning(lock.pid);
|
||||
}
|
||||
|
||||
@@ -214,6 +238,7 @@ export async function acquirePidLock(
|
||||
}
|
||||
|
||||
// Create new lock with exclusive flag
|
||||
const bootId = currentBootId();
|
||||
const lockInfo: PidLockInfo = {
|
||||
pid: lockOwnerPid,
|
||||
startedAt: new Date().toISOString(),
|
||||
@@ -221,6 +246,7 @@ export async function acquirePidLock(
|
||||
uid: process.getuid?.() ?? 0,
|
||||
listen,
|
||||
heartbeat: true,
|
||||
...(bootId ? { bootId } : {}),
|
||||
...(process.env.PASEO_DESKTOP_MANAGED === "1" ? { desktopManaged: true } : {}),
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
stat,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@@ -418,7 +419,7 @@ describe("PluginService", () => {
|
||||
await service.stopAllPlugins();
|
||||
}, 20_000);
|
||||
|
||||
it("lists manifest descriptions for running and disabled plugins without hiding malformed entries", async () => {
|
||||
it("lists manifest metadata for running and disabled plugins without hiding malformed entries", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||
roots.push(home);
|
||||
const running = await createPlugin("running", "export default () => () => {};");
|
||||
@@ -426,7 +427,13 @@ describe("PluginService", () => {
|
||||
const malformed = await createPlugin("malformed", "export default () => () => {};");
|
||||
await writeFile(
|
||||
path.join(running, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "running", description: "Runs checks" }),
|
||||
JSON.stringify({
|
||||
id: "running",
|
||||
description: "Runs checks",
|
||||
name: "Checks",
|
||||
icon: "icon.png",
|
||||
media: ["screenshot.png", "https://example.com/demo.mp4"],
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(disabled, "paseo-plugin.json"),
|
||||
@@ -442,16 +449,123 @@ describe("PluginService", () => {
|
||||
await service.start();
|
||||
|
||||
expect(
|
||||
(await service.listPlugins()).map(({ id, description }) => ({ id, description })),
|
||||
(await service.listPlugins()).map(({ id, description, name, icon, media }) => ({
|
||||
id,
|
||||
description,
|
||||
name,
|
||||
icon,
|
||||
media,
|
||||
})),
|
||||
).toEqual([
|
||||
{ id: "disabled", description: "Waits until enabled" },
|
||||
{ id: "malformed", description: undefined },
|
||||
{ id: "running", description: "Runs checks" },
|
||||
{
|
||||
id: "running",
|
||||
description: "Runs checks",
|
||||
name: "Checks",
|
||||
icon: "icon.png",
|
||||
media: ["screenshot.png", "https://example.com/demo.mp4"],
|
||||
},
|
||||
]);
|
||||
await service.stopAllPlugins();
|
||||
});
|
||||
|
||||
it("prefers an existing directory and installs its selected plugin subdirectory", async () => {
|
||||
it("resolves a bare registry id even when a matching directory exists in the daemon cwd", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||
const owner = path.join(process.cwd(), `registry-shadow-${randomUUID()}`);
|
||||
await mkdir(owner);
|
||||
roots.push(home, owner);
|
||||
const source = `${path.basename(owner)}/example`;
|
||||
const repository = await createPlugin("registry-example", "export default () => () => {};\n");
|
||||
await mkdir(path.join(owner, "example"));
|
||||
await cp(repository, path.join(owner, "example"), { recursive: true });
|
||||
await runGitCommand(["init", "-b", "main"], { cwd: repository });
|
||||
await runGitCommand(["add", "-A"], { cwd: repository });
|
||||
await runGitCommand(
|
||||
[
|
||||
"-c",
|
||||
"user.name=Paseo Tests",
|
||||
"-c",
|
||||
"user.email=paseo@example.test",
|
||||
"commit",
|
||||
"-m",
|
||||
"fixture",
|
||||
],
|
||||
{ cwd: repository },
|
||||
);
|
||||
const { stdout } = await runGitCommand(["rev-parse", "HEAD"], { cwd: repository });
|
||||
const requests: Array<string | undefined> = [];
|
||||
const server = createServer((request, response) => {
|
||||
requests.push(request.url);
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
id: source,
|
||||
name: "Example",
|
||||
description: "Test",
|
||||
categories: [],
|
||||
author: { github: path.basename(owner) },
|
||||
repository: { url: "https://github.com/fixture/example" },
|
||||
artifact: {
|
||||
kind: "git",
|
||||
remote: pathToFileURL(repository).href,
|
||||
commit: stdout.trim(),
|
||||
pluginPath: ".",
|
||||
},
|
||||
media: [],
|
||||
submittedAt: "2026-10-03",
|
||||
reviewedAt: "2026-10-03",
|
||||
updatedAt: "2026-10-03",
|
||||
publishedAt: "2026-10-03",
|
||||
readme: "# Test",
|
||||
}),
|
||||
);
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Missing registry address");
|
||||
const url = `http://127.0.0.1:${address.port}`;
|
||||
const store = createStore(home);
|
||||
store.patch({ pluginsEnabled: false });
|
||||
const service = bindTestSessionHost(
|
||||
new PluginService(pino({ level: "silent" }), store, "0.4.0", {
|
||||
managedSources: new ManagedPluginSources(home, { defaultUrl: url }),
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await service.start();
|
||||
await expect(service.installSource({ source })).resolves.toMatchObject({
|
||||
id: "registry-example",
|
||||
status: "disabled",
|
||||
installation: {
|
||||
identity: { kind: "git", registry: { url, id: source } },
|
||||
currentRevision: stdout.trim(),
|
||||
},
|
||||
});
|
||||
expect(requests).toEqual([`/plugins/${source}.json`]);
|
||||
await expect(
|
||||
service.installSource({ source: `./${source}`, id: "local-example" }),
|
||||
).resolves.toMatchObject({
|
||||
installation: { identity: { kind: "directory", path: path.join(owner, "example") } },
|
||||
});
|
||||
expect(requests).toEqual([`/plugins/${source}.json`]);
|
||||
} finally {
|
||||
await service.stopAllPlugins();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("names a missing explicit directory instead of trying a managed source", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||
roots.push(home);
|
||||
const directory = path.join(home, "missing");
|
||||
const service = createService(home);
|
||||
await expect(service.installSource({ source: directory })).rejects.toThrow(
|
||||
`Plugin directory does not exist: ${directory}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("installs an explicit directory and its selected plugin subdirectory", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||
roots.push(home);
|
||||
const repository = await mkdtemp(path.join(tmpdir(), "owner-repository-"));
|
||||
|
||||
@@ -21,6 +21,7 @@ import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest";
|
||||
import type { DaemonConfigStore } from "../daemon-config-store.js";
|
||||
import { type ManagedPluginCandidate, ManagedPluginSources } from "./managed-source.js";
|
||||
import { readPluginManifest } from "./manifest.js";
|
||||
import { expandTilde } from "../../utils/path.js";
|
||||
import { runPluginBuild } from "./preparation.js";
|
||||
import { PluginRuntime } from "./runtime.js";
|
||||
import { BuiltinPluginLoader, type BuiltinPlugin } from "./builtin/index.js";
|
||||
@@ -215,7 +216,12 @@ export class PluginService {
|
||||
}),
|
||||
};
|
||||
const manifest = await readPluginManifest(path.resolve(source.path)).catch(() => null);
|
||||
if (manifest?.description) item.description = manifest.description;
|
||||
if (manifest) {
|
||||
item.name = manifest.name;
|
||||
item.description = manifest.description;
|
||||
item.icon = manifest.icon;
|
||||
item.media = manifest.media;
|
||||
}
|
||||
item.installation = await this.managedSources
|
||||
?.describe(id, source.path)
|
||||
.catch(() => undefined);
|
||||
@@ -286,16 +292,11 @@ export class PluginService {
|
||||
if (input.id && this.builtinPluginIds.has(input.id)) {
|
||||
throw new Error(`Plugin ID "${input.id}" is reserved for a built-in plugin`);
|
||||
}
|
||||
const directDirectory = path.resolve(input.source);
|
||||
const explicit = /^(npm:|github:|git:(?!\/\/))/.test(input.source);
|
||||
const directInfo = await stat(directDirectory).catch(() => null);
|
||||
const reference = directInfo?.isDirectory()
|
||||
? { source: input.source, pluginPath: undefined }
|
||||
: parsePluginSourceReference(input.source);
|
||||
const directory = path.resolve(reference.source);
|
||||
let info = directInfo;
|
||||
if (!info?.isDirectory() && !explicit) info = await stat(directory).catch(() => null);
|
||||
if (info?.isDirectory()) {
|
||||
const reference = parsePluginSourceReference(input.source);
|
||||
if (reference.kind === "directory") {
|
||||
const directory = path.resolve(expandTilde(reference.source));
|
||||
const info = await stat(directory).catch(() => null);
|
||||
if (!info?.isDirectory()) throw new Error(`Plugin directory does not exist: ${directory}`);
|
||||
if (input.ref) throw new Error("Plugin --ref is only valid for Git sources");
|
||||
const pluginDirectory = resolveLocalPluginPath(directory, reference.pluginPath);
|
||||
return this.installDirectory({ path: pluginDirectory, id: input.id });
|
||||
|
||||
@@ -280,7 +280,60 @@ describe("managed Git plugin sources", () => {
|
||||
});
|
||||
|
||||
describe("registry plugin sources", () => {
|
||||
it("treats owner/repository as GitHub shorthand without contacting the registry while registry installs are off", async () => {
|
||||
it("explains how to choose a revision when --ref is used with a registry id", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-ref-home-"));
|
||||
roots.push(home);
|
||||
const sources = new ManagedPluginSources(home);
|
||||
await expect(
|
||||
sources.prepareInstall({ source: "fixture/example", ref: "main" }),
|
||||
).rejects.toThrow(
|
||||
"Registry installs pin the reviewed revision. Drop --ref, or install from an explicit github:owner/repository source to choose a revision.",
|
||||
);
|
||||
});
|
||||
|
||||
it("names the registry URL and recovery steps when the registry fetch fails", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-offline-home-"));
|
||||
roots.push(home);
|
||||
const server = createServer((request) => request.socket.destroy());
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Missing registry address");
|
||||
const url = `http://127.0.0.1:${address.port}`;
|
||||
try {
|
||||
const sources = new ManagedPluginSources(home, { defaultUrl: url });
|
||||
await expect(sources.prepareInstall({ source: "fixture/example" })).rejects.toThrow(
|
||||
`Could not reach plugin registry ${url}/plugins/fixture/example.json. Plugin fixture/example was not installed. Retry or use an explicit npm: or github: source.`,
|
||||
);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it("explains explicit local and GitHub sources when a registry ID is missing", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-missing-home-"));
|
||||
roots.push(home);
|
||||
const requests: Array<string | undefined> = [];
|
||||
const server = createServer((request, response) => {
|
||||
requests.push(request.url);
|
||||
response.writeHead(404).end();
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Missing registry address");
|
||||
try {
|
||||
const sources = new ManagedPluginSources(home, {
|
||||
defaultUrl: `http://127.0.0.1:${address.port}`,
|
||||
});
|
||||
await expect(sources.prepareInstall({ source: "fixture/missing" })).rejects.toThrow(
|
||||
"If you intended a local directory, use ./fixture/missing. If you intended a GitHub source, use git:fixture/missing, or a full Git URL for another Git host.",
|
||||
);
|
||||
expect(requests).toEqual(["/plugins/fixture/missing.json"]);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps explicit Git sources and persisted Git installs independent of the registry", async () => {
|
||||
const repository = await createRepository();
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-off-home-"));
|
||||
roots.push(home);
|
||||
@@ -298,12 +351,34 @@ describe("registry plugin sources", () => {
|
||||
const sources = new ManagedPluginSources(home, {
|
||||
defaultUrl: `http://127.0.0.1:${address.port}`,
|
||||
});
|
||||
const candidate = await sources.prepareInstall({ source: "fixture/repository" });
|
||||
expect(candidate.record).toEqual({
|
||||
kind: "git",
|
||||
remote: "https://github.com/fixture/repository.git",
|
||||
});
|
||||
await sources.discard(candidate);
|
||||
for (const source of [
|
||||
"git:fixture/repository",
|
||||
"https://github.com/fixture/repository.git",
|
||||
]) {
|
||||
const candidate = await sources.place(
|
||||
"managed-example",
|
||||
await sources.prepareInstall({ source }),
|
||||
);
|
||||
expect(candidate.record).toEqual({
|
||||
kind: "git",
|
||||
remote: "https://github.com/fixture/repository.git",
|
||||
});
|
||||
sources.commit("managed-example", candidate.record);
|
||||
const restarted = new ManagedPluginSources(home, {
|
||||
defaultUrl: `http://127.0.0.1:${address.port}/changed`,
|
||||
});
|
||||
expect((await restarted.preview("managed-example", candidate.directory)).outcome).toBe(
|
||||
"current",
|
||||
);
|
||||
expect(
|
||||
(await restarted.describe("managed-example", candidate.directory)).identity,
|
||||
).toEqual({
|
||||
kind: "git",
|
||||
remote: "https://github.com/fixture/repository.git",
|
||||
pluginPath: ".",
|
||||
});
|
||||
await sources.discard(candidate);
|
||||
}
|
||||
});
|
||||
expect(requests).toEqual([]);
|
||||
} finally {
|
||||
@@ -332,12 +407,17 @@ describe("registry plugin sources", () => {
|
||||
intent: string | string[] | undefined;
|
||||
authorization: string | undefined;
|
||||
}> = [];
|
||||
let missing = false;
|
||||
const server = createServer((req, res) => {
|
||||
requests.push({
|
||||
path: req.url,
|
||||
intent: req.headers["x-paseo-install"],
|
||||
authorization: req.headers.authorization,
|
||||
});
|
||||
if (missing) {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
@@ -353,7 +433,7 @@ describe("registry plugin sources", () => {
|
||||
commit,
|
||||
pluginPath: "packages/example",
|
||||
},
|
||||
screenshots: [],
|
||||
media: [],
|
||||
submittedAt: "2026-10-03",
|
||||
reviewedAt: "2026-10-03",
|
||||
updatedAt: "2026-10-03",
|
||||
@@ -369,7 +449,6 @@ describe("registry plugin sources", () => {
|
||||
const url = `http://${host}/internal`;
|
||||
try {
|
||||
const sources = new ManagedPluginSources(home, {
|
||||
enabled: true,
|
||||
defaultUrl: url,
|
||||
registries: { [host]: { authorization: "Bearer test" } },
|
||||
});
|
||||
@@ -380,7 +459,11 @@ describe("registry plugin sources", () => {
|
||||
).toBe("managed-example");
|
||||
await sources.verifyCandidate("managed-example", candidate);
|
||||
sources.commit("managed-example", candidate.record);
|
||||
const preview = await sources.preview("managed-example", candidate.directory);
|
||||
const restarted = new ManagedPluginSources(home, {
|
||||
defaultUrl: "http://127.0.0.1:1/changed",
|
||||
registries: { [host]: { authorization: "Bearer test" } },
|
||||
});
|
||||
const preview = await restarted.preview("managed-example", candidate.directory);
|
||||
expect(preview.outcome).toBe("current");
|
||||
expect(preview.current?.identity).toEqual({
|
||||
kind: "git",
|
||||
@@ -388,6 +471,10 @@ describe("registry plugin sources", () => {
|
||||
pluginPath: "packages/example",
|
||||
registry: { url, id: "acme/example" },
|
||||
});
|
||||
missing = true;
|
||||
await expect(restarted.preview("managed-example", candidate.directory)).rejects.toThrow(
|
||||
"Check that the installed plugin is still published in this registry before updating",
|
||||
);
|
||||
expect(requests).toEqual([
|
||||
{ path: "/internal/plugins/acme/example.json", intent: "1", authorization: "Bearer test" },
|
||||
{
|
||||
@@ -395,6 +482,11 @@ describe("registry plugin sources", () => {
|
||||
intent: undefined,
|
||||
authorization: "Bearer test",
|
||||
},
|
||||
{
|
||||
path: "/internal/plugins/acme/example.json",
|
||||
intent: undefined,
|
||||
authorization: "Bearer test",
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
@@ -424,27 +516,17 @@ it("installs and updates only the npm artifacts pinned by the plugin registry",
|
||||
const server = createServer((request, response) => {
|
||||
intents.push(request.headers["x-paseo-install"]);
|
||||
response.end(
|
||||
// Install reads only the ID and artifact, so the document omits the directory's display fields.
|
||||
JSON.stringify({
|
||||
id: "acme/example",
|
||||
name: "Example",
|
||||
description: "Test",
|
||||
categories: [],
|
||||
author: { github: "acme" },
|
||||
repository: { url: "https://github.com/acme/example" },
|
||||
artifact: { kind: "npm", package: "paseo-fixture-plugin", ...pin },
|
||||
screenshots: [],
|
||||
submittedAt: "2026-10-03",
|
||||
reviewedAt: "2026-10-03",
|
||||
updatedAt: "2026-10-03",
|
||||
publishedAt: "2026-10-03",
|
||||
readme: "# Example",
|
||||
}),
|
||||
);
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Missing registry address");
|
||||
const options = { enabled: true, defaultUrl: `http://127.0.0.1:${address.port}` };
|
||||
const options = { defaultUrl: `http://127.0.0.1:${address.port}` };
|
||||
try {
|
||||
const sources = new ManagedPluginSources(home, options);
|
||||
const candidate = await sources.place(
|
||||
|
||||
@@ -147,11 +147,13 @@ export class ManagedPluginSources {
|
||||
input: InstallInput,
|
||||
target?: PluginUpdateTarget,
|
||||
): Promise<ManagedPluginCandidate> {
|
||||
const registry = this.registryOptions.enabled
|
||||
? parsePluginRegistryReference(input.source, this.registryOptions.defaultUrl)
|
||||
: null;
|
||||
const registry = parsePluginRegistryReference(input.source, this.registryOptions.defaultUrl);
|
||||
if (registry) {
|
||||
if (input.ref || input.pluginPath)
|
||||
if (input.ref)
|
||||
throw new Error(
|
||||
"Registry installs pin the reviewed revision. Drop --ref, or install from an explicit github:owner/repository source to choose a revision.",
|
||||
);
|
||||
if (input.pluginPath)
|
||||
throw new Error("Registry sources use their reviewed artifact path and revision");
|
||||
const resolved = await resolveRegistryPlugin(registry, this.registryOptions, true);
|
||||
const candidate = await this.prepareInstall(resolved.input, resolved.target);
|
||||
@@ -175,7 +177,7 @@ export class ManagedPluginSources {
|
||||
sourceRoot = path.join(versionRoot, "node_modules", artifact.packageName);
|
||||
record = { kind: "npm" };
|
||||
} else {
|
||||
const remote = normalizeGitSource(input.source, !this.registryOptions.enabled);
|
||||
const remote = normalizeGitSource(input.source);
|
||||
sourceRoot = path.join(versionRoot, "checkout");
|
||||
await clone(remote, sourceRoot);
|
||||
const commit = await resolveRequestedRef(sourceRoot, input.ref);
|
||||
@@ -227,7 +229,7 @@ export class ManagedPluginSources {
|
||||
const location = this.locate(pluginId, configuredPath);
|
||||
let target: PluginUpdateTarget;
|
||||
let outcome: PluginUpdatePreview["outcome"];
|
||||
if (this.registryOptions.enabled && current.identity.registry) {
|
||||
if (current.identity.registry) {
|
||||
if (selection) throw new Error("Registry updates use only the reviewed registry pin");
|
||||
const resolved = await resolveRegistryPlugin(
|
||||
current.identity.registry,
|
||||
@@ -304,7 +306,7 @@ export class ManagedPluginSources {
|
||||
await this.assertCurrent(proposal, configuredPath);
|
||||
const identity = this.locate(proposal.id, configuredPath).identity;
|
||||
const record = this.records[proposal.id];
|
||||
if (this.registryOptions.enabled && identity.kind !== "directory" && identity.registry) {
|
||||
if (identity.kind !== "directory" && identity.registry) {
|
||||
const resolved = await resolveRegistryPlugin(identity.registry, this.registryOptions, false);
|
||||
assertRegistryArtifactIdentity(identity, resolved.artifact);
|
||||
if (!isDeepStrictEqual(resolved.target, proposal.target))
|
||||
@@ -436,10 +438,9 @@ function reviewLinks(current: PluginInstallation, target: PluginUpdateTarget): s
|
||||
}
|
||||
return [];
|
||||
}
|
||||
/** Bare owner/repo is GitHub shorthand unless registry references own that syntax. */
|
||||
function normalizeGitSource(source: string, bareGitHubShorthand: boolean): string {
|
||||
const expandsShorthand =
|
||||
bareGitHubShorthand || source.startsWith("github:") || source.startsWith("git:");
|
||||
/** Only explicit Git prefixes expand GitHub shorthand. */
|
||||
function normalizeGitSource(source: string): string {
|
||||
const expandsShorthand = source.startsWith("github:") || source.startsWith("git:");
|
||||
if (source.startsWith("github:")) {
|
||||
const shorthand = source.slice(7);
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(shorthand)) {
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import {
|
||||
PublishedPluginDetailSchema,
|
||||
PluginRegistryArtifactSchema,
|
||||
PluginRegistryIdSchema,
|
||||
type PluginRegistries,
|
||||
type PluginRegistryIdentity,
|
||||
} from "@getpaseo/protocol/plugin-registry";
|
||||
import type { PluginUpdateTarget } from "@getpaseo/protocol/messages";
|
||||
import { z } from "zod";
|
||||
|
||||
// Install reads only these fields; the rest of a published plugin is the directory's display data.
|
||||
const RegistryInstallDocumentSchema = z.object({
|
||||
id: PluginRegistryIdSchema,
|
||||
artifact: PluginRegistryArtifactSchema,
|
||||
});
|
||||
|
||||
export interface RegistryOptions {
|
||||
/** Off: bare owner/repo is GitHub shorthand and no registry is contacted. */
|
||||
enabled?: boolean;
|
||||
defaultUrl?: string;
|
||||
registries?: PluginRegistries;
|
||||
}
|
||||
@@ -29,14 +35,29 @@ export async function resolveRegistryPlugin(
|
||||
const headers: Record<string, string> = {};
|
||||
if (authorization) headers.Authorization = authorization;
|
||||
if (install) headers["X-Paseo-Install"] = "1";
|
||||
const response = await fetch(`${identity.url.replace(/\/+$/, "")}/plugins/${identity.id}.json`, {
|
||||
headers,
|
||||
redirect: "error",
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
const url = `${identity.url.replace(/\/+$/, "")}/plugins/${identity.id}.json`;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
headers,
|
||||
redirect: "error",
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
} catch (cause) {
|
||||
const guidance = install
|
||||
? `Plugin ${identity.id} was not installed. Retry or use an explicit npm: or github: source.`
|
||||
: `Could not check updates for ${identity.id}. Retry later.`;
|
||||
throw new Error(`Could not reach plugin registry ${url}. ${guidance}`, { cause });
|
||||
}
|
||||
if (response.status === 404) {
|
||||
const guidance = install
|
||||
? `If you intended a local directory, use ./${identity.id}. If you intended a GitHub source, use git:${identity.id}, or a full Git URL for another Git host.`
|
||||
: "Check that the installed plugin is still published in this registry before updating.";
|
||||
throw new Error(`Plugin ${identity.id} was not found in registry ${base.host}. ${guidance}`);
|
||||
}
|
||||
if (!response.ok)
|
||||
throw new Error(`Registry ${base.host} returned ${response.status} for ${identity.id}`);
|
||||
const plugin = PublishedPluginDetailSchema.parse(await response.json());
|
||||
const plugin = RegistryInstallDocumentSchema.parse(await response.json());
|
||||
if (plugin.id !== identity.id) throw new Error("Registry returned a different plugin ID");
|
||||
const artifact = plugin.artifact;
|
||||
const target: PluginUpdateTarget =
|
||||
|
||||
@@ -24,6 +24,69 @@ describe("plugin manifest", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reads display metadata and ignores future top-level fields", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
|
||||
directories.push(directory);
|
||||
const manifest = {
|
||||
id: "review",
|
||||
name: "Review tools",
|
||||
icon: "assets/icon.png",
|
||||
media: ["assets/screenshot.webp", "demo.mp4", "https://example.com/media?id=1"],
|
||||
};
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ ...manifest, futureField: { enabled: true } }),
|
||||
);
|
||||
await expect(readPluginManifest(directory)).resolves.toEqual(manifest);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: " " },
|
||||
{ name: 42 },
|
||||
{ icon: "" },
|
||||
{ icon: 42 },
|
||||
{ icon: "icon.svg" },
|
||||
{ icon: "/icon.png" },
|
||||
{ icon: "../icon.png" },
|
||||
{ icon: "assets/../../icon.png" },
|
||||
{ icon: "https://example.com/icon.png" },
|
||||
{ icon: "C:/icon.png" },
|
||||
{ icon: "assets\\icon.png" },
|
||||
{ icon: "icon.png?query" },
|
||||
{ media: "screenshot.png" },
|
||||
{ media: [42] },
|
||||
{ media: [""] },
|
||||
{ media: ["../demo.mp4"] },
|
||||
{ media: ["/demo.mp4"] },
|
||||
{ media: ["http://example.com/demo.mp4"] },
|
||||
{ media: ["https://"] },
|
||||
{ media: ["//example.com/demo.mp4"] },
|
||||
{ media: ["file:///demo.mp4"] },
|
||||
{ media: ["data:image/png;base64,abc"] },
|
||||
{ media: ["."] },
|
||||
{ media: ["assets/"] },
|
||||
{ id: "INVALID" },
|
||||
{ description: 42 },
|
||||
{ requirements: { paseoo: ">=0.11.0" } },
|
||||
])("rejects invalid known fields: %j", async (fields) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
|
||||
directories.push(directory);
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "example", futureField: true, ...fields }),
|
||||
);
|
||||
await expect(readPluginManifest(directory)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("accepts omitted metadata and empty media", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
|
||||
directories.push(directory);
|
||||
for (const manifest of [{ id: "example" }, { id: "example", media: [] }]) {
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify(manifest));
|
||||
await expect(readPluginManifest(directory)).resolves.toEqual(manifest);
|
||||
}
|
||||
});
|
||||
|
||||
it("reads and validates requirements before any plugin code runs", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
|
||||
directories.push(directory);
|
||||
|
||||
@@ -8,14 +8,33 @@ const MANIFEST_FILENAME = "paseo-plugin.json";
|
||||
const PluginBuildCommandSchema = z
|
||||
.array(z.string().refine((argument) => argument.trim().length > 0))
|
||||
.min(1);
|
||||
const PluginManifestSchema = z
|
||||
.object({
|
||||
id: PluginIdSchema,
|
||||
description: z.string().trim().min(1).optional(),
|
||||
requirements: PluginRequirementsSchema.strict().optional(),
|
||||
build: z.array(PluginBuildCommandSchema).min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
// Manifest asset paths use forward slashes and stay inside the plugin package.
|
||||
const PluginAssetPathSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(value) =>
|
||||
value === value.trim() &&
|
||||
!value.startsWith("/") &&
|
||||
!/[\\:?#\p{Cc}]/u.test(value) &&
|
||||
!value.split("/").includes("..") &&
|
||||
!value.endsWith("/") &&
|
||||
value.split("/").at(-1) !== ".",
|
||||
"Expected a relative file path inside the plugin package",
|
||||
);
|
||||
const PluginManifestSchema = z.object({
|
||||
id: PluginIdSchema,
|
||||
name: z.string().trim().min(1).optional(),
|
||||
description: z.string().trim().min(1).optional(),
|
||||
icon: PluginAssetPathSchema.refine(
|
||||
(value) => /\.png$/i.test(value),
|
||||
"Expected a relative path to a PNG",
|
||||
).optional(),
|
||||
media: z.array(z.union([PluginAssetPathSchema, z.url({ protocol: /^https$/ })])).optional(),
|
||||
// A misspelled requirement must not silently disable compatibility checks.
|
||||
requirements: PluginRequirementsSchema.strict().optional(),
|
||||
build: z.array(PluginBuildCommandSchema).min(1).optional(),
|
||||
});
|
||||
|
||||
export type PluginManifest = z.infer<typeof PluginManifestSchema>;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import type pino from "pino";
|
||||
import pino from "pino";
|
||||
import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee";
|
||||
import { exportPublicKey, generateKeyPair } from "@getpaseo/relay";
|
||||
import { startRelayTransport } from "./relay-transport";
|
||||
@@ -146,6 +146,37 @@ describe("relay-transport control lifecycle", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("releases a stalled encrypted data connection without restarting control", async () => {
|
||||
vi.useFakeTimers();
|
||||
let attachments = 0;
|
||||
controllers.push(
|
||||
startRelayTransport({
|
||||
logger: pino({ level: "silent" }),
|
||||
attachSocket: async () => {
|
||||
attachments += 1;
|
||||
},
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
relayUseTls: true,
|
||||
serverId: "srv_test",
|
||||
daemonKeyPair: generateKeyPair(),
|
||||
createWebSocket: relay.createWebSocket,
|
||||
}),
|
||||
);
|
||||
const control = relay.sockets[0];
|
||||
control.open();
|
||||
const connected = JSON.stringify({ type: "connected", connectionId: "stalled" });
|
||||
control.message(connected);
|
||||
const stalled = relay.sockets[1];
|
||||
stalled.open();
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
expect(stalled.terminateCalls).toBe(1);
|
||||
expect(attachments).toBe(0);
|
||||
expect(control.terminateCalls).toBe(0);
|
||||
control.message(connected);
|
||||
expect(relay.sockets).toHaveLength(3);
|
||||
expect(relay.sockets[2].url).toBe(stalled.url);
|
||||
});
|
||||
|
||||
test("logs relay_control_connected only after first valid control message", () => {
|
||||
const logger = createMockLogger();
|
||||
const controller = startRelayTransport({
|
||||
|
||||
@@ -420,6 +420,10 @@ async function attachEncryptedSocket(
|
||||
attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise<void>,
|
||||
metadata?: ExternalSocketMetadata,
|
||||
): Promise<void> {
|
||||
const handshakeTimeout = setTimeout(() => {
|
||||
logger.warn("relay_e2ee_handshake_timeout_terminating");
|
||||
socket.terminate();
|
||||
}, 15_000);
|
||||
try {
|
||||
const relayTransport = createRelayTransportAdapter(socket, logger);
|
||||
const emitter = new EventEmitter();
|
||||
@@ -440,6 +444,7 @@ async function attachEncryptedSocket(
|
||||
emitter.emit("error", error);
|
||||
},
|
||||
});
|
||||
clearTimeout(handshakeTimeout);
|
||||
const encryptedSocket = createEncryptedRelaySocket({
|
||||
channel,
|
||||
emitter,
|
||||
@@ -459,6 +464,8 @@ async function attachEncryptedSocket(
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(handshakeTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -510,9 +510,7 @@ it("starts the default shell through the worker and accepts quoted commands", as
|
||||
|
||||
session.send({ type: "input", data: `${command}\r` });
|
||||
|
||||
await waitForCondition(() => existsSync(markerPath), 10000);
|
||||
|
||||
expect(readFileSync(markerPath, "utf8")).toBe("shell-ok");
|
||||
await expect.poll(() => readFileSync(markerPath, "utf8"), { timeout: 10000 }).toBe("shell-ok");
|
||||
});
|
||||
|
||||
it("lists subdirectory terminals when querying the workspace root", async () => {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Paseo website
|
||||
|
||||
## Plugin install counts
|
||||
|
||||
Clients report an install by resolving a plugin with `X-Paseo-Install: 1` or
|
||||
`?intent=install`, using `/api/plugins/resolve/<owner>/<slug>` or
|
||||
`/plugins/<owner>/<slug>.json`. Ordinary resolves do not count. These numbers
|
||||
measure install intent, not successful artifact installation or npm downloads.
|
||||
|
||||
The worker requires Cloudflare's `CF-Connecting-IP` header to count a report.
|
||||
It stores the plugin ID plus a SHA-256 hash of the IP in a `WEBSITE_CACHE` KV key
|
||||
with a one hour TTL, never the raw IP. An existing key skips the count; repeats
|
||||
do not extend the hour. Missing or empty IP headers do not count.
|
||||
|
||||
Accepted reports increment the existing all-time total and UTC daily buckets in
|
||||
`WEBSITE_CACHE`. Daily buckets retain 60 days and supply the website's seven-day
|
||||
and thirty-day rankings. `/api/plugins/installs` still returns plugin IDs mapped
|
||||
to all-time totals. The [registry build](https://github.com/getpaseo/plugins/blob/main/scripts/build.ts)
|
||||
reads that endpoint into the published plugin details and index; the website
|
||||
reads windowed counts from KV through its five-minute cache. Existing counts
|
||||
remain intact. The KV guard is advisory: concurrent requests and KV propagation
|
||||
are not serialized.
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"id": "security/overview",
|
||||
"name": "</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">",
|
||||
"description": "</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">",
|
||||
"artifact": {
|
||||
"kind": "npm",
|
||||
"package": "@alhassanaraouf/paseo-base2tone-theme",
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@alhassanaraouf/paseo-base2tone-theme/-/paseo-base2tone-theme-0.1.2.tgz",
|
||||
"integrity": "sha512-baP+qh3MlPgwACvt72XjrULjbDHARofzHfxyGGVVl22iw6qVnMzyfBJXi4exgtplvD/yPqf/XlsqD8wTqHB+lg=="
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"url": "javascript:alert(1)"
|
||||
},
|
||||
"categories": ["themes"],
|
||||
"author": {
|
||||
"github": "security",
|
||||
"name": "</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">"
|
||||
},
|
||||
"media": [
|
||||
"data:image/svg+xml,<svg onload=\"globalThis.__overviewExecuted=1\"/>",
|
||||
"http://example.com/image.png"
|
||||
],
|
||||
"submittedAt": "2026-09-24T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-24T17:12:05.467Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "## Overview security\n\n</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">",
|
||||
"icon": "data:image/png;base64,AAAA"
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { expect, test, type Page } from "playwright/test";
|
||||
import securityPlugin from "./overview.fixture.json" with { type: "json" };
|
||||
import { expect, test, type Page, type Locator } from "playwright/test";
|
||||
import registry from "./registry.fixture.json" with { type: "json" };
|
||||
import { CATEGORIES } from "../src/plugins/categories";
|
||||
|
||||
async function openPlugins(page: Page) {
|
||||
@@ -6,6 +8,13 @@ async function openPlugins(page: Page) {
|
||||
await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible();
|
||||
}
|
||||
|
||||
function pluginCards(page: Page, section: string): Locator {
|
||||
return page
|
||||
.getByRole("region", { name: section })
|
||||
.getByRole("link")
|
||||
.filter({ hasNotText: /^See all$/ });
|
||||
}
|
||||
|
||||
test("browses from the directory into a category, a plugin, and its author", async ({
|
||||
page,
|
||||
context,
|
||||
@@ -34,7 +43,7 @@ test("browses from the directory into a category, a plugin, and its author", asy
|
||||
await expect(page.getByRole("heading", { name: "Fresh Worktrees" })).toHaveCount(1);
|
||||
await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toHaveCount(1);
|
||||
await expect(
|
||||
page.getByRole("heading", { level: 2, name: "Link to this section Behavior", exact: true }),
|
||||
page.getByRole("heading", { level: 2, name: "Behavior", exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Copy to clipboard" }).click();
|
||||
await expect
|
||||
@@ -78,7 +87,7 @@ test("filters by search and clears to all plugins", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await openPlugins(page);
|
||||
|
||||
await searchPlugins(page, "graphite");
|
||||
await submitSearch(page, "graphite");
|
||||
await expect(page).toHaveURL(/\/plugins\/all\?q=graphite$/);
|
||||
await expect(
|
||||
page.getByRole("heading", { level: 1, name: /^Results for “graphite”/ }),
|
||||
@@ -96,7 +105,7 @@ test("filters by search and clears to all plugins", async ({ page }) => {
|
||||
|
||||
test("keeps the directory's ranking window when searching", async ({ page }) => {
|
||||
await page.goto("/plugins?window=month");
|
||||
await searchPlugins(page, "graphite");
|
||||
await submitSearch(page, "graphite");
|
||||
await expect(page).toHaveURL(/\/plugins\/all\?q=graphite&window=month$/);
|
||||
await expect(page.getByRole("link", { name: "This month" })).toHaveAttribute(
|
||||
"aria-current",
|
||||
@@ -104,6 +113,22 @@ test("keeps the directory's ranking window when searching", async ({ page }) =>
|
||||
);
|
||||
});
|
||||
|
||||
test("searches for a term typed before the page finished loading", async ({ page }) => {
|
||||
let loadScripts!: () => void;
|
||||
const scriptsHeld = new Promise<void>((resolve) => (loadScripts = resolve));
|
||||
await page.route(/\.js($|\?)/, async (route) => {
|
||||
await scriptsHeld;
|
||||
await route.continue();
|
||||
});
|
||||
await page.goto("/plugins?window=month", { waitUntil: "domcontentloaded" });
|
||||
await searchPlugins(page, "graphite");
|
||||
loadScripts();
|
||||
await page.waitForLoadState("load");
|
||||
await expect(page.getByRole("button", { name: "Clear search" })).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page).toHaveURL(/\/plugins\/all\?q=graphite&window=month$/);
|
||||
});
|
||||
|
||||
test("clears the search with the clear button", async ({ page }) => {
|
||||
await page.goto("/plugins/all");
|
||||
const searchbox = page.getByRole("searchbox", { name: "Search plugins" });
|
||||
@@ -120,13 +145,52 @@ test("clears the search with the clear button", async ({ page }) => {
|
||||
await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test("replaces history while typing a search", async ({ page }) => {
|
||||
test("searches from the directory on submit, and Back returns to the directory", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await openPlugins(page);
|
||||
const entries = await historyLength(page);
|
||||
|
||||
await typeSearch(page, "gra");
|
||||
await expect(page).toHaveURL(/\/plugins$/);
|
||||
expect(await historyLength(page)).toBe(entries);
|
||||
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page).toHaveURL(/\/plugins\/all\?q=gra$/);
|
||||
await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible();
|
||||
expect(await historyLength(page)).toBe(entries + 1);
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/plugins$/);
|
||||
await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test("filters browse results as you type in one history entry", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.goto("/plugins/all");
|
||||
await searchPlugins(page, "graphite");
|
||||
await expect(page).toHaveURL(/\/plugins\/all\?q=graphite$/);
|
||||
const entries = await historyLength(page);
|
||||
|
||||
await typeSearch(page, "gra");
|
||||
await expect(page).toHaveURL(/\/plugins\/all\?q=gra$/);
|
||||
await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible();
|
||||
await expect(page.getByRole("main").getByRole("link", { name: /Graphite/ })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0);
|
||||
expect(await historyLength(page)).toBe(entries + 1);
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await expect(page).toHaveURL(/\/plugins\/all$/);
|
||||
await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible();
|
||||
await expect(page.getByRole("searchbox", { name: "Search plugins" })).toHaveValue("");
|
||||
|
||||
await page.goForward();
|
||||
await expect(page).toHaveURL(/\/plugins\/all\?q=gra$/);
|
||||
await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible();
|
||||
await expect(page.getByRole("searchbox", { name: "Search plugins" })).toHaveValue("gra");
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible();
|
||||
await expect(page.getByRole("searchbox", { name: "Search plugins" })).toHaveValue("gra");
|
||||
});
|
||||
|
||||
test("keeps old category links working", async ({ page }) => {
|
||||
@@ -167,7 +231,7 @@ test.describe("search engine visits without JavaScript", () => {
|
||||
expect(response?.headers()["x-robots-tag"]).toBeUndefined();
|
||||
await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("heading", { level: 2, name: "Link to this section Behavior", exact: true }),
|
||||
page.getByRole("heading", { level: 2, name: "Behavior", exact: true }),
|
||||
).toBeVisible();
|
||||
await expectPageMetadata(
|
||||
page,
|
||||
@@ -260,6 +324,19 @@ async function searchPlugins(page: Page, term: string) {
|
||||
await page.getByRole("searchbox", { name: "Search plugins" }).fill(term);
|
||||
}
|
||||
|
||||
async function submitSearch(page: Page, term: string) {
|
||||
await searchPlugins(page, term);
|
||||
await page.keyboard.press("Enter");
|
||||
}
|
||||
|
||||
async function typeSearch(page: Page, term: string) {
|
||||
await page.getByRole("searchbox", { name: "Search plugins" }).pressSequentially(term);
|
||||
}
|
||||
|
||||
async function historyLength(page: Page): Promise<number> {
|
||||
return page.evaluate(() => window.history.length);
|
||||
}
|
||||
|
||||
async function expectPageMetadata(page: Page, title: string, path: string) {
|
||||
await expect(page).toHaveTitle(title);
|
||||
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
|
||||
@@ -292,7 +369,7 @@ test("keeps the directory unlinked until the coordinated announcement", async ({
|
||||
await expect(
|
||||
page.getByRole("contentinfo").getByRole("link", { name: "Plugins", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByRole("link", { name: "Community plugins" })).toHaveAttribute(
|
||||
await expect(page.getByRole("link", { name: "Browse plugins" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://paseo.cafe",
|
||||
);
|
||||
@@ -306,6 +383,157 @@ test("keeps the directory unlinked until the coordinated announcement", async ({
|
||||
test.describe("registry fixture layout", () => {
|
||||
test.skip(Boolean(process.env.WEBSITE_TEST_URL), "Requires the local registry fixture");
|
||||
|
||||
test("uses density-aware card thumbnails and keeps detail screenshots original", async ({
|
||||
page,
|
||||
}) => {
|
||||
const plugin = registry.plugins.find((entry) => entry.id === "alhassanaraouf/base2tone")!;
|
||||
const source = plugin.media[0];
|
||||
await openPlugins(page);
|
||||
await expectThumbnailCard(
|
||||
page.getByRole("region", { name: "What’s new" }).getByRole("link", { name: /Base2Tone/ }),
|
||||
source,
|
||||
plugin.id,
|
||||
);
|
||||
await page.goto("/plugins/all");
|
||||
const card = page.getByRole("main").getByRole("link", { name: /Base2Tone/ });
|
||||
await expectThumbnailCard(card, source, plugin.id);
|
||||
await card.click();
|
||||
await expect(
|
||||
page.getByRole("img", { name: "Base2Tone screenshot 1", exact: true }),
|
||||
).toHaveAttribute("src", source);
|
||||
});
|
||||
|
||||
test("shows images and videos as gallery tiles in registry order and opens each in the viewer", async ({
|
||||
page,
|
||||
}) => {
|
||||
const plugin = registry.plugins.find((entry) => entry.id === "omercnet/dracula")!;
|
||||
const [firstImage, video] = plugin.media;
|
||||
await page.goto(`/plugins/${plugin.id}`);
|
||||
const tiles = page.getByRole("link", { name: /^Dracula (screenshot|video) \d$/ });
|
||||
await expect(tiles).toHaveCount(3);
|
||||
const layout = await tiles.evaluateAll(galleryTiles);
|
||||
expect(layout.map((tile) => tile.href)).toEqual(plugin.media);
|
||||
expect(new Set(layout.map((tile) => tile.size)).size).toBe(1);
|
||||
const preview = tiles.nth(1).locator("video");
|
||||
await expect(preview).toHaveAttribute("preload", "metadata");
|
||||
await expect(preview).not.toHaveAttribute("controls");
|
||||
await expect(preview).not.toHaveAttribute("autoplay");
|
||||
expect(await preview.evaluate((element: HTMLVideoElement) => element.muted)).toBe(true);
|
||||
|
||||
const viewer = page.getByRole("dialog");
|
||||
await tiles.nth(1).click();
|
||||
const playing = viewer.getByLabel("Dracula video 2");
|
||||
await expect(playing).toHaveAttribute("src", video);
|
||||
await expect(playing).toHaveAttribute("controls", "");
|
||||
await expect.poll(() => playing.evaluate(isPaused)).toBe(false);
|
||||
// The fixture video is 320x240, so it has to scale up to fill the viewer.
|
||||
await expect.poll(() => playing.evaluate(viewerFill)).toBeCloseTo(1, 2);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(viewer).toBeHidden();
|
||||
await expect(page.locator("dialog video")).toHaveCount(0);
|
||||
|
||||
await tiles.first().click();
|
||||
await expect(viewer.getByRole("img", { name: "Dracula screenshot 1" })).toHaveAttribute(
|
||||
"src",
|
||||
firstImage,
|
||||
);
|
||||
await viewer.getByRole("button", { name: "Close" }).click();
|
||||
await expect(viewer).toBeHidden();
|
||||
expect(page.context().pages()).toHaveLength(1);
|
||||
|
||||
const newTab = page.context().waitForEvent("page");
|
||||
await tiles.first().click({ modifiers: ["ControlOrMeta"] });
|
||||
await expect(await newTab).toHaveURL(firstImage);
|
||||
await expect(viewer).toBeHidden();
|
||||
});
|
||||
|
||||
test("peeks the next gallery tile at the strip's edge, and fills the row with two", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Wide screens show two tiles and part of the third; phones show one and part of the second.
|
||||
for (const [width, expected] of [
|
||||
[1280, [1, 1, "peeks"]],
|
||||
[375, [1, "peeks", 0]],
|
||||
] as const) {
|
||||
await page.setViewportSize({ width, height: 720 });
|
||||
await page.goto("/plugins/omercnet/dracula");
|
||||
const three = page.getByRole("link", { name: /^Dracula (screenshot|video) \d$/ });
|
||||
const shares = await three.evaluateAll(visibleShares);
|
||||
expect(shares.map((share) => (share > 0.1 && share < 0.5 ? "peeks" : share))).toEqual(
|
||||
expected,
|
||||
);
|
||||
}
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto("/plugins/gpambrozio/herald");
|
||||
const two = page.getByRole("link", { name: /^Herald screenshot \d$/ });
|
||||
expect(await two.evaluateAll(visibleShares)).toEqual([1, 1]);
|
||||
});
|
||||
|
||||
test("steps through the viewer with arrow keys and buttons, wrapping at the ends", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/plugins/omercnet/dracula");
|
||||
const tiles = page.getByRole("link", { name: /^Dracula (screenshot|video) \d$/ });
|
||||
const viewer = page.getByRole("dialog");
|
||||
const shows = (name: string) =>
|
||||
expect(
|
||||
viewer.getByRole("img", { name, exact: true }).or(viewer.getByLabel(name, { exact: true })),
|
||||
).toBeVisible();
|
||||
|
||||
await tiles.nth(1).click();
|
||||
await expect.poll(() => viewer.getByLabel("Dracula video 2").evaluate(isPaused)).toBe(false);
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await shows("Dracula screenshot 3");
|
||||
await expect(page.locator("dialog video")).toHaveCount(0);
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await shows("Dracula screenshot 1");
|
||||
await viewer.getByRole("img").click();
|
||||
await page.keyboard.press("ArrowLeft");
|
||||
await shows("Dracula screenshot 3");
|
||||
|
||||
await viewer.getByRole("button", { name: "Previous" }).click();
|
||||
await shows("Dracula video 2");
|
||||
await viewer.getByRole("button", { name: "Next" }).click();
|
||||
await shows("Dracula screenshot 3");
|
||||
await viewer.getByRole("button", { name: "Next" }).click();
|
||||
await shows("Dracula screenshot 1");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(viewer).toBeHidden();
|
||||
});
|
||||
|
||||
test("keeps a video that fails to load visible in the viewer", async ({ page }) => {
|
||||
const plugin = registry.plugins.find((entry) => entry.id === "gpambrozio/launchd-jobs")!;
|
||||
await page.route(plugin.media[0], (route) => route.abort());
|
||||
await page.goto(`/plugins/${plugin.id}`);
|
||||
await page.getByRole("link", { name: "launchd Jobs video 1" }).click();
|
||||
const video = page.getByRole("dialog").getByLabel("launchd Jobs video 1");
|
||||
await expect(video).toBeVisible();
|
||||
await expect(video).toHaveCSS("opacity", "1");
|
||||
await expect(page.getByRole("button", { name: "Next" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("shows the plugin tile on cards when its media has no image", async ({ page }) => {
|
||||
await page.goto("/plugins/all");
|
||||
const card = page.getByRole("main").getByRole("link", { name: /launchd Jobs/ });
|
||||
await expect(card.locator("img, video")).toHaveCount(0);
|
||||
await card.click();
|
||||
await expect(
|
||||
page.getByRole("link", { name: "launchd Jobs video 1" }).locator("video"),
|
||||
).toHaveAttribute(
|
||||
"src",
|
||||
registry.plugins.find((entry) => entry.id === "gpambrozio/launchd-jobs")!.media[0],
|
||||
);
|
||||
});
|
||||
|
||||
test("features listed plugins above What's new in the registry's order", async ({ page }) => {
|
||||
await openPlugins(page);
|
||||
await expect(page.getByRole("heading", { level: 2 }).first()).toHaveText("Featured");
|
||||
const featured = page.getByRole("region", { name: "Featured" });
|
||||
await expect(featured).toContainText("A selection of hand picked plugins");
|
||||
await expect(pluginCards(page, "Featured")).toHaveText([/Dracula/, /Herald/, /launchd Jobs/]);
|
||||
});
|
||||
|
||||
test("lists the nine categories in order with counts, and the newest plugins first", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -315,8 +543,132 @@ test.describe("registry fixture layout", () => {
|
||||
CATEGORIES.map((category) => new RegExp(`^${category.label}\\s*\\d+$`)),
|
||||
);
|
||||
await expect(categories.getByRole("link", { name: /Extras/ })).toContainText("0");
|
||||
await expect(
|
||||
page.getByRole("region", { name: "What’s new" }).getByRole("link", { name: /Added/ }),
|
||||
).toHaveText([/Base2Tone/, /Sayr/, /PromptKit/, /Defer/]);
|
||||
await expect(pluginCards(page, "What’s new")).toHaveText([
|
||||
/Base2Tone/,
|
||||
/Sayr/,
|
||||
/PromptKit/,
|
||||
/Defer/,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function galleryTiles(links: Element[]) {
|
||||
return links.map((link) => ({
|
||||
href: link.getAttribute("href"),
|
||||
size: `${link.clientWidth}x${link.clientHeight}`,
|
||||
}));
|
||||
}
|
||||
|
||||
/** How much of each tile's width the gallery strip shows, rounded to hundredths. */
|
||||
function visibleShares(links: Element[]) {
|
||||
const strip = links[0].parentElement!.getBoundingClientRect();
|
||||
return links.map((link) => {
|
||||
const tile = link.getBoundingClientRect();
|
||||
const shown = Math.min(tile.right, strip.right) - Math.max(tile.left, strip.left);
|
||||
return Math.round((Math.max(shown, 0) / tile.width) * 100) / 100;
|
||||
});
|
||||
}
|
||||
|
||||
/** The share of the viewer's room the media fills along its limiting axis; 1 fills it. */
|
||||
function viewerFill(media: HTMLElement) {
|
||||
const box = media.getBoundingClientRect();
|
||||
return Math.max(box.width / (window.innerWidth * 0.9), box.height / (window.innerHeight * 0.85));
|
||||
}
|
||||
|
||||
function isPaused(video: HTMLVideoElement) {
|
||||
return video.paused;
|
||||
}
|
||||
|
||||
async function expectThumbnailCard(card: Locator, source: string, id: string) {
|
||||
// Card screenshots are decorative and hidden from the accessibility tree.
|
||||
const image = card.locator("img").first();
|
||||
const path = (width: number) =>
|
||||
`/plugins/thumb/${width}/${encodeURIComponent(source)}?plugin=${encodeURIComponent(id)}`;
|
||||
await expect(image).toHaveAttribute("src", path(592));
|
||||
await expect(image).toHaveAttribute("srcset", `${path(592)} 1x, ${path(1184)} 2x`);
|
||||
await expect(image).toHaveAttribute("loading", "lazy");
|
||||
await expect(image).toHaveAttribute("decoding", "async");
|
||||
await image.scrollIntoViewIfNeeded();
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
image.evaluate((element: HTMLImageElement) => element.complete && element.naturalWidth > 0),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
const box = await image.evaluate((element) => {
|
||||
const rect = element.parentElement!.getBoundingClientRect();
|
||||
return { width: rect.width, height: rect.height };
|
||||
});
|
||||
expect(box.width).toBeGreaterThan(0);
|
||||
expect(box.width / box.height).toBeCloseTo(1.6, 2);
|
||||
}
|
||||
|
||||
test("keeps author content inert through SSR and hydration", async ({
|
||||
page,
|
||||
request,
|
||||
}, testInfo) => {
|
||||
await verifyPluginOverviewResponse(request);
|
||||
await openUntrustedPlugin(page);
|
||||
await expectPluginOverviewSafe(page);
|
||||
await page.reload();
|
||||
await expectPluginOverviewSafe(page);
|
||||
await testInfo.attach("plugin-overview", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
});
|
||||
|
||||
async function verifyPluginOverviewResponse(request: import("playwright/test").APIRequestContext) {
|
||||
const response = await request.get("/plugins/security/overview");
|
||||
expect(response.status()).toBe(200);
|
||||
const html = await response.text();
|
||||
expect(html).not.toContain("<script>globalThis.__overviewExecuted=1</script>");
|
||||
expect(html).toContain("\\x3C/script>");
|
||||
}
|
||||
|
||||
async function openUntrustedPlugin(page: Page) {
|
||||
await page.goto("/plugins/security/overview");
|
||||
await expect(page.getByRole("heading", { name: "Overview security", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectPluginOverviewSafe(page: Page) {
|
||||
expect(
|
||||
await page.evaluate(() => (globalThis as { __overviewExecuted?: number }).__overviewExecuted),
|
||||
).toBeUndefined();
|
||||
// Security assertions inspect every emitted attribute, including inaccessible injected elements.
|
||||
const violations = await page.locator(".docs-prose").evaluate((root) => {
|
||||
const bad: string[] = [];
|
||||
for (const element of root.querySelectorAll("*")) {
|
||||
if (
|
||||
["SCRIPT", "IFRAME", "SVG", "FORM", "INPUT", "META", "BASE", "STYLE"].includes(
|
||||
element.tagName,
|
||||
)
|
||||
)
|
||||
bad.push(element.tagName);
|
||||
for (const attr of element.attributes) {
|
||||
if (/^on|^style$|^srcdoc$/i.test(attr.name)) bad.push(attr.name);
|
||||
if (["href", "src"].includes(attr.name) && !attr.value.startsWith("https://"))
|
||||
bad.push(attr.value);
|
||||
}
|
||||
if (
|
||||
element.tagName === "A" &&
|
||||
(element.getAttribute("rel") !== "noopener noreferrer nofollow" ||
|
||||
element.getAttribute("target") !== "_blank")
|
||||
)
|
||||
bad.push("unsafe anchor");
|
||||
}
|
||||
return bad;
|
||||
});
|
||||
expect(violations).toEqual([]);
|
||||
await expect(page.getByRole("link", { name: "Source", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("img", { name: /screenshot/ })).toHaveCount(0);
|
||||
await expect(page.getByRole("heading", { level: 1 })).toHaveText(securityPlugin.name);
|
||||
await expect(
|
||||
page.getByRole("link", { name: securityPlugin.author.name, exact: true }),
|
||||
).toHaveAttribute("href", "/plugins/security");
|
||||
await expect(page.locator('meta[name="description"]')).toHaveAttribute(
|
||||
"content",
|
||||
securityPlugin.description,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { createServer } from "node:http";
|
||||
import registry from "./registry.fixture.json" with { type: "json" };
|
||||
|
||||
import securityPlugin from "./overview.fixture.json" with { type: "json" };
|
||||
|
||||
import overviewCorpus from "../../protocol/tests/fixtures/plugin-overview.json" with { type: "json" };
|
||||
securityPlugin.readme +=
|
||||
"\n\n" +
|
||||
overviewCorpus
|
||||
.map(({ markdown }) => markdown + "\n\n```html\n" + markdown + "\n```")
|
||||
.join("\n\n");
|
||||
|
||||
// Serve the published registry contract so SSR and browser navigation use the same data.
|
||||
createServer((request, response) => {
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
@@ -8,7 +17,9 @@ createServer((request, response) => {
|
||||
response.end(JSON.stringify(registry));
|
||||
return;
|
||||
}
|
||||
const plugin = registry.plugins.find((entry) => request.url === `/plugins/${entry.id}.json`);
|
||||
const plugin = [securityPlugin, ...registry.plugins].find(
|
||||
(entry) => request.url === `/plugins/${entry.id}.json`,
|
||||
);
|
||||
response.statusCode = plugin ? 200 : 404;
|
||||
response.end(JSON.stringify(plugin ?? { error: "Plugin not found" }));
|
||||
}).listen(8188, "127.0.0.1");
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
"url": "https://plugins.paseo.sh"
|
||||
},
|
||||
"generatedAt": "2026-10-04T15:55:47.882Z",
|
||||
"featured": [
|
||||
"omercnet/dracula",
|
||||
"gpambrozio/herald",
|
||||
"acme/not-published",
|
||||
"gpambrozio/launchd-jobs"
|
||||
],
|
||||
"categories": [
|
||||
{
|
||||
"slug": "daemon-management",
|
||||
@@ -74,12 +80,12 @@
|
||||
"name": "alhassanaraouf",
|
||||
"github": "alhassanaraouf"
|
||||
},
|
||||
"screenshots": [
|
||||
"media": [
|
||||
"https://raw.githubusercontent.com/alhassanaraouf/paseo-themes/main/packages/paseo-base2tone-theme/preview.png"
|
||||
],
|
||||
"submittedAt": "2026-09-24T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-24T17:12:05.467Z",
|
||||
"publishedAt": "2026-09-24T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Base2Tone\n\n## Behavior\n\nBase2Tone theme for Paseo, ported from the Obsidian theme deathau/Base2Tone-For-Obsidian.md."
|
||||
@@ -106,13 +112,13 @@
|
||||
"name": "Tom Lundy",
|
||||
"github": "dorasto"
|
||||
},
|
||||
"screenshots": [
|
||||
"media": [
|
||||
"https://raw.githubusercontent.com/dorasto/sayr/main/packages/paseo-plugin/assets/sayr-plguin-1.png",
|
||||
"https://raw.githubusercontent.com/dorasto/sayr/main/packages/paseo-plugin/assets/paseo-plugin-2.png"
|
||||
],
|
||||
"submittedAt": "2026-09-22T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-22T10:39:28.935Z",
|
||||
"publishedAt": "2026-09-22T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Sayr\n\n## Behavior\n\nA paseo plugin for viewing and managing your Sayr tasks"
|
||||
@@ -139,13 +145,13 @@
|
||||
"name": "gpambrozio",
|
||||
"github": "gpambrozio"
|
||||
},
|
||||
"screenshots": [
|
||||
"media": [
|
||||
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/github-board/docs/screenshot.png",
|
||||
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/github-board/docs/detail-panel.png"
|
||||
],
|
||||
"submittedAt": "2026-09-19T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-01T17:46:20.500Z",
|
||||
"publishedAt": "2026-09-19T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# GitHub Board\n\n## Behavior\n\nPaseo plugin: a GitHub sidebar board of your issues, draft and open pull requests, and discussions"
|
||||
@@ -172,13 +178,13 @@
|
||||
"name": "gpambrozio",
|
||||
"github": "gpambrozio"
|
||||
},
|
||||
"screenshots": [
|
||||
"media": [
|
||||
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/herald/docs/panel.png",
|
||||
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/herald/docs/timeline-card.png"
|
||||
],
|
||||
"submittedAt": "2026-09-19T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-27T19:03:31.943Z",
|
||||
"publishedAt": "2026-09-19T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Herald\n\n## Behavior\n\nPaseo plugin: speaks one sentence when an agent needs you, and lists everything waiting in a sidebar panel"
|
||||
@@ -205,12 +211,12 @@
|
||||
"name": "gpambrozio",
|
||||
"github": "gpambrozio"
|
||||
},
|
||||
"screenshots": [
|
||||
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/launchd-jobs/docs/screenshot.png"
|
||||
"media": [
|
||||
"https://raw.githubusercontent.com/web-platform-tests/wpt/master/media/movie_5.mp4"
|
||||
],
|
||||
"submittedAt": "2026-09-19T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-22T17:56:41.061Z",
|
||||
"publishedAt": "2026-09-19T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# launchd Jobs\n\n## Behavior\n\nPaseo plugin: schedule shell commands on your Mac through launchd, from a sidebar panel"
|
||||
@@ -237,12 +243,12 @@
|
||||
"name": "gpambrozio",
|
||||
"github": "gpambrozio"
|
||||
},
|
||||
"screenshots": [
|
||||
"media": [
|
||||
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/model-pricing/docs/screenshot.png"
|
||||
],
|
||||
"submittedAt": "2026-09-19T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-22T18:03:14.979Z",
|
||||
"publishedAt": "2026-09-19T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Model Pricing\n\n## Behavior\n\nPaseo plugin: a sidebar table of what every model costs, across Anthropic, OpenAI, Fireworks AI, Ollama Cloud and OpenRouter"
|
||||
@@ -269,10 +275,10 @@
|
||||
"name": "gpambrozio",
|
||||
"github": "gpambrozio"
|
||||
},
|
||||
"screenshots": [],
|
||||
"media": [],
|
||||
"submittedAt": "2026-09-19T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-01T22:25:58.192Z",
|
||||
"publishedAt": "2026-09-19T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Skills\n\n## Behavior\n\nPaseo plugin: lists the skills an agent can use, shows where each comes from, renders its SKILL.md, and invokes it"
|
||||
@@ -298,13 +304,13 @@
|
||||
"name": "hungcuong9125",
|
||||
"github": "hungcuong9125"
|
||||
},
|
||||
"screenshots": [
|
||||
"media": [
|
||||
"https://raw.githubusercontent.com/hungcuong9125/paseo-prompt-kit/main/docs/images/settings-provider-cli.png",
|
||||
"https://raw.githubusercontent.com/hungcuong9125/paseo-prompt-kit/main/docs/images/settings-direct-api.png"
|
||||
],
|
||||
"submittedAt": "2026-09-22T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-30T02:57:56.473Z",
|
||||
"publishedAt": "2026-09-22T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# PromptKit\n\n## Behavior\n\nPromptKit - rewrite and transform prompts directly from the Paseo composer."
|
||||
@@ -331,13 +337,13 @@
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"screenshots": [
|
||||
"media": [
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/agent-monitor/docs/images/agent-monitor-roster.png",
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/agent-monitor/docs/images/agent-monitor-settings.png"
|
||||
],
|
||||
"submittedAt": "2026-09-17T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:36:35.639Z",
|
||||
"publishedAt": "2026-09-17T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Agent Monitor\n\n## Behavior\n\nA Paseo plugin that provides a host-wide agent triage roster."
|
||||
@@ -364,13 +370,13 @@
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"screenshots": [
|
||||
"media": [
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-beads/docs/images/paseo-beads-wide.png",
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-beads/docs/images/paseo-beads-compact.png"
|
||||
],
|
||||
"submittedAt": "2026-09-17T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:37:29.149Z",
|
||||
"publishedAt": "2026-09-17T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Beads\n\n## Behavior\n\nA dependency-aware Beads work queue for each Paseo workspace."
|
||||
@@ -397,13 +403,14 @@
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"screenshots": [
|
||||
"media": [
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-dracula/screenshot.png",
|
||||
"https://raw.githubusercontent.com/web-platform-tests/wpt/master/media/movie_5.webm",
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-dracula/screenshot-alucard.png"
|
||||
],
|
||||
"submittedAt": "2026-09-17T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:37:18.185Z",
|
||||
"publishedAt": "2026-09-17T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Dracula\n\n## Behavior\n\nDracula Classic and Alucard Classic themes for Paseo."
|
||||
@@ -430,13 +437,13 @@
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"screenshots": [
|
||||
"media": [
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-behind.png",
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-settings.png"
|
||||
],
|
||||
"submittedAt": "2026-09-18T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:36:06.720Z",
|
||||
"publishedAt": "2026-09-18T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Fresh Worktrees\n\n## Behavior\n\nFast-forwards clean local base branches before Paseo creates branch-off worktrees"
|
||||
@@ -463,13 +470,13 @@
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"screenshots": [
|
||||
"media": [
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-omp/docs/images/plugin-manager.png",
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-omp/docs/images/workspace-settings.png"
|
||||
],
|
||||
"submittedAt": "2026-09-17T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:36:36.267Z",
|
||||
"publishedAt": "2026-09-17T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# OMP\n\n## Behavior\n\nPaseo integration for OMP, including its direct provider and workspace tooling."
|
||||
@@ -496,10 +503,10 @@
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"screenshots": [],
|
||||
"media": [],
|
||||
"submittedAt": "2026-09-17T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:37:04.327Z",
|
||||
"publishedAt": "2026-09-17T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# PR Radar\n\n## Behavior\n\nA viewer-aware delivery queue for pull requests linked to Paseo workspaces."
|
||||
@@ -526,13 +533,13 @@
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"screenshots": [
|
||||
"media": [
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-shared-browser/docs/images/shared-browser-wide.png",
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-shared-browser/docs/images/shared-browser-compact.png"
|
||||
],
|
||||
"submittedAt": "2026-09-18T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:38:48.289Z",
|
||||
"publishedAt": "2026-09-18T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Shared Browser\n\n## Behavior\n\nOne real Chromium session per Paseo workspace, shared live across every connected client."
|
||||
@@ -559,10 +566,10 @@
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"screenshots": [],
|
||||
"media": [],
|
||||
"submittedAt": "2026-09-17T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:36:15.719Z",
|
||||
"publishedAt": "2026-09-17T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Tell Agent\n\n## Behavior\n\nSend messages to agents in other workspaces on the same Paseo host."
|
||||
@@ -588,12 +595,12 @@
|
||||
"name": "Tom Gringauz",
|
||||
"github": "tomgrin10"
|
||||
},
|
||||
"screenshots": [
|
||||
"media": [
|
||||
"https://raw.githubusercontent.com/tomgrin10/paseo-defer/main/docs/defer-popover.png"
|
||||
],
|
||||
"submittedAt": "2026-09-22T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-27T09:59:16.553Z",
|
||||
"publishedAt": "2026-09-22T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Defer\n\n## Behavior\n\nDefer messages to Paseo coding agents until a chosen time or usage-window reset."
|
||||
@@ -619,10 +626,10 @@
|
||||
"name": "Tom Gringauz",
|
||||
"github": "tomgrin10"
|
||||
},
|
||||
"screenshots": [],
|
||||
"media": [],
|
||||
"submittedAt": "2026-09-22T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-23T10:36:50.112Z",
|
||||
"publishedAt": "2026-09-22T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Graphite\n\n## Behavior\n\nWorkspace-native Graphite stack status and PR action radar for Paseo."
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"@types/react": "^19.1.6",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"parse5": "^7.3.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.3",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
|
||||
@@ -32,7 +32,9 @@ function AlternativePageContent({ slug }: { slug: string }) {
|
||||
|
||||
return (
|
||||
<SiteShell width="default">
|
||||
<DocsMarkdown>{page.content}</DocsMarkdown>
|
||||
<div className="comparison-prose">
|
||||
<DocsMarkdown>{page.content}</DocsMarkdown>
|
||||
</div>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,12 @@ import {
|
||||
ArrowRight,
|
||||
Blocks,
|
||||
Bot,
|
||||
BookOpen,
|
||||
Braces,
|
||||
Coffee,
|
||||
Compass,
|
||||
ExternalLink,
|
||||
Gem,
|
||||
GitFork,
|
||||
LayoutPanelLeft,
|
||||
Laptop,
|
||||
Lock,
|
||||
Merge,
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
Smartphone,
|
||||
Terminal,
|
||||
Users,
|
||||
Workflow,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -477,7 +477,7 @@ function TurnkeySection() {
|
||||
<div className="space-y-0.5">
|
||||
<h3 className="text-xl font-medium text-white/90">Desktop app</h3>
|
||||
<p className="max-w-lg text-sm leading-relaxed text-white/50">
|
||||
The one click experience, download the app and it just works
|
||||
The one click experience, download the app and it just works, no server required
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -487,8 +487,8 @@ function TurnkeySection() {
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<TurnkeyExtensionCard
|
||||
icon={Smartphone}
|
||||
title="Mobile and web"
|
||||
description="Connect to the same workspaces from any client"
|
||||
title="Mobile"
|
||||
description="Full client and native, not a WebView"
|
||||
ctaHref="/download"
|
||||
ctaLabel="Download"
|
||||
/>
|
||||
@@ -793,92 +793,105 @@ function SdkAutomationExample() {
|
||||
|
||||
function ExtensibleSection() {
|
||||
return (
|
||||
<FeatureSection title="Make it yours" description="Extend Paseo to work just the way you want">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<ExtensibleCard
|
||||
icon={Puzzle}
|
||||
title="Plugins"
|
||||
description="Plugins can add server-side functionality and modify the client with custom components. They work across all clients, including mobile"
|
||||
links={PLUGIN_CARD_LINKS}
|
||||
/>
|
||||
<ExtensibleCard
|
||||
icon={GitFork}
|
||||
title="Fork the repo"
|
||||
description="Paseo is licensed under Apache 2.0. You can inspect the implementation, fork the project, and adapt it to your workflow or organization"
|
||||
links={FORK_CARD_LINKS}
|
||||
/>
|
||||
<FeatureSection
|
||||
title="Extend it with plugins"
|
||||
description="Add new panels and functionality that work in desktop, web and mobile"
|
||||
>
|
||||
<div className="overflow-hidden rounded-2xl border border-white/10 bg-white/[0.02]">
|
||||
<div className="flex flex-col gap-6 border-b border-white/10 p-6 sm:flex-row sm:items-center sm:justify-between md:p-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.06] p-3 text-muted-foreground">
|
||||
<Puzzle className="h-6 w-6" strokeWidth={1.5} />
|
||||
</div>
|
||||
<h3 className="text-xl font-medium text-white/90">Plugins</h3>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<a
|
||||
href={PLUGINS_BROWSE.href}
|
||||
{...(PLUGINS_BROWSE.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-foreground px-3 py-1.5 text-xs text-background transition-colors hover:bg-foreground/90"
|
||||
>
|
||||
Browse plugins
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
<a
|
||||
href="/docs/plugins"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-white/12 px-3 py-1.5 text-xs text-white/80 transition-colors hover:bg-white/10"
|
||||
>
|
||||
Plugin docs
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 md:p-8">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{PLUGIN_CAPABILITIES.map((capability) => (
|
||||
<PluginCapabilityCard key={capability.title} {...capability} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 border-t border-white/10 px-6 py-5 sm:flex-row sm:items-center sm:justify-between md:px-8">
|
||||
<p className="text-sm text-white/45">Paseo is Apache 2.0.</p>
|
||||
<a
|
||||
href="https://github.com/getpaseo/paseo"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex shrink-0 items-center gap-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<GitFork className="h-4 w-4" strokeWidth={1.5} />
|
||||
Fork the repo
|
||||
<ExternalLink className="h-3.5 w-3.5 opacity-70" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</FeatureSection>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExtensibleCardLink {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
external?: boolean;
|
||||
/** Accent links stand out without hover, for destinations worth noticing. */
|
||||
accent?: boolean;
|
||||
}
|
||||
const PLUGINS_BROWSE = PLUGINS_LINKED
|
||||
? { href: "/plugins", external: false }
|
||||
: { href: "https://paseo.cafe", external: true };
|
||||
|
||||
const PLUGIN_CARD_LINKS: ReadonlyArray<ExtensibleCardLink> = [
|
||||
{ href: "/docs/plugins", label: "Plugin documentation", icon: BookOpen },
|
||||
PLUGINS_LINKED
|
||||
? { href: "/plugins", label: "Community plugins", icon: Blocks, accent: true }
|
||||
: {
|
||||
href: "https://paseo.cafe",
|
||||
label: "Community plugins",
|
||||
icon: Coffee,
|
||||
external: true,
|
||||
accent: true,
|
||||
},
|
||||
];
|
||||
|
||||
const FORK_CARD_LINKS: ReadonlyArray<ExtensibleCardLink> = [
|
||||
const PLUGIN_CAPABILITIES: ReadonlyArray<{
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
href: "https://github.com/getpaseo/paseo",
|
||||
label: "View the repository",
|
||||
icon: GitHubIcon,
|
||||
external: true,
|
||||
icon: LayoutPanelLeft,
|
||||
title: "UI",
|
||||
description: "Screens, workspace panels, sidebar items, slash commands, and themes",
|
||||
},
|
||||
{
|
||||
icon: Workflow,
|
||||
title: "Agent lifecycle",
|
||||
description:
|
||||
"Change configuration, environment, and MCP servers. Answer permissions. Follow up when a turn ends",
|
||||
},
|
||||
{
|
||||
icon: Bot,
|
||||
title: "Providers",
|
||||
description: "Add a coding agent as a provider, used the same way as the built-in ones",
|
||||
},
|
||||
];
|
||||
|
||||
function ExtensibleCard({
|
||||
function PluginCapabilityCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
links,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
links: ReadonlyArray<ExtensibleCardLink>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-64 flex-col rounded-xl border border-white/10 bg-white/[0.025] p-6">
|
||||
<div className="mb-8 text-muted-foreground">
|
||||
<Icon className="h-6 w-6" strokeWidth={1.5} />
|
||||
<div className="flex flex-col rounded-xl border border-white/10 bg-white/[0.025] p-5">
|
||||
<div className="mb-5 text-muted-foreground">
|
||||
<Icon className="h-5 w-5" strokeWidth={1.5} />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white/85">{title}</h3>
|
||||
<h3 className="font-medium text-white/85">{title}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-white/45">{description}</p>
|
||||
<div className="mt-auto flex flex-col items-start gap-3 pt-6">
|
||||
{links.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
{...(link.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||
className={
|
||||
link.accent
|
||||
? "inline-flex items-center gap-2 text-sm text-emerald-300/85 transition-colors hover:text-emerald-200"
|
||||
: "inline-flex items-center gap-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
}
|
||||
>
|
||||
<link.icon className="h-4 w-4" />
|
||||
{link.label}
|
||||
{link.external ? <ExternalLink className="h-3.5 w-3.5 opacity-70" /> : null}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -902,8 +915,7 @@ const PRINCIPLES: ReadonlyArray<{ icon: LucideIcon; title: string; description:
|
||||
{
|
||||
icon: Merge,
|
||||
title: "Unified",
|
||||
description:
|
||||
"Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, and more, all used the same way.",
|
||||
description: "Every coding agent, used the same way. Switch providers any time.",
|
||||
},
|
||||
{
|
||||
icon: Blocks,
|
||||
@@ -923,17 +935,17 @@ function PhilosophySection() {
|
||||
title="Philosophy"
|
||||
description="What Paseo stands for, and what every feature is built on"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{PRINCIPLES.map((principle) => (
|
||||
<div
|
||||
key={principle.title}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.025] p-6"
|
||||
className="rounded-xl border border-white/10 bg-white/[0.025] p-5"
|
||||
>
|
||||
<div className="mb-8 text-extra-muted-foreground">
|
||||
<principle.icon className="h-6 w-6" strokeWidth={1.5} />
|
||||
<div className="flex items-center gap-3">
|
||||
<principle.icon className="h-5 w-5 text-extra-muted-foreground" strokeWidth={1.5} />
|
||||
<h3 className="font-medium text-white/85">{principle.title}</h3>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white/85">{principle.title}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-white/45">{principle.description}</p>
|
||||
<p className="mt-3 text-sm leading-relaxed text-white/45">{principle.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ export function SiteShell({ children, width }: SiteShellProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<main className={mainClasses}>
|
||||
<div className="mb-12">
|
||||
<div className="mb-20">
|
||||
<SiteHeader />
|
||||
</div>
|
||||
{children}
|
||||
|
||||
@@ -53,9 +53,10 @@ const MAINTAINER_LINK = (
|
||||
export function FounderNote() {
|
||||
return (
|
||||
<div className="max-w-2xl space-y-5 leading-relaxed text-white/70">
|
||||
<p>Paseo is an independent project used by tens of thousands of developers daily.</p>
|
||||
<p>
|
||||
I build Paseo on my own. There are no investors, no board and no company behind it, and I
|
||||
have turned down funding offers to keep it that way.
|
||||
It is built by one person, full time, with no investors, no board and no company behind it.
|
||||
I have turned down funding offers to keep it that way.
|
||||
</p>
|
||||
<p>
|
||||
A tool that sits between you and your code, your keys and your machines has to stay neutral,
|
||||
@@ -67,7 +68,7 @@ export function FounderNote() {
|
||||
<a href="/hub" className="underline hover:text-white/90">
|
||||
Paseo Hub
|
||||
</a>
|
||||
, an optional hosted service. Your support is what lets me work on Paseo full time.
|
||||
, an optional hosted service. Your support is what lets me keep working on it.
|
||||
</p>
|
||||
<p className="text-white/50">{MAINTAINER_LINK}, maintainer</p>
|
||||
</div>
|
||||
@@ -79,7 +80,6 @@ interface BackingOption {
|
||||
name: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
external: boolean;
|
||||
primary?: boolean;
|
||||
}
|
||||
|
||||
const BACKING_OPTIONS: ReadonlyArray<BackingOption> = [
|
||||
@@ -88,7 +88,6 @@ const BACKING_OPTIONS: ReadonlyArray<BackingOption> = [
|
||||
name: "GitHub Sponsors",
|
||||
icon: GitHubIcon,
|
||||
external: true,
|
||||
primary: true,
|
||||
},
|
||||
{
|
||||
href: OPEN_COLLECTIVE_URL,
|
||||
@@ -127,11 +126,7 @@ export function BackingOptions() {
|
||||
key={option.href}
|
||||
href={option.href}
|
||||
{...(option.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||
className={`flex items-center gap-4 rounded-xl border p-4 transition-colors ${
|
||||
option.primary
|
||||
? "border-white/25 bg-white/[0.06] hover:border-white/40 hover:bg-white/[0.08]"
|
||||
: "border-white/10 bg-white/[0.02] hover:border-white/20 hover:bg-white/[0.05]"
|
||||
}`}
|
||||
className="flex items-center gap-4 rounded-xl border border-white/10 bg-white/[0.02] p-4 transition-colors hover:border-white/20 hover:bg-white/[0.05]"
|
||||
>
|
||||
<option.icon className="h-6 w-6 shrink-0 text-white/60" />
|
||||
<p className="flex items-center gap-1.5 font-medium text-white">
|
||||
@@ -237,9 +232,10 @@ export function SponsorSection() {
|
||||
<SectionHeading as="h2" title="Sponsor Paseo" />
|
||||
<div className="space-y-10">
|
||||
<div className="max-w-2xl space-y-5 leading-relaxed text-white/70">
|
||||
<p>Paseo is an independent project used by tens of thousands of developers daily.</p>
|
||||
<p>
|
||||
I build Paseo on my own, with no investors and no company behind it, and I have turned
|
||||
down funding offers to keep it that way. A tool that sits between you and your code,
|
||||
It is built by one person, full time, with no investors and no company behind it. I have
|
||||
turned down funding to keep it that way. A tool that sits between you and your code,
|
||||
your keys and your machines has to stay neutral, and funding creates pressure to
|
||||
monetize.
|
||||
</p>
|
||||
@@ -248,7 +244,7 @@ export function SponsorSection() {
|
||||
<a href="/hub" className="underline hover:text-white/90">
|
||||
Paseo Hub
|
||||
</a>
|
||||
, and your support is what lets me work on it full time.
|
||||
. Your support is what lets me keep working on it.
|
||||
</p>
|
||||
<p className="text-white/50">{MAINTAINER_LINK}, maintainer</p>
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,7 @@ Both Claude Desktop and Paseo are available on macOS, Windows, and Linux.
|
||||
|
||||
## Mobile
|
||||
|
||||
Paseo ships native iOS and Android apps with the same agent workflow as the desktop app.
|
||||
The mobile app is the full app, native on iOS and Android, with full feature parity with desktop.
|
||||
|
||||
Claude has iOS and Android apps. Dispatch can start local Claude Code work through an active Claude Desktop host or start a cloud session on Anthropic's infrastructure.
|
||||
|
||||
@@ -84,7 +84,7 @@ paseo schedule create --cron "0 9 * * 1" "audit the codebase"
|
||||
|
||||
Both tools support parallel coding sessions, including Git worktrees.
|
||||
|
||||
Paseo also gives each worktree its own dev server URL. Two agents running their dev servers at the same time get `web.fix-auth.my-app.localhost` and `web.add-search.my-app.localhost` instead of port collisions.
|
||||
Paseo also gives each worktree its own dev server URL. Two agents running their dev servers at the same time get `web--fix-auth--my-app.localhost` and `web--add-search--my-app.localhost` instead of port collisions.
|
||||
|
||||
## Voice
|
||||
|
||||
@@ -94,25 +94,31 @@ Claude supports voice in Claude's own mobile and app surfaces. Claude Code itsel
|
||||
|
||||
## Comparison
|
||||
|
||||
| | Paseo | Claude Desktop |
|
||||
| ---------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------ |
|
||||
| License | Open source (Apache-2.0) | Not published as open source |
|
||||
| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows |
|
||||
| Mobile coding workflow | Native Paseo workspace on iOS and Android | Dispatch and Cowork in Claude mobile |
|
||||
| Coding agents | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code + 30+ via ACP catalog + custom | Claude Code |
|
||||
| Product account required | No | Claude account |
|
||||
| Cloud agent | Cloud waitlist | Claude Cowork and remote sessions |
|
||||
| Required cloud connection | No | Claude account and services |
|
||||
| Machines you control | Laptop, workstation, VM, server, or home lab | Local machine or SSH host |
|
||||
| Git worktrees | Yes | Yes |
|
||||
| Per-worktree dev server URLs | Yes | No |
|
||||
| Split panes and tabs | Yes | Yes |
|
||||
| In-app terminal | Yes | Yes |
|
||||
| In-app browser / preview | Yes | Yes |
|
||||
| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | PR monitoring and merge workflows |
|
||||
| CLI | Run, `--host`, ls, send, schedule, loop | Claude Code CLI |
|
||||
| Remote transport | Direct connection or end-to-end encrypted relay | Anthropic Remote and cloud services |
|
||||
| Application plugins | Server code and native client components | No |
|
||||
| Self-hosted control plane | Daemon, web client, and relay | No |
|
||||
| | Paseo | Claude Desktop |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
|
||||
| License | Open source (Apache-2.0) | Not published as open source |
|
||||
| Account required | No | Claude account |
|
||||
| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS, Linux, Windows) |
|
||||
| Mobile app | Yes (native, full parity with desktop) | Claude app (Dispatch and Cowork) |
|
||||
| CLI | Yes (everything the app does) | Yes (Claude Code CLI) |
|
||||
| Remote machines | Yes (install the daemon anywhere) | Yes (SSH host, Anthropic-managed cloud sessions) |
|
||||
| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | Anthropic Remote |
|
||||
| Direct network access | Yes (LAN, Tailscale, VPN) | - |
|
||||
| SSH access | Yes | Yes |
|
||||
| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | Claude Code |
|
||||
| Parallel agents | Yes (isolated worktrees, across machines) | Yes (Git worktrees) |
|
||||
| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | - |
|
||||
| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | - |
|
||||
| Editor | Yes | Yes (file editor) |
|
||||
| Terminals | Yes | Yes |
|
||||
| Diff review | Yes (comments go to the agent) | Yes |
|
||||
| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | PR monitoring and merge workflows |
|
||||
| In-app browser | Yes (element picker, agent browser tools) | Yes (live app preview) |
|
||||
| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | No |
|
||||
| Schedules and heartbeats | Yes | Yes (scheduled tasks) |
|
||||
| Plan usage | Yes | - |
|
||||
| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No |
|
||||
| Voice | Yes (local dictation, realtime voice) | Yes (in Claude's mobile and app surfaces) |
|
||||
| Telemetry | None | - |
|
||||
|
||||
See also: [Paseo vs Codex App](/alternatives/codex-app), [Paseo vs OpenCode Desktop](/alternatives/opencode-desktop), [Paseo vs Conductor](/alternatives/conductor).
|
||||
|
||||
@@ -45,7 +45,7 @@ OpenAI's desktop app and Paseo are available on macOS, Windows, and Linux.
|
||||
|
||||
## Mobile
|
||||
|
||||
Paseo ships native iOS and Android apps with the same agent workflow as the desktop app.
|
||||
The mobile app is the full app, native on iOS and Android, with full feature parity with desktop.
|
||||
|
||||
ChatGPT Remote can start and continue Codex chats, approve actions, and review outputs from iOS and Android through a connected desktop host.
|
||||
|
||||
@@ -55,7 +55,7 @@ Both tools support Git worktrees for parallel work.
|
||||
|
||||
Codex App creates Codex-managed worktrees under `$CODEX_HOME/worktrees` and supports local environment setup scripts and project actions through `.codex` configuration.
|
||||
|
||||
Paseo creates worktrees under `$PASEO_HOME/worktrees`, runs setup and teardown hooks from `paseo.json`, and gives each worktree its own dev server URLs like `web.fix-auth.my-app.localhost` so parallel services don't fight for ports.
|
||||
Paseo creates worktrees under `$PASEO_HOME/worktrees`, runs setup and teardown hooks from `paseo.json`, and gives each worktree its own dev server URLs like `web--fix-auth--my-app.localhost` so parallel services don't fight for ports.
|
||||
|
||||
## GitHub and review
|
||||
|
||||
@@ -88,23 +88,31 @@ Paseo supports dictation and realtime voice mode. Speech-to-text and text-to-spe
|
||||
|
||||
## Comparison
|
||||
|
||||
| | Paseo | Codex App |
|
||||
| ------------------------------- | --------------------------------------------------------------- | ---------------------------------------------------- |
|
||||
| License | Open source (Apache-2.0) | Codex CLI is open source; application is proprietary |
|
||||
| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows |
|
||||
| Mobile coding workflow | Native Paseo workspace on iOS and Android | ChatGPT Remote on iOS and Android |
|
||||
| Providers | Codex, Claude Code, OpenCode, Pi + 30+ via ACP catalog + custom | Codex |
|
||||
| Product account required | No | OpenAI account |
|
||||
| Required Paseo cloud connection | No | Not applicable |
|
||||
| Git worktrees | Yes | Yes |
|
||||
| Per-worktree dev server URLs | Yes | No |
|
||||
| In-app terminal | Yes | Yes |
|
||||
| In-app browser | Yes | Yes |
|
||||
| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Commit, push, PR |
|
||||
| CLI | Run, `--host`, ls, send, schedule, loop | Codex CLI |
|
||||
| Remote transport | Direct connection or end-to-end encrypted relay | OpenAI Remote |
|
||||
| Application plugins | Server code and native client components | No |
|
||||
| Voice | Dictation and realtime voice | Dictation |
|
||||
| Self-hosted control plane | Daemon, web client, and relay | No |
|
||||
| | Paseo | Codex App |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
|
||||
| License | Open source (Apache-2.0) | Codex CLI is open source; application is proprietary |
|
||||
| Account required | No | OpenAI account |
|
||||
| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS, Linux, Windows) |
|
||||
| Mobile app | Yes (native, full parity with desktop) | ChatGPT app (Remote) |
|
||||
| CLI | Yes (everything the app does) | Yes (Codex CLI) |
|
||||
| Remote machines | Yes (install the daemon anywhere) | Yes (connected host, OpenAI-managed cloud) |
|
||||
| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | OpenAI Remote |
|
||||
| Direct network access | Yes (LAN, Tailscale, VPN) | - |
|
||||
| SSH access | Yes | - |
|
||||
| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | Codex |
|
||||
| Parallel agents | Yes (isolated worktrees, across machines) | Yes (Git worktrees) |
|
||||
| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | - |
|
||||
| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | - |
|
||||
| Editor | Yes | - |
|
||||
| Terminals | Yes | Yes |
|
||||
| Diff review | Yes (comments go to the agent) | Yes |
|
||||
| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | Commit, push, PR |
|
||||
| In-app browser | Yes (element picker, agent browser tools) | Yes |
|
||||
| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | No |
|
||||
| Schedules and heartbeats | Yes | - |
|
||||
| Plan usage | Yes | - |
|
||||
| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No |
|
||||
| Voice | Yes (local dictation, realtime voice) | Dictation |
|
||||
| Telemetry | None | - |
|
||||
|
||||
See also: [Paseo vs Claude Desktop](/alternatives/claude-desktop), [Paseo vs OpenCode Desktop](/alternatives/opencode-desktop), [all supported providers](/agents).
|
||||
|
||||
@@ -67,11 +67,11 @@ Conductor lists its API as a Pro feature but does not document a user-facing CLI
|
||||
|
||||
Both tools isolate parallel agents in git worktrees.
|
||||
|
||||
Paseo also gives each worktree its own dev server URL. Two agents running their dev servers at the same time get `web.fix-auth.my-app.localhost` and `web.add-search.my-app.localhost` instead of port collisions.
|
||||
Paseo also gives each worktree its own dev server URL. Two agents running their dev servers at the same time get `web--fix-auth--my-app.localhost` and `web--add-search--my-app.localhost` instead of port collisions.
|
||||
|
||||
## Mobile
|
||||
|
||||
Paseo ships native iOS and Android apps today. Conductor lists its mobile app as coming soon under the Pro plan.
|
||||
The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. Conductor lists its mobile app as coming soon under the Pro plan.
|
||||
|
||||
## Voice
|
||||
|
||||
@@ -79,21 +79,31 @@ Paseo supports local speech-to-text and text-to-speech. Conductor does not curre
|
||||
|
||||
## Comparison
|
||||
|
||||
| | Paseo | Conductor |
|
||||
| ---------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------ |
|
||||
| License | Open source (Apache-2.0) | Closed source |
|
||||
| Platforms | macOS, Linux, Windows | macOS only |
|
||||
| Native mobile | iOS, Android | Coming soon under Pro |
|
||||
| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code + 30+ via ACP catalog + custom | Claude Code, Codex, Cursor, OpenCode |
|
||||
| Git worktrees | Yes | Yes |
|
||||
| Per-worktree dev server URLs | Yes | — |
|
||||
| Split panes and tabs | Yes | — |
|
||||
| In-app terminal | Yes | Yes |
|
||||
| In-app browser | Yes | — |
|
||||
| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Yes |
|
||||
| CLI | Run, `--host`, ls, send, schedule, loop | — |
|
||||
| Application plugins | Server code and native client components | No |
|
||||
| Local voice | Yes | Not documented |
|
||||
| Self-hosted daemon | Yes | — |
|
||||
| | Paseo | Conductor |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------- |
|
||||
| License | Open source (Apache-2.0) | Closed source |
|
||||
| Account required | No | Yes |
|
||||
| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS only) |
|
||||
| Mobile app | Yes (native, full parity with desktop) | Coming soon under Pro |
|
||||
| CLI | Yes (everything the app does) | API under Pro |
|
||||
| Remote machines | Yes (install the daemon anywhere) | No (managed cloud workspaces under Pro) |
|
||||
| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | - |
|
||||
| Direct network access | Yes (LAN, Tailscale, VPN) | No |
|
||||
| SSH access | Yes | No |
|
||||
| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | Claude Code, Codex, Cursor, OpenCode |
|
||||
| Parallel agents | Yes (isolated worktrees, across machines) | Yes (Git worktrees) |
|
||||
| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | - |
|
||||
| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | - |
|
||||
| Editor | Yes | - |
|
||||
| Terminals | Yes | Yes |
|
||||
| Diff review | Yes (comments go to the agent) | - |
|
||||
| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | GitHub |
|
||||
| In-app browser | Yes (element picker, agent browser tools) | - |
|
||||
| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | - |
|
||||
| Schedules and heartbeats | Yes | - |
|
||||
| Plan usage | Yes | - |
|
||||
| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No |
|
||||
| Voice | Yes (local dictation, realtime voice) | - |
|
||||
| Telemetry | None | - |
|
||||
|
||||
See also: [Paseo vs Superset](/alternatives/superset), [Paseo vs OpenChamber](/alternatives/openchamber), [Paseo vs Happy Coder](/alternatives/happy-coder).
|
||||
|
||||
@@ -37,7 +37,7 @@ Paseo's app handles commit, push, opening PRs, watching checks and reviews, and
|
||||
|
||||
## Mobile
|
||||
|
||||
Both tools ship native iOS and Android apps.
|
||||
The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. Happy Coder also ships native iOS and Android apps.
|
||||
|
||||
## Providers
|
||||
|
||||
@@ -53,7 +53,7 @@ Happy Coder does not document an application extension API for adding both serve
|
||||
|
||||
## Worktrees and services
|
||||
|
||||
Paseo runs each agent in its own git worktree. Each worktree gets its own dev server URL like `web.fix-auth.my-app.localhost`, so parallel agents don't fight for the same port.
|
||||
Paseo runs each agent in its own git worktree. Each worktree gets its own dev server URL like `web--fix-auth--my-app.localhost`, so parallel agents don't fight for the same port.
|
||||
|
||||
Happy Coder can start an agent in a selected machine path, including an existing Git worktree. It does not currently create or manage the worktree lifecycle.
|
||||
|
||||
@@ -79,21 +79,31 @@ Both products support voice interaction. Paseo can run speech-to-text and text-t
|
||||
|
||||
## Comparison
|
||||
|
||||
| | Paseo | Happy Coder |
|
||||
| ---------------------------- | --------------------------------------------------------------------------------------- | --------------------------- |
|
||||
| License | Open source (Apache-2.0) | Open source (MIT) |
|
||||
| Desktop app | macOS, Linux, Windows | macOS |
|
||||
| Native mobile | iOS, Android | iOS, Android |
|
||||
| Architecture | Daemon owns agent lifecycle | Wraps the agent CLI |
|
||||
| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code + 30+ via ACP catalog + custom | Claude Code, Codex |
|
||||
| Split workspace | Yes | Yes |
|
||||
| In-app terminal | Yes | Yes |
|
||||
| In-app browser / preview | Yes | Yes |
|
||||
| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | — |
|
||||
| Managed Git worktrees | Yes | Existing worktree paths |
|
||||
| Per-worktree dev server URLs | Yes | — |
|
||||
| CLI | Run, `--host`, ls, send, schedule, loop | Launch and control sessions |
|
||||
| Application plugins | Server code and native client components | No |
|
||||
| Voice | Local or configured cloud speech | Yes |
|
||||
| | Paseo | Happy Coder |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
|
||||
| License | Open source (Apache-2.0) | Open source (MIT) |
|
||||
| Account required | No | - |
|
||||
| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS) |
|
||||
| Mobile app | Yes (native, full parity with desktop) | Yes (native) |
|
||||
| CLI | Yes (everything the app does) | Yes (launch and control sessions) |
|
||||
| Remote machines | Yes (install the daemon anywhere) | - |
|
||||
| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | Yes (end-to-end encrypted) |
|
||||
| Direct network access | Yes (LAN, Tailscale, VPN) | - |
|
||||
| SSH access | Yes | - |
|
||||
| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | Claude Code, Codex |
|
||||
| Parallel agents | Yes (isolated worktrees, across machines) | Existing worktree paths, no worktree management |
|
||||
| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | Yes (wraps the agent CLI) |
|
||||
| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | - |
|
||||
| Editor | Yes | - |
|
||||
| Terminals | Yes | Yes |
|
||||
| Diff review | Yes (comments go to the agent) | Yes |
|
||||
| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | - |
|
||||
| In-app browser | Yes (element picker, agent browser tools) | Yes (previews) |
|
||||
| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | - |
|
||||
| Schedules and heartbeats | Yes | - |
|
||||
| Plan usage | Yes | - |
|
||||
| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No |
|
||||
| Voice | Yes (local dictation, realtime voice) | Yes |
|
||||
| Telemetry | None | - |
|
||||
|
||||
See also: [Paseo vs Conductor](/alternatives/conductor), [Paseo vs Superset](/alternatives/superset), [Paseo vs OpenChamber](/alternatives/openchamber).
|
||||
|
||||
@@ -51,26 +51,35 @@ OpenChamber's CLI runs and manages its server, remote access, and updates. Its A
|
||||
|
||||
## Mobile and voice
|
||||
|
||||
Both tools provide iOS and Android clients and support dictation and spoken replies. Paseo uses a React Native mobile interface. OpenChamber packages its web interface with Capacitor and adds native integrations around the WebView.
|
||||
Both tools provide iOS and Android clients and support dictation and spoken replies. The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. OpenChamber packages its web interface with Capacitor and adds native integrations around the WebView.
|
||||
|
||||
## Comparison
|
||||
|
||||
| | Paseo | OpenChamber |
|
||||
| ---------------------------- | --------------------------------------------------------------------------------------- | -------------------------------- |
|
||||
| License | Open source (Apache-2.0) | Open source (MIT) |
|
||||
| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows |
|
||||
| Mobile implementation | React Native | Capacitor WebView |
|
||||
| Agent harnesses | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code + 30+ via ACP catalog + custom | OpenCode |
|
||||
| Application plugins | Server code and native client components | No |
|
||||
| Split panes and tabs | Yes | Workspace views |
|
||||
| In-app terminal | Yes | Yes |
|
||||
| In-app browser | Yes | Yes |
|
||||
| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Issue, PR, checks, review, merge |
|
||||
| Git worktrees | Yes | Yes |
|
||||
| Per-worktree dev server URLs | Yes | Preview and port detection |
|
||||
| Automation | CLI, SDK, MCP | Server CLI, Agent Control Tool |
|
||||
| Schedules | Yes | Yes |
|
||||
| Voice | Local dictation and realtime voice | Dictation and spoken replies |
|
||||
| Self-hosted daemon | Yes | Yes |
|
||||
| | Paseo | OpenChamber |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
|
||||
| License | Open source (Apache-2.0) | Open source (MIT) |
|
||||
| Account required | No | - |
|
||||
| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS, Linux, Windows) |
|
||||
| Mobile app | Yes (native, full parity with desktop) | Yes (Capacitor WebView) |
|
||||
| CLI | Yes (everything the app does) | Yes (runs and manages the server) |
|
||||
| Remote machines | Yes (install the daemon anywhere) | Yes (server on another machine) |
|
||||
| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | - |
|
||||
| Direct network access | Yes (LAN, Tailscale, VPN) | - |
|
||||
| SSH access | Yes | - |
|
||||
| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | OpenCode |
|
||||
| Parallel agents | Yes (isolated worktrees, across machines) | Yes (Git worktrees, multi-run comparisons) |
|
||||
| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | - |
|
||||
| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | Yes (Agent Control Tool, OpenCode agents only) |
|
||||
| Editor | Yes | - |
|
||||
| Terminals | Yes | Yes |
|
||||
| Diff review | Yes (comments go to the agent) | Yes |
|
||||
| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | GitHub (issue, PR, checks, review, merge) |
|
||||
| In-app browser | Yes (element picker, agent browser tools) | Yes (previews) |
|
||||
| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | Preview and port detection |
|
||||
| Schedules and heartbeats | Yes | Yes (schedules) |
|
||||
| Plan usage | Yes | - |
|
||||
| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No |
|
||||
| Voice | Yes (local dictation, realtime voice) | Dictation and spoken replies |
|
||||
| Telemetry | None | - |
|
||||
|
||||
See also: [Paseo vs Conductor](/alternatives/conductor), [Paseo vs Superset](/alternatives/superset), [Paseo vs Happy Coder](/alternatives/happy-coder).
|
||||
|
||||
@@ -43,7 +43,7 @@ Paseo ships on macOS, Linux, and Windows. OpenCode provides beta desktop builds
|
||||
|
||||
## Mobile
|
||||
|
||||
Paseo ships native iOS and Android apps with the same agent workflow as the desktop app.
|
||||
The mobile app is the full app, native on iOS and Android, with full feature parity with desktop.
|
||||
|
||||
OpenCode Desktop is a desktop app. OpenCode also has web and share-link workflows, but not a native mobile app.
|
||||
|
||||
@@ -78,7 +78,7 @@ paseo schedule create --cron "0 9 * * 1" "audit the codebase"
|
||||
|
||||
## Worktrees and services
|
||||
|
||||
Paseo runs each agent in its own Git worktree. Each worktree gets its own dev server URL like `web.fix-auth.my-app.localhost`, so parallel agents don't fight for ports.
|
||||
Paseo runs each agent in its own Git worktree. Each worktree gets its own dev server URL like `web--fix-auth--my-app.localhost`, so parallel agents don't fight for ports.
|
||||
|
||||
OpenCode supports multi-session work on the same project. If you want worktree isolation around OpenCode sessions, Paseo can provide that by launching OpenCode inside Paseo workspaces.
|
||||
|
||||
@@ -94,23 +94,31 @@ Paseo supports dictation and realtime voice mode. Speech-to-text and text-to-spe
|
||||
|
||||
## Comparison
|
||||
|
||||
| | Paseo | OpenCode Desktop |
|
||||
| ---------------------------- | --------------------------------------------------------------------------------------- | ------------------------------- |
|
||||
| License | Open source (Apache-2.0) | Open source (MIT) |
|
||||
| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows |
|
||||
| Native mobile | iOS, Android | No |
|
||||
| Agent harnesses | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code + 30+ via ACP catalog + custom | OpenCode |
|
||||
| Multi-model support | Through supported agent harnesses | Through OpenCode providers |
|
||||
| Git worktrees | Yes | No built-in worktree manager |
|
||||
| Per-worktree dev server URLs | Yes | No |
|
||||
| Split panes and tabs | Yes | Desktop sessions |
|
||||
| In-app terminal | Yes | OpenCode terminal workflow |
|
||||
| In-app browser | Yes | No |
|
||||
| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | GitHub integration |
|
||||
| CLI | Run, `--host`, ls, send, schedule, loop | OpenCode CLI |
|
||||
| MCP server for orchestration | Yes | MCP support inside OpenCode |
|
||||
| Application plugins | Server code and native client components | No |
|
||||
| Local voice | Yes | No |
|
||||
| Self-hosted daemon | Yes | OpenCode server / local runtime |
|
||||
| | Paseo | OpenCode Desktop |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
|
||||
| License | Open source (Apache-2.0) | Open source (MIT) |
|
||||
| Account required | No | - |
|
||||
| Desktop app | Yes (one click install, daemon bundled) | Yes (beta, macOS, Linux, Windows) |
|
||||
| Mobile app | Yes (native, full parity with desktop) | No |
|
||||
| CLI | Yes (everything the app does) | Yes (OpenCode CLI) |
|
||||
| Remote machines | Yes (install the daemon anywhere) | OpenCode server |
|
||||
| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | - |
|
||||
| Direct network access | Yes (LAN, Tailscale, VPN) | - |
|
||||
| SSH access | Yes | - |
|
||||
| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | OpenCode (many model providers) |
|
||||
| Parallel agents | Yes (isolated worktrees, across machines) | Multiple sessions, no built-in worktree manager |
|
||||
| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | - |
|
||||
| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | MCP support inside OpenCode |
|
||||
| Editor | Yes | - |
|
||||
| Terminals | Yes | OpenCode terminal workflow |
|
||||
| Diff review | Yes (comments go to the agent) | - |
|
||||
| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | GitHub, GitLab integrations |
|
||||
| In-app browser | Yes (element picker, agent browser tools) | No |
|
||||
| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | No |
|
||||
| Schedules and heartbeats | Yes | - |
|
||||
| Plan usage | Yes | - |
|
||||
| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No |
|
||||
| Voice | Yes (local dictation, realtime voice) | No |
|
||||
| Telemetry | None | - |
|
||||
|
||||
See also: [Paseo vs Codex App](/alternatives/codex-app), [Paseo vs Claude Desktop](/alternatives/claude-desktop), [Paseo vs OpenChamber](/alternatives/openchamber).
|
||||
|
||||
@@ -17,7 +17,7 @@ Paseo is an app for orchestrating coding agents, with native clients on desktop,
|
||||
|
||||
Orca is built around terminals. Each agent runs in a terminal pane, and an experimental Chat UI can show supported agents as a chat transcript. Orca ships a broad set of built-in tools around those terminals.
|
||||
|
||||
Paseo is built around agent chat. It keeps a small core for starting agents, following their work, giving direction, and reviewing results, and adds everything else through plugins that run on the daemon and every client.
|
||||
Paseo is built around agent chat. The app ships what every developer uses: editor, terminals, diffs, pull requests, and a browser. Features only some teams need, like issue boards or cloud VMs, are plugins you install, so nothing you do not use is in the way.
|
||||
|
||||
Orca is made by Stably AI, a venture-funded company (Y Combinator). Paseo is independent and open source.
|
||||
|
||||
@@ -31,7 +31,7 @@ Orca runs inside its desktop app by default and reaches other machines in two wa
|
||||
|
||||
Both tools have iOS and Android apps.
|
||||
|
||||
Paseo ships native apps on the App Store and Google Play. To connect from your phone, enable the relay and scan a QR code. The daemon connects outbound to the relay and your phone meets it there, so you do not need a VPN, port forwarding, SSH, or an account. Traffic is end-to-end encrypted, and the [relay server](https://github.com/getpaseo/paseo-relay) is open source. You can also connect directly over Tailscale.
|
||||
The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. They are on the App Store and Google Play. To connect from your phone, enable the relay and scan a QR code. The daemon connects outbound to the relay and your phone meets it there, so you do not need a VPN, port forwarding, SSH, or an account. Traffic is end-to-end encrypted, and the [relay server](https://github.com/getpaseo/paseo-relay) is open source. You can also connect directly over Tailscale.
|
||||
|
||||
Orca's mobile companion is in beta, on the App Store for iOS and as an APK download for Android. It can reply to agents, open sessions as a terminal or Chat UI, commit changes, and create workspaces on the paired desktop. Orca Relay, also in beta, connects the phone when it is not on the same network as the desktop and requires signing in to an Orca account. Without Orca Relay, the phone needs a LAN or Tailscale path to the desktop.
|
||||
|
||||
@@ -51,7 +51,7 @@ Paseo runs Claude Code, Codex, OpenCode, Pi, Antigravity, and Muse Code through
|
||||
|
||||
[Paseo plugins](/docs/plugins) extend Paseo itself. A plugin can add workspace panels, sidebar items, Command Center items, slash commands, composer pills, settings screens, themes, attachment sources, and agent providers. Its server code runs beside the daemon, and its client code runs in every connected client: desktop, browser, iOS, and Android.
|
||||
|
||||
Community plugins are listed at [paseo.cafe](https://paseo.cafe).
|
||||
Community plugins are listed at [paseo.sh/plugins](/plugins).
|
||||
|
||||
Orca has an experimental plugin system that is off by default. Orca plugins can add desktop panels rendered in a sandboxed frame, commands, keybindings, language packs, VM recipes, and agent profiles, and can be installed from git marketplaces. Plugin workers run on the desktop computer, and Orca does not document plugin surfaces in its mobile app.
|
||||
|
||||
@@ -59,13 +59,13 @@ Orca has an experimental plugin system that is off by default. Orca plugins can
|
||||
|
||||
Orca includes many tools in the core app: a Monaco code editor, Design Mode for sending page elements from its browser to an agent, computer use, Linear and Jira boards, agent account switching with usage tracking, and per-workspace cloud VM recipes. If you want these in one app without extensions, Orca provides them.
|
||||
|
||||
Paseo keeps a smaller core. A capability that serves a specialized workflow is meant to be a plugin, so the default app stays short to learn and each plugin can be maintained on its own schedule.
|
||||
Paseo includes an editor, terminals, diff review with comments that go to the agent, pull requests on GitHub, GitLab, Gitea, Forgejo, and Codeberg, a browser with an element picker and agent browser tools, plan usage, schedules, and heartbeats. Boards and cloud VMs are what a plugin adds.
|
||||
|
||||
## Workspaces and review
|
||||
|
||||
Both tools provide Git worktrees, split panes, terminals, diff review, an in-app browser, pull-request workflows, and scheduled agent runs.
|
||||
|
||||
Paseo also gives each worktree its own service URL, such as `web.fix-auth.my-app.localhost`, so parallel development servers do not compete for ports. Orca adds diff annotations that you send back to the agent, port forwarding for SSH worktrees, and pull requests on GitLab, Bitbucket, Azure DevOps, and Gitea as well as GitHub.
|
||||
Paseo has diff review with comments that go to the agent and opens pull requests on GitHub, GitLab, Gitea, Forgejo, and Codeberg. It also gives each worktree its own service URL, such as `web--fix-auth--my-app.localhost`, so parallel development servers do not compete for ports. Orca has port forwarding for SSH worktrees and pull requests on GitLab, Bitbucket, Azure DevOps, and Gitea as well as GitHub.
|
||||
|
||||
## Automation
|
||||
|
||||
@@ -89,24 +89,31 @@ Orca does not require an account for local use. Orca Relay and shared artifacts
|
||||
|
||||
## Comparison
|
||||
|
||||
| | Paseo | Orca |
|
||||
| ---------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------- |
|
||||
| License | Open source (Apache-2.0) | Open source (MIT) |
|
||||
| Funding | Independent | Venture-funded (Y Combinator) |
|
||||
| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows |
|
||||
| Native mobile | iOS, Android | iOS, Android APK (beta) |
|
||||
| Main interface | Agent chat | Terminal, with experimental Chat UI |
|
||||
| Remote machines | Daemon on any machine, direct, SSH, or relay | SSH worktrees, Remote Orca Server |
|
||||
| Relay | Optional, end-to-end encrypted, no account | Beta, Orca account required |
|
||||
| Agent harnesses | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code + 30+ via ACP catalog + custom | Any CLI agent in a terminal |
|
||||
| Application plugins | Server code and native client components on every client | Experimental, desktop only |
|
||||
| In-app terminal | Yes | Yes |
|
||||
| In-app browser | Yes | Yes, with Design Mode |
|
||||
| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Yes, plus GitLab, Bitbucket, and more |
|
||||
| Git worktrees | Yes | Yes |
|
||||
| Per-worktree dev server URLs | Yes | Port forwarding for SSH worktrees |
|
||||
| Automation | CLI, SDK, MCP | CLI, automations |
|
||||
| Voice | Local dictation and realtime voice | Dictation |
|
||||
| Telemetry | None | Anonymous usage data, opt-out |
|
||||
| | Paseo | Orca |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| License | Open source (Apache-2.0) | Open source (MIT) |
|
||||
| Account required | No | No for local use, Orca account for relay |
|
||||
| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS, Linux, Windows) |
|
||||
| Mobile app | Yes (native, full parity with desktop) | Yes (companion app, beta) |
|
||||
| CLI | Yes (everything the app does) | Yes (worktrees, browser, automations) |
|
||||
| Remote machines | Yes (install the daemon anywhere) | Yes (SSH worktrees, Remote Orca Server in beta) |
|
||||
| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | Yes (beta, Orca account required) |
|
||||
| Direct network access | Yes (LAN, Tailscale, VPN) | Yes (LAN, Tailscale) |
|
||||
| SSH access | Yes | Yes (SSH worktrees) |
|
||||
| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | Any CLI agent in a terminal, 30+ listed |
|
||||
| Parallel agents | Yes (isolated worktrees, across machines) | Yes (Git worktrees) |
|
||||
| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | Yes (main interface, experimental Chat UI) |
|
||||
| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | Yes (agents call the Orca CLI through installed skills) |
|
||||
| Editor | Yes | Yes (Monaco) |
|
||||
| Terminals | Yes | Yes |
|
||||
| Diff review | Yes (comments go to the agent) | Yes |
|
||||
| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | GitHub, GitLab, Bitbucket, Azure DevOps, Gitea |
|
||||
| In-app browser | Yes (element picker, agent browser tools) | Yes (Design Mode) |
|
||||
| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | Port forwarding for SSH worktrees |
|
||||
| Schedules and heartbeats | Yes | Yes (scheduled agent runs) |
|
||||
| Plan usage | Yes | Yes (usage tracking, account switching) |
|
||||
| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | Experimental, desktop only |
|
||||
| Voice | Yes (local dictation, realtime voice) | Dictation |
|
||||
| Telemetry | None | Anonymous usage data, opt-out |
|
||||
|
||||
See also: [Paseo vs Conductor](/alternatives/conductor), [Paseo vs Superset](/alternatives/superset), [Paseo vs OpenChamber](/alternatives/openchamber).
|
||||
|
||||
@@ -51,7 +51,7 @@ Both tools provide Git worktrees, split panes, terminals, diff review, an in-app
|
||||
|
||||
Paseo gives supported providers a structured chat interface with modes, slash commands, tool calls, and file attachments. Superset can run any CLI agent in terminal panes and adds lifecycle status for supported agents.
|
||||
|
||||
Paseo also gives each worktree its own service URL, such as `web.fix-auth.my-app.localhost`, so parallel development servers do not compete for ports.
|
||||
Paseo also gives each worktree its own service URL, such as `web--fix-auth--my-app.localhost`, so parallel development servers do not compete for ports.
|
||||
|
||||
## Automation
|
||||
|
||||
@@ -65,23 +65,31 @@ Superset has a free individual tier. Team features, remote access, and integrati
|
||||
|
||||
## Comparison
|
||||
|
||||
| | Paseo | Superset |
|
||||
| ---------------------------- | ---------------------------------------- | -------------------------------------- |
|
||||
| License | Open source (Apache-2.0) | Source-available (Elastic License 2.0) |
|
||||
| Desktop platforms | macOS, Linux, Windows | macOS, experimental Linux |
|
||||
| Native mobile | iOS, Android | Coming soon |
|
||||
| Account required | No | Yes, with GitHub sign-in |
|
||||
| Agent harnesses | Native, ACP, and custom CLI | CLI agents |
|
||||
| Application plugins | Server code and native client components | No |
|
||||
| Split panes and tabs | Yes | Yes |
|
||||
| In-app terminal | Yes | Yes |
|
||||
| In-app browser | Yes | Yes |
|
||||
| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Yes |
|
||||
| Git worktrees | Yes | Yes |
|
||||
| Per-worktree dev server URLs | Yes | Port detection |
|
||||
| Automation | CLI, SDK, MCP | CLI, SDK, MCP |
|
||||
| Schedules | Yes | Yes |
|
||||
| Local voice | Dictation and realtime voice | No documented voice support |
|
||||
| Self-hosted daemon | Yes | Host server |
|
||||
| | Paseo | Superset |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| License | Open source (Apache-2.0) | Source-available (Elastic License 2.0) |
|
||||
| Account required | No | Yes, with GitHub sign-in |
|
||||
| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS, experimental Linux) |
|
||||
| Mobile app | Yes (native, full parity with desktop) | Coming soon |
|
||||
| CLI | Yes (everything the app does) | Yes (plus SDK and MCP server) |
|
||||
| Remote machines | Yes (install the daemon anywhere) | Yes (host server, remote access through Superset cloud on paid plans) |
|
||||
| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | Superset cloud services (paid plans) |
|
||||
| Direct network access | Yes (LAN, Tailscale, VPN) | - |
|
||||
| SSH access | Yes | - |
|
||||
| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | CLI agents, custom terminal agents |
|
||||
| Parallel agents | Yes (isolated worktrees, across machines) | Yes (Git worktrees) |
|
||||
| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | Yes (lifecycle status for supported agents) |
|
||||
| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | Yes (CLI, SDK, and MCP server create workspaces and launch agents) |
|
||||
| Editor | Yes | - |
|
||||
| Terminals | Yes | Yes |
|
||||
| Diff review | Yes (comments go to the agent) | Yes |
|
||||
| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | GitHub |
|
||||
| In-app browser | Yes (element picker, agent browser tools) | Yes |
|
||||
| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | Port detection |
|
||||
| Schedules and heartbeats | Yes | Yes (schedules) |
|
||||
| Plan usage | Yes | - |
|
||||
| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No |
|
||||
| Voice | Yes (local dictation, realtime voice) | - |
|
||||
| Telemetry | None | - |
|
||||
|
||||
See also: [Paseo vs Conductor](/alternatives/conductor), [Paseo vs OpenChamber](/alternatives/openchamber), [Paseo vs Happy Coder](/alternatives/happy-coder).
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type ReactNode, useMemo } from "react";
|
||||
import { type Author, authorAvatarUrl } from "./registry";
|
||||
|
||||
const SIZE_CLASS = {
|
||||
xs: "h-4 w-4 text-[8px]",
|
||||
sm: "h-5 w-5 text-[10px]",
|
||||
lg: "h-14 w-14 text-xl",
|
||||
} as const;
|
||||
|
||||
@@ -3,8 +3,10 @@ import { SiteShell } from "~/components/site-shell";
|
||||
import { ContributeLinks } from "./contribute-links";
|
||||
import type { InstallCounts } from "./installs";
|
||||
import { type BrowseQuery, browseHref } from "./links";
|
||||
import { InstallCount } from "./install-count";
|
||||
import { PLUGIN_GRID_CLASS, PluginCard } from "./plugin-card";
|
||||
import { PluginSearch } from "./plugin-search";
|
||||
import { PluginSection } from "./section";
|
||||
import {
|
||||
addedAgo,
|
||||
CATEGORIES,
|
||||
@@ -45,17 +47,18 @@ export function BrowsePage({
|
||||
query: BrowseQuery;
|
||||
}) {
|
||||
const category = query.category ? getCategory(query.category) : null;
|
||||
const matches = query.q ? searchPlugins(plugins, query.q) : plugins;
|
||||
const scoped = category ? getPluginsInCategory(matches, category.slug) : matches;
|
||||
const results =
|
||||
query.sort === "new" ? newestFirst(scoped) : mostInstalled(scoped, installs, query.window);
|
||||
const ordered =
|
||||
query.sort === "new" ? newestFirst(plugins) : mostInstalled(plugins, installs, query.window);
|
||||
// Search ranks by relevance; the tab's order breaks ties.
|
||||
const matches = query.q ? searchPlugins(ordered, query.q) : ordered;
|
||||
const results = category ? getPluginsInCategory(matches, category.slug) : matches;
|
||||
const title = category?.label ?? "All plugins";
|
||||
const clearHref = browseHref({ ...query, q: undefined });
|
||||
return (
|
||||
<SiteShell width="wide">
|
||||
<div className="lg:flex lg:gap-12">
|
||||
<aside className="mb-8 lg:mb-0 lg:w-48 lg:flex-shrink-0">
|
||||
<PluginSearch scope={query} className="mb-4 lg:mb-6" />
|
||||
<PluginSearch scope={query} live className="mb-4 lg:mb-6" />
|
||||
<CategoryNav plugins={matches} query={query} />
|
||||
</aside>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -63,7 +66,7 @@ export function BrowsePage({
|
||||
<h1 className="text-3xl font-medium tracking-tight">
|
||||
{query.q ? `Results for “${query.q}”${category ? ` in ${title}` : ""}` : title}
|
||||
<span className="ml-3 align-middle text-sm font-normal tabular-nums text-extra-muted-foreground">
|
||||
{scoped.length}
|
||||
{results.length}
|
||||
</span>
|
||||
</h1>
|
||||
{query.q && (
|
||||
@@ -72,36 +75,38 @@ export function BrowsePage({
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<SortTabs query={query} />
|
||||
{results.length === 0 && query.q && (
|
||||
<div className="mt-8 rounded-xl border border-white/10 bg-white/[0.03] px-4 py-10 text-center">
|
||||
<p className="text-sm text-muted-foreground">No plugins match.</p>
|
||||
<a
|
||||
href={clearHref}
|
||||
className="mt-2 inline-block text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"
|
||||
>
|
||||
Clear filters
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{results.length === 0 && !query.q && (
|
||||
<p className="mt-8 text-sm text-muted-foreground">No plugins in this category yet.</p>
|
||||
)}
|
||||
{results.length > 0 && (
|
||||
<div className={`mt-8 ${PLUGIN_GRID_CLASS}`}>
|
||||
{results.map((plugin) =>
|
||||
query.sort === "new" ? (
|
||||
<PluginCard key={plugin.id} plugin={plugin} added={addedAgo(plugin, now)} />
|
||||
) : (
|
||||
<PluginCard
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
installs={installs[plugin.id]?.[query.window] ?? 0}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<PluginSection>
|
||||
<SortTabs query={query} />
|
||||
{results.length === 0 && query.q && (
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.03] px-4 py-10 text-center">
|
||||
<p className="text-sm text-muted-foreground">No plugins match.</p>
|
||||
<a
|
||||
href={clearHref}
|
||||
className="mt-2 inline-block text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"
|
||||
>
|
||||
Clear filters
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{results.length === 0 && !query.q && (
|
||||
<p className="text-sm text-muted-foreground">No plugins in this category yet.</p>
|
||||
)}
|
||||
{results.length > 0 && (
|
||||
<div className={PLUGIN_GRID_CLASS}>
|
||||
{results.map((plugin) =>
|
||||
query.sort === "new" ? (
|
||||
<PluginCard key={plugin.id} plugin={plugin}>
|
||||
{addedAgo(plugin, now)}
|
||||
</PluginCard>
|
||||
) : (
|
||||
<PluginCard key={plugin.id} plugin={plugin}>
|
||||
<InstallCount count={installs[plugin.id]?.[query.window] ?? 0} />
|
||||
</PluginCard>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PluginSection>
|
||||
</div>
|
||||
</div>
|
||||
</SiteShell>
|
||||
@@ -186,7 +191,7 @@ function SortTabs({ query }: { query: BrowseQuery }) {
|
||||
<WindowSwitch current={query.window} hrefs={windowHrefs} />
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between gap-6 border-b border-white/10">
|
||||
<div className="flex gap-6">
|
||||
<a
|
||||
@@ -207,6 +212,6 @@ function SortTabs({ query }: { query: BrowseQuery }) {
|
||||
{windowSwitch && <div className="hidden pb-2 sm:block">{windowSwitch}</div>}
|
||||
</div>
|
||||
{windowSwitch && <div className="mt-4 sm:hidden">{windowSwitch}</div>}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user