mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3448e8b023 |
@@ -1,46 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.91 - 2026-06-08
|
||||
|
||||
### Added
|
||||
|
||||
- **Open multiple desktop windows** ([#1355](https://github.com/getpaseo/paseo/pull/1355) by [@arieel-ost](https://github.com/arieel-ost))
|
||||
- **Open browser pop-ups and links inside workspace tabs** ([#1375](https://github.com/getpaseo/paseo/pull/1375))
|
||||
- **Use the command center from mobile**
|
||||
- **Add OMP as a provider** ([#1388](https://github.com/getpaseo/paseo/pull/1388))
|
||||
|
||||
### Improved
|
||||
|
||||
- New workspaces remember your last provider, mode, and thinking choices
|
||||
- Git controls now default ready branches to pull requests and hide unavailable pull or push actions
|
||||
- Desktop-managed hosts recover more reliably after stale daemon state
|
||||
- Daemon status now explains authentication failures
|
||||
- Project search skips Python virtual environments ([#1356](https://github.com/getpaseo/paseo/pull/1356))
|
||||
- Config files can include `$schema` for editor help
|
||||
- Claude MCP servers preserve always-load tool settings ([#1333](https://github.com/getpaseo/paseo/pull/1333) by [@nodomain](https://github.com/nodomain))
|
||||
- Claude profiles keep their configured models ([#1311](https://github.com/getpaseo/paseo/pull/1311) by [@ilteoood](https://github.com/ilteoood))
|
||||
- Provider loading can wait longer on slow machines ([#1346](https://github.com/getpaseo/paseo/pull/1346) by [@nodomain](https://github.com/nodomain))
|
||||
- The Kimi catalog entry now points to Kimi Code CLI ([#1403](https://github.com/getpaseo/paseo/pull/1403) by [@wbxl2000](https://github.com/wbxl2000))
|
||||
- ACP provider catalog entries are updated for Auggie, Claude Agent, Cline, Codebuddy Code, DimCode, Factory Droid, fast-agent, Gemini, GitHub Copilot, and Nova
|
||||
- Local dictation crash reports show more useful details ([#1379](https://github.com/getpaseo/paseo/pull/1379))
|
||||
- Daemon logs show why managed workers exit
|
||||
|
||||
### Fixed
|
||||
|
||||
- Pi compaction slash commands run correctly ([#1338](https://github.com/getpaseo/paseo/pull/1338) by [@chyendongnhanh338](https://github.com/chyendongnhanh338))
|
||||
- Auto-archiving still works after a merged PR branch is deleted ([#1378](https://github.com/getpaseo/paseo/pull/1378))
|
||||
- Worktrees can check out existing branch refs correctly ([#1358](https://github.com/getpaseo/paseo/pull/1358) by [@dixonl90](https://github.com/dixonl90))
|
||||
- File downloads work when daemon password protection is enabled ([#1351](https://github.com/getpaseo/paseo/pull/1351) by [@nodomain](https://github.com/nodomain))
|
||||
- iOS markdown links are tappable again ([#1334](https://github.com/getpaseo/paseo/pull/1334) by [@kaspesi](https://github.com/kaspesi))
|
||||
- iOS markdown images render correctly
|
||||
- Windows workspaces load their providers correctly ([#1329](https://github.com/getpaseo/paseo/pull/1329))
|
||||
- Removing a localhost host stops its local daemon ([#1297](https://github.com/getpaseo/paseo/pull/1297) by [@mcowger](https://github.com/mcowger))
|
||||
- Provider settings sheets stack correctly
|
||||
- The new workspace screen no longer opens behind the mobile sidebar
|
||||
- Global agent listing works again ([#1420](https://github.com/getpaseo/paseo/pull/1420))
|
||||
- OpenCode compaction summaries stay out of chat
|
||||
- OpenCode agents sharing a workspace keep their own Paseo tools
|
||||
|
||||
## 0.1.90 - 2026-06-04
|
||||
|
||||
### Added
|
||||
|
||||
@@ -238,7 +238,7 @@ initializing → idle ⇄ running
|
||||
|
||||
Each provider implements the `AgentClient` interface in `agent/agent-sdk-types.ts`. Provider implementations live in `agent/providers/`.
|
||||
|
||||
The built-in, user-facing providers are Claude Code, Codex, Copilot, OpenCode, Pi, and OMP. Additional adapters exist in the same directory for ACP-compatible agents and internal use:
|
||||
The built-in, user-facing providers are Claude Code, Codex, Copilot, OpenCode, and Pi. Additional adapters exist in the same directory for ACP-compatible agents and internal use:
|
||||
|
||||
| Provider | Wraps | Session format |
|
||||
| ------------------ | ------------------------------------ | -------------------------------------------------- |
|
||||
|
||||
@@ -35,7 +35,7 @@ Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`)
|
||||
|
||||
## Extending a built-in provider
|
||||
|
||||
Use `extends` to create a new provider entry that inherits from a built-in provider (claude, codex, copilot, opencode, pi, omp). The new provider gets its own entry in the provider list, with its own label, environment, and model definitions.
|
||||
Use `extends` to create a new provider entry that inherits from a built-in provider (claude, codex, copilot, opencode, pi). The new provider gets its own entry in the provider list, with its own label, environment, and model definitions.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -347,41 +347,6 @@ Override the command used to launch any provider with the `command` field. This
|
||||
|
||||
The `command` array completely replaces the default command for that provider. The binary must exist on the system — Paseo checks for its availability and will mark the provider as unavailable if not found.
|
||||
|
||||
### Pi-compatible forks with their own session directory
|
||||
|
||||
OMP already ships as a built-in provider option. It is disabled by default; enable it with:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"omp": { "enabled": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-pi-fork": {
|
||||
"extends": "pi",
|
||||
"label": "My Pi Fork",
|
||||
"command": ["my-pi-fork"],
|
||||
"params": {
|
||||
"sessionDir": "~/.my-pi-fork/sessions"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The session directory is used only for importing sessions that were started outside Paseo. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`.
|
||||
|
||||
---
|
||||
|
||||
## Disabling a provider
|
||||
@@ -399,7 +364,7 @@ Set `enabled: false` to hide a provider from the provider list. The provider wil
|
||||
}
|
||||
```
|
||||
|
||||
This works for both built-in and custom providers. To re-enable, set `enabled: true` or remove the `enabled` field entirely. Most providers are enabled by default; OMP is intentionally disabled by default and requires `enabled: true`.
|
||||
This works for both built-in and custom providers. To re-enable, set `enabled: true` or remove the `enabled` field entirely (providers are enabled by default).
|
||||
|
||||
---
|
||||
|
||||
@@ -639,7 +604,7 @@ Use `disallowedTools` to disable unsupported tools:
|
||||
|
||||
### Valid `extends` values
|
||||
|
||||
Built-in providers: `claude`, `codex`, `copilot`, `opencode`, `pi`, `omp`
|
||||
Built-in providers: `claude`, `codex`, `copilot`, `opencode`, `pi`
|
||||
|
||||
Special value: `acp` — creates a generic ACP provider (requires `command`)
|
||||
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
|
||||
- **Session** — Per-client connection to a daemon. Internal. Code: `Session` (`packages/server/src/server/session.ts`). Don't confuse with: provider-side agent session log.
|
||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, OMP). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ Anyone who builds software:
|
||||
## Current state (May 2026)
|
||||
|
||||
- Desktop (Electron), mobile (iOS/Android), web, CLI
|
||||
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP
|
||||
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi
|
||||
- One-click ACP provider catalog: Cursor, DeepSeek TUI, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
|
||||
- Voice mode: dictate prompts or talk through problems hands-free
|
||||
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees)
|
||||
|
||||
+2
-4
@@ -14,7 +14,7 @@ The only built-in ACP provider today is `copilot` (`copilot-acp-agent.ts`). `Gen
|
||||
|
||||
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
|
||||
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (a Pi-compatible built-in backed by the Pi adapter). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
|
||||
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
|
||||
|
||||
@@ -24,8 +24,6 @@ Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loade
|
||||
|
||||
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
|
||||
|
||||
OMP is a built-in Pi-compatible provider, disabled by default. It uses the `omp` command and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled. Other Pi-compatible forks can still be custom providers that extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
|
||||
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
|
||||
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.
|
||||
@@ -264,7 +262,7 @@ case "my-provider":
|
||||
);
|
||||
```
|
||||
|
||||
Add to the `allProviders` array (current built-ins are `claude`, `codex`, `copilot`, `opencode`, `pi`, `omp`):
|
||||
Add to the `allProviders` array (current built-ins are `claude`, `codex`, `copilot`, `opencode`, `pi`):
|
||||
|
||||
```ts
|
||||
export const allProviders: AgentProvider[] = [
|
||||
|
||||
Generated
+21
-21
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.91-beta.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.91-beta.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -36953,7 +36953,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.91-beta.2",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -37178,12 +37178,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.91-beta.2",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.91",
|
||||
"@getpaseo/protocol": "0.1.91",
|
||||
"@getpaseo/server": "0.1.91",
|
||||
"@getpaseo/client": "0.1.91-beta.2",
|
||||
"@getpaseo/protocol": "0.1.91-beta.2",
|
||||
"@getpaseo/server": "0.1.91-beta.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37429,10 +37429,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.91-beta.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.91",
|
||||
"@getpaseo/relay": "0.1.91",
|
||||
"@getpaseo/protocol": "0.1.91-beta.2",
|
||||
"@getpaseo/relay": "0.1.91-beta.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37452,7 +37452,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.91-beta.2",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37704,7 +37704,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.91-beta.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.25",
|
||||
@@ -37740,7 +37740,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.91-beta.2",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -37971,7 +37971,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.91-beta.2",
|
||||
"dependencies": {
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@@ -37992,7 +37992,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.91-beta.2",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38210,14 +38210,14 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.91-beta.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
|
||||
"@getpaseo/client": "0.1.91",
|
||||
"@getpaseo/highlight": "0.1.91",
|
||||
"@getpaseo/protocol": "0.1.91",
|
||||
"@getpaseo/relay": "0.1.91",
|
||||
"@getpaseo/client": "0.1.91-beta.2",
|
||||
"@getpaseo/highlight": "0.1.91-beta.2",
|
||||
"@getpaseo/protocol": "0.1.91-beta.2",
|
||||
"@getpaseo/relay": "0.1.91-beta.2",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38989,7 +38989,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.91-beta.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.91-beta.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { spawn, type ChildProcess, execSync } from "node:child_process";
|
||||
import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -7,8 +6,6 @@ import path from "node:path";
|
||||
import net from "node:net";
|
||||
import { Buffer } from "node:buffer";
|
||||
import dotenv from "dotenv";
|
||||
import { loadDaemonClientConstructor } from "./helpers/daemon-client-loader";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory";
|
||||
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork";
|
||||
|
||||
interface WaitForServerOptions {
|
||||
@@ -191,7 +188,7 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
|
||||
let daemonProcess: ChildProcess | null = null;
|
||||
let metroProcess: ChildProcess | null = null;
|
||||
let paseoHome: string | null = null;
|
||||
let fakeEditorBinDir: string | null = null;
|
||||
let fakeToolBinDir: string | null = null;
|
||||
let relayProcess: ChildProcess | null = null;
|
||||
|
||||
function resolveOptionalPaseoHomeEnv(value: string | undefined): string | null {
|
||||
@@ -212,24 +209,81 @@ interface OfferPayload {
|
||||
relay: { endpoint: string };
|
||||
}
|
||||
|
||||
interface DaemonClientConfig {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
webSocketFactory: NodeWebSocketFactory;
|
||||
async function createFakeToolBin(): Promise<string> {
|
||||
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-tool-bin-"));
|
||||
const ghPath = path.join(binDir, "gh");
|
||||
await writeFile(
|
||||
ghPath,
|
||||
`#!/usr/bin/env node
|
||||
const { spawnSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function findRealGh() {
|
||||
const fakeBinDir = __dirname;
|
||||
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
|
||||
if (dir === fakeBinDir) continue;
|
||||
const candidate = path.join(dir, "gh");
|
||||
try { fs.accessSync(candidate, fs.constants.X_OK); return candidate; } catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface PairingDaemonClient {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
getDaemonPairingOffer(): Promise<{
|
||||
relayEnabled: boolean;
|
||||
url: string;
|
||||
}>;
|
||||
function forwardToRealGh() {
|
||||
const realGh = findRealGh();
|
||||
if (!realGh) { console.error("[fake-gh] real gh not found in PATH"); process.exit(1); }
|
||||
const result = spawnSync(realGh, process.argv.slice(2), { stdio: "inherit", env: process.env });
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
async function createFakeEditorBin(): Promise<string> {
|
||||
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
|
||||
if (args[0] === "auth" && args[1] === "status") {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (args[0] === "pr" && args[1] === "list") {
|
||||
console.log(JSON.stringify([
|
||||
{
|
||||
number: 515,
|
||||
title: "Review selected start ref",
|
||||
url: "https://github.com/getpaseo/paseo/pull/515",
|
||||
state: "OPEN",
|
||||
body: "Fixture pull request for app e2e.",
|
||||
labels: [],
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/start-from-pr"
|
||||
}
|
||||
]));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (args[0] === "pr" && args[1] === "view" && args[2] === "--json" && args[3]) {
|
||||
const fixture = path.join(process.cwd(), ".paseo-e2e-pr.json");
|
||||
if (fs.existsSync(fixture)) {
|
||||
console.log(fs.readFileSync(fixture, "utf8"));
|
||||
process.exit(0);
|
||||
}
|
||||
forwardToRealGh();
|
||||
}
|
||||
|
||||
if (args[0] === "api" && args[1] === "graphql") {
|
||||
const fixture = path.join(process.cwd(), ".paseo-e2e-timeline.json");
|
||||
if (fs.existsSync(fixture)) {
|
||||
console.log(fs.readFileSync(fixture, "utf8"));
|
||||
process.exit(0);
|
||||
}
|
||||
forwardToRealGh();
|
||||
}
|
||||
|
||||
if (args[0] === "issue" && args[1] === "list") {
|
||||
console.log("[]");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
forwardToRealGh();
|
||||
`,
|
||||
);
|
||||
await chmod(ghPath, 0o755);
|
||||
|
||||
const fakeEditorSource = `#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
@@ -289,29 +343,29 @@ function decodeOfferFromFragmentUrl(url: string): OfferPayload {
|
||||
return offer as OfferPayload;
|
||||
}
|
||||
|
||||
async function loadPairingOfferFromDaemon(port: number): Promise<OfferPayload> {
|
||||
const DaemonClient = await loadDaemonClientConstructor<DaemonClientConfig, PairingDaemonClient>();
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${port}/ws`,
|
||||
clientId: `playwright-global-setup-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
webSocketFactory: createNodeWebSocketFactory(),
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
try {
|
||||
const pairing = await client.getDaemonPairingOffer();
|
||||
if (!pairing.relayEnabled || !pairing.url) {
|
||||
throw new Error("Daemon returned a disabled pairing offer");
|
||||
}
|
||||
return decodeOfferFromFragmentUrl(pairing.url);
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
function loadPairingOfferFromCli(repoRoot: string, paseoHomePath: string): OfferPayload {
|
||||
const stdout = execFileSync(
|
||||
process.execPath,
|
||||
["--import", "tsx", "packages/cli/src/index.ts", "daemon", "pair", "--json"],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_HOME: paseoHomePath,
|
||||
},
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
const payload = JSON.parse(stdout) as { relayEnabled?: boolean; url?: string | null };
|
||||
if (payload.relayEnabled !== true || typeof payload.url !== "string") {
|
||||
throw new Error(`Unexpected daemon pair response: ${stdout}`);
|
||||
}
|
||||
return decodeOfferFromFragmentUrl(payload.url);
|
||||
}
|
||||
|
||||
async function waitForPairingOfferFromDaemon(args: {
|
||||
port: number;
|
||||
async function waitForPairingOfferFromCli(args: {
|
||||
repoRoot: string;
|
||||
paseoHome: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<OfferPayload> {
|
||||
const timeoutMs = args.timeoutMs ?? 15000;
|
||||
@@ -320,7 +374,7 @@ async function waitForPairingOfferFromDaemon(args: {
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
return await loadPairingOfferFromDaemon(args.port);
|
||||
return loadPairingOfferFromCli(args.repoRoot, args.paseoHome);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await sleep(100);
|
||||
@@ -328,7 +382,7 @@ async function waitForPairingOfferFromDaemon(args: {
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Timed out waiting for daemon pairing offer: ${
|
||||
`Timed out waiting for \`paseo daemon pair --json\` to produce a pairing offer: ${
|
||||
lastError instanceof Error ? lastError.message : String(lastError)
|
||||
}`,
|
||||
);
|
||||
@@ -575,7 +629,7 @@ interface DaemonSpawnArgs {
|
||||
relayPort: number;
|
||||
metroPort: number;
|
||||
paseoHome: string;
|
||||
fakeEditorBinDir: string;
|
||||
fakeToolBinDir: string;
|
||||
editorRecordPath: string;
|
||||
dictation: DictationConfig;
|
||||
buffer: ReturnType<typeof createLineBuffer>;
|
||||
@@ -590,7 +644,7 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${args.fakeEditorBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
PATH: `${args.fakeToolBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
PASEO_HOME: args.paseoHome,
|
||||
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
@@ -656,9 +710,9 @@ async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
|
||||
} else if (paseoHome) {
|
||||
console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`);
|
||||
}
|
||||
if (fakeEditorBinDir) {
|
||||
await rm(fakeEditorBinDir, { recursive: true, force: true });
|
||||
fakeEditorBinDir = null;
|
||||
if (fakeToolBinDir) {
|
||||
await rm(fakeToolBinDir, { recursive: true, force: true });
|
||||
fakeToolBinDir = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,7 +727,7 @@ export default async function globalSetup() {
|
||||
const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1";
|
||||
paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-")));
|
||||
const editorRecordPath = path.join(paseoHome, "editor-open-records.jsonl");
|
||||
fakeEditorBinDir = await createFakeEditorBin();
|
||||
fakeToolBinDir = await createFakeToolBin();
|
||||
const metroLineBuffer = createLineBuffer();
|
||||
const daemonLineBuffer = createLineBuffer();
|
||||
|
||||
@@ -691,7 +745,7 @@ export default async function globalSetup() {
|
||||
relayPort,
|
||||
metroPort,
|
||||
paseoHome,
|
||||
fakeEditorBinDir,
|
||||
fakeToolBinDir,
|
||||
editorRecordPath,
|
||||
dictation,
|
||||
buffer: daemonLineBuffer,
|
||||
@@ -711,8 +765,9 @@ export default async function globalSetup() {
|
||||
}),
|
||||
]);
|
||||
|
||||
const offer = await waitForPairingOfferFromDaemon({
|
||||
port,
|
||||
const offer = await waitForPairingOfferFromCli({
|
||||
repoRoot,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
process.env.E2E_DAEMON_PORT = String(port);
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
submitNewWorkspacePrompt,
|
||||
} from "./helpers/new-workspace";
|
||||
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
|
||||
import { createTempGithubRepo, hasGithubAuth } from "./helpers/github-fixtures";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import {
|
||||
expectSidebarWorkspaceSelected,
|
||||
@@ -708,16 +707,10 @@ test.describe("New workspace flow", () => {
|
||||
});
|
||||
|
||||
test("selected GitHub PR shows PR context in the trigger and composer", async ({ page }) => {
|
||||
test.skip(!hasGithubAuth(), "Requires GitHub authentication (gh auth login)");
|
||||
|
||||
const ghRepo = await createTempGithubRepo({
|
||||
category: "new-workspace-pr-ref",
|
||||
prs: [{ title: "Review selected start ref", state: "open" }],
|
||||
});
|
||||
const pr = ghRepo.prs[0]!;
|
||||
const tempRepo = await createTempGitRepo("new-workspace-pr-ref-");
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, pr.localPath);
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
@@ -727,19 +720,19 @@ test.describe("New workspace flow", () => {
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await openStartingRefPicker(page);
|
||||
await selectGitHubPrInPicker(page, pr.number);
|
||||
await selectGitHubPrInPicker(page, 515);
|
||||
|
||||
await expectStartingRefPickerTriggerPr(page, {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
headRef: pr.branch,
|
||||
number: 515,
|
||||
title: "Review selected start ref",
|
||||
headRef: "feature/start-from-pr",
|
||||
});
|
||||
await expectComposerGithubAttachmentPill(page, {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
number: 515,
|
||||
title: "Review selected start ref",
|
||||
});
|
||||
} finally {
|
||||
await ghRepo.cleanup();
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.91-beta.2",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -51,20 +51,20 @@ describe("resolveProvidersToFetch", () => {
|
||||
expect(resolveProvidersToFetch(true, undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns enabled providers", () => {
|
||||
it("returns only enabled importable providers", () => {
|
||||
const providers = resolveProvidersToFetch(true, [
|
||||
{ provider: "claude" },
|
||||
{ provider: "codex" },
|
||||
{ provider: "opencode", enabled: false },
|
||||
{ provider: "z-ai" },
|
||||
]);
|
||||
expect(providers).toEqual(["claude", "codex", "z-ai"]);
|
||||
expect(providers).toEqual(["claude", "codex"]);
|
||||
});
|
||||
|
||||
it("returns an empty array when snapshot has no enabled providers", () => {
|
||||
it("returns an empty array when snapshot has no enabled importable providers", () => {
|
||||
const providers = resolveProvidersToFetch(true, [
|
||||
{ provider: "claude", enabled: false },
|
||||
{ provider: "z-ai", enabled: false },
|
||||
{ provider: "z-ai" },
|
||||
]);
|
||||
expect(providers).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { FetchRecentProviderSessionEntry } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { IMPORTABLE_PROVIDERS } from "@getpaseo/protocol/importable-providers";
|
||||
|
||||
export const IMPORTABLE_PROVIDER_IDS: Set<string> = new Set(IMPORTABLE_PROVIDERS);
|
||||
export const PER_PROVIDER_LIMIT = 15;
|
||||
export const ALL_FILTER_VALUE = "__all__";
|
||||
|
||||
@@ -26,7 +28,9 @@ export function resolveProvidersToFetch(
|
||||
// when the supported daemon floor is >= v0.1.48 (target: 2026-10-05).
|
||||
if (!supportsSnapshot) return null;
|
||||
if (!snapshotEntries) return null;
|
||||
return snapshotEntries.filter((entry) => entry.enabled !== false).map((entry) => entry.provider);
|
||||
return snapshotEntries
|
||||
.filter((entry) => IMPORTABLE_PROVIDER_IDS.has(entry.provider) && entry.enabled !== false)
|
||||
.map((entry) => entry.provider);
|
||||
}
|
||||
|
||||
export function buildProviderLabelMap(
|
||||
|
||||
@@ -541,7 +541,7 @@ describe("ImportSessionSheet", () => {
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fans out one request per enabled provider when snapshot is supported", async () => {
|
||||
it("fans out one request per enabled importable provider when snapshot is supported", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(
|
||||
async (options: { providers?: string[] } | undefined) => ({
|
||||
requestId: `recent-${options?.providers?.[0] ?? "all"}`,
|
||||
@@ -591,15 +591,12 @@ describe("ImportSessionSheet", () => {
|
||||
expect(fetchRecentProviderSessions).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ providers: ["opencode"] }),
|
||||
);
|
||||
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
|
||||
cwd: "/repo/paseo",
|
||||
providers: ["z-ai"],
|
||||
limit: 15,
|
||||
});
|
||||
expect(fetchRecentProviderSessions).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ providers: ["z-ai"] }),
|
||||
);
|
||||
|
||||
await screen.findByText("Session claude");
|
||||
await screen.findByText("Session codex");
|
||||
await screen.findByText("Session z-ai");
|
||||
});
|
||||
|
||||
it("shows partial-failure note when one provider request fails but others succeed", async () => {
|
||||
@@ -719,7 +716,7 @@ describe("ImportSessionSheet", () => {
|
||||
expect(screen.queryByTestId("import-session-filter-all")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a no-importable-providers message when snapshot has no enabled providers", async () => {
|
||||
it("shows a no-importable-providers message when snapshot has no enabled importable providers", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn();
|
||||
const importAgent = vi.fn();
|
||||
|
||||
@@ -735,7 +732,7 @@ describe("ImportSessionSheet", () => {
|
||||
createSnapshotEntry("claude", { enabled: false }),
|
||||
createSnapshotEntry("codex", { enabled: false }),
|
||||
createSnapshotEntry("opencode", { enabled: false }),
|
||||
createSnapshotEntry("z-ai", { enabled: false }),
|
||||
createSnapshotEntry("z-ai"),
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,7 +5,6 @@ describe("resolveProviderIconName", () => {
|
||||
it("returns the built-in identifier for known provider ids", () => {
|
||||
expect(resolveProviderIconName("kiro")).toEqual({ kind: "builtin", id: "kiro" });
|
||||
expect(resolveProviderIconName("claude")).toEqual({ kind: "builtin", id: "claude" });
|
||||
expect(resolveProviderIconName("omp")).toEqual({ kind: "builtin", id: "omp" });
|
||||
});
|
||||
|
||||
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
|
||||
|
||||
export type BuiltinProviderIconName =
|
||||
| "claude"
|
||||
| "codex"
|
||||
| "copilot"
|
||||
| "kiro"
|
||||
| "omp"
|
||||
| "opencode"
|
||||
| "pi";
|
||||
export type BuiltinProviderIconName = "claude" | "codex" | "copilot" | "kiro" | "opencode" | "pi";
|
||||
|
||||
export type ProviderIconName =
|
||||
| { kind: "builtin"; id: BuiltinProviderIconName }
|
||||
@@ -19,7 +12,6 @@ const BUILTIN_PROVIDER_IDS: ReadonlySet<BuiltinProviderIconName> = new Set([
|
||||
"codex",
|
||||
"copilot",
|
||||
"kiro",
|
||||
"omp",
|
||||
"opencode",
|
||||
"pi",
|
||||
]);
|
||||
|
||||
@@ -24,7 +24,6 @@ const BUILTIN_PROVIDER_ICONS: Record<BuiltinProviderIconName, ProviderIconCompon
|
||||
codex: CodexIcon as unknown as ProviderIconComponent,
|
||||
copilot: CopilotIcon as unknown as ProviderIconComponent,
|
||||
kiro: PackagePlus,
|
||||
omp: PiIcon as unknown as ProviderIconComponent,
|
||||
opencode: OpenCodeIcon as unknown as ProviderIconComponent,
|
||||
pi: PiIcon as unknown as ProviderIconComponent,
|
||||
};
|
||||
|
||||
@@ -36,10 +36,10 @@ const CATALOG_DATA = [
|
||||
title: "Auggie CLI",
|
||||
description:
|
||||
"Augment Code's powerful software agent, backed by industry-leading context engine",
|
||||
version: "0.29.0",
|
||||
version: "0.28.0",
|
||||
iconId: "auggie",
|
||||
installLink: "https://www.augmentcode.com/",
|
||||
command: ["npx", "-y", "@augmentcode/auggie@0.29.0", "--acp"],
|
||||
command: ["npx", "-y", "@augmentcode/auggie@0.28.0", "--acp"],
|
||||
env: {
|
||||
AUGMENT_DISABLE_AUTO_UPDATE: "1",
|
||||
},
|
||||
@@ -57,29 +57,29 @@ const CATALOG_DATA = [
|
||||
id: "claude-acp",
|
||||
title: "Claude Agent",
|
||||
description: "ACP wrapper for Anthropic's Claude",
|
||||
version: "0.42.0",
|
||||
version: "0.41.0",
|
||||
iconId: "claude-acp",
|
||||
installLink: "https://github.com/agentclientprotocol/claude-agent-acp",
|
||||
command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp@0.42.0"],
|
||||
command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp@0.41.0"],
|
||||
},
|
||||
{
|
||||
id: "cline",
|
||||
title: "Cline",
|
||||
description:
|
||||
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
version: "3.0.20",
|
||||
version: "3.0.17",
|
||||
iconId: "cline",
|
||||
installLink: "https://cline.bot/cli",
|
||||
command: ["npx", "-y", "cline@3.0.20", "--acp"],
|
||||
command: ["npx", "-y", "cline@3.0.17", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "codebuddy-code",
|
||||
title: "Codebuddy Code",
|
||||
description: "Tencent Cloud's official intelligent coding tool",
|
||||
version: "2.103.4",
|
||||
version: "2.103.0",
|
||||
iconId: "codebuddy-code",
|
||||
installLink: "https://www.codebuddy.cn/cli/",
|
||||
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.103.4", "--acp"],
|
||||
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.103.0", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "codex-acp",
|
||||
@@ -157,10 +157,10 @@ const CATALOG_DATA = [
|
||||
id: "dimcode",
|
||||
title: "DimCode",
|
||||
description: "A coding agent that puts leading models at your command.",
|
||||
version: "0.1.0",
|
||||
version: "0.0.77",
|
||||
iconId: "dimcode",
|
||||
installLink: "https://dimcode.dev/docs/acp.html",
|
||||
command: ["npx", "-y", "dimcode@0.1.0", "acp"],
|
||||
command: ["npx", "-y", "dimcode@0.0.77", "acp"],
|
||||
},
|
||||
{
|
||||
id: "dirac",
|
||||
@@ -176,10 +176,10 @@ const CATALOG_DATA = [
|
||||
id: "factory-droid",
|
||||
title: "Factory Droid",
|
||||
description: "Factory Droid - AI coding agent powered by Factory AI",
|
||||
version: "0.142.0",
|
||||
version: "0.140.0",
|
||||
iconId: "factory-droid",
|
||||
installLink: "https://factory.ai/product/cli",
|
||||
command: ["npx", "-y", "droid@0.142.0", "exec", "--output-format", "acp-daemon"],
|
||||
command: ["npx", "-y", "droid@0.140.0", "exec", "--output-format", "acp-daemon"],
|
||||
env: {
|
||||
DROID_DISABLE_AUTO_UPDATE: "true",
|
||||
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
|
||||
@@ -189,28 +189,28 @@ const CATALOG_DATA = [
|
||||
id: "fast-agent",
|
||||
title: "fast-agent",
|
||||
description: "Code and build agents with comprehensive multi-provider support",
|
||||
version: "0.7.16",
|
||||
version: "0.7.15",
|
||||
iconId: "fast-agent",
|
||||
installLink: "https://fast-agent.ai/acp/",
|
||||
command: ["uvx", "--from", "fast-agent-acp==0.7.16", "fast-agent-acp", "-x"],
|
||||
command: ["uvx", "--from", "fast-agent-acp==0.7.15", "fast-agent-acp", "-x"],
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
title: "Gemini CLI",
|
||||
description: "Google's official CLI for Gemini",
|
||||
version: "0.45.2",
|
||||
version: "0.45.0",
|
||||
iconId: "gemini",
|
||||
installLink: "https://geminicli.com",
|
||||
command: ["npx", "-y", "@google/gemini-cli@0.45.2", "--acp"],
|
||||
command: ["npx", "-y", "@google/gemini-cli@0.45.0", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "github-copilot-cli",
|
||||
title: "GitHub Copilot",
|
||||
description: "GitHub's AI pair programmer",
|
||||
version: "1.0.60",
|
||||
version: "1.0.59",
|
||||
iconId: "github-copilot-cli",
|
||||
installLink: "https://github.com/features/copilot/cli/",
|
||||
command: ["npx", "-y", "@github/copilot@1.0.60", "--acp"],
|
||||
command: ["npx", "-y", "@github/copilot@1.0.59", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "glm-acp-agent",
|
||||
@@ -279,11 +279,11 @@ const CATALOG_DATA = [
|
||||
},
|
||||
{
|
||||
id: "kimi",
|
||||
title: "Kimi Code CLI",
|
||||
description: "Moonshot AI's open-source terminal coding agent",
|
||||
version: "0.11.0",
|
||||
title: "Kimi CLI",
|
||||
description: "Moonshot AI's coding assistant",
|
||||
version: "1.41.0",
|
||||
iconId: "kimi",
|
||||
installLink: "https://github.com/MoonshotAI/kimi-code",
|
||||
installLink: "https://github.com/MoonshotAI/kimi-cli",
|
||||
command: ["kimi", "acp"],
|
||||
},
|
||||
{
|
||||
@@ -309,10 +309,10 @@ const CATALOG_DATA = [
|
||||
id: "nova",
|
||||
title: "Nova",
|
||||
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
|
||||
version: "1.1.15",
|
||||
version: "1.1.14",
|
||||
iconId: "nova",
|
||||
installLink: "https://www.compassap.ai/portfolio/nova.html",
|
||||
command: ["npx", "-y", "@compass-ai/nova@1.1.15", "acp"],
|
||||
command: ["npx", "-y", "@compass-ai/nova@1.1.14", "acp"],
|
||||
},
|
||||
{
|
||||
id: "opencode",
|
||||
|
||||
@@ -20,9 +20,6 @@ export const PROVIDER_COMMAND_TEMPLATES: Record<
|
||||
pi: {
|
||||
resume: "pi --session {sessionId}",
|
||||
},
|
||||
omp: {
|
||||
resume: "omp --session {sessionId}",
|
||||
},
|
||||
opencode: {
|
||||
resume: "opencode --session {sessionId}",
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.91-beta.2",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -27,9 +27,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.91",
|
||||
"@getpaseo/protocol": "0.1.91",
|
||||
"@getpaseo/server": "0.1.91",
|
||||
"@getpaseo/client": "0.1.91-beta.2",
|
||||
"@getpaseo/protocol": "0.1.91-beta.2",
|
||||
"@getpaseo/server": "0.1.91-beta.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -5,11 +5,15 @@ import type { CommandError, CommandOptions, SingleResult } from "../../output/in
|
||||
import { agentRunSchema, type AgentRunResult } from "./run.js";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
|
||||
const IMPORT_PROVIDER_LIST = ["claude", "codex", "opencode", "pi", "acp"] as const;
|
||||
const IMPORT_PROVIDERS = new Set<string>(IMPORT_PROVIDER_LIST);
|
||||
const IMPORT_PROVIDER_HELP = IMPORT_PROVIDER_LIST.join(", ");
|
||||
|
||||
export function addImportOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
.description("Import an existing provider session as a Paseo agent")
|
||||
.argument("<id>", "Provider session/thread ID to import")
|
||||
.requiredOption("--provider <provider>", "Agent provider id")
|
||||
.requiredOption("--provider <provider>", `Agent provider: ${IMPORT_PROVIDER_HELP}`)
|
||||
.option("--cwd <path>", "Working directory for providers that require it")
|
||||
.option(
|
||||
"--label <key=value>",
|
||||
@@ -48,6 +52,14 @@ function parseImportProvider(provider: string | undefined): string {
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
if (!IMPORT_PROVIDERS.has(normalizedProvider)) {
|
||||
throw {
|
||||
code: "INVALID_PROVIDER",
|
||||
message: `Unsupported provider: ${normalizedProvider}`,
|
||||
details: `Supported providers: ${IMPORT_PROVIDER_HELP}`,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
return normalizedProvider;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,39 +23,11 @@ describe("buildAgentLsFetchOptions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches global non-archived agents for -g", () => {
|
||||
expect(buildAgentLsFetchOptions({ global: true })).toEqual({});
|
||||
});
|
||||
|
||||
it("keeps -a within the active scope", () => {
|
||||
it("uses the unscoped archived query for -a", () => {
|
||||
expect(buildAgentLsFetchOptions({ all: true })).toEqual({
|
||||
scope: "active",
|
||||
filter: {
|
||||
includeArchived: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches all global agents for -a -g", () => {
|
||||
expect(buildAgentLsFetchOptions({ all: true, global: true })).toEqual({
|
||||
filter: {
|
||||
includeArchived: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("applies filters to global queries", () => {
|
||||
expect(
|
||||
buildAgentLsFetchOptions({
|
||||
global: true,
|
||||
label: ["surface=workspace"],
|
||||
thinking: " medium ",
|
||||
}),
|
||||
).toEqual({
|
||||
filter: {
|
||||
labels: { surface: "workspace" },
|
||||
thinkingOptionId: "medium",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ export function addLsOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
.description("List agents. By default excludes archived agents.")
|
||||
.option("-a, --all", "Include archived agents")
|
||||
.option("-g, --global", "List agents across all directories")
|
||||
.option("-g, --global", "Legacy no-op (kept for compatibility)")
|
||||
.option(
|
||||
"--label <key=value>",
|
||||
"Filter by label (can be used multiple times)",
|
||||
@@ -107,7 +107,7 @@ export type AgentLsResult = ListResult<AgentListItem>;
|
||||
export interface AgentLsOptions extends CommandOptions {
|
||||
/** -a: Include archived agents */
|
||||
all?: boolean;
|
||||
/** -g: List agents across all directories */
|
||||
/** Legacy flag retained for CLI compatibility */
|
||||
global?: boolean;
|
||||
/** Filter by specific status */
|
||||
status?: string;
|
||||
@@ -133,7 +133,7 @@ function parseLabelFilters(labels: string[] | undefined): Record<string, string>
|
||||
}
|
||||
|
||||
export function buildAgentLsFetchOptions(
|
||||
options: Pick<AgentLsOptions, "all" | "global" | "label" | "thinking">,
|
||||
options: Pick<AgentLsOptions, "all" | "label" | "thinking">,
|
||||
): FetchAgentsOptions {
|
||||
const labelFilters = parseLabelFilters(options.label);
|
||||
const normalizedThinkingOptionId = options.thinking?.trim();
|
||||
@@ -150,7 +150,7 @@ export function buildAgentLsFetchOptions(
|
||||
}
|
||||
|
||||
const fetchOptions: FetchAgentsOptions = {};
|
||||
if (!options.global) {
|
||||
if (!options.all) {
|
||||
fetchOptions.scope = "active";
|
||||
}
|
||||
if (Object.keys(daemonFilter).length > 0) {
|
||||
@@ -162,9 +162,7 @@ export function buildAgentLsFetchOptions(
|
||||
/**
|
||||
* Agent ls command semantics:
|
||||
* - `paseo agent ls` → active non-archived agents
|
||||
* - `paseo agent ls -g` → global non-archived agents
|
||||
* - `paseo agent ls -a` → active agents, including archived
|
||||
* - `paseo agent ls -ag` → global agents, including archived
|
||||
* - `paseo agent ls -a` → include archived agents
|
||||
*/
|
||||
export async function runLsCommand(
|
||||
options: AgentLsOptions,
|
||||
|
||||
@@ -18,7 +18,7 @@ const PROVIDERS: ProviderListItem[] = AGENT_PROVIDER_DEFINITIONS.map((def) => ({
|
||||
provider: def.id,
|
||||
label: def.label,
|
||||
status: "available",
|
||||
enabled: def.enabledByDefault === false ? "Disabled" : "Enabled",
|
||||
enabled: "Enabled",
|
||||
defaultMode: def.defaultModeId ?? "-",
|
||||
modes: def.modes.length > 0 ? def.modes.map((m) => m.label).join(", ") : "-",
|
||||
}));
|
||||
|
||||
@@ -50,8 +50,6 @@ try {
|
||||
assert(result.stdout.includes("--all"), "help should mention --all flag");
|
||||
assert(result.stdout.includes("-g"), "help should mention -g flag");
|
||||
assert(result.stdout.includes("--global"), "help should mention --global flag");
|
||||
assert(result.stdout.includes("across all directories"), "help should describe global scope");
|
||||
assert(!result.stdout.includes("Legacy no-op"), "help should not describe -g as a no-op");
|
||||
assert(result.stdout.includes("--host"), "help should mention --host option");
|
||||
assert(!result.stdout.includes("--ui"), "help should not mention --ui");
|
||||
console.log("✓ paseo ls --help shows options\n");
|
||||
|
||||
@@ -200,16 +200,10 @@ try {
|
||||
data.some((p: { provider: string }) => p.provider === "opencode"),
|
||||
"should include opencode",
|
||||
);
|
||||
const rows = data as ProviderListRow[];
|
||||
for (const provider of ["claude", "codex", "opencode"] as const) {
|
||||
const row = rows.find((p) => p.provider === provider);
|
||||
assert(row, `should include ${provider}`);
|
||||
assert.strictEqual(row.enabled, "Enabled", `${provider} should report Enabled`);
|
||||
}
|
||||
|
||||
const omp = rows.find((p) => p.provider === "omp");
|
||||
assert(omp, "should include omp");
|
||||
assert.strictEqual(omp.enabled, "Disabled", "omp should report Disabled by default");
|
||||
assert(
|
||||
data.every((p: ProviderListRow) => p.enabled === "Enabled"),
|
||||
"enabled providers should report Enabled",
|
||||
);
|
||||
console.log("✓ provider ls --json outputs valid JSON\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.91-beta.2",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -35,8 +35,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.91",
|
||||
"@getpaseo/relay": "0.1.91",
|
||||
"@getpaseo/protocol": "0.1.91-beta.2",
|
||||
"@getpaseo/relay": "0.1.91-beta.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.91-beta.2",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -359,9 +359,8 @@ describe("daemon-manager commands", () => {
|
||||
|
||||
expect(thrown).toBeInstanceOf(Error);
|
||||
const message = thrown?.message ?? "";
|
||||
const recentLogsLabel = message.match(/Recent logs \(([^)]*)\):/)?.[1];
|
||||
expect(message).toContain("Daemon failed to start: exit code 1");
|
||||
expect(recentLogsLabel?.split(/[\\/]/).at(-1)).toBe("daemon.log");
|
||||
expect(message).toContain(`Recent logs (${mocks.paseoHome}/daemon.log):`);
|
||||
expect(message).toContain("recent daemon failure");
|
||||
expect(mocks.spawnProcess).toHaveBeenCalledWith(
|
||||
"node",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.91-beta.2",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.91-beta.2",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.91-beta.2",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Providers eligible for "import recent session" discovery. ACP-based
|
||||
* providers (gemini, copilot, generic acp) are excluded because they either
|
||||
* don't expose persisted history quickly or duplicate other providers.
|
||||
*/
|
||||
export const IMPORTABLE_PROVIDERS = ["claude", "codex", "opencode", "pi"] as const;
|
||||
@@ -49,7 +49,6 @@ export const ProviderOverrideSchema = z.object({
|
||||
description: z.string().optional(),
|
||||
command: z.array(z.string().min(1)).min(1).optional(),
|
||||
env: z.record(z.string()).optional(),
|
||||
params: z.record(z.unknown()).optional(),
|
||||
models: z.array(ProviderProfileModelSchema).optional(),
|
||||
additionalModels: z.array(ProviderProfileModelSchema).optional(),
|
||||
disallowedTools: z.array(z.string()).optional(),
|
||||
@@ -57,7 +56,7 @@ export const ProviderOverrideSchema = z.object({
|
||||
order: z.number().optional(),
|
||||
});
|
||||
|
||||
const BUILTIN_PROVIDER_IDS = ["claude", "codex", "copilot", "opencode", "pi", "omp"] as const;
|
||||
const BUILTIN_PROVIDER_IDS = ["claude", "codex", "copilot", "opencode", "pi"] as const;
|
||||
const PROVIDER_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
|
||||
|
||||
export const ProviderOverridesSchema = z
|
||||
|
||||
@@ -25,7 +25,6 @@ export interface AgentProviderDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
enabledByDefault?: boolean;
|
||||
defaultModeId: string | null;
|
||||
modes: AgentProviderModeDefinition[];
|
||||
voice?: {
|
||||
@@ -212,14 +211,6 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
||||
defaultModeId: null,
|
||||
modes: [],
|
||||
},
|
||||
{
|
||||
id: "omp",
|
||||
label: "OMP",
|
||||
description: "Pi-compatible coding agent distributed as Oh My Pi",
|
||||
enabledByDefault: false,
|
||||
defaultModeId: null,
|
||||
modes: [],
|
||||
},
|
||||
];
|
||||
|
||||
export const DEV_AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.91-beta.2",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.91-beta.2",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -59,10 +59,10 @@
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
|
||||
"@getpaseo/client": "0.1.91",
|
||||
"@getpaseo/highlight": "0.1.91",
|
||||
"@getpaseo/protocol": "0.1.91",
|
||||
"@getpaseo/relay": "0.1.91",
|
||||
"@getpaseo/client": "0.1.91-beta.2",
|
||||
"@getpaseo/highlight": "0.1.91-beta.2",
|
||||
"@getpaseo/protocol": "0.1.91-beta.2",
|
||||
"@getpaseo/relay": "0.1.91-beta.2",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -191,23 +191,11 @@ export function runSupervisor(options: SupervisorOptions): void {
|
||||
const heartbeat = setInterval(() => {
|
||||
const message: SupervisorHeartbeatMessage = { type: "paseo:supervisor-heartbeat" };
|
||||
if (currentChild.connected) {
|
||||
currentChild.send?.(message, (error) => {
|
||||
if (error) {
|
||||
writeLifecycleLog("Worker heartbeat IPC send failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
writeLifecycleLog("Worker heartbeat skipped because IPC channel is disconnected");
|
||||
currentChild.send?.(message, () => undefined);
|
||||
}
|
||||
}, 1000);
|
||||
heartbeat.unref();
|
||||
|
||||
child.on("disconnect", () => {
|
||||
writeLifecycleLog("Worker IPC channel disconnected");
|
||||
});
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
process.stdout.write(chunk);
|
||||
writeDurableChunk(chunk);
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
PersistedAgentDescriptor,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { ProviderDefinition } from "./provider-registry.js";
|
||||
import { PaseoToolingRuntime } from "./paseo-tooling/runtime.js";
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
@@ -522,7 +523,20 @@ test("createAgent injects daemon append system prompt at runtime only", async ()
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const client = new TestAgentClient();
|
||||
class CaptureClient extends TestAgentClient {
|
||||
lastLaunchContext: AgentLaunchContext | null = null;
|
||||
|
||||
override async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
config?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
this.lastLaunchContext = launchContext ?? null;
|
||||
return super.resumeSession(handle, config, launchContext);
|
||||
}
|
||||
}
|
||||
|
||||
const client = new CaptureClient();
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: client,
|
||||
@@ -551,7 +565,20 @@ test("daemon append system prompt is injected into Pi configs", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const client = new TestAgentClient();
|
||||
class CaptureClient extends TestAgentClient {
|
||||
lastLaunchContext: AgentLaunchContext | null = null;
|
||||
|
||||
override async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
config?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
this.lastLaunchContext = launchContext ?? null;
|
||||
return super.resumeSession(handle, config, launchContext);
|
||||
}
|
||||
}
|
||||
|
||||
const client = new CaptureClient();
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
pi: client as unknown as AgentClient,
|
||||
@@ -948,28 +975,38 @@ test("createAgent passes persistSession to provider create options", async () =>
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("createAgent injects paseo MCP server only into provider launch config", async () => {
|
||||
test("createAgent passes internal paseo tooling only through runtime launch context", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
class CaptureClient extends TestAgentClient {
|
||||
lastConfig: AgentSessionConfig | null = null;
|
||||
lastLaunchContext: AgentLaunchContext | null = null;
|
||||
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
override async createSession(
|
||||
config: AgentSessionConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
this.lastConfig = config;
|
||||
this.lastLaunchContext = launchContext ?? null;
|
||||
return new TestAgentSession(config);
|
||||
}
|
||||
}
|
||||
|
||||
const client = new CaptureClient();
|
||||
const paseoToolingRuntime = new PaseoToolingRuntime();
|
||||
paseoToolingRuntime.setEndpoints({
|
||||
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
|
||||
httpBaseUrl: "http://127.0.0.1:6767",
|
||||
});
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: client,
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
|
||||
paseoToolingRuntime,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000103",
|
||||
});
|
||||
|
||||
@@ -991,15 +1028,15 @@ test("createAgent injects paseo MCP server only into provider launch config", as
|
||||
},
|
||||
});
|
||||
expect(client.lastConfig?.mcpServers).toEqual({
|
||||
paseo: {
|
||||
type: "http",
|
||||
url: `http://127.0.0.1:6767/mcp/agents?callerAgentId=${snapshot.id}`,
|
||||
},
|
||||
custom: {
|
||||
type: "stdio",
|
||||
command: "custom-mcp",
|
||||
},
|
||||
});
|
||||
expect(client.lastLaunchContext?.paseoTooling?.mcpUrl).toBe(
|
||||
`http://127.0.0.1:6767/mcp/agents?callerAgentId=${snapshot.id}`,
|
||||
);
|
||||
expect(client.lastLaunchContext?.paseoTooling?.httpBaseUrl).toBe("http://127.0.0.1:6767");
|
||||
|
||||
const stored = await storage.get(snapshot.id);
|
||||
expect(stored?.config?.mcpServers).toEqual({
|
||||
@@ -1010,18 +1047,36 @@ test("createAgent injects paseo MCP server only into provider launch config", as
|
||||
});
|
||||
});
|
||||
|
||||
test("resumeAgentFromPersistence replaces stored internal paseo MCP with current runtime URL", async () => {
|
||||
test("resumeAgentFromPersistence strips stored internal paseo MCP and passes current runtime tooling", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const client = new TestAgentClient();
|
||||
class CaptureClient extends TestAgentClient {
|
||||
lastLaunchContext: AgentLaunchContext | null = null;
|
||||
|
||||
override async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
config?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
this.lastLaunchContext = launchContext ?? null;
|
||||
return super.resumeSession(handle, config, launchContext);
|
||||
}
|
||||
}
|
||||
|
||||
const client = new CaptureClient();
|
||||
const paseoToolingRuntime = new PaseoToolingRuntime();
|
||||
paseoToolingRuntime.setEndpoints({
|
||||
mcpBaseUrl: "http://127.0.0.1:6768/mcp/agents",
|
||||
httpBaseUrl: "http://127.0.0.1:6768",
|
||||
});
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: client,
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
mcpBaseUrl: "http://127.0.0.1:6768/mcp/agents",
|
||||
paseoToolingRuntime,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000105",
|
||||
});
|
||||
const handle: AgentPersistenceHandle = {
|
||||
@@ -1047,15 +1102,14 @@ test("resumeAgentFromPersistence replaces stored internal paseo MCP with current
|
||||
});
|
||||
|
||||
expect(client.resumeOverrides[0]?.mcpServers).toEqual({
|
||||
paseo: {
|
||||
type: "http",
|
||||
url: `http://127.0.0.1:6768/mcp/agents?callerAgentId=${snapshot.id}`,
|
||||
},
|
||||
custom: {
|
||||
type: "stdio",
|
||||
command: "custom-mcp",
|
||||
},
|
||||
});
|
||||
expect(client.lastLaunchContext?.paseoTooling?.mcpUrl).toBe(
|
||||
`http://127.0.0.1:6768/mcp/agents?callerAgentId=${snapshot.id}`,
|
||||
);
|
||||
expect(snapshot.config.mcpServers).toEqual({
|
||||
custom: {
|
||||
type: "stdio",
|
||||
@@ -1119,7 +1173,6 @@ test("createAgent preserves a user-provided paseo MCP config", async () => {
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000104",
|
||||
});
|
||||
|
||||
@@ -5737,6 +5790,24 @@ test.each([
|
||||
codex: { enabled: false, derivedFromProviderId: null },
|
||||
},
|
||||
],
|
||||
[
|
||||
"derived",
|
||||
"claude",
|
||||
"zai",
|
||||
{
|
||||
claude: { enabled: true, derivedFromProviderId: null },
|
||||
zai: { enabled: true, derivedFromProviderId: "claude" },
|
||||
},
|
||||
],
|
||||
[
|
||||
"outside importable allowlist",
|
||||
"claude",
|
||||
"gemini",
|
||||
{
|
||||
claude: { enabled: true, derivedFromProviderId: null },
|
||||
gemini: { enabled: true, derivedFromProviderId: null },
|
||||
},
|
||||
],
|
||||
])(
|
||||
"listImportablePersistedAgents skips %s providers in fan-out",
|
||||
async (_reason, includedProvider, skippedProvider, providerDefinitions) => {
|
||||
@@ -5756,25 +5827,6 @@ test.each([
|
||||
},
|
||||
);
|
||||
|
||||
test("listImportablePersistedAgents includes derived providers that list persisted agents", async () => {
|
||||
const claudeClient = new RecordingPersistedAgentsClient("claude");
|
||||
const ompClient = new RecordingPersistedAgentsClient("omp");
|
||||
const manager = new AgentManager({
|
||||
clients: { claude: claudeClient, omp: ompClient },
|
||||
providerDefinitions: {
|
||||
claude: { enabled: true, derivedFromProviderId: null },
|
||||
omp: { enabled: true, derivedFromProviderId: "pi" },
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
const result = await manager.listImportablePersistedAgents();
|
||||
|
||||
expect(claudeClient.calls).toBe(1);
|
||||
expect(ompClient.calls).toBe(1);
|
||||
expect(result.map((d) => d.provider).sort()).toEqual(["claude", "omp"]);
|
||||
});
|
||||
|
||||
test("listImportablePersistedAgents narrows to the providerFilter when supplied", async () => {
|
||||
const claudeClient = new RecordingPersistedAgentsClient("claude");
|
||||
const codexClient = new RecordingPersistedAgentsClient("codex");
|
||||
|
||||
@@ -54,9 +54,11 @@ import {
|
||||
} from "./agent-stream-coalescer.js";
|
||||
import { ForegroundRunState, type ForegroundTurnWaiter } from "./foreground-run-state.js";
|
||||
import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
import { IMPORTABLE_PROVIDERS } from "./provider-registry.js";
|
||||
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
|
||||
import { isSystemInjectedEnvelope } from "./agent-prompt.js";
|
||||
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
|
||||
import { stripInternalPaseoMcpServer } from "./runtime-mcp-config.js";
|
||||
import type { PaseoToolingRuntime } from "./paseo-tooling/runtime.js";
|
||||
|
||||
const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000;
|
||||
const INTERRUPT_SESSION_TIMEOUT_MS = 2_000;
|
||||
@@ -188,7 +190,7 @@ export interface AgentManagerOptions {
|
||||
onAgentAttention?: AgentAttentionCallback;
|
||||
durableTimelineStore?: AgentTimelineStore;
|
||||
terminalManager?: TerminalManager | null;
|
||||
mcpBaseUrl?: string;
|
||||
paseoToolingRuntime?: PaseoToolingRuntime;
|
||||
appendSystemPrompt?: string;
|
||||
agentStreamCoalesceWindowMs?: number;
|
||||
rescueTimeouts?: AgentManagerRescueTimeouts;
|
||||
@@ -418,6 +420,7 @@ function buildExplicitTimelineSeedForRegister(
|
||||
export class AgentManager {
|
||||
private readonly clients = new Map<AgentProvider, AgentClient>();
|
||||
private readonly providerEnabled = new Map<AgentProvider, boolean>();
|
||||
private readonly providerDerivedFromId = new Map<AgentProvider, string | null>();
|
||||
private readonly agents = new Map<string, LiveManagedAgent>();
|
||||
private readonly timelineStore = new InMemoryAgentTimelineStore();
|
||||
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
|
||||
@@ -430,7 +433,7 @@ export class AgentManager {
|
||||
private readonly previousStatuses = new Map<string, AgentLifecycleStatus>();
|
||||
private readonly backgroundTasks = new Set<Promise<void>>();
|
||||
private readonly agentStreamCoalescer: AgentStreamCoalescer;
|
||||
private mcpBaseUrl: string | null;
|
||||
private readonly paseoToolingRuntime: PaseoToolingRuntime | null;
|
||||
private appendSystemPrompt: string;
|
||||
private onAgentAttention?: AgentAttentionCallback;
|
||||
private onAgentArchived?: AgentArchivedCallback;
|
||||
@@ -442,7 +445,7 @@ export class AgentManager {
|
||||
this.registry = options?.registry;
|
||||
this.durableTimelineStore = options?.durableTimelineStore;
|
||||
this.onAgentAttention = options?.onAgentAttention;
|
||||
this.mcpBaseUrl = options?.mcpBaseUrl ?? null;
|
||||
this.paseoToolingRuntime = options?.paseoToolingRuntime ?? null;
|
||||
this.appendSystemPrompt = options.appendSystemPrompt ?? "";
|
||||
this.logger = options.logger.child({ module: "agent", component: "agent-manager" });
|
||||
this.rescueTimeouts = {
|
||||
@@ -476,6 +479,7 @@ export class AgentManager {
|
||||
for (const [provider, definition] of Object.entries(input.providerDefinitions)) {
|
||||
if (definition) {
|
||||
this.providerEnabled.set(provider, definition.enabled);
|
||||
this.providerDerivedFromId.set(provider, definition.derivedFromProviderId ?? null);
|
||||
}
|
||||
}
|
||||
for (const [provider, client] of Object.entries(input.clients)) {
|
||||
@@ -497,10 +501,6 @@ export class AgentManager {
|
||||
this.onAgentArchived = callback;
|
||||
}
|
||||
|
||||
setMcpBaseUrl(url: string | null): void {
|
||||
this.mcpBaseUrl = url;
|
||||
}
|
||||
|
||||
setAppendSystemPrompt(prompt: string | null | undefined): void {
|
||||
this.appendSystemPrompt = prompt ?? "";
|
||||
}
|
||||
@@ -648,9 +648,15 @@ export class AgentManager {
|
||||
provider: AgentProvider,
|
||||
providerFilter: Set<string> | undefined,
|
||||
): boolean {
|
||||
if (!IMPORTABLE_PROVIDERS.includes(provider as (typeof IMPORTABLE_PROVIDERS)[number])) {
|
||||
return false;
|
||||
}
|
||||
if (this.providerEnabled.get(provider) === false) {
|
||||
return false;
|
||||
}
|
||||
if (this.providerDerivedFromId.get(provider) != null) {
|
||||
return false;
|
||||
}
|
||||
if (providerFilter && !providerFilter.has(provider)) {
|
||||
return false;
|
||||
}
|
||||
@@ -3451,16 +3457,10 @@ export class AgentManager {
|
||||
|
||||
private async prepareSessionConfig(
|
||||
config: AgentSessionConfig,
|
||||
agentId: string,
|
||||
_agentId: string,
|
||||
): Promise<PreparedSessionConfig> {
|
||||
const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config));
|
||||
const launchConfig = this.applyDaemonAppendSystemPrompt(
|
||||
withRuntimePaseoMcpServer({
|
||||
config: storedConfig,
|
||||
agentId,
|
||||
mcpBaseUrl: this.mcpBaseUrl,
|
||||
}),
|
||||
);
|
||||
const launchConfig = this.applyDaemonAppendSystemPrompt(storedConfig);
|
||||
return { storedConfig, launchConfig };
|
||||
}
|
||||
|
||||
@@ -3480,6 +3480,7 @@ export class AgentManager {
|
||||
private buildLaunchContext(agentId: string, env?: Record<string, string>): AgentLaunchContext {
|
||||
return {
|
||||
agentId,
|
||||
paseoTooling: this.paseoToolingRuntime?.createLaunchContext(agentId),
|
||||
env: {
|
||||
...env,
|
||||
PASEO_AGENT_ID: agentId,
|
||||
|
||||
@@ -546,9 +546,23 @@ export interface AgentSessionConfig {
|
||||
internal?: boolean;
|
||||
}
|
||||
|
||||
export interface PaseoToolingProviderSessionRef {
|
||||
provider: AgentProvider;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface PaseoToolingLaunchContext {
|
||||
agentId: string;
|
||||
mcpUrl: string | null;
|
||||
httpBaseUrl: string | null;
|
||||
token: string;
|
||||
bindProviderSession(ref: PaseoToolingProviderSessionRef): () => void;
|
||||
}
|
||||
|
||||
export interface AgentLaunchContext {
|
||||
agentId?: string;
|
||||
env?: Record<string, string>;
|
||||
paseoTooling?: PaseoToolingLaunchContext;
|
||||
}
|
||||
|
||||
export interface AgentCreateSessionOptions {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
import type express from "express";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AgentProvider } from "../agent-sdk-types.js";
|
||||
import type { PaseoToolRegistry } from "./registry.js";
|
||||
import type { PaseoToolingRuntime } from "./runtime.js";
|
||||
|
||||
const ToolExecuteBodySchema = z.object({
|
||||
provider: z.string().min(1),
|
||||
sessionId: z.string().min(1),
|
||||
tool: z.string().min(1),
|
||||
input: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export interface MountPaseoToolingHttpAdapterOptions {
|
||||
app: express.Express;
|
||||
registry: PaseoToolRegistry;
|
||||
runtime: PaseoToolingRuntime;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export function mountPaseoToolingHttpAdapter(options: MountPaseoToolingHttpAdapterOptions): void {
|
||||
const { app, registry, runtime, logger } = options;
|
||||
const requireAuth: express.RequestHandler = (req, res, next) => {
|
||||
if (req.header("authorization") === `Bearer ${runtime.token}`) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
};
|
||||
|
||||
app.get("/api/paseo-tooling/manifest", requireAuth, (_req, res) => {
|
||||
res.json({ tools: registry.listManifest() });
|
||||
});
|
||||
|
||||
const runExecuteRequest = async (req: express.Request, res: express.Response): Promise<void> => {
|
||||
const parsed = ToolExecuteBodySchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const callerAgentId = runtime.resolveProviderSession({
|
||||
provider: parsed.data.provider as AgentProvider,
|
||||
sessionId: parsed.data.sessionId,
|
||||
});
|
||||
if (!callerAgentId) {
|
||||
res.status(404).json({ error: "Provider session is not bound to a Paseo agent" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await registry.executeTool({
|
||||
name: parsed.data.tool,
|
||||
input: parsed.data.input ?? {},
|
||||
callerAgentId,
|
||||
});
|
||||
res.json({ output: formatPaseoToolResult(result) });
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{
|
||||
err,
|
||||
provider: parsed.data.provider,
|
||||
sessionId: parsed.data.sessionId,
|
||||
tool: parsed.data.tool,
|
||||
},
|
||||
"Paseo tooling HTTP execution failed",
|
||||
);
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : "Tool execution failed" });
|
||||
}
|
||||
};
|
||||
|
||||
app.post("/api/paseo-tooling/execute", requireAuth, (req, res) => {
|
||||
void runExecuteRequest(req, res);
|
||||
});
|
||||
}
|
||||
|
||||
function formatPaseoToolResult(result: unknown): unknown {
|
||||
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
||||
return result;
|
||||
}
|
||||
const record = result as Record<string, unknown>;
|
||||
if (record.structuredContent !== undefined) {
|
||||
return record.structuredContent;
|
||||
}
|
||||
if (!Array.isArray(record.content)) {
|
||||
return result;
|
||||
}
|
||||
const text = record.content
|
||||
.map((part) => {
|
||||
if (!part || typeof part !== "object" || Array.isArray(part)) {
|
||||
return null;
|
||||
}
|
||||
const value = (part as Record<string, unknown>).text;
|
||||
return typeof value === "string" ? value : null;
|
||||
})
|
||||
.filter((part): part is string => part !== null)
|
||||
.join("\n");
|
||||
return text || record.content;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
|
||||
import {
|
||||
createPaseoToolRegistry,
|
||||
type AgentMcpServerOptions,
|
||||
type PaseoToolingRegistryOptions,
|
||||
} from "./registry.js";
|
||||
|
||||
export async function createAgentMcpServer(options: AgentMcpServerOptions): Promise<McpServer> {
|
||||
const server = new McpServer({
|
||||
name: "agent-mcp",
|
||||
version: "2.0.0",
|
||||
});
|
||||
const registry = createPaseoToolRegistry(options);
|
||||
for (const tool of registry.listTools()) {
|
||||
server.registerTool(tool.name, tool.config, tool.handler);
|
||||
}
|
||||
return server;
|
||||
}
|
||||
|
||||
export type { AgentMcpServerOptions, PaseoToolingRegistryOptions };
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { AgentLaunchContext, AgentSessionConfig } from "../agent-sdk-types.js";
|
||||
|
||||
const PASEO_MCP_SERVER_NAME = "paseo";
|
||||
|
||||
export function withPaseoToolingMcpServer(
|
||||
config: AgentSessionConfig,
|
||||
launchContext: AgentLaunchContext | undefined,
|
||||
): AgentSessionConfig {
|
||||
const mcpUrl = launchContext?.paseoTooling?.mcpUrl;
|
||||
if (!mcpUrl || config.mcpServers?.[PASEO_MCP_SERVER_NAME]) {
|
||||
return config;
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
mcpServers: {
|
||||
[PASEO_MCP_SERVER_NAME]: {
|
||||
type: "http",
|
||||
url: mcpUrl,
|
||||
},
|
||||
...config.mcpServers,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { createPaseoToolRegistry } from "./registry.js";
|
||||
|
||||
describe("Paseo tooling registry", () => {
|
||||
test("executes tools with runtime caller identity", async () => {
|
||||
const callerCwd = mkdtempSync(path.join(os.tmpdir(), "paseo-tooling-caller-"));
|
||||
const requestedCwd = path.join(callerCwd, "child");
|
||||
const createdTerminals: Array<{ cwd: string; name?: string }> = [];
|
||||
const terminalManager = {
|
||||
createTerminal: async (input: { cwd: string; name?: string }) => {
|
||||
createdTerminals.push(input);
|
||||
return { id: "terminal-1", name: input.name ?? "Terminal", cwd: input.cwd };
|
||||
},
|
||||
};
|
||||
const agentManager = {
|
||||
getAgent: (agentId: string) =>
|
||||
agentId === "agent-1" ? { cwd: callerCwd, config: {} } : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
const registry = createPaseoToolRegistry({
|
||||
agentManager: agentManager as never,
|
||||
agentStorage: {} as never,
|
||||
terminalManager: terminalManager as never,
|
||||
providerSnapshotManager: {} as never,
|
||||
agentScopedTools: true,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
const result = await registry.executeTool({
|
||||
name: "create_terminal",
|
||||
callerAgentId: "agent-1",
|
||||
input: {
|
||||
cwd: "child",
|
||||
name: "Build",
|
||||
},
|
||||
});
|
||||
|
||||
expect(createdTerminals).toEqual([{ cwd: requestedCwd, name: "Build" }]);
|
||||
expect(result.structuredContent).toEqual({
|
||||
id: "terminal-1",
|
||||
name: "Build",
|
||||
cwd: requestedCwd,
|
||||
});
|
||||
} finally {
|
||||
rmSync(callerCwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
import type {
|
||||
AgentProvider,
|
||||
PaseoToolingLaunchContext,
|
||||
PaseoToolingProviderSessionRef,
|
||||
} from "../agent-sdk-types.js";
|
||||
|
||||
interface PaseoToolingRuntimeEndpoints {
|
||||
mcpBaseUrl: string | null;
|
||||
httpBaseUrl: string | null;
|
||||
}
|
||||
|
||||
export class PaseoToolingRuntime {
|
||||
private mcpBaseUrl: string | null = null;
|
||||
private httpBaseUrl: string | null = null;
|
||||
private enabled = true;
|
||||
private readonly sessionBindings = new Map<string, string>();
|
||||
readonly token = randomBytes(32).toString("hex");
|
||||
|
||||
setEnabled(enabled: boolean): void {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
setEndpoints(endpoints: PaseoToolingRuntimeEndpoints): void {
|
||||
this.mcpBaseUrl = endpoints.mcpBaseUrl;
|
||||
this.httpBaseUrl = endpoints.httpBaseUrl;
|
||||
}
|
||||
|
||||
createLaunchContext(agentId: string): PaseoToolingLaunchContext | undefined {
|
||||
if (!this.enabled || (!this.mcpBaseUrl && !this.httpBaseUrl)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
agentId,
|
||||
mcpUrl: this.mcpBaseUrl ? `${this.mcpBaseUrl}?callerAgentId=${agentId}` : null,
|
||||
httpBaseUrl: this.httpBaseUrl,
|
||||
token: this.token,
|
||||
bindProviderSession: (ref) => this.bindProviderSession({ ...ref, agentId }),
|
||||
};
|
||||
}
|
||||
|
||||
bindProviderSession(ref: PaseoToolingProviderSessionRef & { agentId: string }): () => void {
|
||||
const key = providerSessionKey(ref.provider, ref.sessionId);
|
||||
this.sessionBindings.set(key, ref.agentId);
|
||||
return () => {
|
||||
if (this.sessionBindings.get(key) === ref.agentId) {
|
||||
this.sessionBindings.delete(key);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
resolveProviderSession(ref: PaseoToolingProviderSessionRef): string | null {
|
||||
return this.sessionBindings.get(providerSessionKey(ref.provider, ref.sessionId)) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
function providerSessionKey(provider: AgentProvider, sessionId: string): string {
|
||||
return `${provider}:${sessionId}`;
|
||||
}
|
||||
@@ -320,7 +320,7 @@ describe("ProviderOverrideSchema", () => {
|
||||
});
|
||||
|
||||
describe("migrateProviderSettings", () => {
|
||||
const builtinProviderIds = ["claude", "codex", "copilot", "opencode", "pi", "omp"];
|
||||
const builtinProviderIds = ["claude", "codex", "copilot", "opencode", "pi"];
|
||||
|
||||
test("passes through entries already in the new format", () => {
|
||||
const migrated = migrateProviderSettings(
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { AgentModelDefinition } from "./agent-sdk-types.js";
|
||||
const mockState = vi.hoisted(() => {
|
||||
interface ConstructorEntry {
|
||||
runtimeSettings?: unknown;
|
||||
providerParams?: unknown;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -207,11 +206,10 @@ vi.mock("./providers/pi/agent.js", () => ({
|
||||
readonly provider = "pi";
|
||||
readonly runtimeSettings?: unknown;
|
||||
|
||||
constructor(options: { runtimeSettings?: unknown; providerParams?: unknown }) {
|
||||
constructor(options: { runtimeSettings?: unknown }) {
|
||||
this.runtimeSettings = options.runtimeSettings;
|
||||
mockState.constructorArgs.pi.push({
|
||||
runtimeSettings: options.runtimeSettings,
|
||||
providerParams: options.providerParams,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -414,39 +412,6 @@ test("built-in override applies env", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("OMP is a disabled built-in backed by the Pi adapter", () => {
|
||||
const registry = buildProviderRegistry(logger);
|
||||
|
||||
expect(registry.omp).toMatchObject({
|
||||
id: "omp",
|
||||
label: "OMP",
|
||||
enabled: false,
|
||||
derivedFromProviderId: null,
|
||||
});
|
||||
expect(registry.omp.createClient(logger).provider).toBe("omp");
|
||||
expect(mockState.constructorArgs.pi.at(-1)).toEqual({
|
||||
runtimeSettings: {
|
||||
command: {
|
||||
mode: "replace",
|
||||
argv: ["omp"],
|
||||
},
|
||||
},
|
||||
providerParams: {
|
||||
sessionDir: "~/.omp/agent/sessions",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("OMP can be enabled without custom provider boilerplate", () => {
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
omp: { enabled: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(registry.omp.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test("new provider extending claude appears in registry", () => {
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
@@ -464,36 +429,6 @@ test("new provider extending claude appears in registry", () => {
|
||||
expect(registry.zai.createClient(logger).provider).toBe("zai");
|
||||
});
|
||||
|
||||
test("new provider extending pi passes params to the base provider constructor", () => {
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
omp: {
|
||||
extends: "pi",
|
||||
label: "OMP",
|
||||
command: ["omp"],
|
||||
params: {
|
||||
sessionDir: "~/.omp/agent/sessions",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(registry.omp.createClient(logger).provider).toBe("omp");
|
||||
expect(mockState.constructorArgs.pi.at(-1)).toEqual({
|
||||
runtimeSettings: {
|
||||
command: {
|
||||
mode: "replace",
|
||||
argv: ["omp"],
|
||||
},
|
||||
env: undefined,
|
||||
disallowedTools: undefined,
|
||||
},
|
||||
providerParams: {
|
||||
sessionDir: "~/.omp/agent/sessions",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("new provider extending acp uses GenericACPAgentClient", () => {
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
|
||||
@@ -69,6 +69,8 @@ export interface ProviderDefinition extends AgentProviderDefinition {
|
||||
fetchModes: (options: ListModesOptions) => Promise<AgentMode[]>;
|
||||
}
|
||||
|
||||
export { IMPORTABLE_PROVIDERS } from "@getpaseo/protocol/importable-providers";
|
||||
|
||||
export interface BuildProviderRegistryOptions {
|
||||
runtimeSettings?: AgentProviderRuntimeSettingsMap;
|
||||
providerOverrides?: Record<string, ProviderOverride>;
|
||||
@@ -80,7 +82,6 @@ interface ProviderClientFactoryOptions extends Pick<
|
||||
BuildProviderRegistryOptions,
|
||||
"workspaceGitService"
|
||||
> {
|
||||
providerParams?: unknown;
|
||||
customProvider?: {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -102,7 +103,6 @@ interface ResolvedProvider {
|
||||
profileModelsAreAdditive: boolean;
|
||||
enabled: boolean;
|
||||
derivedFromProviderId: string | null;
|
||||
providerParams?: unknown;
|
||||
createBaseClient: (logger: Logger) => AgentClient;
|
||||
}
|
||||
|
||||
@@ -129,27 +129,10 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
||||
env: runtimeSettings?.env,
|
||||
}),
|
||||
opencode: (logger, runtimeSettings) => new OpenCodeAgentClient(logger, runtimeSettings),
|
||||
pi: (logger, runtimeSettings, options) =>
|
||||
pi: (logger, runtimeSettings) =>
|
||||
new PiRpcAgentClient({
|
||||
logger,
|
||||
runtimeSettings,
|
||||
providerParams: options?.providerParams,
|
||||
}),
|
||||
omp: (logger, runtimeSettings, options) =>
|
||||
new PiRpcAgentClient({
|
||||
logger,
|
||||
runtimeSettings: mergeRuntimeSettings(
|
||||
{
|
||||
command: {
|
||||
mode: "replace",
|
||||
argv: ["omp"],
|
||||
},
|
||||
},
|
||||
runtimeSettings,
|
||||
),
|
||||
providerParams: options?.providerParams ?? {
|
||||
sessionDir: "~/.omp/agent/sessions",
|
||||
},
|
||||
}),
|
||||
mock: (logger) => new MockLoadTestAgentClient(logger),
|
||||
"mock-slow": () => new MockSlowProviderClient(),
|
||||
@@ -541,13 +524,11 @@ function buildResolvedBuiltinProviders(
|
||||
profileModels: override?.models ?? [],
|
||||
additionalModels: override?.additionalModels ?? [],
|
||||
profileModelsAreAdditive: false,
|
||||
enabled: override?.enabled ?? definition.enabledByDefault ?? true,
|
||||
enabled: override?.enabled !== false,
|
||||
derivedFromProviderId: null,
|
||||
providerParams: override?.params,
|
||||
createBaseClient: (logger) =>
|
||||
factory(logger, mergedRuntimeSettings, {
|
||||
workspaceGitService: options.workspaceGitService,
|
||||
providerParams: override?.params,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -593,7 +574,6 @@ function addDerivedProviders(
|
||||
profileModelsAreAdditive: false,
|
||||
enabled: override.enabled !== false,
|
||||
derivedFromProviderId: null,
|
||||
providerParams: override.params,
|
||||
createBaseClient: (logger) =>
|
||||
providerId === "cursor"
|
||||
? new CursorACPAgentClient({
|
||||
@@ -628,7 +608,6 @@ function addDerivedProviders(
|
||||
);
|
||||
const baseDefinition = baseProvider.definition;
|
||||
const baseFactory = getProviderClientFactory(baseProviderId);
|
||||
const providerParams = override.params ?? baseProvider.providerParams;
|
||||
|
||||
resolvedProviders.set(providerId, {
|
||||
definition: createDerivedDefinition(providerId, baseDefinition, override),
|
||||
@@ -638,10 +617,8 @@ function addDerivedProviders(
|
||||
profileModelsAreAdditive: false,
|
||||
enabled: override.enabled !== false,
|
||||
derivedFromProviderId: baseProviderId,
|
||||
providerParams,
|
||||
createBaseClient: (logger) =>
|
||||
baseFactory(logger, mergedRuntimeSettings, {
|
||||
providerParams,
|
||||
customProvider: {
|
||||
id: providerId,
|
||||
label: override.label ?? providerId,
|
||||
|
||||
@@ -53,9 +53,7 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
const manager = new ProviderSnapshotManager({ logger: createTestLogger() });
|
||||
try {
|
||||
const ids = manager.listRegisteredProviderIds();
|
||||
expect(ids).toEqual(
|
||||
expect.arrayContaining(["claude", "codex", "opencode", "copilot", "pi", "omp"]),
|
||||
);
|
||||
expect(ids).toEqual(expect.arrayContaining(["claude", "codex", "opencode", "copilot", "pi"]));
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
@@ -256,7 +254,7 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
try {
|
||||
const entries = await manager.listProviders({ cwd: "/tmp/project", wait: true });
|
||||
const providers = entries.map((entry) => entry.provider).sort();
|
||||
expect(providers).toEqual(["claude", "codex", "copilot", "omp", "opencode", "pi"]);
|
||||
expect(providers).toEqual(["claude", "codex", "copilot", "opencode", "pi"]);
|
||||
for (const entry of entries) {
|
||||
expect(entry.enabled).toBe(false);
|
||||
expect(entry.status).toBe("unavailable");
|
||||
|
||||
@@ -93,6 +93,7 @@ import {
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { withPaseoToolingMcpServer } from "../paseo-tooling/provider-mcp.js";
|
||||
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
|
||||
import { platformShell, spawnProcess } from "../../../utils/spawn.js";
|
||||
|
||||
@@ -596,29 +597,30 @@ export class ACPAgentClient implements AgentClient {
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
this.assertProvider(config);
|
||||
const session = new ACPAgentSession(
|
||||
const launchConfig = withPaseoToolingMcpServer(
|
||||
{ ...config, provider: this.provider },
|
||||
{
|
||||
provider: this.provider,
|
||||
logger: this.logger,
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
defaultCommand: this.defaultCommand,
|
||||
defaultModes: this.defaultModes,
|
||||
modelTransformer: this.modelTransformer,
|
||||
sessionResponseTransformer: this.sessionResponseTransformer,
|
||||
configOptionsTransformer: this.configOptionsTransformer,
|
||||
modeIdTransformer: this.modeIdTransformer,
|
||||
toolSnapshotTransformer: this.toolSnapshotTransformer,
|
||||
providerModeWriter: this.providerModeWriter,
|
||||
beforeModeWriter: this.beforeModeWriter,
|
||||
thinkingOptionWriter: this.thinkingOptionWriter,
|
||||
capabilities: this.capabilities,
|
||||
agentId: launchContext?.agentId,
|
||||
launchEnv: launchContext?.env,
|
||||
waitForInitialCommands: this.waitForInitialCommands,
|
||||
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
|
||||
},
|
||||
launchContext,
|
||||
);
|
||||
const session = new ACPAgentSession(launchConfig, {
|
||||
provider: this.provider,
|
||||
logger: this.logger,
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
defaultCommand: this.defaultCommand,
|
||||
defaultModes: this.defaultModes,
|
||||
modelTransformer: this.modelTransformer,
|
||||
sessionResponseTransformer: this.sessionResponseTransformer,
|
||||
configOptionsTransformer: this.configOptionsTransformer,
|
||||
modeIdTransformer: this.modeIdTransformer,
|
||||
toolSnapshotTransformer: this.toolSnapshotTransformer,
|
||||
providerModeWriter: this.providerModeWriter,
|
||||
beforeModeWriter: this.beforeModeWriter,
|
||||
thinkingOptionWriter: this.thinkingOptionWriter,
|
||||
capabilities: this.capabilities,
|
||||
agentId: launchContext?.agentId,
|
||||
launchEnv: launchContext?.env,
|
||||
waitForInitialCommands: this.waitForInitialCommands,
|
||||
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
|
||||
});
|
||||
await session.initializeNewSession();
|
||||
return session;
|
||||
}
|
||||
@@ -638,12 +640,15 @@ export class ACPAgentClient implements AgentClient {
|
||||
throw new Error(`${this.provider} resume requires the original working directory`);
|
||||
}
|
||||
|
||||
const mergedConfig: AgentSessionConfig = {
|
||||
...storedConfig,
|
||||
...overrides,
|
||||
provider: this.provider,
|
||||
cwd,
|
||||
};
|
||||
const mergedConfig = withPaseoToolingMcpServer(
|
||||
{
|
||||
...storedConfig,
|
||||
...overrides,
|
||||
provider: this.provider,
|
||||
cwd,
|
||||
},
|
||||
launchContext,
|
||||
);
|
||||
const session = new ACPAgentSession(mergedConfig, {
|
||||
provider: this.provider,
|
||||
logger: this.logger,
|
||||
|
||||
@@ -45,6 +45,7 @@ import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
|
||||
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js";
|
||||
import { realClaudeRewindSdk, revertClaudeConversation, revertClaudeFiles } from "./rewind.js";
|
||||
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
|
||||
import { withPaseoToolingMcpServer } from "../../paseo-tooling/provider-mcp.js";
|
||||
import { claudeProjectDirSync } from "./project-dir.js";
|
||||
|
||||
import {
|
||||
@@ -1297,7 +1298,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
launchContext?: AgentLaunchContext,
|
||||
options?: AgentCreateSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
const claudeConfig = this.assertConfig(config);
|
||||
const claudeConfig = this.assertConfig(withPaseoToolingMcpServer(config, launchContext));
|
||||
return new ClaudeAgentSession(claudeConfig, {
|
||||
defaults: this.defaults,
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
@@ -1325,7 +1326,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
provider: "claude",
|
||||
cwd: merged.cwd,
|
||||
};
|
||||
const claudeConfig = this.assertConfig(mergedConfig);
|
||||
const claudeConfig = this.assertConfig(withPaseoToolingMcpServer(mergedConfig, launchContext));
|
||||
return new ClaudeAgentSession(claudeConfig, {
|
||||
defaults: this.defaults,
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
|
||||
@@ -85,6 +85,7 @@ import {
|
||||
toDiagnosticErrorMessage,
|
||||
} from "./diagnostic-utils.js";
|
||||
import { runProviderTurn } from "./provider-runner.js";
|
||||
import { withPaseoToolingMcpServer } from "../paseo-tooling/provider-mcp.js";
|
||||
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||
|
||||
function assertChildWithPipes(
|
||||
@@ -5433,7 +5434,10 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
// TODO: Honor persistSession=false if app-server adds support, or route
|
||||
// utility generations through `codex exec --ephemeral` in a larger change.
|
||||
}
|
||||
const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER };
|
||||
const sessionConfig = withPaseoToolingMcpServer(
|
||||
{ ...config, provider: CODEX_PROVIDER },
|
||||
launchContext,
|
||||
);
|
||||
const goalsEnabled = await this.resolveGoalsEnabled();
|
||||
const autoReviewEnabled = await this.resolveAutoReviewEnabled();
|
||||
const session = new CodexAppServerAgentSession(
|
||||
@@ -5458,12 +5462,15 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
const storedConfig = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
||||
const merged: AgentSessionConfig = {
|
||||
...storedConfig,
|
||||
...overrides,
|
||||
provider: CODEX_PROVIDER,
|
||||
cwd: overrides?.cwd ?? storedConfig.cwd ?? process.cwd(),
|
||||
};
|
||||
const merged = withPaseoToolingMcpServer(
|
||||
{
|
||||
...storedConfig,
|
||||
...overrides,
|
||||
provider: CODEX_PROVIDER,
|
||||
cwd: overrides?.cwd ?? storedConfig.cwd ?? process.cwd(),
|
||||
},
|
||||
launchContext,
|
||||
);
|
||||
const goalsEnabled = await this.resolveGoalsEnabled();
|
||||
const autoReviewEnabled = await this.resolveAutoReviewEnabled();
|
||||
const session = new CodexAppServerAgentSession(
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
TestOpenCodeRuntime,
|
||||
} from "./opencode/test-utils/test-opencode-runtime.js";
|
||||
import type {
|
||||
AgentLaunchContext,
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
ToolCallTimelineItem,
|
||||
@@ -852,6 +853,55 @@ describe("OpenCode adapter context-window normalization", () => {
|
||||
});
|
||||
|
||||
describe("OpenCode adapter startTurn error handling", () => {
|
||||
test("uses native Paseo tooling plugin instead of registering internal MCP", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
const cwd = tmpCwd();
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const boundSessions: string[] = [];
|
||||
const unboundSessions: string[] = [];
|
||||
const launchContext: AgentLaunchContext = {
|
||||
agentId: "agent-1",
|
||||
env: { PASEO_AGENT_ID: "agent-1" },
|
||||
paseoTooling: {
|
||||
agentId: "agent-1",
|
||||
mcpUrl: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1",
|
||||
httpBaseUrl: "http://127.0.0.1:6767",
|
||||
token: "tool-token",
|
||||
bindProviderSession: ({ provider, sessionId }) => {
|
||||
expect(provider).toBe("opencode");
|
||||
boundSessions.push(sessionId);
|
||||
return () => unboundSessions.push(sessionId);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const session = await client.createSession(
|
||||
{
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
},
|
||||
launchContext,
|
||||
);
|
||||
|
||||
await collectTurnEvents(streamSession(session, "hello"));
|
||||
|
||||
expect(runtime.acquisitions[0]?.env?.PASEO_AGENT_ID).toBe("agent-1");
|
||||
expect(runtime.acquisitions[0]?.env?.OPENCODE_CONFIG_CONTENT).toContain(
|
||||
"paseo-tooling-plugin.mjs",
|
||||
);
|
||||
expect(openCodeClient.calls.mcpAdd).toEqual([]);
|
||||
expect(boundSessions).toEqual(["session-1"]);
|
||||
|
||||
await session.close();
|
||||
expect(unboundSessions).toEqual(["session-1"]);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("dynamically adds injected MCP servers without config-backed connect", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
|
||||
@@ -78,6 +78,11 @@ import {
|
||||
type OpenCodeRuntime,
|
||||
type OpenCodeServerAcquisition,
|
||||
} from "./opencode/runtime.js";
|
||||
import {
|
||||
bindOpenCodePaseoToolingSession,
|
||||
ensureOpenCodePaseoToolingRuntime,
|
||||
withOpenCodePaseoToolingEnv,
|
||||
} from "./opencode/paseo-tooling.js";
|
||||
import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js";
|
||||
import { revertOpenCodeConversationAndFiles } from "./opencode/rewind.js";
|
||||
|
||||
@@ -1280,9 +1285,14 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
options?: AgentCreateSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
const openCodeConfig = this.assertConfig(config);
|
||||
const paseoToolingRuntime = ensureOpenCodePaseoToolingRuntime();
|
||||
const acquisition = await this.runtime.acquireServer({
|
||||
force: false,
|
||||
env: launchContext?.env,
|
||||
env: withOpenCodePaseoToolingEnv({
|
||||
env: launchContext?.env,
|
||||
launchContext,
|
||||
runtime: paseoToolingRuntime,
|
||||
}),
|
||||
});
|
||||
const { url } = acquisition.server;
|
||||
const client = this.runtime.createClient({
|
||||
@@ -1307,6 +1317,10 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
|
||||
const unbindPaseoToolingSession = bindOpenCodePaseoToolingSession({
|
||||
launchContext,
|
||||
sessionId: session.id,
|
||||
});
|
||||
|
||||
return new OpenCodeAgentSession(
|
||||
openCodeConfig,
|
||||
@@ -1317,6 +1331,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
acquisition.release,
|
||||
options?.persistSession,
|
||||
launchContext?.agentId,
|
||||
unbindPaseoToolingSession,
|
||||
);
|
||||
} catch (error) {
|
||||
acquisition.release();
|
||||
@@ -1342,7 +1357,15 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
cwd,
|
||||
};
|
||||
const openCodeConfig = this.assertConfig(config);
|
||||
const acquisition = await this.runtime.acquireServer({ force: false });
|
||||
const paseoToolingRuntime = ensureOpenCodePaseoToolingRuntime();
|
||||
const acquisition = await this.runtime.acquireServer({
|
||||
force: false,
|
||||
env: withOpenCodePaseoToolingEnv({
|
||||
env: launchContext?.env,
|
||||
launchContext,
|
||||
runtime: paseoToolingRuntime,
|
||||
}),
|
||||
});
|
||||
const { url } = acquisition.server;
|
||||
const client = this.runtime.createClient({
|
||||
baseUrl: url,
|
||||
@@ -1351,6 +1374,10 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
|
||||
try {
|
||||
await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
|
||||
const unbindPaseoToolingSession = bindOpenCodePaseoToolingSession({
|
||||
launchContext,
|
||||
sessionId: handle.sessionId,
|
||||
});
|
||||
|
||||
return new OpenCodeAgentSession(
|
||||
openCodeConfig,
|
||||
@@ -1361,6 +1388,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
acquisition.release,
|
||||
undefined,
|
||||
launchContext?.agentId,
|
||||
unbindPaseoToolingSession,
|
||||
);
|
||||
} catch (error) {
|
||||
acquisition.release();
|
||||
@@ -2510,10 +2538,6 @@ function appendOpenCodeMessagePartDelta(
|
||||
const knownPartType = partID ? state.partTypes.get(partID) : undefined;
|
||||
const isReasoning = knownPartType === "reasoning" || field === "reasoning";
|
||||
|
||||
if (messageID && state.compactionSummaryMessageIds.has(messageID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isReasoning) {
|
||||
if (partID) {
|
||||
state.streamedPartKeys.add(`reasoning:${partID}`);
|
||||
@@ -2531,10 +2555,6 @@ function appendOpenCodeMessagePartDelta(
|
||||
if (messageRole === "user") {
|
||||
return;
|
||||
}
|
||||
if (messageID && state.suppressAssistantMessagesUntilIdle?.active === true) {
|
||||
state.compactionSummaryMessageIds.add(messageID);
|
||||
return;
|
||||
}
|
||||
if (partID) {
|
||||
state.streamedPartKeys.add(`text:${partID}`);
|
||||
}
|
||||
@@ -2810,6 +2830,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
releaseServer?: () => void,
|
||||
persistSession = true,
|
||||
private readonly agentId?: string,
|
||||
private readonly unbindPaseoToolingSession?: () => void,
|
||||
) {
|
||||
this.config = config;
|
||||
this.client = client;
|
||||
@@ -3582,6 +3603,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
await this.deleteProviderSessionIfEphemeral();
|
||||
this.activeForegroundTurnId = null;
|
||||
} finally {
|
||||
this.unbindPaseoToolingSession?.();
|
||||
this.releaseServer?.();
|
||||
this.releaseServer = null;
|
||||
}
|
||||
|
||||
-122
@@ -1,122 +0,0 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import {
|
||||
canRunRealProvider,
|
||||
createRealProviderClient,
|
||||
getRealProviderConfig,
|
||||
} from "../../daemon-e2e/real-provider-test-config.js";
|
||||
import { OpenCodeServerManager } from "./opencode/server-manager.js";
|
||||
|
||||
const OPENCODE_REAL_TEST_MODEL = getRealProviderConfig("opencode").model;
|
||||
const logger = pino({ level: "silent" });
|
||||
|
||||
function tmpCwd(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "opencode-compact-dump-"));
|
||||
}
|
||||
|
||||
function waitForTurnToFinish(session: {
|
||||
subscribe: (callback: (event: AgentStreamEvent) => void) => () => void;
|
||||
}): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let unsubscribe: () => void = () => undefined;
|
||||
const timeout = setTimeout(() => {
|
||||
unsubscribe();
|
||||
reject(new Error("Timed out waiting for OpenCode compact turn to finish"));
|
||||
}, 60_000);
|
||||
unsubscribe = session.subscribe((event) => {
|
||||
if (
|
||||
event.type !== "turn_completed" &&
|
||||
event.type !== "turn_failed" &&
|
||||
event.type !== "turn_canceled"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
if (event.type === "turn_failed") {
|
||||
reject(new Error(event.error));
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function dumpCompactEvents(events: AgentStreamEvent[]): void {
|
||||
console.info(
|
||||
"OPENCODE_COMPACT_EVENT_DUMP",
|
||||
JSON.stringify(
|
||||
events.map((event) => ({
|
||||
type: event.type,
|
||||
turnId: "turnId" in event ? event.turnId : undefined,
|
||||
item: event.type === "timeline" ? event.item : undefined,
|
||||
usage: event.type === "turn_completed" ? event.usage : undefined,
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe("OpenCode compact event dump (real)", () => {
|
||||
let canRun = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
canRun = await canRunRealProvider("opencode");
|
||||
});
|
||||
|
||||
beforeEach((context) => {
|
||||
if (!canRun) {
|
||||
context.skip();
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await OpenCodeServerManager.getInstance(logger).shutdown();
|
||||
});
|
||||
|
||||
test("dumps live events emitted by a real /compact turn", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const client = createRealProviderClient("opencode", logger);
|
||||
|
||||
try {
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
model: OPENCODE_REAL_TEST_MODEL,
|
||||
modeId: "build",
|
||||
});
|
||||
|
||||
await session.run("Reply with exactly: COMPACT_SEED_OK");
|
||||
|
||||
const compactEvents: AgentStreamEvent[] = [];
|
||||
const unsubscribe = session.subscribe((event) => {
|
||||
compactEvents.push(event);
|
||||
});
|
||||
const compactFinished = waitForTurnToFinish(session);
|
||||
|
||||
try {
|
||||
await session.startTurn("/compact");
|
||||
await compactFinished;
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
dumpCompactEvents(compactEvents);
|
||||
|
||||
expect(compactEvents.some((event) => event.type === "turn_completed")).toBe(true);
|
||||
expect(
|
||||
compactEvents.filter(
|
||||
(event) => event.type === "timeline" && event.item.type === "assistant_message",
|
||||
),
|
||||
).toEqual([]);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { ensureOpenCodePaseoToolingRuntime } from "./paseo-tooling.js";
|
||||
|
||||
describe("OpenCode Paseo tooling plugin", () => {
|
||||
const originalPaseoHome = process.env.PASEO_HOME;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPaseoHome === undefined) {
|
||||
delete process.env.PASEO_HOME;
|
||||
} else {
|
||||
process.env.PASEO_HOME = originalPaseoHome;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("normalizes OpenCode string arguments before executing Paseo tools", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "opencode-paseo-tooling-test-"));
|
||||
process.env.PASEO_HOME = paseoHome;
|
||||
const runtime = ensureOpenCodePaseoToolingRuntime();
|
||||
const postedBodies: unknown[] = [];
|
||||
const fetchMock = vi.fn(async (url: string | URL, init?: RequestInit) => {
|
||||
const pathname = new URL(url.toString()).pathname;
|
||||
if (pathname === "/api/paseo-tooling/manifest") {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
tools: [
|
||||
{
|
||||
name: "list_agents",
|
||||
description: "List agents",
|
||||
inputJsonSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
sinceHours: { type: "integer", default: 48 },
|
||||
statuses: {
|
||||
type: "array",
|
||||
items: { type: "string", enum: ["running", "idle"] },
|
||||
},
|
||||
limit: { type: "integer", default: 50 },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
postedBodies.push(JSON.parse(String(init?.body)) as unknown);
|
||||
return new Response(JSON.stringify({ output: "ok" }), { status: 200 });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
try {
|
||||
const plugin = (await import(
|
||||
`${pathToFileURL(runtime.pluginPath).href}?test=${Date.now()}`
|
||||
)) as {
|
||||
server: (
|
||||
input: unknown,
|
||||
options: { baseUrl: string; token: string },
|
||||
) => Promise<{
|
||||
tool: Record<
|
||||
string,
|
||||
{ execute: (args: unknown, context: { sessionID: string }) => Promise<string> }
|
||||
>;
|
||||
}>;
|
||||
};
|
||||
const server = await plugin.server({}, { baseUrl: "http://127.0.0.1:6767", token: "t" });
|
||||
|
||||
await server.tool.paseo_list_agents?.execute(
|
||||
{
|
||||
includeArchived: "False",
|
||||
sinceHours: "",
|
||||
statuses: '["running"]',
|
||||
limit: "5",
|
||||
},
|
||||
{ sessionID: "session-1" },
|
||||
);
|
||||
|
||||
expect(postedBodies).toEqual([
|
||||
{
|
||||
provider: "opencode",
|
||||
sessionId: "session-1",
|
||||
tool: "list_agents",
|
||||
input: {
|
||||
includeArchived: false,
|
||||
statuses: ["running"],
|
||||
limit: 5,
|
||||
},
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
rmSync(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import type { AgentLaunchContext } from "../../agent-sdk-types.js";
|
||||
|
||||
const PASEO_OPENCODE_PLUGIN_FILENAME = "paseo-tooling-plugin.mjs";
|
||||
|
||||
export interface OpenCodePaseoToolingRuntime {
|
||||
pluginPath: string;
|
||||
}
|
||||
|
||||
function resolvePaseoHome(): string {
|
||||
return process.env.PASEO_HOME?.trim() || path.join(homedir(), ".paseo");
|
||||
}
|
||||
|
||||
function resolveRuntimeDirectory(): string {
|
||||
return path.join(resolvePaseoHome(), "opencode");
|
||||
}
|
||||
|
||||
export function ensureOpenCodePaseoToolingRuntime(): OpenCodePaseoToolingRuntime {
|
||||
const dir = resolveRuntimeDirectory();
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const pluginPath = path.join(dir, PASEO_OPENCODE_PLUGIN_FILENAME);
|
||||
const require = createRequire(import.meta.url);
|
||||
const zodImportUrl = pathToFileURL(require.resolve("zod")).href;
|
||||
writeFileSync(pluginPath, createOpenCodePaseoToolingPluginSource(zodImportUrl), "utf8");
|
||||
return { pluginPath };
|
||||
}
|
||||
|
||||
export function withOpenCodePaseoToolingEnv(params: {
|
||||
env?: Record<string, string>;
|
||||
launchContext?: AgentLaunchContext;
|
||||
runtime: OpenCodePaseoToolingRuntime;
|
||||
}): Record<string, string> | undefined {
|
||||
const tooling = params.launchContext?.paseoTooling;
|
||||
if (!tooling?.httpBaseUrl) {
|
||||
return params.env;
|
||||
}
|
||||
|
||||
const pluginSpec = [
|
||||
pathToFileURL(params.runtime.pluginPath).href,
|
||||
{
|
||||
baseUrl: tooling.httpBaseUrl,
|
||||
token: tooling.token,
|
||||
},
|
||||
];
|
||||
return {
|
||||
...params.env,
|
||||
OPENCODE_CONFIG_CONTENT: mergeOpenCodeConfigContent(
|
||||
params.env?.OPENCODE_CONFIG_CONTENT,
|
||||
pluginSpec,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function bindOpenCodePaseoToolingSession(params: {
|
||||
launchContext?: AgentLaunchContext;
|
||||
sessionId: string;
|
||||
}): (() => void) | undefined {
|
||||
return params.launchContext?.paseoTooling?.bindProviderSession({
|
||||
provider: "opencode",
|
||||
sessionId: params.sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
function mergeOpenCodeConfigContent(existing: string | undefined, pluginSpec: unknown[]): string {
|
||||
const base = parseConfigContent(existing);
|
||||
const existingPlugins = Array.isArray(base.plugin) ? base.plugin : [];
|
||||
const pluginUrl = pluginSpec[0];
|
||||
const withoutExistingPaseoPlugin = existingPlugins.filter((entry) => {
|
||||
const spec = Array.isArray(entry) ? entry[0] : entry;
|
||||
return spec !== pluginUrl;
|
||||
});
|
||||
return JSON.stringify({
|
||||
...base,
|
||||
plugin: [...withoutExistingPaseoPlugin, pluginSpec],
|
||||
});
|
||||
}
|
||||
|
||||
function parseConfigContent(existing: string | undefined): Record<string, unknown> {
|
||||
if (!existing?.trim()) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(existing) as unknown;
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function createOpenCodePaseoToolingPluginSource(zodImportUrl: string): string {
|
||||
return `
|
||||
import { z } from ${JSON.stringify(zodImportUrl)};
|
||||
|
||||
export async function server(_input, options) {
|
||||
const manifestResponse = await fetch(new URL("/api/paseo-tooling/manifest", options.baseUrl), {
|
||||
headers: { authorization: "Bearer " + options.token },
|
||||
});
|
||||
if (!manifestResponse.ok) {
|
||||
throw new Error("Failed to load Paseo tooling manifest: " + manifestResponse.status);
|
||||
}
|
||||
const manifest = await manifestResponse.json();
|
||||
const tools = {};
|
||||
for (const item of manifest.tools ?? []) {
|
||||
const id = "paseo_" + item.name;
|
||||
tools[id] = {
|
||||
description: item.description,
|
||||
args: jsonSchemaObjectToZodShape(item.inputJsonSchema),
|
||||
execute: async (args, context) => {
|
||||
const input = normalizeJsonSchemaInput(args, item.inputJsonSchema);
|
||||
const response = await fetch(new URL("/api/paseo-tooling/execute", options.baseUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: "Bearer " + options.token,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
provider: "opencode",
|
||||
sessionId: context.sessionID,
|
||||
tool: item.name,
|
||||
input,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error ?? "Paseo tool failed: " + response.status);
|
||||
}
|
||||
return typeof payload.output === "string" ? payload.output : JSON.stringify(payload.output ?? payload, null, 2);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { tool: tools };
|
||||
}
|
||||
|
||||
function jsonSchemaObjectToZodShape(schema) {
|
||||
const properties = schema?.properties && typeof schema.properties === "object" ? schema.properties : {};
|
||||
const required = new Set(Array.isArray(schema?.required) ? schema.required : []);
|
||||
const shape = {};
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
let parsed = jsonSchemaToZod(value);
|
||||
if (!required.has(key)) parsed = parsed.optional();
|
||||
shape[key] = parsed;
|
||||
}
|
||||
return shape;
|
||||
}
|
||||
|
||||
function jsonSchemaToZod(schema) {
|
||||
if (!schema || typeof schema !== "object") return z.any();
|
||||
if (schema.anyOf) return unionToZod(schema.anyOf);
|
||||
if (schema.oneOf) return unionToZod(schema.oneOf);
|
||||
if (schema.const !== undefined) return z.literal(schema.const);
|
||||
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
|
||||
if (schema.enum.length === 1) return z.literal(schema.enum[0]);
|
||||
if (schema.enum.every((item) => typeof item === "string")) return z.enum(schema.enum);
|
||||
return z.union(schema.enum.map((item) => z.literal(item)));
|
||||
}
|
||||
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
||||
const nullable = types.includes("null");
|
||||
const type = types.find((item) => item !== "null");
|
||||
let result;
|
||||
switch (type) {
|
||||
case "string":
|
||||
result = z.string();
|
||||
if (typeof schema.minLength === "number") result = result.min(schema.minLength);
|
||||
if (typeof schema.maxLength === "number") result = result.max(schema.maxLength);
|
||||
break;
|
||||
case "integer":
|
||||
result = z.number().int();
|
||||
if (typeof schema.minimum === "number") result = result.min(schema.minimum);
|
||||
if (typeof schema.maximum === "number") result = result.max(schema.maximum);
|
||||
break;
|
||||
case "number":
|
||||
result = z.number();
|
||||
if (typeof schema.minimum === "number") result = result.min(schema.minimum);
|
||||
if (typeof schema.maximum === "number") result = result.max(schema.maximum);
|
||||
break;
|
||||
case "boolean":
|
||||
result = z.boolean();
|
||||
break;
|
||||
case "array":
|
||||
result = z.array(jsonSchemaToZod(schema.items));
|
||||
break;
|
||||
case "object":
|
||||
result = z.object(jsonSchemaObjectToZodShape(schema));
|
||||
break;
|
||||
default:
|
||||
result = z.any();
|
||||
break;
|
||||
}
|
||||
if (schema.description && typeof result.describe === "function") result = result.describe(schema.description);
|
||||
return nullable ? result.nullable() : result;
|
||||
}
|
||||
|
||||
function normalizeJsonSchemaInput(input, schema) {
|
||||
if (!schema || typeof schema !== "object") return input;
|
||||
|
||||
if (schema.anyOf) return normalizeUnionInput(input, schema.anyOf);
|
||||
if (schema.oneOf) return normalizeUnionInput(input, schema.oneOf);
|
||||
|
||||
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
||||
const type = types.find((item) => item !== "null");
|
||||
|
||||
if (typeof input === "string" && !types.includes("string")) {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed || trimmed === "undefined" || trimmed === "null") return undefined;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "boolean":
|
||||
if (typeof input === "string") {
|
||||
const normalized = input.trim().toLowerCase();
|
||||
if (normalized === "true" || normalized === "yes" || normalized === "1") return true;
|
||||
if (normalized === "false" || normalized === "no" || normalized === "0") return false;
|
||||
}
|
||||
return input;
|
||||
case "integer":
|
||||
case "number":
|
||||
if (typeof input === "string" && input.trim() !== "" && /^-?\\d+(\\.\\d+)?$/.test(input.trim())) {
|
||||
return Number(input);
|
||||
}
|
||||
return input;
|
||||
case "array": {
|
||||
const value = parseJsonString(input);
|
||||
if (!Array.isArray(value)) return value;
|
||||
return value.map((item) => normalizeJsonSchemaInput(item, schema.items)).filter((item) => item !== undefined);
|
||||
}
|
||||
case "object": {
|
||||
const value = parseJsonString(input);
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
||||
const properties = schema.properties && typeof schema.properties === "object" ? schema.properties : {};
|
||||
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
||||
const normalized = {};
|
||||
for (const [key, propertySchema] of Object.entries(properties)) {
|
||||
if (!(key in value)) continue;
|
||||
const next = normalizeJsonSchemaInput(value[key], propertySchema);
|
||||
if (next !== undefined || required.has(key)) normalized[key] = next;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
default:
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUnionInput(input, schemas) {
|
||||
const preferred = schemas.find((item) => item?.type && item.type !== "null") ?? schemas[0];
|
||||
return normalizeJsonSchemaInput(input, preferred);
|
||||
}
|
||||
|
||||
function parseJsonString(value) {
|
||||
if (typeof value !== "string") return value;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) return value;
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function unionToZod(items) {
|
||||
const nonNull = items.filter((item) => item?.type !== "null");
|
||||
const nullable = nonNull.length !== items.length;
|
||||
const parsed = nonNull.map(jsonSchemaToZod);
|
||||
const result = parsed.length === 0 ? z.null() : parsed.length === 1 ? parsed[0] : z.union(parsed);
|
||||
return nullable ? result.nullable() : result;
|
||||
}
|
||||
`;
|
||||
}
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client";
|
||||
import type { Event as OpenCodeEvent, OpencodeClient } from "@opencode-ai/sdk/v2/client";
|
||||
|
||||
import type { OpenCodeRuntime, OpenCodeServerAcquisition } from "../runtime.js";
|
||||
|
||||
@@ -92,7 +92,7 @@ export class TestOpenCodeClient {
|
||||
sessionMessagesResponse: OpenCodeResponse = { data: [] };
|
||||
sessionPromptAsyncEvents: unknown[] = [idleEvent()];
|
||||
sessionPromptAsyncResponse: OpenCodeResponse = {};
|
||||
sessionSummarizeEvents: unknown[] = [idleEvent()];
|
||||
sessionSummarizeEvents: OpenCodeEvent[] = [];
|
||||
sessionSummarizeResponse: OpenCodeResponse = { data: {} };
|
||||
sessionUpdateResponse: OpenCodeResponse = {};
|
||||
private readonly queuedEventStream = createQueuedEventStream();
|
||||
|
||||
@@ -1,15 +1,4 @@
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
fstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
openSync,
|
||||
readSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { closeSync, existsSync, fstatSync, openSync, readSync } from "node:fs";
|
||||
import pino from "pino";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
@@ -671,128 +660,6 @@ describe("PiRpcAgentSession", () => {
|
||||
});
|
||||
|
||||
describe("PiRpcAgentClient", () => {
|
||||
test("lists JSONL persisted sessions from configured provider params", async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "paseo-pi-sessions-"));
|
||||
const cwd = path.join(root, "workspace");
|
||||
const otherCwd = path.join(root, "other");
|
||||
const sessionsDir = path.join(root, "sessions");
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
const sessionFile = path.join(sessionsDir, "20260101_session.jsonl");
|
||||
writeFileSync(
|
||||
sessionFile,
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "pi-session-jsonl",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
cwd,
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
id: "entry-1",
|
||||
timestamp: "2026-01-01T00:00:01.000Z",
|
||||
message: { role: "user", content: "first prompt" },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "session_info",
|
||||
id: "info-1",
|
||||
timestamp: "2026-01-01T00:00:02.000Z",
|
||||
name: "Imported Pi session",
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
id: "entry-2",
|
||||
timestamp: "2026-01-01T00:00:03.000Z",
|
||||
message: { role: "user", content: [{ type: "text", text: "last prompt" }] },
|
||||
}),
|
||||
].join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(sessionsDir, "other.jsonl"),
|
||||
`${JSON.stringify({ type: "session", version: 3, id: "other", cwd: otherCwd })}\n`,
|
||||
"utf8",
|
||||
);
|
||||
const client = new PiRpcAgentClient({
|
||||
logger: pino({ level: "silent" }),
|
||||
runtime: new FakePi(),
|
||||
providerParams: { sessionDir: sessionsDir },
|
||||
});
|
||||
|
||||
await expect(client.listPersistedAgents({ cwd })).resolves.toEqual([
|
||||
{
|
||||
provider: "pi",
|
||||
sessionId: "pi-session-jsonl",
|
||||
cwd,
|
||||
title: "Imported Pi session",
|
||||
lastActivityAt: new Date("2026-01-01T00:00:03.000Z"),
|
||||
persistence: {
|
||||
provider: "pi",
|
||||
sessionId: "pi-session-jsonl",
|
||||
nativeHandle: sessionFile,
|
||||
metadata: { provider: "pi", cwd },
|
||||
},
|
||||
timeline: [
|
||||
{ type: "user_message", text: "first prompt" },
|
||||
{ type: "user_message", text: "last prompt" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("lists JSONL persisted sessions from Pi's configured agent directory", async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "paseo-pi-default-sessions-"));
|
||||
const cwd = path.join(root, "workspace");
|
||||
const agentDir = path.join(root, ".pi", "agent");
|
||||
const sessionsDir = path.join(agentDir, "sessions");
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
const sessionFile = path.join(sessionsDir, "20260102_session.jsonl");
|
||||
writeFileSync(
|
||||
sessionFile,
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "pi-default-session",
|
||||
timestamp: "2026-01-02T00:00:00.000Z",
|
||||
cwd,
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
id: "entry-1",
|
||||
timestamp: "2026-01-02T00:00:01.000Z",
|
||||
message: { role: "user", content: "default dir prompt" },
|
||||
}),
|
||||
].join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
const client = new PiRpcAgentClient({
|
||||
logger: pino({ level: "silent" }),
|
||||
runtime: new FakePi(),
|
||||
runtimeSettings: {
|
||||
env: {
|
||||
PI_CODING_AGENT_DIR: agentDir,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(client.listPersistedAgents({ cwd })).resolves.toMatchObject([
|
||||
{
|
||||
provider: "pi",
|
||||
sessionId: "pi-default-session",
|
||||
cwd,
|
||||
title: "default dir prompt",
|
||||
persistence: {
|
||||
provider: "pi",
|
||||
sessionId: "pi-default-session",
|
||||
nativeHandle: sessionFile,
|
||||
metadata: { provider: "pi", cwd },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("lists models from a short-lived Pi session in the requested cwd", async () => {
|
||||
const pi = new FakePi();
|
||||
const client = createClient(pi);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
type AgentCapabilityFlags,
|
||||
@@ -31,6 +30,7 @@ import {
|
||||
type PersistedAgentDescriptor,
|
||||
} from "../../agent-sdk-types.js";
|
||||
import { runProviderTurn } from "../provider-runner.js";
|
||||
import { withPaseoToolingMcpServer } from "../../paseo-tooling/provider-mcp.js";
|
||||
import {
|
||||
checkProviderLaunchAvailable,
|
||||
resolveProviderLaunch,
|
||||
@@ -53,7 +53,6 @@ import {
|
||||
} from "./history-mapper.js";
|
||||
import { PiCliRuntime } from "./cli-runtime.js";
|
||||
import { revertPiConversation } from "./rewind.js";
|
||||
import { listPiPersistedAgents } from "./session-descriptor.js";
|
||||
import type { PiRuntime, PiRuntimeSession } from "./runtime.js";
|
||||
import type {
|
||||
PiAgentSessionEvent,
|
||||
@@ -88,14 +87,6 @@ const QUESTION_COMMENT_HEADER = "Comment";
|
||||
const PI_ASK_USER_FREEFORM_SENTINEL = "✏️ Type custom response...";
|
||||
const COMBINED_ASK_USER_METADATA = "ask_user_select_optional_comment";
|
||||
|
||||
export const PiProviderParamsSchema = z
|
||||
.object({
|
||||
sessionDir: z.string().min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
type PiProviderParams = z.infer<typeof PiProviderParamsSchema>;
|
||||
|
||||
const PI_HANDLED_BUILTIN_SLASH_COMMANDS: AgentSlashCommand[] = [
|
||||
{
|
||||
name: "compact",
|
||||
@@ -138,7 +129,6 @@ const PI_THINKING_OPTIONS: ReadonlyArray<{
|
||||
interface PiRpcAgentClientOptions {
|
||||
logger: Logger;
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
providerParams?: unknown;
|
||||
runtime?: PiRuntime;
|
||||
}
|
||||
|
||||
@@ -1837,13 +1827,11 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
|
||||
private readonly logger: Logger;
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
private readonly providerParams: PiProviderParams;
|
||||
private readonly runtime: PiRuntime;
|
||||
|
||||
constructor(options: PiRpcAgentClientOptions) {
|
||||
this.logger = options.logger;
|
||||
this.runtimeSettings = options.runtimeSettings;
|
||||
this.providerParams = PiProviderParamsSchema.parse(options.providerParams ?? {});
|
||||
this.runtime = options.runtime ?? createRuntime(options.logger, options.runtimeSettings);
|
||||
}
|
||||
|
||||
@@ -1851,18 +1839,19 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
config: AgentSessionConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers);
|
||||
const launchConfig = withPaseoToolingMcpServer(config, launchContext);
|
||||
const mcpConfig = await this.prepareMcpConfig(launchConfig.cwd, launchConfig.mcpServers);
|
||||
const paseoExtension = createPiPaseoExtensionFile();
|
||||
let runtimeSession: PiRuntimeSession;
|
||||
try {
|
||||
runtimeSession = await this.runtime.startSession({
|
||||
cwd: config.cwd,
|
||||
model: config.model,
|
||||
cwd: launchConfig.cwd,
|
||||
model: launchConfig.model,
|
||||
thinkingOptionId:
|
||||
normalizePiThinkingOption(config.thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL,
|
||||
normalizePiThinkingOption(launchConfig.thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL,
|
||||
systemPrompt: composeSystemPromptParts(
|
||||
config.systemPrompt,
|
||||
config.daemonAppendSystemPrompt,
|
||||
launchConfig.systemPrompt,
|
||||
launchConfig.daemonAppendSystemPrompt,
|
||||
),
|
||||
env: launchContext?.env,
|
||||
mcpConfigPath: mcpConfig?.path,
|
||||
@@ -1876,7 +1865,7 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
try {
|
||||
return new PiRpcAgentSession({
|
||||
runtimeSession,
|
||||
config,
|
||||
config: launchConfig,
|
||||
initialState: await runtimeSession.getState(),
|
||||
capabilities: withPiMcpCapability(mcpConfig !== null),
|
||||
cleanup: combineCleanup([mcpConfig?.cleanup, paseoExtension.cleanup]),
|
||||
@@ -1892,7 +1881,7 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
const sessionFile = handle.nativeHandle;
|
||||
if (!sessionFile) {
|
||||
@@ -1902,7 +1891,8 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
const persistenceMetadata = parsePersistenceMetadata(handle.metadata);
|
||||
const resumeConfig = buildResumeConfig(persistenceMetadata, overrides);
|
||||
|
||||
const mcpConfig = await this.prepareMcpConfig(resumeConfig.cwd, resumeConfig.config.mcpServers);
|
||||
const launchConfig = withPaseoToolingMcpServer(resumeConfig.config, launchContext);
|
||||
const mcpConfig = await this.prepareMcpConfig(resumeConfig.cwd, launchConfig.mcpServers);
|
||||
const paseoExtension = createPiPaseoExtensionFile();
|
||||
let runtimeSession: PiRuntimeSession;
|
||||
try {
|
||||
@@ -1912,8 +1902,8 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
model: resumeConfig.model,
|
||||
thinkingOptionId: normalizePiThinkingOption(resumeConfig.thinkingOptionId) ?? undefined,
|
||||
systemPrompt: composeSystemPromptParts(
|
||||
resumeConfig.config.systemPrompt,
|
||||
resumeConfig.config.daemonAppendSystemPrompt,
|
||||
launchConfig.systemPrompt,
|
||||
launchConfig.daemonAppendSystemPrompt,
|
||||
),
|
||||
mcpConfigPath: mcpConfig?.path,
|
||||
extensionPaths: [paseoExtension.path],
|
||||
@@ -1926,7 +1916,7 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
try {
|
||||
return new PiRpcAgentSession({
|
||||
runtimeSession,
|
||||
config: resumeConfig.config,
|
||||
config: launchConfig,
|
||||
initialState: await runtimeSession.getState(),
|
||||
capabilities: withPiMcpCapability(mcpConfig !== null),
|
||||
cleanup: combineCleanup([mcpConfig?.cleanup, paseoExtension.cleanup]),
|
||||
@@ -1953,14 +1943,9 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
async listPersistedAgents(
|
||||
options?: ListPersistedAgentsOptions,
|
||||
_options?: ListPersistedAgentsOptions,
|
||||
): Promise<PersistedAgentDescriptor[]> {
|
||||
return await listPiPersistedAgents({
|
||||
...options,
|
||||
provider: PI_PROVIDER,
|
||||
sessionDir: this.providerParams.sessionDir,
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
|
||||
@@ -121,36 +121,6 @@ describe("PiCliRuntime", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("uses the configured command when resuming a session", async () => {
|
||||
const child = createPiChild();
|
||||
replyToCommands(child, () => ({}));
|
||||
const launches: PiRuntimeLaunch[] = [];
|
||||
const runtime = new PiCliRuntime({
|
||||
logger: pino({ level: "silent" }),
|
||||
command: ["pi"],
|
||||
runtimeSettings: {
|
||||
command: {
|
||||
mode: "replace",
|
||||
argv: ["omp"],
|
||||
},
|
||||
},
|
||||
spawnProcess: (launch) => {
|
||||
launches.push(launch);
|
||||
return child;
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.startSession({ cwd: "/workspace/project", session: "/tmp/omp-session.jsonl" });
|
||||
|
||||
expect(launches).toEqual([
|
||||
expect.objectContaining({
|
||||
cwd: "/workspace/project",
|
||||
session: "/tmp/omp-session.jsonl",
|
||||
argv: ["omp", "--mode", "rpc", "--session", "/tmp/omp-session.jsonl"],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("passes an appended system prompt to Pi", async () => {
|
||||
const child = createPiChild();
|
||||
replyToCommands(child, () => ({}));
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
import { open, readdir, readFile, stat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type {
|
||||
AgentPersistenceHandle,
|
||||
AgentTimelineItem,
|
||||
ListPersistedAgentsOptions,
|
||||
PersistedAgentDescriptor,
|
||||
} from "../../agent-sdk-types.js";
|
||||
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
|
||||
import { createRealpathAwarePathMatcher } from "../../../../utils/path.js";
|
||||
|
||||
const PI_PROVIDER = "pi";
|
||||
const PI_CONFIG_DIR_NAME = ".pi";
|
||||
const PI_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR";
|
||||
const PI_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
|
||||
const HEAD_BYTES = 64 * 1024;
|
||||
const TAIL_BYTES = 256 * 1024;
|
||||
const FULL_SCAN_LINE_LIMIT = 2_000;
|
||||
|
||||
interface PiSessionDescriptorOptions extends ListPersistedAgentsOptions {
|
||||
provider?: string;
|
||||
sessionDir?: string;
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
homeDir?: string;
|
||||
}
|
||||
|
||||
interface PiSessionHeader {
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
createdAt: Date | null;
|
||||
}
|
||||
|
||||
interface PiSessionTail {
|
||||
title: string | null;
|
||||
lastActivityAt: Date | null;
|
||||
lastUserMessage: string | null;
|
||||
}
|
||||
|
||||
export async function listPiPersistedAgents(
|
||||
options: PiSessionDescriptorOptions = {},
|
||||
): Promise<PersistedAgentDescriptor[]> {
|
||||
const provider = options.provider ?? PI_PROVIDER;
|
||||
const sessionsDir = await resolvePiSessionsDir(options);
|
||||
const files = await walkJsonlFiles(sessionsDir);
|
||||
const matchesCwd = options.cwd ? createRealpathAwarePathMatcher(options.cwd) : null;
|
||||
const limit = options.limit ?? 20;
|
||||
const descriptors: PersistedAgentDescriptor[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const descriptor = await readPiSessionDescriptor(file, provider);
|
||||
if (!descriptor) continue;
|
||||
if (matchesCwd && !matchesCwd(descriptor.cwd)) continue;
|
||||
descriptors.push(descriptor);
|
||||
}
|
||||
|
||||
return descriptors
|
||||
.sort((left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime())
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
async function resolvePiSessionsDir(options: PiSessionDescriptorOptions): Promise<string> {
|
||||
const env = options.env ?? process.env;
|
||||
const homeDir = options.homeDir ?? homedir();
|
||||
const baseDir = options.cwd ?? process.cwd();
|
||||
|
||||
if (options.sessionDir?.trim()) {
|
||||
return resolveConfigPath(options.sessionDir, { baseDir, homeDir });
|
||||
}
|
||||
|
||||
const agentDir = resolvePiAgentDir({ runtimeSettings: options.runtimeSettings, env, homeDir });
|
||||
|
||||
const envSessionDir =
|
||||
options.runtimeSettings?.env?.[PI_SESSION_DIR_ENV] ?? env[PI_SESSION_DIR_ENV];
|
||||
if (envSessionDir?.trim()) {
|
||||
return resolveConfigPath(envSessionDir, { baseDir, homeDir });
|
||||
}
|
||||
|
||||
const settingsSessionDir = await readConfiguredSessionDir({
|
||||
agentDir,
|
||||
cwd: options.cwd,
|
||||
});
|
||||
if (settingsSessionDir?.trim()) {
|
||||
return resolveConfigPath(settingsSessionDir, { baseDir, homeDir });
|
||||
}
|
||||
|
||||
return path.join(agentDir, "sessions");
|
||||
}
|
||||
|
||||
function resolvePiAgentDir(input: {
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
env: NodeJS.ProcessEnv;
|
||||
homeDir: string;
|
||||
}): string {
|
||||
const configured = input.runtimeSettings?.env?.[PI_AGENT_DIR_ENV] ?? input.env[PI_AGENT_DIR_ENV];
|
||||
if (configured?.trim()) {
|
||||
return resolveConfigPath(configured, { baseDir: process.cwd(), homeDir: input.homeDir });
|
||||
}
|
||||
return path.join(input.homeDir, PI_CONFIG_DIR_NAME, "agent");
|
||||
}
|
||||
|
||||
async function readConfiguredSessionDir(input: {
|
||||
agentDir: string;
|
||||
cwd: string | undefined;
|
||||
}): Promise<string | null> {
|
||||
const values = await Promise.all([
|
||||
readSessionDirFromSettings(path.join(input.agentDir, "settings.json")),
|
||||
input.cwd
|
||||
? readSessionDirFromSettings(path.join(input.cwd, PI_CONFIG_DIR_NAME, "settings.json"))
|
||||
: null,
|
||||
]);
|
||||
return values[1] ?? values[0] ?? null;
|
||||
}
|
||||
|
||||
async function readSessionDirFromSettings(settingsPath: string): Promise<string | null> {
|
||||
try {
|
||||
const parsed = JSON.parse(await readFile(settingsPath, "utf8")) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const sessionDir = Reflect.get(parsed, "sessionDir");
|
||||
return typeof sessionDir === "string" && sessionDir.trim() ? sessionDir : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveConfigPath(value: string, options: { baseDir: string; homeDir: string }): string {
|
||||
if (value === "~") {
|
||||
return options.homeDir;
|
||||
}
|
||||
if (value.startsWith("~/")) {
|
||||
return path.join(options.homeDir, value.slice(2));
|
||||
}
|
||||
return path.isAbsolute(value) ? value : path.resolve(options.baseDir, value);
|
||||
}
|
||||
|
||||
async function walkJsonlFiles(root: string): Promise<string[]> {
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
entries = await readdir(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const entryPath = path.join(root, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return await walkJsonlFiles(entryPath);
|
||||
}
|
||||
return entry.isFile() && entry.name.endsWith(".jsonl") ? [entryPath] : [];
|
||||
}),
|
||||
);
|
||||
return files.flat();
|
||||
}
|
||||
|
||||
async function readPiSessionDescriptor(
|
||||
filePath: string,
|
||||
provider: string,
|
||||
): Promise<PersistedAgentDescriptor | null> {
|
||||
const firstLine = await readFirstLine(filePath);
|
||||
if (!firstLine) return null;
|
||||
const header = parseSessionHeader(firstLine);
|
||||
if (!header) return null;
|
||||
|
||||
const tail = await readTail(filePath).catch(() => "");
|
||||
const tailInfo = parseSessionTail(tail);
|
||||
const headInfo = await scanSessionHead(filePath);
|
||||
const title = tailInfo.title ?? headInfo.title ?? headInfo.firstUserMessage;
|
||||
const lastActivityAt =
|
||||
tailInfo.lastActivityAt ?? (await readFileMtime(filePath)) ?? header.createdAt ?? new Date(0);
|
||||
const timeline = buildPreviewTimeline({
|
||||
firstUserMessage: headInfo.firstUserMessage,
|
||||
lastUserMessage: tailInfo.lastUserMessage,
|
||||
});
|
||||
|
||||
const persistence: AgentPersistenceHandle = {
|
||||
provider,
|
||||
sessionId: header.sessionId,
|
||||
nativeHandle: filePath,
|
||||
metadata: {
|
||||
provider,
|
||||
cwd: header.cwd,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
provider,
|
||||
sessionId: header.sessionId,
|
||||
cwd: header.cwd,
|
||||
title,
|
||||
lastActivityAt,
|
||||
persistence,
|
||||
timeline,
|
||||
};
|
||||
}
|
||||
|
||||
async function readFirstLine(filePath: string): Promise<string | null> {
|
||||
const handle = await open(filePath, "r").catch(() => null);
|
||||
if (!handle) return null;
|
||||
try {
|
||||
const buffer = Buffer.alloc(HEAD_BYTES);
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
||||
if (bytesRead <= 0) return null;
|
||||
const chunk = buffer.subarray(0, bytesRead).toString("utf8");
|
||||
const newlineIndex = chunk.indexOf("\n");
|
||||
return (newlineIndex === -1 ? chunk : chunk.slice(0, newlineIndex)).trim();
|
||||
} finally {
|
||||
await handle.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function readTail(filePath: string): Promise<string> {
|
||||
const fileStats = await stat(filePath);
|
||||
const start = Math.max(0, fileStats.size - TAIL_BYTES);
|
||||
const length = fileStats.size - start;
|
||||
const handle = await open(filePath, "r");
|
||||
try {
|
||||
const buffer = Buffer.alloc(length);
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, start);
|
||||
return buffer.subarray(0, bytesRead).toString("utf8");
|
||||
} finally {
|
||||
await handle.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function readFileMtime(filePath: string): Promise<Date | null> {
|
||||
try {
|
||||
return (await stat(filePath)).mtime;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseSessionHeader(firstLine: string): PiSessionHeader | null {
|
||||
const entry = parseJsonRecord(firstLine);
|
||||
if (!entry || entry.type !== "session") return null;
|
||||
const sessionId = typeof entry.id === "string" ? entry.id : null;
|
||||
const cwd = typeof entry.cwd === "string" ? entry.cwd : null;
|
||||
if (!sessionId || !cwd) return null;
|
||||
const createdAt = parseDate(entry.timestamp);
|
||||
return { sessionId, cwd, createdAt };
|
||||
}
|
||||
|
||||
function parseSessionTail(tail: string): PiSessionTail {
|
||||
const lines = tail.split(/\r?\n/u);
|
||||
let title: string | null = null;
|
||||
let lastActivityAt: Date | null = null;
|
||||
let fallbackTimestamp: Date | null = null;
|
||||
let lastUserMessage: string | null = null;
|
||||
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const entry = parseJsonRecord(lines[index].trim());
|
||||
if (!entry) continue;
|
||||
|
||||
if (!title && entry.type === "session_info") {
|
||||
title = readNonEmptyString(entry.name);
|
||||
}
|
||||
|
||||
const entryTimestamp = parseDate(entry.timestamp);
|
||||
if (!fallbackTimestamp && entryTimestamp) {
|
||||
fallbackTimestamp = entryTimestamp;
|
||||
}
|
||||
|
||||
if (entry.type !== "message") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!lastActivityAt && entryTimestamp) {
|
||||
lastActivityAt = entryTimestamp;
|
||||
}
|
||||
|
||||
if (!lastUserMessage && isRecord(entry.message) && entry.message.role === "user") {
|
||||
lastUserMessage = extractMessageText(entry.message.content);
|
||||
}
|
||||
|
||||
if (title && lastActivityAt && lastUserMessage) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { title, lastActivityAt: lastActivityAt ?? fallbackTimestamp, lastUserMessage };
|
||||
}
|
||||
|
||||
async function scanSessionHead(filePath: string): Promise<{
|
||||
title: string | null;
|
||||
firstUserMessage: string | null;
|
||||
}> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await readFile(filePath, "utf8");
|
||||
} catch {
|
||||
return { title: null, firstUserMessage: null };
|
||||
}
|
||||
|
||||
let title: string | null = null;
|
||||
let firstUserMessage: string | null = null;
|
||||
let lineCount = 0;
|
||||
|
||||
for (const rawLine of content.split(/\r?\n/u)) {
|
||||
lineCount += 1;
|
||||
const entry = parseJsonRecord(rawLine.trim());
|
||||
if (!entry) continue;
|
||||
|
||||
if (entry.type === "session_info") {
|
||||
title = readNonEmptyString(entry.name) ?? title;
|
||||
}
|
||||
|
||||
if (!firstUserMessage && entry.type === "message" && isRecord(entry.message)) {
|
||||
if (entry.message.role === "user") {
|
||||
firstUserMessage = extractMessageText(entry.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
if (title && firstUserMessage) {
|
||||
break;
|
||||
}
|
||||
if (lineCount >= FULL_SCAN_LINE_LIMIT && firstUserMessage) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { title, firstUserMessage };
|
||||
}
|
||||
|
||||
function buildPreviewTimeline(input: {
|
||||
firstUserMessage: string | null;
|
||||
lastUserMessage: string | null;
|
||||
}): AgentTimelineItem[] {
|
||||
const items: AgentTimelineItem[] = [];
|
||||
if (input.firstUserMessage) {
|
||||
items.push({ type: "user_message", text: input.firstUserMessage });
|
||||
}
|
||||
if (input.lastUserMessage && input.lastUserMessage !== input.firstUserMessage) {
|
||||
items.push({ type: "user_message", text: input.lastUserMessage });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function parseJsonRecord(line: string): Record<string, unknown> | null {
|
||||
if (!line) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(line) as unknown;
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function parseDate(value: unknown): Date | null {
|
||||
if (typeof value !== "string" && typeof value !== "number") {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function extractMessageText(content: unknown): string | null {
|
||||
if (typeof content === "string") {
|
||||
return content.trim() || null;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return null;
|
||||
}
|
||||
const text = content
|
||||
.flatMap((part) =>
|
||||
isRecord(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : [],
|
||||
)
|
||||
.join("\n\n")
|
||||
.trim();
|
||||
return text || null;
|
||||
}
|
||||
@@ -26,28 +26,6 @@ export function stripInternalPaseoMcpServer(config: AgentSessionConfig): AgentSe
|
||||
return next;
|
||||
}
|
||||
|
||||
export function withRuntimePaseoMcpServer(params: {
|
||||
config: AgentSessionConfig;
|
||||
agentId: string;
|
||||
mcpBaseUrl: string | null;
|
||||
}): AgentSessionConfig {
|
||||
const storedConfig = stripInternalPaseoMcpServer(params.config);
|
||||
if (!params.mcpBaseUrl || storedConfig.mcpServers?.[PASEO_MCP_SERVER_NAME]) {
|
||||
return storedConfig;
|
||||
}
|
||||
|
||||
return {
|
||||
...storedConfig,
|
||||
mcpServers: {
|
||||
[PASEO_MCP_SERVER_NAME]: {
|
||||
type: "http",
|
||||
url: `${params.mcpBaseUrl}?callerAgentId=${params.agentId}`,
|
||||
},
|
||||
...storedConfig.mcpServers,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isInternalPaseoMcpServer(config: McpServerConfig): boolean {
|
||||
if (config.type !== "http" && config.type !== "sse") {
|
||||
return false;
|
||||
|
||||
@@ -99,6 +99,10 @@ import { AgentManager } from "./agent/agent-manager.js";
|
||||
import { AgentStorage } from "./agent/agent-storage.js";
|
||||
import { attachAgentStoragePersistence } from "./persistence-hooks.js";
|
||||
import { createAgentMcpServer } from "./agent/mcp-server.js";
|
||||
import { createPaseoToolRegistry } from "./agent/paseo-tooling/registry.js";
|
||||
import { mountPaseoToolingHttpAdapter } from "./agent/paseo-tooling/http-adapter.js";
|
||||
import { PaseoToolingRuntime } from "./agent/paseo-tooling/runtime.js";
|
||||
import type { PaseoToolingRegistryOptions } from "./agent/paseo-tooling/registry.js";
|
||||
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
||||
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
|
||||
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
|
||||
@@ -165,6 +169,14 @@ function createAgentMcpBaseUrl(listenTarget: ListenTarget | null): string | null
|
||||
).toString();
|
||||
}
|
||||
|
||||
function createDaemonHttpBaseUrl(listenTarget: ListenTarget | null): string | null {
|
||||
if (!listenTarget || listenTarget.type !== "tcp") {
|
||||
return null;
|
||||
}
|
||||
const host = resolveAgentMcpClientHost(listenTarget.host);
|
||||
return `http://${formatHostForHttpUrl(host)}:${listenTarget.port}`;
|
||||
}
|
||||
|
||||
function summarizeAgentMcpDebugMessage(body: unknown): Record<string, unknown> {
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
||||
return {
|
||||
@@ -542,10 +554,13 @@ export async function createPaseoDaemon(
|
||||
extraClients: config.agentClients,
|
||||
});
|
||||
const initialAgentManagerState = providerSnapshotManager.getAgentManagerProviderState();
|
||||
const paseoToolingRuntime = new PaseoToolingRuntime();
|
||||
paseoToolingRuntime.setEnabled(config.mcpInjectIntoAgents !== false);
|
||||
const agentManager = new AgentManager({
|
||||
clients: initialAgentManagerState.clients,
|
||||
providerDefinitions: initialAgentManagerState.providerDefinitions,
|
||||
registry: agentStorage,
|
||||
paseoToolingRuntime,
|
||||
appendSystemPrompt: config.appendSystemPrompt,
|
||||
logger,
|
||||
});
|
||||
@@ -689,79 +704,85 @@ export async function createPaseoDaemon(
|
||||
const agentMcpRoute = "/mcp/agents";
|
||||
const agentMcpTransports: AgentMcpTransportMap = new Map();
|
||||
|
||||
const createAgentMcpTransport = async (callerAgentId?: string) => {
|
||||
const agentMcpServer = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
terminalManager,
|
||||
getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
|
||||
scheduleService,
|
||||
providerSnapshotManager,
|
||||
github,
|
||||
workspaceGitService,
|
||||
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
||||
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
|
||||
markWorkspaceArchiving: markWorkspaceArchivingExternal,
|
||||
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
|
||||
createPaseoWorktree: async (input, serviceOptions) => {
|
||||
return createPaseoWorktreeWorkflow(
|
||||
{
|
||||
paseoHome: config.paseoHome,
|
||||
worktreesRoot: config.worktreesRoot,
|
||||
createPaseoWorktree: async (workflowInput, workflowOptions) => {
|
||||
return createRegisteredPaseoWorktree(workflowInput, {
|
||||
github,
|
||||
...(workflowOptions?.resolveDefaultBranch
|
||||
? {
|
||||
resolveDefaultBranch: workflowOptions.resolveDefaultBranch,
|
||||
}
|
||||
: {}),
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
});
|
||||
},
|
||||
warmWorkspaceGitData: async (workspace) => {
|
||||
await Promise.all(
|
||||
wsServer
|
||||
?.listActiveSessions()
|
||||
.map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [],
|
||||
);
|
||||
},
|
||||
emitWorkspaceUpdateForCwd: async (cwd, emitOptions) => {
|
||||
await Promise.all(
|
||||
wsServer
|
||||
?.listActiveSessions()
|
||||
.map((session) => session.emitWorkspaceUpdatesForExternalCwds([cwd])) ?? [],
|
||||
);
|
||||
void emitOptions;
|
||||
},
|
||||
cacheWorkspaceSetupSnapshot: () => {},
|
||||
emit: emitExternalSessionMessage,
|
||||
sessionLogger: logger,
|
||||
terminalManager,
|
||||
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
||||
serviceProxy,
|
||||
scriptRuntimeStore,
|
||||
getDaemonTcpPort: () =>
|
||||
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
|
||||
getDaemonTcpHost: () =>
|
||||
boundListenTarget?.type === "tcp" ? boundListenTarget.host : null,
|
||||
serviceProxyPublicBaseUrl,
|
||||
onScriptsChanged: null,
|
||||
const buildPaseoToolingRegistryOptions = (
|
||||
callerAgentId?: string,
|
||||
): PaseoToolingRegistryOptions => ({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
terminalManager,
|
||||
getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
|
||||
scheduleService,
|
||||
providerSnapshotManager,
|
||||
github,
|
||||
workspaceGitService,
|
||||
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
||||
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
|
||||
markWorkspaceArchiving: markWorkspaceArchivingExternal,
|
||||
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
|
||||
createPaseoWorktree: async (input, serviceOptions) => {
|
||||
return createPaseoWorktreeWorkflow(
|
||||
{
|
||||
paseoHome: config.paseoHome,
|
||||
worktreesRoot: config.worktreesRoot,
|
||||
createPaseoWorktree: async (workflowInput, workflowOptions) => {
|
||||
return createRegisteredPaseoWorktree(workflowInput, {
|
||||
github,
|
||||
...(workflowOptions?.resolveDefaultBranch
|
||||
? {
|
||||
resolveDefaultBranch: workflowOptions.resolveDefaultBranch,
|
||||
}
|
||||
: {}),
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
});
|
||||
},
|
||||
input,
|
||||
serviceOptions,
|
||||
);
|
||||
},
|
||||
paseoHome: config.paseoHome,
|
||||
worktreesRoot: config.worktreesRoot,
|
||||
callerAgentId,
|
||||
enableVoiceTools: false,
|
||||
resolveSpeakHandler: (agentId) => wsServer?.resolveVoiceSpeakHandler(agentId) ?? null,
|
||||
resolveCallerContext: (agentId) => wsServer?.resolveVoiceCallerContext(agentId) ?? null,
|
||||
logger,
|
||||
});
|
||||
warmWorkspaceGitData: async (workspace) => {
|
||||
await Promise.all(
|
||||
wsServer
|
||||
?.listActiveSessions()
|
||||
.map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [],
|
||||
);
|
||||
},
|
||||
emitWorkspaceUpdateForCwd: async (cwd, emitOptions) => {
|
||||
await Promise.all(
|
||||
wsServer
|
||||
?.listActiveSessions()
|
||||
.map((session) => session.emitWorkspaceUpdatesForExternalCwds([cwd])) ?? [],
|
||||
);
|
||||
void emitOptions;
|
||||
},
|
||||
cacheWorkspaceSetupSnapshot: () => {},
|
||||
emit: emitExternalSessionMessage,
|
||||
sessionLogger: logger,
|
||||
terminalManager,
|
||||
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
||||
serviceProxy,
|
||||
scriptRuntimeStore,
|
||||
getDaemonTcpPort: () =>
|
||||
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
|
||||
getDaemonTcpHost: () =>
|
||||
boundListenTarget?.type === "tcp" ? boundListenTarget.host : null,
|
||||
serviceProxyPublicBaseUrl,
|
||||
onScriptsChanged: null,
|
||||
},
|
||||
input,
|
||||
serviceOptions,
|
||||
);
|
||||
},
|
||||
paseoHome: config.paseoHome,
|
||||
worktreesRoot: config.worktreesRoot,
|
||||
callerAgentId,
|
||||
enableVoiceTools: false,
|
||||
resolveSpeakHandler: (agentId) => wsServer?.resolveVoiceSpeakHandler(agentId) ?? null,
|
||||
resolveCallerContext: (agentId) => wsServer?.resolveVoiceCallerContext(agentId) ?? null,
|
||||
logger,
|
||||
});
|
||||
|
||||
const createAgentMcpTransport = async (callerAgentId?: string) => {
|
||||
const agentMcpServer = await createAgentMcpServer(
|
||||
buildPaseoToolingRegistryOptions(callerAgentId),
|
||||
);
|
||||
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
@@ -870,6 +891,16 @@ export async function createPaseoDaemon(
|
||||
void runAgentMcpRequest(req, res);
|
||||
};
|
||||
|
||||
mountPaseoToolingHttpAdapter({
|
||||
app,
|
||||
registry: createPaseoToolRegistry({
|
||||
...buildPaseoToolingRegistryOptions(),
|
||||
agentScopedTools: true,
|
||||
}),
|
||||
runtime: paseoToolingRuntime,
|
||||
logger,
|
||||
});
|
||||
|
||||
app.post(agentMcpRoute, handleAgentMcpRequest);
|
||||
app.get(agentMcpRoute, handleAgentMcpRequest);
|
||||
app.delete(agentMcpRoute, handleAgentMcpRequest);
|
||||
@@ -917,10 +948,18 @@ export async function createPaseoDaemon(
|
||||
const logAndResolve = async () => {
|
||||
boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer);
|
||||
const mcpBaseUrl = mcpEnabled ? createAgentMcpBaseUrl(boundListenTarget) : null;
|
||||
const httpBaseUrl = createDaemonHttpBaseUrl(boundListenTarget);
|
||||
agentMcpBaseUrl = config.mcpInjectIntoAgents === false ? null : mcpBaseUrl;
|
||||
agentManager.setMcpBaseUrl(agentMcpBaseUrl);
|
||||
paseoToolingRuntime.setEndpoints({
|
||||
mcpBaseUrl: agentMcpBaseUrl,
|
||||
httpBaseUrl: config.mcpInjectIntoAgents === false ? null : httpBaseUrl,
|
||||
});
|
||||
daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => {
|
||||
agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null);
|
||||
paseoToolingRuntime.setEnabled(value !== false);
|
||||
paseoToolingRuntime.setEndpoints({
|
||||
mcpBaseUrl: value ? mcpBaseUrl : null,
|
||||
httpBaseUrl: value ? httpBaseUrl : null,
|
||||
});
|
||||
});
|
||||
daemonConfigStore.onFieldChange("appendSystemPrompt", (value) => {
|
||||
agentManager.setAppendSystemPrompt(typeof value === "string" ? value : "");
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { appendFileSync, mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createPaseoDaemon } from "./bootstrap.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import { resolvePaseoHome } from "./paseo-home.js";
|
||||
@@ -43,31 +41,6 @@ function isPidAlive(pid: number): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function writeWorkerLifecycleLog(
|
||||
paseoHome: string,
|
||||
message: string,
|
||||
fields: Record<string, unknown> = {},
|
||||
): void {
|
||||
try {
|
||||
const logPath = path.join(paseoHome, "daemon.log");
|
||||
mkdirSync(path.dirname(logPath), { recursive: true });
|
||||
appendFileSync(
|
||||
logPath,
|
||||
`${JSON.stringify({
|
||||
level: "warn",
|
||||
time: new Date().toISOString(),
|
||||
pid: process.pid,
|
||||
name: "DaemonWorker",
|
||||
msg: message,
|
||||
...fields,
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
} catch {
|
||||
// Exit-reason logging must never prevent the worker from exiting.
|
||||
}
|
||||
}
|
||||
|
||||
function bootstrapFromEnvironment(): BootstrapResult {
|
||||
try {
|
||||
const paseoHome = resolvePaseoHome();
|
||||
@@ -97,7 +70,7 @@ function applyCliFlagOverrides(config: ReturnType<typeof loadConfig>): void {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { paseoHome, logger, config } = bootstrapFromEnvironment();
|
||||
const { logger, config } = bootstrapFromEnvironment();
|
||||
let daemon: Awaited<ReturnType<typeof createPaseoDaemon>> | null = null;
|
||||
let shutdownPromise: Promise<number> | null = null;
|
||||
let exitHookInstalled = false;
|
||||
@@ -200,25 +173,13 @@ async function main() {
|
||||
|
||||
const supervisorPid = process.ppid;
|
||||
let lastSupervisorHeartbeatAt = Date.now();
|
||||
let supervisorExitRequested = false;
|
||||
const exitAfterSupervisorLoss = (reason: string) => {
|
||||
if (supervisorExitRequested) {
|
||||
let supervisorShutdownRequested = false;
|
||||
const requestSupervisorShutdown = (reason: string) => {
|
||||
if (supervisorShutdownRequested) {
|
||||
return;
|
||||
}
|
||||
supervisorExitRequested = true;
|
||||
|
||||
writeWorkerLifecycleLog(paseoHome, "Supervisor liveness lost; worker exiting", {
|
||||
reason,
|
||||
supervisorPid,
|
||||
currentParentPid: process.ppid,
|
||||
ipcConnected: typeof process.connected === "boolean" ? process.connected : null,
|
||||
heartbeatAgeMs: Date.now() - lastSupervisorHeartbeatAt,
|
||||
});
|
||||
|
||||
// The supervisor owns the worker's stdout/stderr pipes. Once it is gone,
|
||||
// logging during graceful shutdown can block on the broken pipe and leave
|
||||
// the daemon orphaned, so supervisor loss is a hard process boundary.
|
||||
process.exit(0);
|
||||
supervisorShutdownRequested = true;
|
||||
beginShutdown(reason);
|
||||
};
|
||||
|
||||
process.on("message", (message: unknown) => {
|
||||
@@ -231,23 +192,13 @@ async function main() {
|
||||
lastSupervisorHeartbeatAt = Date.now();
|
||||
}
|
||||
});
|
||||
process.on("disconnect", () => exitAfterSupervisorLoss("ipc_disconnect_event"));
|
||||
process.on("disconnect", () => requestSupervisorShutdown("supervisor disconnect"));
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const ipcConnected = typeof process.connected === "boolean" ? process.connected : true;
|
||||
const heartbeatExpired = Date.now() - lastSupervisorHeartbeatAt > 3500;
|
||||
const supervisorChanged = process.ppid !== supervisorPid;
|
||||
|
||||
if (ipcConnected === false) {
|
||||
exitAfterSupervisorLoss("ipc_disconnected");
|
||||
return;
|
||||
}
|
||||
if (supervisorChanged) {
|
||||
exitAfterSupervisorLoss("supervisor_parent_pid_changed");
|
||||
return;
|
||||
}
|
||||
if (heartbeatExpired && !isPidAlive(supervisorPid)) {
|
||||
exitAfterSupervisorLoss("supervisor_pid_dead");
|
||||
if (ipcConnected === false || !isPidAlive(supervisorPid) || heartbeatExpired) {
|
||||
requestSupervisorShutdown("supervisor disconnect");
|
||||
}
|
||||
}, 1000);
|
||||
timer.unref();
|
||||
|
||||
@@ -151,7 +151,7 @@ const AgentMetadataGenerationSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const BUILTIN_PROVIDER_IDS = ["claude", "codex", "copilot", "opencode", "pi", "omp"] as const;
|
||||
const BUILTIN_PROVIDER_IDS = ["claude", "codex", "copilot", "opencode", "pi"] as const;
|
||||
|
||||
function isLegacyProviderEntry(value: unknown): boolean {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.91-beta.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -43,12 +43,6 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) {
|
||||
>
|
||||
Privacy
|
||||
</a>
|
||||
<a
|
||||
href="/sponsor"
|
||||
className="block text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Sponsor
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -34,12 +34,6 @@ export function SiteHeader() {
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
<a
|
||||
href="/sponsor"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Sponsor
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.gg/jz8T2uahpH"
|
||||
target="_blank"
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
import { Route as rootRouteImport } from "./routes/__root";
|
||||
import { Route as VtcodeRouteImport } from "./routes/vtcode";
|
||||
import { Route as StakpakRouteImport } from "./routes/stakpak";
|
||||
import { Route as SponsorRouteImport } from "./routes/sponsor";
|
||||
import { Route as SigitRouteImport } from "./routes/sigit";
|
||||
import { Route as QwenCodeRouteImport } from "./routes/qwen-code";
|
||||
import { Route as QoderRouteImport } from "./routes/qoder";
|
||||
@@ -71,11 +70,6 @@ const StakpakRoute = StakpakRouteImport.update({
|
||||
path: "/stakpak",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const SponsorRoute = SponsorRouteImport.update({
|
||||
id: "/sponsor",
|
||||
path: "/sponsor",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const SigitRoute = SigitRouteImport.update({
|
||||
id: "/sigit",
|
||||
path: "/sigit",
|
||||
@@ -362,7 +356,6 @@ export interface FileRoutesByFullPath {
|
||||
"/qoder": typeof QoderRoute;
|
||||
"/qwen-code": typeof QwenCodeRoute;
|
||||
"/sigit": typeof SigitRoute;
|
||||
"/sponsor": typeof SponsorRoute;
|
||||
"/stakpak": typeof StakpakRoute;
|
||||
"/vtcode": typeof VtcodeRoute;
|
||||
"/blog/$": typeof BlogSplatRoute;
|
||||
@@ -413,7 +406,6 @@ export interface FileRoutesByTo {
|
||||
"/qoder": typeof QoderRoute;
|
||||
"/qwen-code": typeof QwenCodeRoute;
|
||||
"/sigit": typeof SigitRoute;
|
||||
"/sponsor": typeof SponsorRoute;
|
||||
"/stakpak": typeof StakpakRoute;
|
||||
"/vtcode": typeof VtcodeRoute;
|
||||
"/blog/$": typeof BlogSplatRoute;
|
||||
@@ -467,7 +459,6 @@ export interface FileRoutesById {
|
||||
"/qoder": typeof QoderRoute;
|
||||
"/qwen-code": typeof QwenCodeRoute;
|
||||
"/sigit": typeof SigitRoute;
|
||||
"/sponsor": typeof SponsorRoute;
|
||||
"/stakpak": typeof StakpakRoute;
|
||||
"/vtcode": typeof VtcodeRoute;
|
||||
"/blog/$": typeof BlogSplatRoute;
|
||||
@@ -522,7 +513,6 @@ export interface FileRouteTypes {
|
||||
| "/qoder"
|
||||
| "/qwen-code"
|
||||
| "/sigit"
|
||||
| "/sponsor"
|
||||
| "/stakpak"
|
||||
| "/vtcode"
|
||||
| "/blog/$"
|
||||
@@ -573,7 +563,6 @@ export interface FileRouteTypes {
|
||||
| "/qoder"
|
||||
| "/qwen-code"
|
||||
| "/sigit"
|
||||
| "/sponsor"
|
||||
| "/stakpak"
|
||||
| "/vtcode"
|
||||
| "/blog/$"
|
||||
@@ -626,7 +615,6 @@ export interface FileRouteTypes {
|
||||
| "/qoder"
|
||||
| "/qwen-code"
|
||||
| "/sigit"
|
||||
| "/sponsor"
|
||||
| "/stakpak"
|
||||
| "/vtcode"
|
||||
| "/blog/$"
|
||||
@@ -680,7 +668,6 @@ export interface RootRouteChildren {
|
||||
QoderRoute: typeof QoderRoute;
|
||||
QwenCodeRoute: typeof QwenCodeRoute;
|
||||
SigitRoute: typeof SigitRoute;
|
||||
SponsorRoute: typeof SponsorRoute;
|
||||
StakpakRoute: typeof StakpakRoute;
|
||||
VtcodeRoute: typeof VtcodeRoute;
|
||||
}
|
||||
@@ -701,13 +688,6 @@ declare module "@tanstack/react-router" {
|
||||
preLoaderRoute: typeof StakpakRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/sponsor": {
|
||||
id: "/sponsor";
|
||||
path: "/sponsor";
|
||||
fullPath: "/sponsor";
|
||||
preLoaderRoute: typeof SponsorRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/sigit": {
|
||||
id: "/sigit";
|
||||
path: "/sigit";
|
||||
@@ -1116,7 +1096,6 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
QoderRoute: QoderRoute,
|
||||
QwenCodeRoute: QwenCodeRoute,
|
||||
SigitRoute: SigitRoute,
|
||||
SponsorRoute: SponsorRoute,
|
||||
StakpakRoute: StakpakRoute,
|
||||
VtcodeRoute: VtcodeRoute,
|
||||
};
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { SiteShell } from "~/components/site-shell";
|
||||
import { pageMeta } from "~/meta";
|
||||
|
||||
export const Route = createFileRoute("/sponsor")({
|
||||
head: () =>
|
||||
pageMeta(
|
||||
"Sponsor Paseo",
|
||||
"Support the independent open-source project behind Paseo. Built by Mo, funded by the community.",
|
||||
"/sponsor",
|
||||
),
|
||||
component: Sponsor,
|
||||
});
|
||||
|
||||
function Sponsor() {
|
||||
return (
|
||||
<SiteShell>
|
||||
<h1 className="text-3xl font-medium tracking-tight mb-8">Sponsor</h1>
|
||||
|
||||
{/* Founder note */}
|
||||
<div className="space-y-6 text-white/70 leading-relaxed max-w-2xl">
|
||||
<p className="font-medium">
|
||||
Hey, I'm{" "}
|
||||
<a
|
||||
href="https://github.com/boudra"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-white/80"
|
||||
>
|
||||
Mo
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Paseo started as a personal project: I wanted to run coding agents from anywhere without
|
||||
giving up control of my code, my keys, or my workflow. It grew into something bigger
|
||||
because developers felt the same need: a single place to orchestrate agents across
|
||||
devices, without vendor lock-in.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
I work on Paseo full-time. It's an independent, self-funded open-source project. I
|
||||
don't have investors, a board, or a big team. I'm just shipping software the
|
||||
best I know how and hope it's useful to other people.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Sponsorship is what makes that sustainable. It lets me stay focused on the product instead
|
||||
of chasing monetization, and it sends a clear signal that independent, open-source tools
|
||||
have a place in developer workflows.
|
||||
</p>
|
||||
|
||||
<p>Thank you.</p>
|
||||
</div>
|
||||
|
||||
{/* Sponsor links */}
|
||||
<section className="mt-12 space-y-4">
|
||||
<h2 className="text-xl font-medium">Support Paseo</h2>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<a
|
||||
href="https://github.com/sponsors/boudra"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 rounded-xl border border-white/10 bg-white/[0.03] p-5 hover:border-white/20 hover:bg-white/[0.05] transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="text-white/60"
|
||||
>
|
||||
<path d="M12 0C5.37 0 0 5.484 0 12.252c0 5.418 3.438 10.013 8.205 11.637.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.738-4.042-1.61-4.042-1.61-.546-1.403-1.333-1.776-1.333-1.776-1.089-.756.084-.741.084-.741 1.205.087 1.838 1.262 1.838 1.262 1.07 1.87 2.809 1.33 3.495 1.017.108-.79.417-1.33.76-1.636-2.665-.31-5.467-1.35-5.467-6.005 0-1.327.465-2.413 1.235-3.262-.124-.31-.535-1.556.117-3.243 0 0 1.008-.33 3.3 1.248a11.2 11.2 0 0 1 3.003-.404c1.02.005 2.045.138 3.003.404 2.29-1.578 3.297-1.248 3.297-1.248.653 1.687.242 2.933.118 3.243.77.85 1.233 1.935 1.233 3.262 0 4.667-2.807 5.692-5.48 5.995.43.38.823 1.133.823 2.285 0 1.65-.015 2.98-.015 3.386 0 .315.218.694.825.576C20.565 22.26 24 17.667 24 12.252 24 5.484 18.627 0 12 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="font-medium text-white">GitHub Sponsors</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://opencollective.com/paseo-ai/donate?interval=month&amount=10&contributeAs=me"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 rounded-xl border border-white/10 bg-white/[0.03] p-5 hover:border-white/20 hover:bg-white/[0.05] transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 39.98 40.05"
|
||||
className="text-white/60"
|
||||
>
|
||||
<path
|
||||
d="M32.76 19.97c0 2.53-.73 4.94-2 6.94l5.14 5.16c2.5-3.36 4.08-7.57 4.08-12.1 0-4.5-1.57-8.7-4.08-12.08l-5.13 5.16c1.26 2 2 4.3 2 6.93z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M20 32.8c-7.02 0-12.78-5.78-12.78-12.83 0-7.04 5.76-12.82 12.77-12.82 2.6 0 4.9.73 6.9 2.1l5.13-5.15C28.68 1.58 24.5 0 20 0 9 0 0 8.94 0 20.08s9 19.97 20 19.97c4.6 0 8.8-1.57 12.14-4.1L27 30.8c-1.98 1.26-4.4 2-7 2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p className="font-medium text-white">Open Collective</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://buymeacoffee.com/paseo"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 rounded-xl border border-white/10 bg-white/[0.03] p-5 hover:border-white/20 hover:bg-white/[0.05] transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
className="text-white/60"
|
||||
>
|
||||
<path
|
||||
d="M20.216 6.415l-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 00-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 00-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 01-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 013.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 01-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 01-4.743.295 37.059 37.059 0 01-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0011.343.376.483.483 0 01.535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 01.39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 01-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a5.884 5.884 0 01-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38 0 0 1.243.065 1.658.065.447 0 1.786-.065 1.786-.065.783 0 1.434-.6 1.499-1.38l.94-9.95a3.996 3.996 0 00-1.322-.238c-.826 0-1.491.284-2.26.613z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p className="font-medium text-white">Buy Me a Coffee</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
@@ -42,7 +42,6 @@ function discoverAgentRoutes(): string[] {
|
||||
"docs",
|
||||
"download",
|
||||
"index",
|
||||
"sponsor",
|
||||
"privacy",
|
||||
]);
|
||||
return fs
|
||||
|
||||
@@ -45,7 +45,7 @@ Pick any of these from the in-app provider catalog. Each entry is a one-click in
|
||||
- [Hermes Agent](https://hermes-agent.nousresearch.com/docs/user-guide/features/acp), Nous Research's self-improving agent.
|
||||
- [Junie](https://junie.jetbrains.com/docs/junie-cli-acp.html), JetBrains' coding agent.
|
||||
- [Kilo Code](https://kilo.ai/docs/code-with-ai/platforms/cli), open-source coding agent.
|
||||
- [Kimi Code CLI](https://github.com/MoonshotAI/kimi-code), Moonshot AI's coding assistant.
|
||||
- [Kimi Code CLI](https://github.com/MoonshotAI/kimi-cli), Moonshot AI's coding assistant.
|
||||
- [Minion Code](https://github.com/femto/minion-code), Minion-framework coding agent.
|
||||
- [Mistral Vibe](https://github.com/mistralai/mistral-vibe), Mistral's open-source CLI assistant.
|
||||
- [Nova](https://www.compassap.ai/portfolio/nova.html), Compass AI's software engineer.
|
||||
|
||||
Reference in New Issue
Block a user