Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] dcf6d0ac71 style: format reproduction test 2026-10-07 05:49:32 +02:00
paseo-bot[bot] 292d864f0e test(pi): reproduce empty Tintinweb foreground subagent transcript (#5828)
Failing reproduction only. The captured @tintinweb/pi-subagents 0.19.0
foreground Agent result has no Output file line, so the adapter hands
Paseo no child transcript to read.
2026-10-07 05:49:28 +02:00
paseo-bot[bot] 8f1091da64 Let a Claude agent leave Auto mode when Claude Code uses Bedrock or Vertex (#6272)
* fix(claude): let an agent leave Auto mode when Claude Code uses Bedrock or Vertex

Changing the mode of a Claude agent whose process is not running starts the
process first. It was started in the agent's current mode, so an agent in Auto
on Bedrock or Vertex failed the Auto check before the new mode applied, and
every mode change and message failed the same way.

Start the process in the requested mode instead.

Closes #5878

* fix(claude): relaunch a query whose mode change Claude Code rejected

A mode change that starts the query now launches it in the requested mode. If
Claude Code then rejects the mode, the agent keeps its previous mode, so mark
that query for relaunch instead of reusing it in the rejected mode.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-07 04:55:01 +02:00
Bae Seongmin 4dd0e50e78 fix(server): time out stalled encrypted relay handshakes (#5648) 2026-10-07 03:53:18 +02:00
paseo-bot[bot] dc785bb29a Keep both subagent cards labeled when Claude launches two background agents together (#6268)
* fix(server): keep a subagent card labeled when two background agents launch together

The daemon's stream coalescer kept only the last update per tool call
inside its 60 ms window. When Claude launches two background Agent calls
in one message, the first child's task_started arrives a few ms after the
second call starts streaming, so its labeled card is buffered and then
overwritten by the unlabeled completed Agent update. The coalescer now
folds updates with the same merge the timeline projection applies, so a
labeled detail is not replaced by an unknown one.

Closes #6252

* refactor(server): compute the merged tool call before building the coalescer entry

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-07 02:54:09 +02:00
Mohamed Boudra 93bfb34e21 feat(website): copy states what Paseo does, plugins section and comparison tables (#6270)
The homepage, README, sponsor copy and comparison pages now say plainly
what Paseo is: a one-click desktop app with the daemon bundled, a native
mobile app with full parity, parallel agents in worktrees, and plugins
that add panels and functionality on desktop, web and mobile.

Every comparison page uses one table with the same rows so readers can
compare products row by row. The product columns use a fixed layout so
column widths no longer follow the longest cell.

Sponsor copy opens with the project's size before the one-person fact,
so a reader does not take "one person" to mean a side project.
2026-10-07 00:59:14 +01:00
Mohamed Boudra 2d85fe4e04 feat(plugins): declare display metadata in plugin manifests (#6263)
* feat(plugins): declare display metadata in plugin manifests

* style(website): format plugin page description

* fix(plugins): preserve default names and clarify registry follow-up

* test: fix stale plugin card selectors and terminal marker race
2026-10-06 23:18:24 +01:00
Mohamed Boudra 1463396ebc fix(website): plugin search ranks name matches first
Search filtered by substring and the browse page then sorted by installs,
so an exact name like "OMP" landed behind plugins that only mentioned it
in a description. Results now rank by the field matched (name, author,
description), then match quality from the shared text matcher, with the
tab's order breaking ties.
2026-10-06 23:50:27 +02:00
Mohamed Boudra d4ea77b910 fix(website): plugin install command block fits the command 2026-10-06 23:26:35 +02:00
Mohamed Boudra f715221de8 feat(website): plugins page copy describes browsing community plugins 2026-10-06 23:26:35 +02:00
Mohamed Boudra aedba5ec27 fix(website): plugin page content spans the same width as the header and footer
The page capped itself at max-w-3xl inside the site shell, so the install command, gallery, and README stopped short of the nav's right edge.
2026-10-06 23:19:16 +02:00
Mohamed Boudra bdc58fb07c feat(website): plugins hero, author and description on cards, install counts with an icon
The plugins page opens with the directory copy, plugin and author counts,
Submit a plugin and Read the docs, and search. Featured and What's new cards
show the description and the author with avatar; the trailing slot carries
installs on Featured and the added date on What's new. Every install count
on the site is the same download icon and number.
2026-10-06 23:18:20 +02:00
50 changed files with 1082 additions and 585 deletions
+19 -9
View File
@@ -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 -->
@@ -93,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}` },
},
);
+11 -1
View File
@@ -206,7 +206,17 @@ 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`],
@@ -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({
+3
View File
@@ -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(),
@@ -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 } : {}),
};
@@ -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",
});
@@ -2441,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;
@@ -3128,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;
}
@@ -3172,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(
@@ -3298,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(
@@ -3307,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(
@@ -3330,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.
@@ -1,7 +1,10 @@
import { describe, expect, test } from "vitest";
import { fileURLToPath } from "node:url";
import { createPiExtensionHost } from "../index.js";
import { readSubagentFixture, verifySubagentFixture } from "../subagent-fixture-test.js";
import {
readSubagentFixture,
verifySubagentFixture,
} from "../subagent-fixture-test.js";
describe("@tintinweb/pi-subagents adapter", () => {
test("exposes the background output file while the child is running", () => {
@@ -13,51 +16,105 @@ describe("@tintinweb/pi-subagents adapter", () => {
result: {
details: { agentId: "native-1", status: "background" },
content: [
{ type: "text", text: "Agent started in background.\nOutput file: /tmp/child.output\n" },
{
type: "text",
text: "Agent started in background.\nOutput file: /tmp/child.output\n",
},
],
},
});
expect(mapping?.subagents).toEqual([
expect.objectContaining({ id: "call-1", status: "running" }),
]);
expect(mapping?.childSessions).toEqual([{ id: "call-1", file: "/tmp/child.output" }]);
expect(mapping?.childSessions).toEqual([
{ id: "call-1", file: "/tmp/child.output" },
]);
});
test("maps captured foreground lifecycle live and on replay", async () => {
const events = await verifySubagentFixture(
readSubagentFixture(new URL("./fixtures/foreground.json", import.meta.url)),
readSubagentFixture(
new URL("./fixtures/foreground.json", import.meta.url)
)
);
expect(
events
.filter((event) => event.event.type === "upsert")
.map((event) => (event.event.type === "upsert" ? event.event.status : null)),
.map((event) =>
event.event.type === "upsert" ? event.event.status : null
)
).toEqual(["running", "completed"]);
});
// #5828: the extension writes a transcript for foreground agents too, but its
// foreground result carries no `Output file:` line, so the child opens empty.
test("exposes the foreground child's transcript when it completes", () => {
const fixture = readSubagentFixture(
new URL("./fixtures/foreground.json", import.meta.url)
);
const end = fixture.events.find(
(event) => event.type === "tool_execution_end"
) as {
toolCallId: string;
result: Parameters<
ReturnType<typeof createPiExtensionHost>["mapToolCall"]
>[0]["result"];
};
const mapping = createPiExtensionHost().mapToolCall({
callId: end.toolCallId,
toolName: "Agent",
args: {
subagent_type: "general-purpose",
prompt: "list the files in this empty workspace",
},
status: "completed",
result: end.result,
});
expect(mapping?.subagents).toEqual([
expect.objectContaining({ id: end.toolCallId, status: "completed" }),
]);
expect(mapping?.childSessions).toEqual([
{ id: end.toolCallId, file: expect.any(String) },
]);
});
test("uses a structured notification to complete a background child", async () => {
const source = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url));
const source = readSubagentFixture(
new URL("./fixtures/background.json", import.meta.url)
);
const file = (
source.messages.find((message) => message.role === "custom") as {
details: { outputFile: string };
}
).details.outputFile;
const fixture = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url), {
from: file,
to: fileURLToPath(new URL("./fixtures/child-session.jsonl", import.meta.url)),
});
const fixture = readSubagentFixture(
new URL("./fixtures/background.json", import.meta.url),
{
from: file,
to: fileURLToPath(
new URL("./fixtures/child-session.jsonl", import.meta.url)
),
}
);
const events = await verifySubagentFixture(fixture);
expect(
events
.filter((event) => event.event.type === "upsert")
.map((event) => (event.event.type === "upsert" ? event.event.status : null)),
.map((event) =>
event.event.type === "upsert" ? event.event.status : null
)
).toEqual(["running", "running", "completed"]);
expect(events.filter((event) => event.event.type === "timeline").length).toBeGreaterThan(0);
expect(
events.filter((event) => event.event.type === "timeline").length
).toBeGreaterThan(0);
});
test("completes every background child in a grouped notification", async () => {
const events = await verifySubagentFixture(
readSubagentFixture(new URL("./fixtures/background-group.json", import.meta.url)),
readSubagentFixture(
new URL("./fixtures/background-group.json", import.meta.url)
)
);
const finalStatus = new Map<string, string>();
for (const { event } of events) {
if (event.type === "upsert" && event.status) finalStatus.set(event.id, event.status);
if (event.type === "upsert" && event.status)
finalStatus.set(event.id, event.status);
}
expect(Object.fromEntries(finalStatus)).toEqual({
call_277173: "completed",
@@ -73,7 +130,7 @@ describe("@tintinweb/pi-subagents adapter", () => {
args: { prompt: "foo" },
status: "completed",
result: { details: { agentId: "other", status: "completed" } },
}),
})
).toBeUndefined();
});
});
@@ -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" }> {
@@ -1,115 +0,0 @@
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { expect, test } from "vitest";
import { OpenCodeV2AgentClient } from "../agent/providers/opencode/v2/agent.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
// Reproduction for #6006. Needs an OpenCode 2.x binary in OPENCODE_V2_BIN.
const OPENCODE_V2_BIN = process.env.OPENCODE_V2_BIN;
const PROVIDER = "paseo-catalog-drift";
const KEPT_MODEL = "kept-model";
const RETIRED_MODEL = "retired-model";
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",
models: Object.fromEntries(
modelIds.map((id) => [
id,
{
id,
name: id,
attachment: false,
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: 128_000, output: 8_000 },
cost: { input: 0, output: 0 },
},
]),
),
},
};
}
test.runIf(OPENCODE_V2_BIN)(
"a model OpenCode dropped from its catalog stays in the picker and fails to apply",
async () => {
const logger = pino({ level: "silent" });
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "opencode-v2-catalog-drift-")));
const modelsPath = path.join(root, "models.json");
const cwd = path.join(root, "work");
mkdirSync(cwd);
writeFileSync(modelsPath, JSON.stringify(catalog([KEPT_MODEL, RETIRED_MODEL])));
const provider = new OpenCodeV2AgentClient({
logger,
settings: {
command: { mode: "replace", argv: [OPENCODE_V2_BIN!] },
env: {
OPENCODE_MODELS_PATH: modelsPath,
OPENCODE_DISABLE_MODELS_FETCH: "1",
OPENCODE_DISABLE_AUTO_UPDATE: "1",
PASEO_CATALOG_DRIFT_KEY: "test",
XDG_CONFIG_HOME: path.join(root, "config"),
XDG_DATA_HOME: path.join(root, "data"),
XDG_CACHE_HOME: path.join(root, "cache"),
},
},
});
const daemon = await createTestPaseoDaemon({ agentClients: { opencode: provider }, logger });
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
const before = await client.listProviderModels("opencode", { cwd });
expect(before.models?.map((entry) => entry.id)).toContain(`${PROVIDER}/${RETIRED_MODEL}`);
// OpenCode's catalog drops the model while Paseo sits idle.
writeFileSync(modelsPath, JSON.stringify(catalog([KEPT_MODEL])));
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "catalog drift",
model: `${PROVIDER}/${KEPT_MODEL}`,
modeId: "build",
});
const listed = await client.listProviderModels("opencode", { cwd });
const offered = listed.models?.map((entry) => entry.id) ?? [];
let switchError: string | null = null;
if (offered.includes(`${PROVIDER}/${RETIRED_MODEL}`)) {
try {
await client.setAgentModel(agent.id, `${PROVIDER}/${RETIRED_MODEL}`);
} catch (error) {
switchError = error instanceof Error ? error.message : String(error);
}
}
// Every model the picker offers must be one OpenCode accepts.
expect({ offered, switchError }).toEqual({
offered: [`${PROVIDER}/${KEPT_MODEL}`],
switchError: null,
});
} finally {
await client.close().catch(() => undefined);
await daemon.close().catch(() => undefined);
rmSync(root, { recursive: true, force: true });
}
},
180_000,
);
@@ -419,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 () => () => {};");
@@ -427,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"),
@@ -443,11 +449,23 @@ 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();
});
+6 -1
View File
@@ -216,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);
@@ -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);
+27 -8
View File
@@ -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 () => {
+15 -9
View File
@@ -8,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,
@@ -346,7 +353,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",
);
@@ -508,11 +515,7 @@ test.describe("registry fixture layout", () => {
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(featured.getByRole("link", { name: /Added/ })).toHaveText([
/Dracula/,
/Herald/,
/launchd Jobs/,
]);
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 ({
@@ -524,9 +527,12 @@ 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/,
]);
});
});
@@ -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>
@@ -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;
+13 -11
View File
@@ -3,6 +3,7 @@ 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";
@@ -46,10 +47,11 @@ 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 (
@@ -64,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 && (
@@ -93,13 +95,13 @@ export function BrowsePage({
<div className={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}>
{addedAgo(plugin, now)}
</PluginCard>
) : (
<PluginCard
key={plugin.id}
plugin={plugin}
installs={installs[plugin.id]?.[query.window] ?? 0}
/>
<PluginCard key={plugin.id} plugin={plugin}>
<InstallCount count={installs[plugin.id]?.[query.window] ?? 0} />
</PluginCard>
),
)}
</div>
@@ -21,7 +21,21 @@ export function ContributeLinks() {
);
}
/** Closing section of the directory: what a plugin can do, with Read the docs and Submit buttons. */
/** "Submit a plugin" and "Read the docs", as a primary and a secondary button. */
export function ContributeButtons() {
return (
<>
<a href={SUBMIT_URL} className={PRIMARY_BUTTON_CLASS}>
Submit a plugin
</a>
<a href={DOCS_URL} className={SECONDARY_BUTTON_CLASS}>
Read the docs
</a>
</>
);
}
/** Closing section of the directory: what a plugin can do, with Submit and Read the docs buttons. */
export function ContributeSection() {
return (
<section
@@ -36,12 +50,7 @@ export function ContributeSection() {
with the plugin docs, then submit it to get it listed here.
</p>
<div className="mt-6 flex flex-wrap justify-center gap-3">
<a href={DOCS_URL} className={PRIMARY_BUTTON_CLASS}>
Read the docs
</a>
<a href={SUBMIT_URL} className={SECONDARY_BUTTON_CLASS}>
Submit a plugin
</a>
<ContributeButtons />
</div>
</section>
);
+37
View File
@@ -0,0 +1,37 @@
import { ContributeButtons } from "./contribute-links";
import type { BrowseQuery } from "./links";
import { PluginSearch } from "./plugin-search";
interface PluginsHeroProps {
pluginCount: number;
authorCount: number;
/** Where the search box sends its term. */
searchScope: BrowseQuery;
}
function plural(count: number, word: string): string {
return `${count} ${word}${count === 1 ? "" : "s"}`;
}
/** Header of the plugin directory: title, counts, and copy, then the contribute buttons and search. */
export function PluginsHero({ pluginCount, authorCount, searchScope }: PluginsHeroProps) {
return (
<header>
<h1 className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="text-3xl font-medium tracking-tight">Plugins</span>
<span className="text-sm font-normal tabular-nums text-extra-muted-foreground">
{plural(pluginCount, "plugin")} by {plural(authorCount, "author")}
</span>
</h1>
<p className="mt-3 max-w-xl text-sm leading-relaxed text-muted-foreground">
Browse community plugins that extend Paseo with new functionality.
</p>
<div className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap gap-3">
<ContributeButtons />
</div>
<PluginSearch scope={searchScope} className="w-full sm:w-64" />
</div>
</header>
);
}
@@ -0,0 +1,13 @@
import { Download } from "lucide-react";
import { formatInstalls } from "./registry";
/** An install count as a download icon and the number; inherits the surrounding text style. */
export function InstallCount({ count }: { count: number }) {
return (
<span className="inline-flex items-center gap-1 tabular-nums">
<Download aria-hidden className="h-3 w-3" />
{formatInstalls(count)}
<span className="sr-only">installs</span>
</span>
);
}
+38 -42
View File
@@ -1,8 +1,10 @@
import { Download } from "lucide-react";
import type { ReactNode } from "react";
import { AuthorAvatar } from "./author-link";
import { InstallCount } from "./install-count";
import { pluginCardScreenshot } from "./thumbnails";
import { pluginHref } from "./links";
import { PluginTile } from "./plugin-tile";
import { firstMediaImage, formatInstalls, getCategory, type Plugin } from "./registry";
import { firstMediaImage, getAuthor, getCategory, type Plugin } from "./registry";
export const PLUGIN_GRID_CLASS =
"grid gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4";
@@ -32,57 +34,49 @@ function PluginShot({ plugin }: { plugin: Plugin }) {
);
}
/** What's new card: screenshot, name, and when it was added. */
export function NewPluginCard({ plugin, added }: { plugin: Plugin; added: string }) {
/**
* What's new and Featured card, in three rows under the screenshot: tile and name, description,
* then author and `children`, such as when the plugin was added or its install count.
*/
export function NewPluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) {
const author = getAuthor(plugin);
return (
<a href={pluginHref(plugin.id)} className="group block w-[70%] flex-shrink-0 sm:w-auto">
<PluginShot plugin={plugin} />
<div className="mt-3 flex items-center gap-2.5">
<PluginTile plugin={plugin} size="sm" />
<div className="min-w-0">
<p className="truncate text-sm font-medium text-white">{plugin.name}</p>
<p className="truncate text-xs text-extra-muted-foreground">Added {added}</p>
</div>
<div className="mt-3 flex items-center gap-2">
<PluginTile plugin={plugin} size="xs" />
<p className="min-w-0 truncate text-sm font-medium text-white">{plugin.name}</p>
</div>
<p className="mt-2 line-clamp-2 min-h-10 text-xs leading-5 text-muted-foreground">
{plugin.description}
</p>
<p className="mt-2 flex min-w-0 items-center gap-1.5 text-xs text-extra-muted-foreground">
<AuthorAvatar author={author} size="xs" />
<span className="truncate">{author.name}</span>
{children !== undefined && (
<>
<span aria-hidden>·</span>
<span className="flex-shrink-0">{children}</span>
</>
)}
</p>
</a>
);
}
/** Grid card: screenshot, name, one muted stat, and a two-line description. */
export function PluginCard({
plugin,
installs,
added,
}: {
plugin: Plugin;
/** Shown with a download icon; otherwise `added` is shown. */
installs?: number;
added?: string;
}) {
/** Grid card: screenshot; tile, name, and `children` such as an install count; description. */
export function PluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) {
return (
<a href={pluginHref(plugin.id)} className="group block">
<PluginShot plugin={plugin} />
<div className="mt-3 flex items-start gap-2.5">
<PluginTile plugin={plugin} size="sm" />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<p className="truncate text-sm font-medium text-white">{plugin.name}</p>
<span className="inline-flex flex-shrink-0 items-center gap-1 text-xs tabular-nums text-extra-muted-foreground">
{installs === undefined ? (
added
) : (
<>
<Download className="h-3 w-3" />
{formatInstalls(installs)}
</>
)}
</span>
</div>
<p className="mt-0.5 line-clamp-2 min-h-10 text-xs leading-5 text-muted-foreground">
{plugin.description}
</p>
</div>
<div className="mt-3 flex items-center gap-2">
<PluginTile plugin={plugin} size="xs" />
<p className="min-w-0 flex-1 truncate text-sm font-medium text-white">{plugin.name}</p>
<span className="flex-shrink-0 text-xs text-extra-muted-foreground">{children}</span>
</div>
<p className="mt-2 line-clamp-2 min-h-10 text-xs leading-5 text-muted-foreground">
{plugin.description}
</p>
</a>
);
}
@@ -110,7 +104,9 @@ export function PluginRankRow({
{getCategory(plugin.categories[0])?.label}
</p>
</div>
<span className="text-xs tabular-nums text-muted-foreground">{formatInstalls(installs)}</span>
<span className="text-xs text-muted-foreground">
<InstallCount count={installs} />
</span>
</a>
);
}
@@ -2,6 +2,7 @@ import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
import type { Plugin } from "./registry";
const SIZE_CLASS = {
xs: "h-6 w-6 rounded-md text-xs",
sm: "h-9 w-9 rounded-lg text-sm",
lg: "h-14 w-14 rounded-xl text-xl",
} as const;
@@ -228,6 +228,32 @@ describe("plugin registry", () => {
expect(searchPlugins(plugins, " ")).toEqual(plugins);
expect(searchPlugins(plugins, "nothing")).toEqual([]);
});
it("ranks name, then author, then description matches, keeping the given order on ties", () => {
const named = (id: string, name: string, description: string, github = "acme"): Plugin => ({
...plugin,
id,
name,
description,
author: { github },
});
// Given in install order: description-only matches first, the exact name last.
const plugins = [
named("acme/editor", "Remote Editor", "Composer pill to open a workspace"),
named("acme/kit", "PromptKit", "Rewrite prompts"),
named("omp/tools", "Tools", "Helpers", "omp"),
named("acme/history", "History", "Inspect raw composer records"),
named("acme/omp", "OMP", "Paseo integration for OMP"),
];
expect(searchPlugins(plugins, "omp").map((p) => p.id)).toEqual([
"acme/omp",
"acme/kit",
"omp/tools",
"acme/editor",
"acme/history",
]);
expect(searchPlugins(plugins, "prompt").map((p) => p.id)).toEqual(["acme/kit"]);
expect(searchPlugins(plugins, "tools omp").map((p) => p.id)).toEqual(["omp/tools"]);
});
it("strips the README title and quoted description that the page already shows", () => {
expect(readmeBody("# Example\n\n> An example\n> plugin\n\n## Usage\n")).toBe("## Usage\n");
+33 -9
View File
@@ -1,6 +1,7 @@
import type { z } from "zod";
import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
import { pluginMediaKind, type PublishedPluginSchema } from "@getpaseo/protocol/plugin-registry";
import { scoreTextFields } from "@getpaseo/protocol/search/text-match";
import { CATEGORIES, type Category, type CategorySlug } from "./categories";
import type { InstallCounts, InstallWindow } from "./installs";
export { CATEGORIES, type Category, type CategorySlug };
@@ -66,16 +67,39 @@ export function formatInstalls(count: number): string {
const k = count / 1000;
return `${k % 1 === 0 ? k.toFixed(0) : k.toFixed(1)}k`;
}
/** Plugins whose name, description, ID, or author contains the term, case-insensitively. */
/** Fields a search term can hit, best first: a name match outranks an author match, which outranks a description match. */
function searchFieldGroups(plugin: Plugin): string[][] {
const author = getAuthor(plugin);
const name = [plugin.name, plugin.id.split("/")[1]];
const owner = [author.name, author.github, plugin.id];
return [name, owner, [plugin.description], [...name, ...owner, plugin.description]];
}
interface SearchRank {
field: number;
tier: number;
}
function searchRank(plugin: Plugin, term: string): SearchRank | null {
const groups = searchFieldGroups(plugin);
for (let field = 0; field < groups.length; field += 1) {
const score = scoreTextFields(term, groups[field], { subsequence: false });
if (score) return { field, tier: score.tier };
}
return null;
}
/**
* Plugins whose name, author, ID, or description contains every word of the term, ignoring case.
* Name matches come first, then author, then description; within each, exact words beat partial
* ones, and equal matches keep the given order.
*/
export function searchPlugins(plugins: Plugin[], term: string): Plugin[] {
const needle = term.trim().toLowerCase();
if (!needle) return plugins;
return plugins.filter((plugin) =>
[plugin.name, plugin.description, plugin.id, getAuthor(plugin).name]
.join(" ")
.toLowerCase()
.includes(needle),
);
if (!term.trim()) return plugins;
return plugins
.flatMap((plugin) => {
const rank = searchRank(plugin, term);
return rank ? [{ plugin, rank }] : [];
})
.sort((a, b) => a.rank.field - b.rank.field || a.rank.tier - b.rank.tier)
.map(({ plugin }) => plugin);
}
/** The featured IDs that name a listed plugin, in the registry's order, each once. */
export function featuredPlugins(plugins: Plugin[], featured: string[]): Plugin[] {
+14 -14
View File
@@ -56,20 +56,20 @@ describe("plugin card thumbnails", () => {
expect(html).not.toContain("/plugins/thumb/");
});
it.each([PluginCard, NewPluginCard])(
"renders a thumbnail instead of downloading the original screenshot (%s)",
(Card) => {
const html = renderToStaticMarkup(createElement(Card, { plugin, added: "today" }));
expect(html).toContain(
`src="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample"`,
);
expect(html).toContain(
`srcSet="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample 1x, /plugins/thumb/1184/${encodeURIComponent(source)}?plugin=acme%2Fexample 2x"`,
);
expect(html).toContain('loading="lazy"');
expect(html).toContain('decoding="async"');
},
);
it.each([
["PluginCard", () => createElement(PluginCard, { plugin }, "today")],
["NewPluginCard", () => createElement(NewPluginCard, { plugin }, "today")],
])("renders a thumbnail instead of downloading the original screenshot (%s)", (_name, card) => {
const html = renderToStaticMarkup(card());
expect(html).toContain(
`src="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample"`,
);
expect(html).toContain(
`srcSet="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample 1x, /plugins/thumb/1184/${encodeURIComponent(source)}?plugin=acme%2Fexample 2x"`,
);
expect(html).toContain('loading="lazy"');
expect(html).toContain('decoding="async"');
});
});
function thumbnailRequest(
+3 -3
View File
@@ -6,7 +6,7 @@ export const Route = createFileRoute("/")({
head: () =>
pageMeta(
"Paseo – Run Claude Code, Codex, Copilot, OpenCode from anywhere",
"Self-hosted daemon for Claude Code, Codex, Copilot, OpenCode, and Pi. Agents run on your machine with your full dev environment. Connect from phone, desktop, or web.",
"Open source app for Claude Code, Codex, OpenCode, Pi, and 30+ more coding agents. Run many agents in parallel on your machines. Editor, terminals, diffs, pull requests, and a browser in one window. One download on desktop. The full app on iOS and Android.",
"/",
),
component: Home,
@@ -24,9 +24,9 @@ function Home() {
}
subtitle={
<>
Run any coding agent from anywhere.
Run many coding agents at once, on your machines.
<br />
Self-hosted, multi-provider, open source
Desktop and mobile. Open source.
</>
}
/>
@@ -16,6 +16,7 @@ import {
} from "~/plugins";
import { AuthorAvatar } from "~/plugins/author-link";
import { PluginsNotFound } from "~/plugins/not-found";
import { InstallCount } from "~/plugins/install-count";
import { PluginCard } from "~/plugins/plugin-card";
import "~/styles.css";
@@ -82,7 +83,9 @@ function AuthorPage() {
<div className="mt-12 grid gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3">
{plugins.map((plugin) => (
<PluginCard key={plugin.id} plugin={plugin} installs={installs[plugin.id]?.all ?? 0} />
<PluginCard key={plugin.id} plugin={plugin}>
<InstallCount count={installs[plugin.id]?.all ?? 0} />
</PluginCard>
))}
</div>
</SiteShell>
@@ -6,7 +6,6 @@ import { SiteShell } from "~/components/site-shell";
import { pageMeta } from "~/meta";
import {
firstMediaImage,
formatInstalls,
getAuthor,
getCategory,
getRegistry,
@@ -16,7 +15,8 @@ import {
pluginVersion,
readmeBody,
} from "~/plugins";
import { AuthorLink } from "~/plugins/author-link";
import { AuthorAvatar, AuthorLink } from "~/plugins/author-link";
import { InstallCount } from "~/plugins/install-count";
import { categoryHref } from "~/plugins/links";
import { MediaGallery } from "~/plugins/media-gallery";
import { PluginsNotFound } from "~/plugins/not-found";
@@ -48,6 +48,8 @@ export const Route = createFileRoute("/plugins/$owner_/$slug")({
const META_LINK_CLASS =
"inline-flex items-center gap-1 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground";
const AUTHOR_LINK_CLASS =
"inline-flex items-center gap-1.5 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground";
function PluginPage() {
const { plugin, installs } = Route.useLoaderData();
@@ -57,43 +59,42 @@ function PluginPage() {
return (
<SiteShell width="default">
<div className="max-w-3xl">
<a href="/plugins" className="text-sm text-muted-foreground hover:text-foreground">
← Plugins
</a>
<h1 className="mt-4 text-3xl font-medium tracking-tight">{plugin.name}</h1>
<p className="mt-3 text-lg leading-relaxed text-white/70">{plugin.description}</p>
<div className="mt-6">
<CodeBlock size="sm">{installCommand(plugin)}</CodeBlock>
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-2 text-xs text-extra-muted-foreground [&>*+*]:before:mr-2 [&>*+*]:before:content-['·'_/_'']">
<AuthorLink author={author} className={META_LINK_CLASS}>
{author.name}
</AuthorLink>
{category && (
<a href={categoryHref(category.slug)} className={META_LINK_CLASS}>
{category.label}
</a>
)}
<span className="tabular-nums">{formatInstalls(installs)} installs</span>
<span className="font-mono">{pluginVersion(plugin)}</span>
<PluginContentLink href={plugin.repository.url} className={META_LINK_CLASS}>
Source
<a href="/plugins" className="text-sm text-muted-foreground hover:text-foreground">
← Plugins
</a>
<h1 className="mt-4 text-3xl font-medium tracking-tight">{plugin.name}</h1>
<p className="mt-3 text-lg leading-relaxed text-white/70">{plugin.description}</p>
<div className="mt-6 w-fit max-w-full">
<CodeBlock size="sm">{installCommand(plugin)}</CodeBlock>
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-2 text-xs text-extra-muted-foreground [&>*+*]:before:mr-2 [&>*+*]:before:content-['·'_/_'']">
<AuthorLink author={author} className={AUTHOR_LINK_CLASS}>
<AuthorAvatar author={author} size="xs" />
{author.name}
</AuthorLink>
{category && (
<a href={categoryHref(category.slug)} className={META_LINK_CLASS}>
{category.label}
</a>
)}
<InstallCount count={installs} />
<span className="font-mono">{pluginVersion(plugin)}</span>
<PluginContentLink href={plugin.repository.url} className={META_LINK_CLASS}>
Source
<ExternalLink className="h-3 w-3" />
</PluginContentLink>
{npm && (
<PluginContentLink href={npm} className={META_LINK_CLASS}>
npm
<ExternalLink className="h-3 w-3" />
</PluginContentLink>
{npm && (
<PluginContentLink href={npm} className={META_LINK_CLASS}>
npm
<ExternalLink className="h-3 w-3" />
</PluginContentLink>
)}
</div>
)}
</div>
<MediaGallery name={plugin.name} media={plugin.media} />
<MediaGallery name={plugin.name} media={plugin.media} />
<div className="mt-10 border-t border-white/10 pt-10">
<PluginOverview>{readmeBody(plugin.readme)}</PluginOverview>
</div>
<div className="mt-10 border-t border-white/10 pt-10">
<PluginOverview>{readmeBody(plugin.readme)}</PluginOverview>
</div>
</SiteShell>
);
+18 -24
View File
@@ -26,8 +26,10 @@ import {
type InstallWindow,
mostInstalled,
newestFirst,
pluginOwner,
} from "~/plugins";
import { ContributeSection } from "~/plugins/contribute-links";
import { PluginsHero } from "~/plugins/hero";
import {
type BrowseQuery,
browseHref,
@@ -37,10 +39,9 @@ import {
parseSearchTerm,
parseSort,
parseWindow,
SUBMIT_URL,
} from "~/plugins/links";
import { InstallCount } from "~/plugins/install-count";
import { NewPluginCard, PluginRankRow } from "~/plugins/plugin-card";
import { PluginSearch } from "~/plugins/plugin-search";
import { PluginSection, PluginSectionHeader, PluginSectionTitle } from "~/plugins/section";
import { WindowSwitch } from "~/plugins/window-switch";
import "~/styles.css";
@@ -60,7 +61,6 @@ const CATEGORY_ICONS: Record<CategorySlug, LucideIcon> = {
utils: Wrench,
};
const SUBMIT_CLASS = "text-sm text-muted-foreground transition-colors hover:text-foreground";
// What's new and Featured cards: a swipeable row on phones, a grid from `sm` up.
const CARD_ROW_CLASS =
"-mx-6 flex gap-4 overflow-x-auto px-6 pb-1 sm:mx-0 sm:grid sm:grid-cols-2 sm:px-0 lg:grid-cols-4";
@@ -110,45 +110,37 @@ function PluginsPage() {
[],
);
const searchScope = useMemo<BrowseQuery>(() => ({ sort: "installs", window }), [window]);
const authorCount = new Set(plugins.map(pluginOwner)).size;
return (
<SiteShell width="default">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-3xl font-medium tracking-tight">
Plugins
<span className="ml-3 align-middle text-sm font-normal tabular-nums text-extra-muted-foreground">
{plugins.length}
</span>
</h1>
<div className="flex flex-wrap items-center gap-x-5 gap-y-3">
<a href={SUBMIT_URL} className={SUBMIT_CLASS}>
Submit a plugin
</a>
<PluginSearch scope={searchScope} className="w-full sm:w-56" />
</div>
</div>
<PluginsHero
pluginCount={plugins.length}
authorCount={authorCount}
searchScope={searchScope}
/>
<div className="mt-10 flex flex-col gap-14">
<div className="mt-16 flex flex-col gap-14">
{featured.length > 0 && (
<PluginSection labelledBy="featured">
<PluginSectionHeader>
<div>
<PluginSectionTitle id="featured">Featured</PluginSectionTitle>
<p className="mt-1 text-sm text-muted-foreground">
A selection of hand picked plugins
</p>
<p className="text-sm text-muted-foreground">A selection of hand picked plugins</p>
</div>
<a
href={browseHref({ sort: "installs", window: DEFAULT_WINDOW })}
className={SEE_ALL_CLASS}
>
See all plugins
See all
<ChevronRight className="h-3.5 w-3.5" />
</a>
</PluginSectionHeader>
<div className={CARD_ROW_CLASS}>
{featured.map((plugin) => (
<NewPluginCard key={plugin.id} plugin={plugin} added={addedAgo(plugin, now)} />
<NewPluginCard key={plugin.id} plugin={plugin}>
<InstallCount count={installs[plugin.id]?.all ?? 0} />
</NewPluginCard>
))}
</div>
</PluginSection>
@@ -164,7 +156,9 @@ function PluginsPage() {
</PluginSectionHeader>
<div className={CARD_ROW_CLASS}>
{newest.map((plugin) => (
<NewPluginCard key={plugin.id} plugin={plugin} added={addedAgo(plugin, now)} />
<NewPluginCard key={plugin.id} plugin={plugin}>
{addedAgo(plugin, now)}
</NewPluginCard>
))}
</div>
</PluginSection>
+1 -1
View File
@@ -7,7 +7,7 @@ export const Route = createFileRoute("/sponsor")({
head: () =>
pageMeta(
"Sponsor Paseo",
"Paseo is built by one person with no investors. Sponsor the work on GitHub Sponsors, Open Collective or Buy Me a Coffee, or sponsor it as a company.",
"Paseo is an independent project used by tens of thousands of developers daily, built by one person with no investors. Sponsor the work on GitHub Sponsors, Open Collective or Buy Me a Coffee, or sponsor it as a company.",
"/sponsor",
),
component: Sponsor,
+18
View File
@@ -504,6 +504,24 @@
background-color: rgba(255, 255, 255, 0.02);
}
/* Comparison tables: the browser would size each column by its longest cell,
so the two product columns get equal width regardless of content. */
.comparison-prose table {
display: table;
table-layout: fixed;
}
.comparison-prose th,
.comparison-prose td {
overflow-wrap: anywhere;
hyphens: auto;
}
.comparison-prose th:first-child,
.comparison-prose td:first-child {
width: max(22%, 7.5rem);
}
.docs-prose pre {
background-color: var(--color-card) !important;
border: 1px solid var(--color-border);
+34 -2
View File
@@ -152,6 +152,39 @@ In the app, enter only the source identifier.
:::
## Icons and screenshots
Declare your display name, icon, screenshots, and demo videos in `paseo-plugin.json`:
```json
{
"id": "review-tools",
"name": "Review tools",
"description": "Reviews changes before merge",
"icon": "assets/icon.png",
"media": ["assets/screenshot.png", "https://example.com/review-demo.mp4"],
"requirements": { "paseo": ">=0.11.0" }
}
```
All three fields are optional. `name` gives the registry and website a display name without
changing the installation ID. When omitted, the registry falls back to a humanized ID.
Registry and website display requires the registry builder to read these fields from the pinned
manifest. That update is a separate follow-up; until it lands, these fields are exposed only
through the daemon's installed-plugin list.
`icon` must point to a PNG inside the plugin package. `media` accepts paths to images and videos
inside the package or HTTPS URLs, in display order. Paths are relative to the manifest; use
forward slashes and keep them inside the package. See the [manifest reference](/docs/plugins/reference#project-files)
for validation rules.
Commit local assets alongside the manifest at the pinned revision. For npm, add their directory
(for example, `assets/`) to `package.json`'s `files` list and verify it with `npm pack --dry-run`.
The scaffold documents these fields in its manifest's `$comment`; add your display name and
assets before publishing.
Manifests using these fields fail to install on daemons before 0.11.0. Set
`requirements.paseo` to `>=0.11.0` or a narrower supported range.
## Your listing page
Write `OVERVIEW.md` beside `paseo-plugin.json` for the plugin page inside Paseo.
@@ -170,8 +203,7 @@ The repository overview takes precedence over a registry import stopgap. Online
fails when the pinned commit has no `OVERVIEW.md`, except for unchanged imported records
that already carry `plugins/<owner>/<slug>.md` in the registry repository. Every version bump
requires a repository overview and removes the stopgap in the same PR. A bump without the
repository overview fails validation. Neither README files nor a `paseo-listing.json`
`readme` override supplies the overview.
repository overview fails validation. README files do not supply the overview.
Author overviews and registry stopgaps follow the same content contract, in this order.
Choose headings only when they help; length follows complexity. A theme needs one paragraph.
+18 -6
View File
@@ -52,12 +52,24 @@ The required root manifest is `paseo-plugin.json`:
}
```
| Field | Required | Behavior |
| -------------- | -------- | ---------------------------------------------------------------------- |
| `id` | Yes | Default installation ID. |
| `description` | No | Non-empty summary shown below the plugin ID in **Settings → Plugins**. |
| `requirements` | No | Supported Paseo versions, described below. |
| `build` | No | Preparation commands, described in the CLI reference. |
| Field | Required | Behavior |
| -------------- | -------- | ----------------------------------------------------------------------------------------- |
| `id` | Yes | Default installation ID. |
| `name` | No | Non-empty display name for the registry and website; independent of the installation ID. |
| `icon` | No | Relative path to a PNG inside the plugin package. |
| `media` | No | Array of image or video paths inside the package, or HTTPS URLs. An empty array is valid. |
| `description` | No | Non-empty summary shown below the plugin ID in **Settings → Plugins**. |
| `requirements` | No | Supported Paseo versions, described below. |
| `build` | No | Preparation commands, described in the CLI reference. |
Paths are relative to `paseo-plugin.json`, use forward slashes, and cannot contain `..`
segments. See [icons and screenshots](/docs/plugins/publishing#icons-and-screenshots) for an example.
Paseo validates the references without opening local assets or fetching URLs.
Unknown top-level fields are ignored. Known fields still validate, and unknown keys inside
`requirements` are rejected so a misspelled constraint cannot silently skip a compatibility check.
Manifests using `name`, `icon`, or `media` require Paseo 0.11.0 or later; older daemons reject
these fields during installation.
### Requirements
+1 -2
View File
@@ -657,8 +657,7 @@ The repository overview takes precedence over a registry import stopgap. Online
fails when the pinned commit has no `OVERVIEW.md`, except for unchanged imported records
that already carry `plugins/<owner>/<slug>.md` in the registry repository. Every version bump
requires a repository overview and removes the stopgap in the same PR. A bump without the
repository overview fails validation. Neither README files nor a `paseo-listing.json`
`readme` override supplies the overview.
repository overview fails validation. README files do not supply the overview.
Author overviews and registry stopgaps follow the same content contract, in this order.
Choose headings only when they help; length follows complexity. A theme needs one paragraph.