Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 3448e8b023 Keep agent tools bound to active sessions 2026-06-08 14:47:39 +07:00
157 changed files with 5240 additions and 7485 deletions
-2
View File
@@ -6,8 +6,6 @@ body:
- type: markdown
attributes:
value: |
I'm a solo maintainer and don't always keep up with GitHub Issues daily. If something is urgent or blocking you, [Discord](https://discord.gg/jz8T2uahpH) is the fastest place to reach me.
Before opening, please:
- search existing issues for the same symptom
+1 -1
View File
@@ -2,4 +2,4 @@ blank_issues_enabled: false
contact_links:
- name: Discord
url: https://discord.gg/jz8T2uahpH
about: Urgent or blocking issues, quick questions, sharing a video of a bug, or anything that's better as a chat.
about: Quick questions, sharing a video of a bug, or anything that's better as a chat. A lot of issues start better here.
+2 -49
View File
@@ -20,10 +20,6 @@ env:
jobs:
format:
runs-on: ubuntu-latest
env:
# This job never executes Electron. Skipping the hosted binary avoids
# unrelated npm ci failures when Electron's CDN returns 504.
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
@@ -40,8 +36,6 @@ jobs:
lint:
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
@@ -64,8 +58,6 @@ jobs:
typecheck:
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
@@ -96,8 +88,6 @@ jobs:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
name: server-tests (${{ matrix.os }})
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
with:
@@ -141,35 +131,8 @@ jobs:
node-version: "22"
cache: "npm"
- name: Install dependencies with Electron retry
if: runner.os != 'Windows'
run: |
for attempt in 1 2 3; do
if npm ci; then
exit 0
else
exit_code=$?
fi
if [ "$attempt" -eq 3 ]; then
exit $exit_code
fi
sleep $((attempt * 20))
done
- name: Install dependencies with Electron retry
if: runner.os == 'Windows'
shell: pwsh
run: |
for ($attempt = 1; $attempt -le 3; $attempt++) {
npm ci
if ($LASTEXITCODE -eq 0) {
exit 0
}
if ($attempt -eq 3) {
exit $LASTEXITCODE
}
Start-Sleep -Seconds (20 * $attempt)
}
- name: Install dependencies
run: npm ci
- name: Build server stack
run: npm run build:server
@@ -179,8 +142,6 @@ jobs:
app-tests:
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
@@ -203,8 +164,6 @@ jobs:
sdk-tests:
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
@@ -230,8 +189,6 @@ jobs:
playwright:
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
@@ -272,8 +229,6 @@ jobs:
relay-tests:
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
@@ -298,8 +253,6 @@ jobs:
shard: [1, 2, 3]
runs-on: ubuntu-latest
name: cli-tests (shard ${{ matrix.shard }}/3)
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
-75
View File
@@ -1,80 +1,5 @@
# Changelog
## 0.1.93 - 2026-06-10
### Added
- **Claude Fable 5 is available in the Claude model picker** ([#1443](https://github.com/getpaseo/paseo/pull/1443) by [@0-Captain](https://github.com/0-Captain))
## 0.1.92 - 2026-06-10
### Added
- **Skills autocomplete inside prompts**
### Improved
- Provider catalog is inline in Host settings ([#1423](https://github.com/getpaseo/paseo/pull/1423))
- Manual update checks skip staged rollout delays
- CodeWhale replaces DeepSeek TUI in the provider catalog
- ACP provider catalog entries are updated for Cline, Codebuddy Code, DimCode, Factory Droid, Gemini, Nova, and Qoder
- OMP has its own icon and website page
- Model selector descriptions are clearer
- ACP provider errors show the provider's real failure message
### Fixed
- New Paseo worktree branches can push their first commits
- Imported sessions no longer open blank or in the wrong workspace
- Windows Explorer opens the selected workspace instead of Documents ([#1412](https://github.com/getpaseo/paseo/pull/1412) by [@bjspi](https://github.com/bjspi))
- Windows editor shortcuts installed as command shims launch correctly ([#1387](https://github.com/getpaseo/paseo/pull/1387) by [@Peter7896](https://github.com/Peter7896))
- ACP providers that cannot use MCP servers can start correctly
- Removed hosts no longer leave host pages stuck connecting
- File preview links open in your external browser
- Chat stays pinned to the latest message while output streams
- The mobile composer send button no longer shifts while typing
## 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
-4
View File
@@ -32,10 +32,6 @@
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
</p>
> [!NOTE]
> I'm a solo maintainer and don't always keep up with GitHub Issues daily.
> If something is urgent or blocking you, [Discord](https://discord.gg/jz8T2uahpH) is the fastest place to reach me.
---
Run agents in parallel on your own machines. Ship from your phone or your desk.
+1 -1
View File
@@ -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 |
| ------------------ | ------------------------------------ | -------------------------------------------------- |
+16 -71
View File
@@ -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).
---
@@ -409,7 +374,7 @@ The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open st
ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout.
Paseo also ships an in-app ACP provider catalog for common agents, including CodeWhale, Cursor, DeepAgents, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Catalog entries create the same `extends: "acp"` provider config shown below.
Paseo also ships an in-app ACP provider catalog for common agents, including Cursor, DeepAgents, DeepSeek TUI, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Catalog entries create the same `extends: "acp"` provider config shown below.
### Adding a generic ACP provider
@@ -438,25 +403,6 @@ Required fields for ACP providers:
- `label`
- `command` — the command to spawn the agent process (must support ACP over stdio)
By default, Paseo injects its internal MCP server into ACP providers so agents can use Paseo tools such as subagent creation. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `params.supportsMcpServers: false`:
```json
{
"agents": {
"providers": {
"my-agent": {
"extends": "acp",
"label": "My Agent",
"command": ["my-agent", "acp"],
"params": {
"supportsMcpServers": false
}
}
}
}
}
```
### Generic ACP diagnostics
Paseo diagnostics for `extends: "acp"` providers report the configured command, resolved launcher binary, version output, ACP `initialize`, ACP `session/new`, model count, modes, and final status.
@@ -593,19 +539,18 @@ When an `additionalModels` entry has the same `id` as a discovered model, it upd
Every entry under `agents.providers` accepts these fields:
| Field | Type | Required | Description |
| ------------------ | ------------------------- | ----------------- | ------------------------------------------------------------------ |
| `extends` | `string` | Yes (custom only) | Built-in provider ID to inherit from, or `"acp"` |
| `label` | `string` | Yes (custom only) | Display name in the UI |
| `description` | `string` | No | Short description shown in the UI |
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
| `params` | `Record<string, unknown>` | No | Provider-specific options such as `supportsMcpServers: false` |
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
| `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) |
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
| `order` | `number` | No | Sort order in the provider list |
| Field | Type | Required | Description |
| ------------------ | ------------------------ | ----------------- | ------------------------------------------------------------------ |
| `extends` | `string` | Yes (custom only) | Built-in provider ID to inherit from, or `"acp"` |
| `label` | `string` | Yes (custom only) | Display name in the UI |
| `description` | `string` | No | Short description shown in the UI |
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
| `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) |
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
| `order` | `number` | No | Sort order in the provider list |
### Model definition
@@ -659,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
View File
@@ -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).
+2 -2
View File
@@ -70,8 +70,8 @@ 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
- One-click ACP provider catalog: CodeWhale, Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
- 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)
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
+3 -13
View File
@@ -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.
@@ -36,8 +34,6 @@ Every provider adapter owns its canonical user-message timeline rows. When a for
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
---
## Provider Snapshot Refresh Contract
@@ -266,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[] = [
@@ -317,13 +313,7 @@ interface AgentClient {
isAvailable(): Promise<boolean>;
// Optional:
listModes?(options: ListModesOptions): Promise<AgentMode[]>;
listImportableSessions?(
options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]>;
importSession?(
input: ImportProviderSessionInput,
context: ImportProviderSessionContext,
): Promise<ImportedProviderSession>;
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
getDiagnostic?(): Promise<{ diagnostic: string }>;
}
```
+2 -2
View File
@@ -89,7 +89,7 @@ Use the beta path when you need to:
## Staged rollout (stable channel)
Stable desktop releases go out via a linear time-based rollout for automatic update checks: 0% admitted when the updater manifests appear, 100% admitted 36 hours later, linear ramp in between. Manual checks bypass the rollout so a user can install immediately when they click **Check**. Beta releases bypass the rollout entirely — beta users always receive updates immediately.
Stable desktop releases go out via a linear time-based rollout: 0% admitted when the updater manifests appear, 100% admitted 36 hours later, linear ramp in between. Beta releases bypass the rollout entirely — beta users always receive updates immediately.
The rollout is driven by a `rolloutHours` field stamped into the GitHub Release manifests (`latest-mac.yml`, `latest-linux.yml`, `latest.yml`) by the `finalize-rollout` job in `desktop-release.yml`.
@@ -173,7 +173,7 @@ If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1.<N+
- **No pause / kill switch.** Once a stable user is admitted, they will install the update on next quit (`autoInstallOnAppQuit = true`). To stop new admissions, ship a superseding release. To "recall" already-admitted users, ship a hotfix `+1` patch.
- **No rollback.** `allowDowngrade = false`. Bad release = ship a hotfix.
- **Bootstrap caveat.** Clients running a build older than the rollout feature ignore `rolloutHours` and admit immediately. Rollout protection only applies to clients running the rollout-aware version or later.
- **Up to ~30 min automatic admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window. Clicking **Check** is manual and bypasses rollout admission.
- **Up to ~30 min admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window.
## Mobile builds (EAS)
+1 -1
View File
@@ -1 +1 @@
sha256-YZNXy7OyeGAmQqbNObKeuKOpM+8ova8djd5BDBUH2+A=
sha256-EpxQim3vnwfPLPi8IAtF82tGrLNZiZP8IwN7rR9P7xo=
+21 -21
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.93",
"version": "0.1.91-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.93",
"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.93",
"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.93",
"version": "0.1.91-beta.2",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.93",
"@getpaseo/protocol": "0.1.93",
"@getpaseo/server": "0.1.93",
"@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.93",
"version": "0.1.91-beta.2",
"dependencies": {
"@getpaseo/protocol": "0.1.93",
"@getpaseo/relay": "0.1.93",
"@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.93",
"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.93",
"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.93",
"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.93",
"version": "0.1.91-beta.2",
"dependencies": {
"zod": "^3.23.8"
},
@@ -37992,7 +37992,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.93",
"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.93",
"version": "0.1.91-beta.2",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/client": "0.1.93",
"@getpaseo/highlight": "0.1.93",
"@getpaseo/protocol": "0.1.93",
"@getpaseo/relay": "0.1.93",
"@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.93",
"version": "0.1.91-beta.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.93",
"version": "0.1.91-beta.2",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [
-1
View File
@@ -1,4 +1,3 @@
source "https://rubygems.org"
gem "fastlane", "~> 2.234"
gem "multi_json"
@@ -4,7 +4,7 @@ import { getServerId } from "./helpers/server-id";
import {
expectProviderInstalledInSettings,
installAcpCatalogProvider,
openAddProviderArea,
openAddProviderModal,
openSettingsHost,
openSettingsHostSection,
} from "./helpers/settings";
@@ -21,7 +21,7 @@ test.describe("ACP provider catalog", () => {
await openSettingsHost(page, getServerId());
// Providers moved to their own host section; add-provider lives there now.
await openSettingsHostSection(page, getServerId(), "providers");
await openAddProviderArea(page);
await openAddProviderModal(page);
await installAcpCatalogProvider(page, ACP_PROVIDER.name);
await expectProviderInstalledInSettings(page, ACP_PROVIDER.name);
+105 -50
View File
@@ -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);
-12
View File
@@ -43,18 +43,6 @@ export interface SeedDaemonClient {
fetchAgents(options?: { scope?: "active" }): Promise<{
entries: Array<{ agent: { id: string; cwd: string; title?: string | null } }>;
}>;
fetchRecentProviderSessions(options: {
cwd: string;
providers: string[];
limit: number;
}): Promise<{
entries: Array<{
providerId: string;
providerHandleId: string;
cwd: string;
firstPromptPreview?: string | null;
}>;
}>;
updateAgent(agentId: string, updates: { name?: string }): Promise<void>;
waitForAgentUpsert(
agentId: string,
+3 -14
View File
@@ -180,18 +180,6 @@ export async function goBackInSettings(page: Page): Promise<void> {
await page.getByRole("button", { name: "Back", exact: true }).click();
}
export async function closeCompactSettings(page: Page): Promise<void> {
await goBackInSettings(page);
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
}
export async function removeCurrentHostFromSettings(page: Page): Promise<void> {
await page.getByTestId("host-page-remove-host-button").click();
await expect(page.getByTestId("remove-host-confirm-modal")).toBeVisible();
await page.getByTestId("remove-host-confirm").click();
await expect(page).toHaveURL(/\/settings$/);
}
export async function expectSettingsBackButton(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
}
@@ -331,8 +319,8 @@ export async function serveJson(page: Page, url: string, body: unknown): Promise
});
}
export async function openAddProviderArea(page: Page): Promise<void> {
await page.getByTestId("host-page-add-provider-card").scrollIntoViewIfNeeded();
export async function openAddProviderModal(page: Page): Promise<void> {
await page.getByRole("button", { name: "Add provider", exact: true }).click();
await expect(page.getByRole("textbox", { name: "Search providers" })).toBeVisible();
}
@@ -344,6 +332,7 @@ export async function findAcpCatalogProvider(page: Page, providerName: string):
export async function installAcpCatalogProvider(page: Page, providerName: string): Promise<void> {
await findAcpCatalogProvider(page, providerName);
await page.getByRole("button", { name: "Add", exact: true }).click();
await expect(page.getByRole("textbox", { name: "Search providers" })).toHaveCount(0);
}
export async function expectProviderInstalledInSettings(
@@ -1,230 +0,0 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import { spawn } from "node:child_process";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test, type Page } from "./fixtures";
import { connectSeedClient, type SeededWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
const OPENCODE_REAL_MODEL = "openrouter/google/gemini-2.5-flash-lite";
const OPENCODE_SEED_TIMEOUT_MS = 45_000;
const PASEO_REPO_PATH = path.resolve(__dirname, "../../..");
interface OpenCodeSeedResult {
stdout: string;
stderr: string;
code: number | null;
signal: NodeJS.Signals | null;
timedOut: boolean;
}
interface ImportableOpenCodeSession {
providerHandleId: string;
}
interface OpenCodeImportScenario {
workspace: SeededWorkspace;
prompt: string;
promptPreview: string;
response: string;
}
let workspace: SeededWorkspace | null = null;
test.setTimeout(150_000);
test.afterEach(async () => {
await workspace?.cleanup().catch(() => undefined);
workspace = null;
});
test("imports a real OpenCode session from the workspace import sheet", async ({ page }) => {
const scenario = await seedPaseoWorkspaceWithOpenCodeSession();
workspace = scenario.workspace;
const importableSession = await waitForImportableOpenCodeSession(scenario);
await openWorkspace(page, scenario.workspace);
await importOpenCodeSession(page, importableSession);
await expectImportSheetClosed(page);
await expectImportedSessionOpen(page, scenario);
});
async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportScenario> {
const response = `PASEO_OPENCODE_IMPORT_E2E_OK_${randomUUID().slice(0, 8)}`;
const prompt = `Do not use tools. Reply with exactly: ${response}`;
const promptPreview = JSON.stringify(prompt);
await launchOpenCodeSessionInWorkspace(PASEO_REPO_PATH, prompt);
const client = await connectSeedClient();
try {
const opened = await client.openProject(PASEO_REPO_PATH);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${PASEO_REPO_PATH}`);
}
return {
prompt,
promptPreview,
response,
workspace: {
client,
repoPath: PASEO_REPO_PATH,
workspaceId: opened.workspace.id,
workspaceName: opened.workspace.name,
workspaceDirectory: opened.workspace.workspaceDirectory,
projectId: opened.workspace.projectId,
projectDisplayName: opened.workspace.projectDisplayName,
cleanup: async () => {
await client.close().catch(() => undefined);
},
},
};
} catch (error) {
await client.close().catch(() => undefined);
throw error;
}
}
async function launchOpenCodeSessionInWorkspace(repoPath: string, prompt: string): Promise<void> {
const result = await runOpenCodeSeed(repoPath, prompt);
if (result.code !== 0 || result.timedOut) {
throw new Error(formatOpenCodeLaunchError(result, prompt));
}
}
function openCodeSeedArgs(repoPath: string, prompt: string): string[] {
return [
"run",
"--print-logs",
"--log-level",
"INFO",
"--dir",
repoPath,
"--model",
OPENCODE_REAL_MODEL,
"--format",
"json",
prompt,
];
}
function runOpenCodeSeed(repoPath: string, prompt: string): Promise<OpenCodeSeedResult> {
return new Promise((resolve, reject) => {
const child = spawn("opencode", openCodeSeedArgs(repoPath, prompt), {
cwd: repoPath,
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
}, OPENCODE_SEED_TIMEOUT_MS);
child.stdout.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
child.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
child.on("close", (code, signal) => {
clearTimeout(timeout);
resolve({ stdout, stderr, code, signal, timedOut });
});
});
}
function formatOpenCodeLaunchError(result: OpenCodeSeedResult, prompt: string): string {
return [
"OpenCode launch failed",
`command: ${["opencode", ...openCodeSeedArgs(PASEO_REPO_PATH, prompt)].join(" ")}`,
`exit: ${result.code ?? "null"}`,
result.signal ? `signal: ${result.signal}` : null,
result.timedOut ? `timed out after ${OPENCODE_SEED_TIMEOUT_MS}ms` : null,
result.stdout.trim() ? `stdout:\n${result.stdout.trim()}` : null,
result.stderr.trim() ? `stderr:\n${result.stderr.trim()}` : null,
]
.filter((part): part is string => part !== null)
.join("\n\n");
}
async function openWorkspace(page: Page, seed: SeededWorkspace): Promise<void> {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), seed.workspaceId));
await waitForWorkspaceTabsVisible(page);
}
async function waitForImportableOpenCodeSession(
scenario: OpenCodeImportScenario,
): Promise<ImportableOpenCodeSession> {
let importableSession: ImportableOpenCodeSession | null = null;
await expect
.poll(
async () => {
importableSession = await findImportableOpenCodeSession(scenario);
return importableSession?.providerHandleId ?? "";
},
{
timeout: 15_000,
intervals: [500, 1_000],
},
)
.not.toBe("");
return importableSession!;
}
async function findImportableOpenCodeSession(
scenario: OpenCodeImportScenario,
): Promise<ImportableOpenCodeSession | null> {
const sessions = await scenario.workspace.client.fetchRecentProviderSessions({
cwd: scenario.workspace.repoPath,
providers: ["opencode"],
limit: 5,
});
const entry = sessions.entries.find(
(session) =>
session.providerId === "opencode" && session.firstPromptPreview === scenario.promptPreview,
);
if (!entry) {
return null;
}
return { providerHandleId: entry.providerHandleId };
}
async function importOpenCodeSession(
page: Page,
session: ImportableOpenCodeSession,
): Promise<void> {
await page.getByRole("button", { name: "Workspace actions" }).click();
await page.getByTestId("workspace-header-import-agent").click();
await expect(page.getByTestId("import-session-sheet")).toBeVisible({ timeout: 15_000 });
const importSheet = page.getByTestId("import-session-sheet");
const sessionRow = importSheet.getByTestId(
`import-session-session-opencode-${session.providerHandleId}`,
);
await expect(sessionRow).toBeVisible({ timeout: 60_000 });
await sessionRow.click();
}
async function expectImportSheetClosed(page: Page): Promise<void> {
await expect(page.getByTestId("import-session-sheet")).toHaveCount(0, { timeout: 15_000 });
}
async function expectImportedSessionOpen(
page: Page,
scenario: OpenCodeImportScenario,
): Promise<void> {
await expect(
page.locator('[data-testid="user-message"]', { hasText: scenario.promptPreview }),
).toBeVisible({ timeout: 30_000 });
await expect(
page.locator('[data-testid="assistant-message"]', { hasText: scenario.response }),
).toBeVisible({ timeout: 30_000 });
}
+9 -16
View File
@@ -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,9 +1,7 @@
import { test, expect } from "./fixtures";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { gotoAppShell, openSettings } from "./helpers/app";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import {
closeCompactSettings,
openSettingsSection,
expectSettingsHeader,
openAddHostFlow,
@@ -33,18 +31,9 @@ import {
selectSettingsHost,
expectSettingsHostPickerLabel,
openSettingsHostSection,
removeCurrentHostFromSettings,
} from "./helpers/settings";
import { getServerId } from "./helpers/server-id";
async function openWorkspace(
page: import("@playwright/test").Page,
workspace: { workspaceId: string },
) {
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
await expect(page.getByTestId("menu-button")).toBeVisible();
}
test.describe("Settings sidebar navigation", () => {
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
await gotoAppShell(page);
@@ -198,20 +187,4 @@ test.describe("Settings — compact master-detail", () => {
await openSettingsHostSection(page, secondaryServerId, "connections");
});
test("removing the last active host returns to welcome after settings closes", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "remove-host-compact-" });
await openWorkspace(page, workspace);
await openCompactSettings(page);
await openSettingsHostSection(page, getServerId(), "host");
await removeCurrentHostFromSettings(page);
await closeCompactSettings(page);
await expect(page).toHaveURL(/\/welcome$/);
await expect(page.getByTestId("welcome-direct-connection")).toBeVisible();
});
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.93",
"version": "0.1.91-beta.2",
"private": true,
"main": "index.ts",
"scripts": {
@@ -22,7 +22,7 @@
"test": "vitest run",
"test:browser": "vitest run --project browser",
"test:e2e": "playwright test --project='Desktop Chrome'",
"test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider",
"test:e2e:real": "playwright test --project=real-provider",
"test:e2e:ui": "playwright test --ui",
"build": "npm run build:web",
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
+1 -1
View File
@@ -22,7 +22,7 @@ export default defineConfig({
baseURL,
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: process.env.E2E_RECORD_VIDEO === "1" ? "on" : "retain-on-failure",
video: "retain-on-failure",
},
projects: [
{
@@ -560,95 +560,6 @@ describe("bottom anchor controller driver", () => {
expect(harness.driver.getSnapshot().mode).toBe("detached");
});
it("keeps initial native content growth anchored before layout scroll events arrive", () => {
const harness = createDriverHarness({
transportBehavior: {
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
},
measurementState: createMeasurementState({
containerKey: "native-virtualized",
viewportWidth: 0,
viewportHeight: 0,
contentHeight: 0,
offsetY: 0,
viewportMeasuredForKey: null,
contentMeasuredForKey: null,
}),
});
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 0;
});
harness.context.measurementState.contentHeight = 1348;
harness.context.measurementState.contentMeasuredForKey = "native-virtualized";
harness.driver.handleContentSizeChange({
previousContentHeight: 0,
contentHeight: 1348,
});
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingVerification: {
requestId: null,
},
});
harness.context.measurementState.viewportWidth = 390;
harness.context.measurementState.viewportHeight = 546;
harness.context.measurementState.viewportMeasuredForKey = "native-virtualized";
harness.context.measurementState.offsetY = 50;
harness.context.nearBottom = false;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 50,
});
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
});
it("keeps native sticky content changes anchored when measured height is unchanged", () => {
const harness = createDriverHarness({
transportBehavior: {
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
},
measurementState: createMeasurementState({
containerKey: "native-virtualized",
viewportWidth: 390,
viewportHeight: 546,
contentHeight: 546,
offsetY: 0,
viewportMeasuredForKey: "native-virtualized",
contentMeasuredForKey: "native-virtualized",
}),
});
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 0;
harness.context.nearBottom = true;
});
harness.driver.prepareForStickyContentChange();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingVerification: {
requestId: null,
},
});
harness.context.measurementState.offsetY = 50;
harness.context.nearBottom = false;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 50,
});
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
});
});
describe("controller helper predicates", () => {
@@ -244,6 +244,29 @@ function createBottomAnchorControllerDriver(
let stickyMeasurementRevision = 0;
let lastVerifiedStickyMeasurementRevision = 0;
const _getLogContext = (extra?: Record<string, unknown>) => {
const measurementState = input.getMeasurementState();
const distanceFromBottom = Math.max(
0,
measurementState.contentHeight - (measurementState.offsetY + measurementState.viewportHeight),
);
return {
agentId: input.getAgentId(),
requestReason: pendingRequest?.reason ?? null,
authoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(),
contentHeight: measurementState.contentHeight,
viewportHeight: measurementState.viewportHeight,
offset: measurementState.offsetY,
distanceFromBottom,
renderStrategy: input.getRenderStrategy(),
blockedReason,
mode,
containerKey: measurementState.containerKey,
transportBehavior: input.getTransportBehavior(),
...extra,
};
};
const setBlockedReason = (nextBlockedReason: BottomAnchorBlockedReason | null) => {
if (blockedReason === nextBlockedReason) {
return;
@@ -304,6 +327,7 @@ function createBottomAnchorControllerDriver(
});
const scheduleVerification = (attemptContext: AttemptContext, delayFramesOverride?: number) => {
const _scheduledMeasurementState = input.getMeasurementState();
if (verificationHandle) {
input.cancelFrame(verificationHandle);
}
@@ -536,12 +560,6 @@ function createBottomAnchorControllerDriver(
});
if (shouldRestick && !pendingRequest) {
pendingVerification = { requestId: null, retries: 0 };
if (attemptHandle) {
input.cancelFrame(attemptHandle);
attemptHandle = null;
}
runAttempt(false);
return;
}
if (shouldRestick || pendingRequest) {
evaluate(false, "content_size_change");
@@ -558,16 +576,6 @@ function createBottomAnchorControllerDriver(
return;
}
markStickyMeasurementChanged();
if (!pendingRequest) {
pendingVerification = { requestId: null, retries: 0 };
if (attemptHandle) {
input.cancelFrame(attemptHandle);
attemptHandle = null;
}
runAttempt(false);
return;
}
evaluate(false, "content_size_change");
},
handleScrollNearBottomChange(params) {
const { nextIsNearBottom, scrollDelta } = params;
@@ -648,7 +656,11 @@ export const __private__ = {
previousContentHeight: number;
contentHeight: number;
}): boolean {
return input.mode === "sticky-bottom" && input.contentHeight > input.previousContentHeight;
return (
input.mode === "sticky-bottom" &&
input.previousContentHeight > 0 &&
input.contentHeight > input.previousContentHeight
);
},
shouldDetachFromScrollAway(input: {
mode: BottomAnchorMode;
+12
View File
@@ -84,6 +84,7 @@ import { resolveActiveHost } from "@/utils/active-host";
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
import {
buildHostRootRoute,
mapPathnameToServer,
parseHostAgentRouteFromPathname,
parseServerIdFromPathname,
parseWorkspaceOpenIntent,
@@ -810,6 +811,7 @@ function OpenProjectListener() {
}
function AppWithSidebar({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const params = useGlobalSearchParams<{ open?: string | string[] }>();
const hosts = useHosts();
@@ -818,6 +820,16 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
const shouldShowAppChrome =
storeReady && activeServerId !== null && hosts.some((host) => host.serverId === activeServerId);
useEffect(() => {
if (!activeServerId || hosts.length === 0) {
return;
}
if (hosts.some((host) => host.serverId === activeServerId)) {
return;
}
router.replace(mapPathnameToServer(pathname, hosts[0].serverId));
}, [activeServerId, hosts, pathname, router]);
// Parse selectedAgentKey directly from pathname
// useLocalSearchParams doesn't update when navigating between same-pattern routes
const selectedAgentKey = useMemo(() => {
@@ -1,25 +0,0 @@
import { Redirect, Slot, useLocalSearchParams } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { useHosts } from "@/runtime/host-runtime";
import { resolveKnownHostRoute } from "@/utils/host-routes";
export default function HostRouteLayout() {
return (
<HostRouteBootstrapBoundary>
<KnownHostRoute />
</HostRouteBootstrapBoundary>
);
}
function KnownHostRoute() {
const params = useLocalSearchParams<{ serverId?: string | string[] }>();
const hosts = useHosts();
const routeServerId = typeof params.serverId === "string" ? params.serverId : null;
const resolution = resolveKnownHostRoute({ routeServerId, hosts });
if (resolution.kind === "redirect") {
return <Redirect href={resolution.href} />;
}
return <Slot />;
}
@@ -27,7 +27,7 @@ export const ACP_PROVIDER_ICON_SVGS = {
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 466.73 532.09">\n <path fill="currentColor" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/>\n</svg>\n',
deepagents:
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 128 128">\n <path fill="currentColor" d="M40.1024 85.0722C47.6207 77.5537 51.8469 67.3453 51.8469 56.7136C51.8469 46.0818 47.617 35.8734 40.1024 28.355L11.7446 0C4.22995 7.5185 0 17.7269 0 28.3586C0 38.9903 4.22995 49.1987 11.7446 56.7172L40.0987 85.0722H40.1024Z"/>\n <path fill="currentColor" d="M99.4385 87.698C91.9239 80.1832 81.7121 75.9531 71.0844 75.9531C60.4566 75.9531 50.2448 80.1832 42.7266 87.698L71.0844 116.057C78.599 123.571 88.8107 127.802 99.4421 127.802C110.074 127.802 120.282 123.571 127.8 116.057L99.4421 87.698H99.4385Z"/>\n <path fill="currentColor" d="M11.8146 115.987C19.3329 123.502 29.541 127.732 40.1724 127.732V87.6289H0.0664062C0.0700559 98.2606 4.29635 108.469 11.8146 115.987Z"/>\n <path fill="currentColor" d="M110.387 45.7684C102.869 38.2535 92.6608 34.0198 82.0258 34.0234C71.3943 34.0234 61.1863 38.2535 53.668 45.772L82.0258 74.1306L110.387 45.7684Z"/>\n</svg>\n',
codewhale:
"deepseek-tui":
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32">\n <rect width="32" height="32" fill="currentColor" opacity="0.18"/>\n <text x="50%" y="55%" text-anchor="middle" dominant-baseline="middle" font-family="\'Noto Serif SC\', serif" font-weight="700" font-size="20" fill="currentColor">深</text>\n <rect x="0" y="29" width="32" height="3" fill="currentColor"/>\n</svg>\n',
dimcode:
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">\n<path d="M3.12109 11.0078H1.99902V5.49316H3.12109V11.0078ZM3.7041 5.49316C4.91979 5.49316 5.31142 5.57449 5.80762 5.7373C6.50208 5.97365 6.85299 6.40958 6.86133 7.04492V9.45605C6.86125 10.207 6.37767 10.6797 5.41016 10.874C4.95546 10.9633 4.69632 11.0078 3.7041 11.0078V10.6064C4.72131 10.6064 4.95994 10.5507 5.34863 10.4404C5.91072 10.2671 6.19576 9.93907 6.2041 9.45605V7.04492C6.20402 6.4883 5.83211 6.13886 5.08789 5.99707C4.74057 5.93405 4.58897 5.89978 3.7041 5.89453V5.49316ZM9.16797 5.49316V11.0078H8.0459V5.49316H9.16797ZM14 6.79297V11.0078H12.8779V8.0791L13.8877 6.79297H14ZM14 5.49316V5.7373L11.3594 8.97852H10.7852L9.74219 6.94531V6.07812H9.86719L11.0723 8.33203L13.4258 5.49316H14Z" fill="currentColor"/>\n</svg>\n',

Before

Width:  |  Height:  |  Size: 397 B

After

Width:  |  Height:  |  Size: 397 B

@@ -1,24 +1,36 @@
import { useCallback, useMemo, useState } from "react";
import { Pressable, Text, View } from "react-native";
import { useCallback, useMemo, useReducer, useState } from "react";
import { Alert, Pressable, Text, View } from "react-native";
import { SvgXml } from "react-native-svg";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ExternalLink, PackagePlus, Search } from "lucide-react-native";
import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import {
AdaptiveModalSheet,
AdaptiveTextInput,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import {
buildAcpProviderConfigPatch,
useAcpProviderCatalog,
type AcpProviderCatalogItem,
} from "@/hooks/use-acp-provider-catalog";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import type { Theme } from "@/styles/theme";
import { openExternalUrl } from "@/utils/open-external-url";
interface ProviderCatalogListProps {
interface AddProviderModalProps {
serverId: string;
installingProviderId: string | null;
onInstall: (entry: AcpProviderCatalogItem) => Promise<void> | void;
visible: boolean;
onClose: () => void;
}
type InstallState = "installed" | "available";
const FLEX_ONE_STYLE = { flex: 1 } as const;
const ACTION_BUTTON_STYLE = { width: 92 } as const;
const MODAL_SNAP_POINTS = ["78%", "92%"];
const ADD_PROVIDER_HEADER: SheetHeader = { title: "Add provider" };
const SEARCH_ICON_SIZE = 16;
const PROVIDER_FALLBACK_ICON_SIZE = 20;
const PROVIDER_REMOTE_ICON_SIZE = 24;
@@ -33,6 +45,14 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
function getInstallState(
entry: AcpProviderCatalogItem,
installedProviderIds: Set<string>,
): InstallState {
if (installedProviderIds.has(entry.id)) return "installed";
return "available";
}
function matchesSearch(entry: AcpProviderCatalogItem, query: string): boolean {
const normalized = query.trim().toLowerCase();
if (!normalized) return true;
@@ -41,13 +61,22 @@ function matchesSearch(entry: AcpProviderCatalogItem, query: string): boolean {
);
}
interface CatalogRowProps {
interface ProviderCatalogRowProps {
entry: AcpProviderCatalogItem;
state: InstallState;
installing: boolean;
onInstall: (entry: AcpProviderCatalogItem) => void;
}
function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) {
function ProviderCatalogRow({ entry, state, installing, onInstall }: ProviderCatalogRowProps) {
const isAvailable = state === "available";
let actionLabel = "Add";
if (installing) {
actionLabel = "Adding";
} else if (state === "installed") {
actionLabel = "Installed";
}
const handleInstall = useCallback(() => {
onInstall(entry);
}, [entry, onInstall]);
@@ -96,43 +125,72 @@ function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) {
</View>
<Button
size="sm"
variant="default"
disabled={installing}
variant={isAvailable ? "default" : "secondary"}
disabled={!isAvailable || installing}
loading={installing}
onPress={handleInstall}
style={styles.actionButton}
style={ACTION_BUTTON_STYLE}
testID={`install-provider-${entry.id}`}
>
{installing ? "Adding" : "Add"}
{actionLabel}
</Button>
</View>
);
}
export function ProviderCatalogList({
serverId,
installingProviderId,
onInstall,
}: ProviderCatalogListProps) {
const { entries: catalogEntries } = useAcpProviderCatalog();
const { entries: providerEntries } = useProvidersSnapshot(serverId);
export function AddProviderModal({ serverId, visible, onClose }: AddProviderModalProps) {
const { entries } = useAcpProviderCatalog();
const { entries: providerEntries, refresh } = useProvidersSnapshot(serverId);
const { patchConfig } = useDaemonConfig(serverId);
const [search, setSearch] = useState("");
const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0);
const [installingProviderId, setInstallingProviderId] = useState<string | null>(null);
const installedIds = useMemo(
const handleClose = useCallback(() => {
setSearch("");
bumpSearchResetKey();
onClose();
}, [onClose]);
const installedProviderIds = useMemo(
() => new Set(providerEntries?.map((entry) => entry.provider) ?? []),
[providerEntries],
);
const filteredEntries = useMemo(
() => entries.filter((entry) => matchesSearch(entry, search)),
[entries, search],
);
const availableEntries = useMemo(
() =>
catalogEntries
.filter((entry) => !installedIds.has(entry.id))
.filter((entry) => matchesSearch(entry, search)),
[catalogEntries, installedIds, search],
const handleInstall = useCallback(
async (entry: AcpProviderCatalogItem) => {
if (installingProviderId) return;
setInstallingProviderId(entry.id);
try {
await patchConfig(buildAcpProviderConfigPatch(entry));
await refresh([entry.id]);
handleClose();
} catch (installError) {
Alert.alert(
"Unable to install provider",
installError instanceof Error ? installError.message : String(installError),
);
} finally {
setInstallingProviderId((current) => (current === entry.id ? null : current));
}
},
[installingProviderId, handleClose, patchConfig, refresh],
);
return (
<View>
<AdaptiveModalSheet
header={ADD_PROVIDER_HEADER}
visible={visible}
onClose={handleClose}
desktopMaxWidth={680}
snapPoints={MODAL_SNAP_POINTS}
testID="add-provider-modal"
>
<View style={styles.searchField}>
<View style={styles.searchIcon}>
<ThemedSearch size={SEARCH_ICON_SIZE} uniProps={foregroundMutedColorMapping} />
@@ -140,6 +198,8 @@ export function ProviderCatalogList({
<AdaptiveTextInput
testID="provider-catalog-search"
accessibilityLabel="Search providers"
initialValue={search}
resetKey={`provider-catalog-search-${searchResetKey}`}
value={search}
onChangeText={setSearch}
placeholder="Search providers"
@@ -149,25 +209,32 @@ export function ProviderCatalogList({
/>
</View>
{availableEntries.length === 0 ? (
{filteredEntries.length === 0 ? (
<View style={styles.stateBox}>
<Text style={styles.stateText}>
{search.trim().length > 0 ? "No providers found" : "All providers are installed"}
</Text>
<Text style={styles.stateText}>No providers found</Text>
</View>
) : (
) : null}
{filteredEntries.length > 0 ? (
<View style={styles.list}>
{availableEntries.map((entry) => (
<CatalogRow
{filteredEntries.map((entry) => (
<ProviderCatalogRow
key={entry.id}
entry={entry}
state={getInstallState(entry, installedProviderIds)}
installing={installingProviderId === entry.id}
onInstall={onInstall}
onInstall={handleInstall}
/>
))}
</View>
)}
</View>
) : null}
<View style={styles.actions}>
<Button style={FLEX_ONE_STYLE} variant="secondary" onPress={handleClose}>
Cancel
</Button>
</View>
</AdaptiveModalSheet>
);
}
@@ -181,7 +248,6 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: 1,
borderColor: theme.colors.border,
paddingHorizontal: theme.spacing[3],
marginBottom: theme.spacing[3],
},
searchIcon: {
width: 18,
@@ -254,10 +320,6 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
actionButton: {
width: 92,
flexShrink: 0,
},
stateBox: {
minHeight: 96,
borderRadius: theme.borderRadius.lg,
@@ -273,4 +335,7 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
actions: {
flexDirection: "row",
},
}));
@@ -63,6 +63,8 @@ import {
type ProviderSelectorProvider,
} from "@/provider-selection/provider-selection";
// TODO: this should be configured per provider in the provider manifest
const PROVIDERS_WITH_MODEL_DESCRIPTIONS = new Set(["opencode", "pi"]);
const DESKTOP_PROVIDER_VIEW_MIN_HEIGHT = 220;
const DESKTOP_PROVIDER_VIEW_MAX_HEIGHT = 400;
const DESKTOP_PROVIDER_VIEW_BASE_HEIGHT = 80;
@@ -196,10 +198,12 @@ function ModelRow({
],
);
const showDescription = row.description && PROVIDERS_WITH_MODEL_DESCRIPTIONS.has(row.provider);
return (
<ComboboxItem
label={row.modelLabel}
description={row.description}
description={showDescription ? row.description : undefined}
selected={isSelected}
elevated={elevated}
onPress={onPress}
+2 -2
View File
@@ -9,6 +9,7 @@ import Markdown, {
import {
ActivityIndicator,
Image as RNImage,
Linking,
ScrollView as RNScrollView,
Text,
type TextProps,
@@ -24,7 +25,6 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { openExternalUrl } from "@/utils/open-external-url";
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
@@ -208,7 +208,7 @@ function FilePreviewMarkdownLink({
const handlePress = useCallback(() => {
if (!href) return;
if (onLinkPress?.(href) === false) return;
void openExternalUrl(href);
void Linking.openURL(href);
}, [href, onLinkPress]);
return (
@@ -1,14 +0,0 @@
import Svg, { Path } from "react-native-svg";
interface OmpIconProps {
size?: number;
color?: string;
}
export function OmpIcon({ size = 16, color = "currentColor" }: OmpIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 64 64" fill={color}>
<Path d="M10 14h44v9H43v33h-9V23h-9v22h-9V23H10z" fill={color} />
</Svg>
);
}
@@ -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(
@@ -71,9 +71,7 @@ vi.mock("lucide-react-native", () => {
return Icon;
};
return {
ChevronDown: icon("ChevronDown"),
Inbox: icon("Inbox"),
Layers: icon("Layers"),
RotateCw: icon("RotateCw"),
};
});
@@ -83,38 +81,35 @@ vi.mock("@/components/ui/loading-spinner", () => ({
React.createElement("span", { "data-testid": "import-session-loading-spinner" }),
}));
vi.mock("@/components/ui/combobox", () => ({
Combobox: ({
vi.mock("@/components/ui/segmented-control", () => ({
SegmentedControl: ({
options,
value,
onSelect,
open,
onValueChange,
testID,
}: {
options: ReadonlyArray<{ id: string; label: string }>;
options: ReadonlyArray<{ value: string; label: string; testID?: string }>;
value: string;
onSelect: (id: string) => void;
open?: boolean;
}) => {
if (!open) return null;
return React.createElement(
onValueChange: (value: string) => void;
testID?: string;
}) =>
React.createElement(
"div",
{ "data-testid": "import-session-combobox" },
{ "data-testid": testID },
options.map((option) =>
React.createElement(
"button",
{
key: option.id,
key: option.value,
type: "button",
"data-testid": `import-session-filter-${option.id === "__all__" ? "all" : option.id}`,
"data-selected": value === option.id,
onClick: () => onSelect(option.id),
"data-testid": option.testID,
"data-selected": value === option.value,
onClick: () => onValueChange(option.value),
},
option.label,
),
),
);
},
ComboboxItem: ({ label }: { label: string }) => React.createElement("span", null, label),
),
}));
vi.mock("@/components/adaptive-modal-sheet", () => ({
@@ -480,13 +475,7 @@ describe("ImportSessionSheet", () => {
it("imports a selected session by provider handle and reports the imported agent", async () => {
const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-provider-sessions",
entries: [
createProviderSessionEntry({
providerId: "claude",
providerLabel: "Claude Code",
cwd: "/repo/paseo-realpath",
}),
],
entries: [createProviderSessionEntry({ providerId: "claude", providerLabel: "Claude Code" })],
}));
const importAgent = vi.fn(async () => createImportedAgentSnapshot("agent-imported"));
const onClose = vi.fn();
@@ -510,7 +499,7 @@ describe("ImportSessionSheet", () => {
expect(importAgent).toHaveBeenCalledWith({
providerId: "claude",
providerHandleId: "provider-thread-1",
cwd: "/repo/paseo-realpath",
cwd: "/repo/paseo",
});
});
expect(onImportedAgent).toHaveBeenCalledWith("agent-imported");
@@ -552,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"}`,
@@ -602,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 () => {
@@ -689,13 +675,11 @@ describe("ImportSessionSheet", () => {
await screen.findByText("Session claude");
await screen.findByText("Session codex");
fireEvent.click(screen.getByTestId("import-session-filter-trigger"));
fireEvent.click(screen.getByTestId("import-session-filter-codex"));
screen.getByText("Session codex");
expect(screen.queryByText("Session claude")).toBeNull();
fireEvent.click(screen.getByTestId("import-session-filter-trigger"));
fireEvent.click(screen.getByTestId("import-session-filter-all"));
screen.getByText("Session claude");
@@ -732,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();
@@ -748,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"),
],
},
},
@@ -1,16 +1,16 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, type PressableStateCallbackType, Text, View } from "react-native";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Pressable, type PressableStateCallbackType, ScrollView, Text, View } from "react-native";
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
import type {
DaemonClient,
FetchRecentProviderSessionEntry,
} from "@getpaseo/client/internal/daemon-client";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import { ChevronDown, Inbox, Layers, RotateCw } from "lucide-react-native";
import { Inbox, RotateCw } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
import { getProviderIcon } from "@/components/provider-icons";
import { formatTimeAgo } from "@/utils/time";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
@@ -88,7 +88,6 @@ interface SheetStatusMessagesProps {
isSnapshotUnsupported: boolean;
hasNoImportableProviders: boolean;
isLoadingSessions: boolean;
hasRows: boolean;
allQueriesErrored: boolean;
erroredProviderLabels: ReadonlyArray<string>;
importErrored: boolean;
@@ -99,7 +98,6 @@ function SheetStatusMessages({
isSnapshotUnsupported,
hasNoImportableProviders,
isLoadingSessions,
hasRows,
allQueriesErrored,
erroredProviderLabels,
importErrored,
@@ -116,7 +114,7 @@ function SheetStatusMessages({
{hasNoImportableProviders ? (
<Text style={styles.statusText}>No importable providers are enabled.</Text>
) : null}
{isLoadingSessions && !hasRows ? (
{isLoadingSessions ? (
<View style={styles.statusRow}>
<LoadingSpinner color={theme.colors.foregroundMuted} />
<Text style={styles.statusText}>Loading recent sessions...</Text>
@@ -178,6 +176,25 @@ function SheetEmptyState({ title }: { title: string }) {
);
}
function buildProviderFilterOptions(
providers: ReadonlyArray<string>,
providerLabelById: ReadonlyMap<string, string>,
): SegmentedControlOption<string>[] {
const options: SegmentedControlOption<string>[] = [
{ value: ALL_FILTER_VALUE, label: "All", testID: "import-session-filter-all" },
];
for (const provider of providers) {
const ProviderIcon = getProviderIcon(provider);
options.push({
value: provider,
label: providerLabelById.get(provider) ?? provider,
testID: `import-session-filter-${provider}`,
icon: ({ color, size }) => <ProviderIcon color={color} size={size} />,
});
}
return options;
}
function ImportSessionSheetRow({
entry,
disabled,
@@ -254,7 +271,6 @@ export function ImportSessionSheet({
onImported,
}: ImportSessionSheetProps) {
const queryClient = useQueryClient();
const { theme } = useUnistyles();
const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, {
cwd,
@@ -299,8 +315,6 @@ export function ImportSessionSheet({
const filterProviders = useMemo(() => [...(providersToFetch ?? [])].sort(), [providersToFetch]);
const [selectedProvider, setSelectedProvider] = useState<string>(ALL_FILTER_VALUE);
const [isFilterOpen, setIsFilterOpen] = useState(false);
const filterAnchorRef = useRef<View>(null);
useEffect(() => {
if (
@@ -316,84 +330,24 @@ export function ImportSessionSheet({
return aggregatedEntries.filter((entry) => entry.providerId === selectedProvider);
}, [aggregatedEntries, selectedProvider]);
const filterComboboxOptions = useMemo<ComboboxOption[]>(
() => [
{ id: ALL_FILTER_VALUE, label: "All providers" },
...filterProviders.map((provider) => ({
id: provider,
label: providerLabelById.get(provider) ?? provider,
})),
],
const filterOptions = useMemo(
() => buildProviderFilterOptions(filterProviders, providerLabelById),
[filterProviders, providerLabelById],
);
const selectedProviderLabel = useMemo(
() =>
filterComboboxOptions.find((opt) => opt.id === selectedProvider)?.label ?? "All providers",
[filterComboboxOptions, selectedProvider],
);
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
const filterTriggerStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.filterTrigger,
Boolean(hovered) && styles.filterTriggerHovered,
pressed && styles.filterTriggerPressed,
],
[],
);
const handleFilterSelect = useCallback((id: string) => {
setSelectedProvider(id);
setIsFilterOpen(false);
}, []);
const filterOptionIcons = useMemo(() => {
const map = new Map<string, React.ReactNode>();
map.set(ALL_FILTER_VALUE, <Layers size={14} color={theme.colors.foregroundMuted} />);
for (const provider of filterProviders) {
const ProviderIcon = getProviderIcon(provider);
map.set(provider, <ProviderIcon size={14} color={theme.colors.foregroundMuted} />);
}
return map;
}, [filterProviders, theme.colors.foregroundMuted]);
const renderFilterOption = useCallback(
({
option,
selected,
active,
onPress,
}: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => (
<ComboboxItem
label={option.label}
selected={selected}
active={active}
onPress={onPress}
leadingSlot={filterOptionIcons.get(option.id)}
/>
),
[filterOptionIcons],
);
const importMutation = useMutation({
mutationFn: async (entry: FetchRecentProviderSessionEntry) => {
if (!client) {
throw new Error("Host is not connected");
}
if (!entry.cwd) {
const effectiveCwd = cwd ?? entry.cwd;
if (!effectiveCwd) {
throw new Error("Session is missing a working directory");
}
const agent = await client.importAgent({
providerId: entry.providerId,
providerHandleId: entry.providerHandleId,
cwd: entry.cwd,
cwd: effectiveCwd,
});
return agent;
},
@@ -469,48 +423,25 @@ export function ImportSessionSheet({
snapPoints={IMPORT_SHEET_SNAP_POINTS}
>
{showFilter ? (
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
<Pressable
onPress={handleFilterOpen}
style={filterTriggerStyle}
testID="import-session-filter-trigger"
accessibilityRole="button"
accessibilityLabel={`Filter: ${selectedProviderLabel}`}
>
{selectedProvider === ALL_FILTER_VALUE ? (
<Layers size={14} color={theme.colors.foregroundMuted} />
) : (
(() => {
const ProviderIcon = getProviderIcon(selectedProvider);
return <ProviderIcon size={14} color={theme.colors.foregroundMuted} />;
})()
)}
<Text style={styles.filterTriggerText} numberOfLines={1}>
{selectedProviderLabel}
</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</Pressable>
<Combobox
options={filterComboboxOptions}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.filterRow}
>
<SegmentedControl
testID="import-session-filters"
size="sm"
options={filterOptions}
value={selectedProvider}
onSelect={handleFilterSelect}
renderOption={renderFilterOption}
searchable={false}
title="Filter by provider"
open={isFilterOpen}
onOpenChange={setIsFilterOpen}
anchorRef={filterAnchorRef}
desktopPlacement="bottom-start"
desktopPreventInitialFlash
onValueChange={setSelectedProvider}
/>
</View>
</ScrollView>
) : null}
<SheetStatusMessages
isClientReady={Boolean(client)}
isSnapshotUnsupported={isSnapshotUnsupported}
hasNoImportableProviders={hasNoImportableProviders}
isLoadingSessions={isLoadingSessions}
hasRows={visibleEntries.length > 0}
allQueriesErrored={allQueriesErrored}
erroredProviderLabels={erroredProviderLabels}
importErrored={importMutation.isError}
@@ -535,31 +466,9 @@ export function ImportSessionSheet({
}
const styles = StyleSheet.create((theme) => ({
filterTriggerWrap: {
paddingBottom: theme.spacing[2],
},
filterTrigger: {
filterRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
alignSelf: "flex-start",
paddingVertical: theme.spacing[1.5],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
filterTriggerHovered: {
backgroundColor: theme.colors.surface2,
},
filterTriggerPressed: {
backgroundColor: theme.colors.surface3,
},
filterTriggerText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
paddingBottom: theme.spacing[2],
},
list: {
gap: theme.spacing[1],
@@ -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",
]);
@@ -5,7 +5,6 @@ import { ClaudeIcon } from "@/components/icons/claude-icon";
import { CodexIcon } from "@/components/icons/codex-icon";
import { CopilotIcon } from "@/components/icons/copilot-icon";
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
import { OmpIcon } from "@/components/icons/omp-icon";
import { PiIcon } from "@/components/icons/pi-icon";
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
import {
@@ -25,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: OmpIcon as unknown as ProviderIconComponent,
opencode: OpenCodeIcon as unknown as ProviderIconComponent,
pi: PiIcon as unknown as ProviderIconComponent,
};
@@ -3,8 +3,8 @@ import { describe, expect, test } from "vitest";
import { validateDraftSubmission } from "./workspace-tab-core";
const baseComposerState = {
providerDefinitions: [{ id: "codewhale" }],
selectedProvider: "codewhale",
providerDefinitions: [{ id: "deepseek-tui" }],
selectedProvider: "deepseek-tui",
isModelLoading: false,
effectiveModelId: "",
availableModels: [],
+3 -8
View File
@@ -170,12 +170,10 @@ function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean
function buildRealtimeVoiceButtonStyle(
hovered: boolean | undefined,
voiceButtonDisabled: boolean,
reserveLeadingSpace: boolean,
): object[] {
const hoveredStyle = hovered ? styles.iconButtonHovered : undefined;
const disabledStyle = voiceButtonDisabled ? styles.buttonDisabled : undefined;
const reserveStyle = reserveLeadingSpace ? styles.realtimeVoiceButtonCompactReserve : undefined;
return [styles.realtimeVoiceButton, reserveStyle, hoveredStyle, disabledStyle].filter(
return [styles.realtimeVoiceButton, hoveredStyle, disabledStyle].filter(
(value): value is object => Boolean(value),
);
}
@@ -1417,8 +1415,8 @@ export function Composer({
const voiceButtonDisabled = !isConnected || isVoiceSwitching;
const realtimeVoiceButtonStyle = useCallback(
(state: PressableStateCallbackType & { hovered?: boolean }) =>
buildRealtimeVoiceButtonStyle(state.hovered, voiceButtonDisabled, isCompactLayout),
[isCompactLayout, voiceButtonDisabled],
buildRealtimeVoiceButtonStyle(state.hovered, voiceButtonDisabled),
[voiceButtonDisabled],
);
const cancelButton = useMemo(
@@ -1853,9 +1851,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
alignItems: "center",
justifyContent: "center",
},
realtimeVoiceButtonCompactReserve: {
marginLeft: theme.spacing[1],
},
realtimeVoiceButtonActive: {
backgroundColor: theme.colors.palette.green[600],
borderColor: theme.colors.palette.green[800],
+74 -32
View File
@@ -9,7 +9,6 @@ export interface AcpProviderCatalogEntry {
installLink: string;
command: readonly [string, ...string[]];
env?: Readonly<Record<string, string>>;
params?: Readonly<Record<string, unknown>>;
}
const CATALOG_DATA = [
@@ -37,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",
},
@@ -54,33 +53,42 @@ const CATALOG_DATA = [
installLink: "https://www.autohand.ai/cli/",
command: ["npx", "-y", "@autohandai/autohand-acp@0.2.1"],
},
{
id: "claude-acp",
title: "Claude Agent",
description: "ACP wrapper for Anthropic's Claude",
version: "0.41.0",
iconId: "claude-acp",
installLink: "https://github.com/agentclientprotocol/claude-agent-acp",
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.23",
version: "3.0.17",
iconId: "cline",
installLink: "https://cline.bot/cli",
command: ["npx", "-y", "cline@3.0.23", "--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.105.0",
version: "2.103.0",
iconId: "codebuddy-code",
installLink: "https://www.codebuddy.cn/cli/",
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.105.0", "--acp"],
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.103.0", "--acp"],
},
{
id: "codewhale",
title: "CodeWhale",
description: "Terminal coding agent for DeepSeek V4 and open models",
version: "0.8.55",
iconId: "codewhale",
installLink: "https://codewhale.net/",
command: ["codewhale", "serve", "--acp"],
id: "codex-acp",
title: "Codex CLI",
description: "ACP adapter for OpenAI's coding assistant",
version: "0.13.0",
iconId: "codex-acp",
installLink: "https://github.com/zed-industries/codex-acp",
command: ["codex-acp"],
},
{
id: "cortex-code",
@@ -127,6 +135,15 @@ const CATALOG_DATA = [
installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview",
command: ["npx", "-y", "deepagents-acp@0.1.12"],
},
{
id: "deepseek-tui",
title: "DeepSeek TUI",
description: "Terminal coding agent for DeepSeek V4",
version: "0.8.39",
iconId: "deepseek-tui",
installLink: "https://github.com/Hmbown/DeepSeek-TUI",
command: ["deepseek", "serve", "--acp"],
},
{
id: "devin",
title: "Devin CLI",
@@ -140,10 +157,10 @@ const CATALOG_DATA = [
id: "dimcode",
title: "DimCode",
description: "A coding agent that puts leading models at your command.",
version: "0.1.5",
version: "0.0.77",
iconId: "dimcode",
installLink: "https://dimcode.dev/docs/acp.html",
command: ["npx", "-y", "dimcode@0.1.5", "acp"],
command: ["npx", "-y", "dimcode@0.0.77", "acp"],
},
{
id: "dirac",
@@ -159,33 +176,41 @@ const CATALOG_DATA = [
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.144.0",
version: "0.140.0",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.144.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",
},
params: { supportsMcpServers: false },
},
{
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.46.0",
version: "0.45.0",
iconId: "gemini",
installLink: "https://geminicli.com",
command: ["npx", "-y", "@google/gemini-cli@0.46.0", "--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.59",
iconId: "github-copilot-cli",
installLink: "https://github.com/features/copilot/cli/",
command: ["npx", "-y", "@github/copilot@1.0.59", "--acp"],
},
{
id: "glm-acp-agent",
@@ -254,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"],
},
{
@@ -284,10 +309,28 @@ const CATALOG_DATA = [
id: "nova",
title: "Nova",
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
version: "1.1.16",
version: "1.1.14",
iconId: "nova",
installLink: "https://www.compassap.ai/portfolio/nova.html",
command: ["npx", "-y", "@compass-ai/nova@1.1.16", "acp"],
command: ["npx", "-y", "@compass-ai/nova@1.1.14", "acp"],
},
{
id: "opencode",
title: "OpenCode",
description: "The open source coding agent",
version: "1.14.39",
iconId: "opencode",
installLink: "https://opencode.ai/docs/acp/",
command: ["opencode", "acp"],
},
{
id: "pi-acp",
title: "pi ACP",
description: "ACP adapter for pi coding agent",
version: "0.0.27",
iconId: "pi-acp",
installLink: "https://github.com/svkozak/pi-acp",
command: ["npx", "-y", "pi-acp@0.0.27"],
},
{
id: "poolside",
@@ -302,10 +345,10 @@ const CATALOG_DATA = [
id: "qoder",
title: "Qoder CLI",
description: "AI coding assistant with agentic capabilities",
version: "1.0.16",
version: "1.0.14",
iconId: "qoder",
installLink: "https://qoder.com",
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.16", "--acp"],
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.14", "--acp"],
},
{
id: "qwen-code",
@@ -359,6 +402,5 @@ export const ACP_PROVIDER_CATALOG: AcpProviderCatalogEntry[] = CATALOG_DATA.map(
installLink: entry.installLink,
command: entry.command,
env: "env" in entry ? entry.env : undefined,
params: "params" in entry ? entry.params : undefined,
iconSvg: entry.iconId ? (ACP_PROVIDER_ICON_SVGS[entry.iconId] ?? null) : null,
}));
@@ -38,22 +38,13 @@ function createUpdater(
}
describe("desktop app updater — check", () => {
it("forwards manual check intent and the requested release channel to the port", async () => {
it("forwards the requested release channel to the port", async () => {
const { updater, port } = createUpdater();
port.nextCheckResult(buildFakeCheckResult());
await updater.checkForUpdates({ releaseChannel: "beta" });
expect(port.recordedChecks).toEqual([{ releaseChannel: "beta", intent: "manual" }]);
});
it("forwards automatic check intent independently from silent UI state", async () => {
const { updater, port } = createUpdater();
port.nextCheckResult(buildFakeCheckResult());
await updater.checkForUpdates({ releaseChannel: "stable", intent: "automatic", silent: true });
expect(port.recordedChecks).toEqual([{ releaseChannel: "stable", intent: "automatic" }]);
expect(port.recordedChecks).toEqual([{ releaseChannel: "beta" }]);
});
it("moves to 'checking' during a non-silent check", async () => {
@@ -75,11 +66,7 @@ describe("desktop app updater — check", () => {
expect(updater.getSnapshot().status).toBe("available");
const deferred = port.deferNextCheck();
const pending = updater.checkForUpdates({
releaseChannel: "stable",
intent: "automatic",
silent: true,
});
const pending = updater.checkForUpdates({ releaseChannel: "stable", silent: true });
expect(updater.getSnapshot().status).toBe("available");
deferred.resolve(buildFakeCheckResult({ hasUpdate: true, readyToInstall: true }));
@@ -141,7 +128,7 @@ describe("desktop app updater — check", () => {
const statusBeforeSilent = updater.getSnapshot().status;
port.failNextCheck(new Error("boom"));
await updater.checkForUpdates({ releaseChannel: "stable", intent: "automatic", silent: true });
await updater.checkForUpdates({ releaseChannel: "stable", silent: true });
expect(updater.getSnapshot().status).toBe(statusBeforeSilent);
});
@@ -239,20 +226,6 @@ describe("desktop app updater — subscribe", () => {
describe("formatStatusText", () => {
const formatVersion = (version: string | null | undefined) =>
version ? `v${version.replace(/^v/i, "")}` : "\u2014";
const formatLastCheckedAt = (timestamp: number) => `time-${timestamp}`;
it("shows when an up-to-date check completed", () => {
expect(
formatStatusText({
status: "up-to-date",
availableUpdate: null,
installMessage: null,
lastCheckedAt: 42,
formatVersion,
formatLastCheckedAt,
}),
).toBe("Up to date. Last checked at time-42.");
});
it("uses the latest version in the 'available' message when present", () => {
expect(
@@ -260,9 +233,7 @@ describe("formatStatusText", () => {
status: "available",
availableUpdate: buildFakeCheckResult({ latestVersion: "1.2.3" }),
installMessage: null,
lastCheckedAt: null,
formatVersion,
formatLastCheckedAt,
}),
).toBe("Update ready: v1.2.3");
});
@@ -273,9 +244,7 @@ describe("formatStatusText", () => {
status: "available",
availableUpdate: null,
installMessage: null,
lastCheckedAt: null,
formatVersion,
formatLastCheckedAt,
}),
).toBe("An app update is ready to install.");
});
@@ -286,9 +255,7 @@ describe("formatStatusText", () => {
status: "installed",
availableUpdate: null,
installMessage: "Restart now",
lastCheckedAt: null,
formatVersion,
formatLastCheckedAt,
}),
).toBe("Restart now");
});
@@ -1,6 +1,5 @@
import type {
DesktopAppUpdateCheckResult,
DesktopAppUpdateCheckIntent,
DesktopAppUpdateInstallResult,
DesktopReleaseChannel,
} from "@/desktop/updates/desktop-updates";
@@ -30,7 +29,6 @@ export interface DesktopAppUpdaterSnapshot {
export interface DesktopAppUpdaterPort {
checkDesktopAppUpdate(input: {
releaseChannel: DesktopReleaseChannel;
intent: DesktopAppUpdateCheckIntent;
}): Promise<DesktopAppUpdateCheckResult>;
installDesktopAppUpdate(input: {
releaseChannel: DesktopReleaseChannel;
@@ -54,7 +52,6 @@ export interface DesktopAppUpdater {
subscribe(listener: () => void): () => void;
checkForUpdates(options?: {
releaseChannel: DesktopReleaseChannel;
intent?: DesktopAppUpdateCheckIntent;
silent?: boolean;
}): Promise<DesktopAppUpdateCheckResult | null>;
installUpdate(options: {
@@ -105,18 +102,9 @@ export function formatStatusText(input: {
status: DesktopAppUpdateStatus;
availableUpdate: DesktopAppUpdateCheckResult | null;
installMessage: string | null;
lastCheckedAt: number | null;
formatVersion: (version: string | null | undefined) => string;
formatLastCheckedAt: (timestamp: number) => string;
}): string {
const {
status,
availableUpdate,
installMessage,
lastCheckedAt,
formatVersion,
formatLastCheckedAt,
} = input;
const { status, availableUpdate, installMessage, formatVersion } = input;
if (status === "checking") {
return "Checking for app updates...";
@@ -127,10 +115,7 @@ export function formatStatusText(input: {
}
if (status === "up-to-date") {
if (lastCheckedAt != null) {
return `Up to date. Last checked at ${formatLastCheckedAt(lastCheckedAt)}.`;
}
return "Up to date.";
return "App is up to date.";
}
if (status === "pending") {
@@ -170,13 +155,12 @@ export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopApp
async function checkForUpdates(options?: {
releaseChannel: DesktopReleaseChannel;
intent?: DesktopAppUpdateCheckIntent;
silent?: boolean;
}): Promise<DesktopAppUpdateCheckResult | null> {
if (!options) {
return null;
}
const { releaseChannel, intent = "manual", silent = false } = options;
const { releaseChannel, silent = false } = options;
const requestVersion = state.requestVersion + 1;
commit({
@@ -187,7 +171,7 @@ export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopApp
});
try {
const result = await deps.port.checkDesktopAppUpdate({ releaseChannel, intent });
const result = await deps.port.checkDesktopAppUpdate({ releaseChannel });
if (requestVersion !== state.requestVersion) {
return result;
}
@@ -23,7 +23,6 @@ export interface DesktopRuntimeInfo {
}
export type DesktopReleaseChannel = "stable" | "beta";
export type DesktopAppUpdateCheckIntent = "automatic" | "manual";
export interface LocalDaemonUpdateResult {
exitCode: number;
@@ -100,15 +99,10 @@ export async function getDesktopRuntimeInfo(): Promise<DesktopRuntimeInfo> {
export async function checkDesktopAppUpdate({
releaseChannel,
intent,
}: {
releaseChannel: DesktopReleaseChannel;
intent: DesktopAppUpdateCheckIntent;
}): Promise<DesktopAppUpdateCheckResult> {
const result = await invokeDesktopCommand<unknown>("check_app_update", {
releaseChannel,
intent,
});
const result = await invokeDesktopCommand<unknown>("check_app_update", { releaseChannel });
if (!isRecord(result)) {
throw new Error("Unexpected response while checking desktop updates.");
}
@@ -1,16 +1,12 @@
import type {
DesktopAppUpdateCheckResult,
DesktopAppUpdateCheckIntent,
DesktopAppUpdateInstallResult,
DesktopReleaseChannel,
} from "@/desktop/updates/desktop-updates";
import type { DesktopAppUpdaterPort } from "@/desktop/updates/desktop-app-updater";
export interface FakeDesktopAppUpdaterPort extends DesktopAppUpdaterPort {
readonly recordedChecks: Array<{
releaseChannel: DesktopReleaseChannel;
intent: DesktopAppUpdateCheckIntent;
}>;
readonly recordedChecks: Array<{ releaseChannel: DesktopReleaseChannel }>;
readonly recordedInstalls: Array<{ releaseChannel: DesktopReleaseChannel }>;
nextCheckResult(result: DesktopAppUpdateCheckResult): void;
deferNextCheck(): {
@@ -57,10 +53,7 @@ function buildInstallResult(
}
export function createFakeDesktopAppUpdaterPort(): FakeDesktopAppUpdaterPort {
const recordedChecks: Array<{
releaseChannel: DesktopReleaseChannel;
intent: DesktopAppUpdateCheckIntent;
}> = [];
const recordedChecks: Array<{ releaseChannel: DesktopReleaseChannel }> = [];
const recordedInstalls: Array<{ releaseChannel: DesktopReleaseChannel }> = [];
const checkOutcomes: CheckOutcome[] = [];
const installOutcomes: InstallOutcome[] = [];
@@ -62,10 +62,10 @@ export function UpdateCalloutSource() {
useEffect(() => {
if (!isDesktopApp) return;
void checkForUpdates({ intent: "automatic", silent: true });
void checkForUpdates({ silent: true });
intervalRef.current = setInterval(() => {
void checkForUpdates({ intent: "automatic", silent: true });
void checkForUpdates({ silent: true });
}, CHECK_INTERVAL_MS);
return () => {
@@ -5,7 +5,6 @@ import {
installDesktopAppUpdate,
shouldShowDesktopUpdateSection,
type DesktopAppUpdateCheckResult,
type DesktopAppUpdateCheckIntent,
type DesktopAppUpdateInstallResult,
} from "@/desktop/updates/desktop-updates";
import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
@@ -16,7 +15,6 @@ import {
formatStatusText,
type DesktopAppUpdateStatus,
} from "@/desktop/updates/desktop-app-updater";
import { formatMessageTimestamp } from "@/utils/time";
export type { DesktopAppUpdateStatus };
@@ -29,10 +27,7 @@ export interface UseDesktopAppUpdaterReturn {
lastCheckedAt: number | null;
isChecking: boolean;
isInstalling: boolean;
checkForUpdates: (options?: {
intent?: DesktopAppUpdateCheckIntent;
silent?: boolean;
}) => Promise<DesktopAppUpdateCheckResult | null>;
checkForUpdates: (options?: { silent?: boolean }) => Promise<DesktopAppUpdateCheckResult | null>;
installUpdate: () => Promise<DesktopAppUpdateInstallResult | null>;
}
@@ -62,15 +57,11 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
);
const checkForUpdates = useCallback(
async (options: { intent?: DesktopAppUpdateCheckIntent; silent?: boolean } = {}) => {
async (options: { silent?: boolean } = {}) => {
if (!isDesktopApp) {
return null;
}
return updater.checkForUpdates({
releaseChannel,
intent: options.intent ?? "manual",
silent: options.silent,
});
return updater.checkForUpdates({ releaseChannel, silent: options.silent });
},
[isDesktopApp, releaseChannel, updater],
);
@@ -86,7 +77,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
if (!isDesktopApp) {
return;
}
void checkForUpdates({ intent: "automatic", silent: true });
void checkForUpdates({ silent: true });
}, [checkForUpdates, isDesktopApp]);
useEffect(() => {
@@ -95,7 +86,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
}
const intervalId = setInterval(() => {
void checkForUpdates({ intent: "automatic", silent: true });
void checkForUpdates({ silent: true });
}, PENDING_RECHECK_MS);
return () => {
@@ -110,9 +101,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
status: snapshot.status,
availableUpdate: snapshot.availableUpdate,
installMessage: snapshot.installMessage,
lastCheckedAt: snapshot.lastCheckedAt,
formatVersion: formatVersionWithPrefix,
formatLastCheckedAt: (timestamp) => formatMessageTimestamp(new Date(timestamp)),
}),
availableUpdate: snapshot.availableUpdate,
errorMessage: snapshot.errorMessage,
-19
View File
@@ -166,25 +166,6 @@ describe("git-actions-policy", () => {
});
});
it("keeps push available for a no-upstream Paseo worktree with local commits", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isPaseoOwnedWorktree: true,
isOnBaseBranch: false,
aheadCount: 1,
aheadOfOrigin: null,
behindOfOrigin: null,
}),
);
const pushAction = actions.secondary.find((action) => action.id === "push");
expect(pushAction).toMatchObject({
disabled: false,
unavailableMessage: undefined,
});
});
it("shows update-from-base only on feature branches that are behind the base branch", () => {
const actions = buildGitActions(
createInput({
+7 -22
View File
@@ -70,8 +70,8 @@ export interface BuildGitActionsInput {
baseRefLabel: string;
aheadCount: number;
behindBaseCount: number;
aheadOfOrigin: number | null;
behindOfOrigin: number | null;
aheadOfOrigin: number;
behindOfOrigin: number;
shouldPromoteArchive: boolean;
shipDefault: "merge" | "pr";
runtime: Record<GitActionId, GitActionRuntimeState>;
@@ -489,20 +489,11 @@ function buildDisablePullRequestAutoMergeAction(input: BuildGitActionsInput): Gi
}
function canPull(input: BuildGitActionsInput): boolean {
return input.hasRemote && !input.hasUncommittedChanges && (input.behindOfOrigin ?? 0) > 0;
return input.hasRemote && !input.hasUncommittedChanges && input.behindOfOrigin > 0;
}
function canPush(input: BuildGitActionsInput): boolean {
return input.hasRemote && hasPushableCommits(input) && (input.behindOfOrigin ?? 0) === 0;
}
function hasPushableCommits(input: BuildGitActionsInput): boolean {
if ((input.aheadOfOrigin ?? 0) > 0) {
return true;
}
// No-upstream Paseo worktrees are first-pushable: the daemon push sets upstream with `git push -u`.
// Do not fold this into aheadOfOrigin; null also covers deleted/pruned upstream branches.
return input.isPaseoOwnedWorktree && input.aheadOfOrigin === null && input.aheadCount > 0;
return input.hasRemote && input.aheadOfOrigin > 0 && input.behindOfOrigin === 0;
}
function canMergeFromBase(input: BuildGitActionsInput): boolean {
@@ -596,9 +587,6 @@ function getPullUnavailableMessage(input: BuildGitActionsInput): string | undefi
if (input.hasUncommittedChanges) {
return "Pull isn't available while you have local changes so commit or stash them first";
}
if (input.behindOfOrigin === null) {
return "Pull isn't available here because this branch is not connected to a remote yet";
}
if (input.behindOfOrigin === 0) {
return "Pull isn't available because this branch is already up to date";
}
@@ -609,10 +597,10 @@ function getPushUnavailableMessage(input: BuildGitActionsInput): string | undefi
if (!input.hasRemote) {
return "Push isn't available here because this branch is not connected to a remote yet";
}
if ((input.behindOfOrigin ?? 0) > 0) {
if (input.behindOfOrigin > 0) {
return "Push isn't available yet because there are newer changes to bring in first";
}
if (!hasPushableCommits(input)) {
if (input.aheadOfOrigin === 0) {
return "Push isn't available because there is nothing new to send";
}
return undefined;
@@ -625,16 +613,13 @@ function getPullAndPushUnavailableMessage(input: BuildGitActionsInput): string |
if (input.hasUncommittedChanges) {
return "Pull and push isn't available while you have local changes so commit or stash them first";
}
if (input.behindOfOrigin === null) {
return "Pull and push isn't available because there are no incoming changes to pull first";
}
if (input.behindOfOrigin === 0 && input.aheadOfOrigin === 0) {
return "Pull and push isn't available because this branch is already in sync";
}
if (input.behindOfOrigin === 0) {
return "Pull and push isn't available because there are no incoming changes to pull first";
}
if ((input.aheadOfOrigin ?? 0) === 0) {
if (input.aheadOfOrigin === 0) {
return "Pull and push isn't available because there is nothing new to send after pulling";
}
return undefined;
+6 -6
View File
@@ -59,8 +59,8 @@ interface DerivedGitActionsState {
actionsDisabled: boolean;
aheadCount: number;
behindBaseCount: number;
aheadOfOrigin: number | null;
behindOfOrigin: number | null;
aheadOfOrigin: number;
behindOfOrigin: number;
hasPullRequest: boolean;
hasRemote: boolean;
isPaseoOwnedWorktree: boolean;
@@ -71,16 +71,16 @@ interface DerivedGitActionsState {
interface GitCommitCounts {
aheadCount: number;
behindBaseCount: number;
aheadOfOrigin: number | null;
behindOfOrigin: number | null;
aheadOfOrigin: number;
behindOfOrigin: number;
}
function extractGitCommitCounts(gitStatus: CheckoutStatusPayload | null): GitCommitCounts {
return {
aheadCount: gitStatus?.aheadBehind?.ahead ?? 0,
behindBaseCount: gitStatus?.aheadBehind?.behind ?? 0,
aheadOfOrigin: gitStatus?.aheadOfOrigin ?? null,
behindOfOrigin: gitStatus?.behindOfOrigin ?? null,
aheadOfOrigin: gitStatus?.aheadOfOrigin ?? 0,
behindOfOrigin: gitStatus?.behindOfOrigin ?? 0,
};
}
@@ -37,7 +37,7 @@ describe("ACP provider catalog", () => {
it("uses PATH commands for entries that were binary distributions upstream", () => {
expect(findProvider("amp-acp").command).toEqual(["amp-acp"]);
expect(findProvider("cursor").command).toEqual(["cursor-agent", "acp"]);
expect(findProvider("codewhale").command).toEqual(["codewhale", "serve", "--acp"]);
expect(findProvider("deepseek-tui").command).toEqual(["deepseek", "serve", "--acp"]);
expect(findProvider("devin").command).toEqual(["devin", "acp"]);
expect(findProvider("goose").command).toEqual(["goose", "acp"]);
expect(findProvider("junie").command).toEqual(["junie", "--acp", "true"]);
@@ -66,12 +66,4 @@ describe("ACP provider catalog", () => {
AUGMENT_DISABLE_AUTO_UPDATE: "1",
});
});
it("preserves provider params in the daemon config patch", () => {
const droidPatch = buildAcpProviderConfigPatch(findProvider("factory-droid"));
expect(droidPatch.providers?.["factory-droid"]?.params).toEqual({
supportsMcpServers: false,
});
});
});
@@ -19,7 +19,6 @@ export function buildAcpProviderConfigPatch(
description: entry.description,
command: [...entry.command],
env: entry.env ? { ...entry.env } : {},
...(entry.params ? { params: { ...entry.params } } : {}),
},
},
};
+54 -128
View File
@@ -11,13 +11,7 @@ import { useAutocomplete } from "./use-autocomplete";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { CLIENT_SLASH_COMMANDS, type ClientSlashCommand } from "@/client-slash-commands";
import {
applySlashCommandReplacement,
filterAndRankCommandAutocompleteEntries,
filterInlineSkillCommandEntries,
findActiveSlashCommand,
type SlashCommandRange,
} from "@/utils/agent-command-autocomplete";
import { filterAndRankCommandAutocompleteEntries } from "@/utils/agent-command-autocomplete";
import {
applyFileMentionReplacement,
findActiveFileMention,
@@ -139,63 +133,6 @@ function mapCommandToOption(entry: AvailableCommand): AgentAutocompleteOption {
type AutocompleteMode = "command" | "file" | null;
interface BuildAutocompleteOptionsInput {
isVisible: boolean;
mode: AutocompleteMode;
commands: AgentSlashCommand[];
isDraftContext: boolean;
commandFilterQuery: string;
activeSlashCommand: SlashCommandRange | null;
activeFileMention: FileMentionRange | null;
fileSuggestions: DirectorySuggestionEntry[];
}
function buildCommandAutocompleteOptions(input: BuildAutocompleteOptionsInput) {
if (!input.isVisible) {
return [];
}
if (input.mode === "command") {
const providerCommands = input.commands.map(
(command): AvailableCommand => ({ source: "provider", command }),
);
const clientCommandNames = new Set(CLIENT_SLASH_COMMANDS.map((command) => command.name));
const rootCommands: AvailableCommand[] = input.isDraftContext
? providerCommands
: [
...CLIENT_SLASH_COMMANDS.map(
(command): AvailableCommand => ({ source: "client", command }),
),
...providerCommands.filter((entry) => !clientCommandNames.has(entry.command.name)),
];
const availableCommands =
input.activeSlashCommand?.position === "inline"
? filterInlineSkillCommandEntries(providerCommands)
: rootCommands;
const matches = filterAndRankCommandAutocompleteEntries(
availableCommands,
input.commandFilterQuery,
);
const orderedMatches = orderAutocompleteOptions(matches);
return orderedMatches.map(mapCommandToOption);
}
const activeFileMention = input.activeFileMention;
if (input.mode === "file" && activeFileMention) {
const orderedEntries = orderAutocompleteOptions(input.fileSuggestions);
return orderedEntries.map((entry) => ({
type: "workspace_entry" as const,
id: `${entry.kind}:${entry.path}`,
label: entry.path,
kind: entry.kind,
entryPath: entry.path,
mention: activeFileMention,
}));
}
return [];
}
function resolveAutocompleteMode(args: {
showFileAutocomplete: boolean;
showCommandAutocomplete: boolean;
@@ -224,17 +161,6 @@ function resolveAutocompleteIsVisible(args: {
return false;
}
function resolveCanLoadCommands(args: {
serverId: string;
agentId: string;
isDraftContext: boolean;
}): boolean {
if (!args.serverId) {
return false;
}
return Boolean(args.agentId) || args.isDraftContext;
}
function resolveAutocompleteIsLoading(args: {
mode: AutocompleteMode;
isCommandsLoading: boolean;
@@ -283,16 +209,8 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
canExecuteClientSlashCommand,
} = input;
const activeSlashCommand = useMemo(
() =>
findActiveSlashCommand({
text: userInput,
cursorIndex,
}),
[cursorIndex, userInput],
);
const showCommandAutocomplete = activeSlashCommand !== null;
const commandFilterQuery = activeSlashCommand?.query ?? "";
const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
const commandFilterQuery = showCommandAutocomplete ? userInput.slice(1) : "";
const activeFileMention = useMemo(
() =>
@@ -317,8 +235,8 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
);
const isDraftContext = normalizedDraftConfig !== undefined;
const queryDraftConfig = normalizedDraftConfig;
const canLoadCommands = resolveCanLoadCommands({ serverId, agentId, isDraftContext });
const queryDraftConfig = isDraftContext ? normalizedDraftConfig : undefined;
const canLoadCommands = Boolean(serverId) && (Boolean(agentId) || isDraftContext);
const agentCwd = useSessionStore(
(state) => state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? "",
@@ -391,29 +309,54 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
placeholderData: keepPreviousData,
});
const options = useMemo<AgentAutocompleteOption[]>(
() =>
buildCommandAutocompleteOptions({
activeFileMention,
const options = useMemo<AgentAutocompleteOption[]>(() => {
if (!isVisible) {
return [];
}
if (mode === "command") {
const providerCommands = commands.map(
(command): AvailableCommand => ({ source: "provider", command }),
);
const clientCommandNames = new Set(CLIENT_SLASH_COMMANDS.map((command) => command.name));
const availableCommands: AvailableCommand[] = isDraftContext
? providerCommands
: [
...CLIENT_SLASH_COMMANDS.map(
(command): AvailableCommand => ({ source: "client", command }),
),
...providerCommands.filter((entry) => !clientCommandNames.has(entry.command.name)),
];
const matches = filterAndRankCommandAutocompleteEntries(
availableCommands,
commandFilterQuery,
commands,
activeSlashCommand,
fileSuggestions: fileSuggestionsQuery.data ?? [],
isDraftContext,
isVisible,
mode,
}),
[
activeFileMention,
activeSlashCommand,
commandFilterQuery,
commands,
fileSuggestionsQuery.data,
isDraftContext,
isVisible,
mode,
],
);
);
const orderedMatches = orderAutocompleteOptions(matches);
return orderedMatches.map(mapCommandToOption);
}
if (mode === "file" && activeFileMention) {
const orderedEntries = orderAutocompleteOptions(fileSuggestionsQuery.data ?? []);
return orderedEntries.map((entry) => ({
type: "workspace_entry" as const,
id: `${entry.kind}:${entry.path}`,
label: entry.path,
kind: entry.kind,
entryPath: entry.path,
mention: activeFileMention,
}));
}
return [];
}, [
activeFileMention,
commandFilterQuery,
commands,
fileSuggestionsQuery.data,
isDraftContext,
isVisible,
mode,
]);
const onSelectOption = useCallback(
(option: AutocompleteOption) => {
@@ -429,20 +372,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
}
if (selected.type === "client_command" || selected.type === "provider_command") {
if (!activeSlashCommand) {
setUserInput(`/${selected.id} `);
onAutocompleteApplied?.();
return;
}
const nextInput = applySlashCommandReplacement({
text: userInput,
command: activeSlashCommand,
commandName: selected.id,
});
const shouldAppendSpace =
activeSlashCommand.position === "start" && activeSlashCommand.end === userInput.length;
setUserInput(shouldAppendSpace ? `${nextInput} ` : nextInput);
setUserInput(`/${selected.id} `);
onAutocompleteApplied?.();
return;
}
@@ -461,7 +391,6 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
onClientSlashCommand,
setUserInput,
userInput,
activeSlashCommand,
],
);
@@ -470,10 +399,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
options,
query: mode === "command" ? commandFilterQuery : fileFilterQuery,
onSelectOption,
onEscape:
mode === "command" && activeSlashCommand?.position === "start"
? () => setUserInput("")
: undefined,
onEscape: mode === "command" ? () => setUserInput("") : undefined,
});
const isLoading = resolveAutocompleteIsLoading({
@@ -9,7 +9,6 @@ export interface AgentSlashCommand {
name: string;
description: string;
argumentHint: string;
kind?: string;
}
export interface DraftCommandConfig {
@@ -56,7 +56,7 @@ describe("combined model selector data", () => {
providerLabel: "Codex",
modelId: "gpt-5.4",
modelLabel: "GPT-5.4",
description: "gpt-5.4",
description: undefined,
isDefault: undefined,
},
],
@@ -69,22 +69,22 @@ describe("combined model selector data", () => {
expect(
buildSelectableProviderSelectorProviders([
snapshotEntry({
provider: "codewhale",
label: "CodeWhale",
provider: "deepseek-tui",
label: "DeepSeek TUI",
models: [],
}),
]),
).toEqual([
{
id: "codewhale",
label: "CodeWhale",
id: "deepseek-tui",
label: "DeepSeek TUI",
modelSelection: {
kind: "models",
rows: [
{
favoriteKey: "codewhale:",
provider: "codewhale",
providerLabel: "CodeWhale",
favoriteKey: "deepseek-tui:",
provider: "deepseek-tui",
providerLabel: "DeepSeek TUI",
modelId: "",
modelLabel: "Default",
description: undefined,
@@ -100,8 +100,8 @@ describe("combined model selector data", () => {
expect(
buildSelectableProviderSelectorProviders([
snapshotEntry({
provider: "codewhale",
label: "CodeWhale",
provider: "deepseek-tui",
label: "DeepSeek TUI",
enabled: false,
models: [],
}),
@@ -235,8 +235,8 @@ describe("combined model selector data", () => {
models: [codexModel],
}),
snapshotEntry({
provider: "codewhale",
label: "CodeWhale",
provider: "deepseek-tui",
label: "DeepSeek TUI",
models: [],
}),
]);
@@ -252,7 +252,7 @@ describe("combined model selector data", () => {
expect(
resolveSelectedModelLabel({
providers,
selectedProvider: "codewhale",
selectedProvider: "deepseek-tui",
selectedModel: "",
isLoading: false,
}),
@@ -307,7 +307,7 @@ describe("combined model selector data", () => {
allowsEmptyAutoSubmit: false,
providerCount: 1,
selection: {
provider: "codewhale",
provider: "deepseek-tui",
modelId: "",
availableModels: [],
isModelLoading: false,
@@ -47,7 +47,7 @@ function buildModelRows(
providerLabel,
modelId: model.id,
modelLabel: model.label,
description: model.description ?? model.id,
description: model.description,
isDefault: model.isDefault,
}));
}
+1 -1
View File
@@ -523,7 +523,7 @@ function DesktopAppUpdateRow() {
if (!isDesktopApp) {
return undefined;
}
void checkForUpdates({ intent: "automatic", silent: true });
void checkForUpdates({ silent: true });
return undefined;
}, [checkForUpdates, isDesktopApp]),
);
@@ -97,6 +97,7 @@ vi.mock("lucide-react-native", () => {
const icon = (name: string) => () => React.createElement("span", { "data-icon": name });
return {
ChevronRight: icon("ChevronRight"),
Plus: icon("Plus"),
};
});
@@ -142,8 +143,8 @@ vi.mock("@/stores/provider-settings-store", () => ({
selector({ open: openProviderSettingsMock }),
}));
vi.mock("@/components/provider-catalog-list", () => ({
ProviderCatalogList: () => null,
vi.mock("@/components/add-provider-modal", () => ({
AddProviderModal: () => null,
}));
vi.mock("@/hooks/use-providers-snapshot", () => ({
@@ -6,17 +6,13 @@ import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { buildProviderDefinitions } from "@/utils/provider-definitions";
import {
buildAcpProviderConfigPatch,
type AcpProviderCatalogItem,
} from "@/hooks/use-acp-provider-catalog";
import { ProviderCatalogList } from "@/components/provider-catalog-list";
import { AddProviderModal } from "@/components/add-provider-modal";
import { getProviderIcon } from "@/components/provider-icons";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Switch } from "@/components/ui/switch";
import { SettingsSection } from "@/screens/settings/settings-section";
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
import { ChevronRight } from "lucide-react-native";
import { ChevronRight, Plus } from "lucide-react-native";
type ProviderDefinition = ReturnType<typeof buildProviderDefinitions>[number];
type ProviderEntry = NonNullable<ReturnType<typeof useProvidersSnapshot>["entries"]>[number];
@@ -181,12 +177,13 @@ export interface ProvidersSectionProps {
}
export function ProvidersSection({ serverId }: ProvidersSectionProps) {
const { theme } = useUnistyles();
const isConnected = useHostRuntimeIsConnected(serverId);
const { entries, isLoading, refresh } = useProvidersSnapshot(serverId);
const { entries, isLoading } = useProvidersSnapshot(serverId);
const { patchConfig } = useDaemonConfig(serverId);
const openProviderSettings = useProviderSettingsStore((state) => state.open);
const [isAddProviderOpen, setIsAddProviderOpen] = useState(false);
const [pendingProviderId, setPendingProviderId] = useState<string | null>(null);
const [installingProviderId, setInstallingProviderId] = useState<string | null>(null);
const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]);
const hasServer = serverId.length > 0;
@@ -197,7 +194,8 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
},
[openProviderSettings, serverId],
);
const handleOpenAddProvider = useCallback(() => setIsAddProviderOpen(true), []);
const handleCloseAddProvider = useCallback(() => setIsAddProviderOpen(false), []);
const handleToggleEnabled = useCallback(
async (providerId: string, enabled: boolean) => {
setPendingProviderId(providerId);
@@ -215,29 +213,37 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
[patchConfig],
);
const handleInstall = useCallback(
async (entry: AcpProviderCatalogItem) => {
if (installingProviderId) return;
setInstallingProviderId(entry.id);
try {
await patchConfig(buildAcpProviderConfigPatch(entry));
await refresh([entry.id]);
} catch (error) {
Alert.alert(
"Unable to add provider",
error instanceof Error ? error.message : String(error),
);
} finally {
setInstallingProviderId((current) => (current === entry.id ? null : current));
}
},
[installingProviderId, patchConfig, refresh],
const headerActions = useMemo(
() =>
hasServer && isConnected ? (
<View style={styles.headerActions}>
<Pressable
onPress={handleOpenAddProvider}
hitSlop={8}
style={settingsStyles.sectionHeaderLink}
accessibilityRole="button"
accessibilityLabel="Add provider"
testID="add-provider-button"
>
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={settingsStyles.sectionHeaderLinkText}>Add provider</Text>
</Pressable>
</View>
) : undefined,
[
hasServer,
isConnected,
handleOpenAddProvider,
theme.iconSize.sm,
theme.colors.foregroundMuted,
],
);
return (
<>
<SettingsSection
title="Providers"
trailing={headerActions}
testID="host-page-providers-card"
style={styles.sectionSpacing}
>
@@ -273,18 +279,8 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
) : null}
</SettingsSection>
{hasServer && isConnected ? (
<SettingsSection
title="Add provider"
testID="host-page-add-provider-card"
style={styles.addProviderSection}
>
<ProviderCatalogList
serverId={serverId}
installingProviderId={installingProviderId}
onInstall={handleInstall}
/>
</SettingsSection>
{hasServer && isConnected && isAddProviderOpen ? (
<AddProviderModal serverId={serverId} visible onClose={handleCloseAddProvider} />
) : null}
</>
);
@@ -294,9 +290,6 @@ const styles = StyleSheet.create((theme) => ({
sectionSpacing: {
marginBottom: theme.spacing[4],
},
addProviderSection: {
marginTop: theme.spacing[4],
},
emptyCard: {
padding: theme.spacing[4],
alignItems: "center",
@@ -305,6 +298,11 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
row: {
gap: theme.spacing[3],
minHeight: 56,
@@ -1,11 +1,6 @@
import { describe, expect, it } from "vitest";
import {
applySlashCommandReplacement,
filterAndRankCommandAutocompleteEntries,
filterInlineSkillCommandEntries,
findActiveSlashCommand,
} from "./agent-command-autocomplete";
import { filterAndRankCommandAutocompleteEntries } from "./agent-command-autocomplete";
describe("filterAndRankCommandAutocompleteEntries", () => {
const entries = [
@@ -32,81 +27,3 @@ describe("filterAndRankCommandAutocompleteEntries", () => {
expect(result.map((entry) => entry.command.name)).toEqual(["exit"]);
});
});
describe("findActiveSlashCommand", () => {
it("detects a slash command token in the middle of the prompt", () => {
const text = "use /tas before implementation";
expect(
findActiveSlashCommand({
text,
cursorIndex: "use /tas".length,
}),
).toEqual({
start: 4,
end: "use /tas".length,
query: "tas",
position: "inline",
});
});
it("classifies a slash command token at the prompt start", () => {
expect(
findActiveSlashCommand({
text: "/rew",
cursorIndex: "/rew".length,
}),
).toEqual({
start: 0,
end: "/rew".length,
query: "rew",
position: "start",
});
});
it("returns null when the cursor is outside the slash token", () => {
expect(
findActiveSlashCommand({
text: "use /taste now",
cursorIndex: "use /taste now".length,
}),
).toBeNull();
});
it("returns null for slash-delimited paths", () => {
expect(
findActiveSlashCommand({
text: "read /tmp/project",
cursorIndex: "read /tmp/project".length,
}),
).toBeNull();
});
});
describe("applySlashCommandReplacement", () => {
it("replaces only the active slash token", () => {
const text = "use /tas before implementation";
expect(
applySlashCommandReplacement({
text,
command: { start: 4, end: "use /tas".length, query: "tas", position: "inline" },
commandName: "taste",
}),
).toBe("use /taste before implementation");
});
});
describe("filterInlineSkillCommandEntries", () => {
it("keeps provider skills and drops executable commands", () => {
const entries = [
{ source: "client" as const, command: { name: "clear", kind: "command" } },
{ source: "provider" as const, command: { name: "compact", kind: "command" } },
{ source: "provider" as const, command: { name: "taste", kind: "skill" } },
];
expect(filterInlineSkillCommandEntries(entries).map((entry) => entry.command.name)).toEqual([
"taste",
]);
});
});
@@ -4,34 +4,9 @@ interface CommandAutocompleteEntry {
command: {
name: string;
aliases?: readonly string[];
kind?: string;
};
}
interface InlineSkillCommandEntry extends CommandAutocompleteEntry {
source: "provider" | "client";
}
export type SlashCommandPosition = "start" | "inline";
export interface SlashCommandRange {
start: number;
end: number;
query: string;
position: SlashCommandPosition;
}
interface FindActiveSlashCommandInput {
text: string;
cursorIndex: number;
}
interface ApplySlashCommandReplacementInput {
text: string;
command: SlashCommandRange;
commandName: string;
}
interface ScoredCommandAutocompleteEntry<TEntry> {
entry: TEntry;
score: MatchScore;
@@ -71,49 +46,3 @@ export function filterAndRankCommandAutocompleteEntries<TEntry extends CommandAu
return scoredEntries.map((scored) => scored.entry);
}
export function filterInlineSkillCommandEntries<TEntry extends InlineSkillCommandEntry>(
entries: readonly TEntry[],
): TEntry[] {
return entries.filter((entry) => entry.source === "provider" && entry.command.kind === "skill");
}
const INVALID_SLASH_COMMAND_QUERY_CHARS = /[/\s\n\r\t"']/;
export function findActiveSlashCommand(
input: FindActiveSlashCommandInput,
): SlashCommandRange | null {
const clampedCursor = Math.max(0, Math.min(input.cursorIndex, input.text.length));
const beforeCursor = input.text.slice(0, clampedCursor);
for (
let slashIndex = beforeCursor.lastIndexOf("/");
slashIndex >= 0;
slashIndex = slashIndex === 0 ? -1 : beforeCursor.lastIndexOf("/", slashIndex - 1)
) {
const previousCharacter = slashIndex > 0 ? input.text[slashIndex - 1] : "";
if (previousCharacter && !/\s/.test(previousCharacter)) {
continue;
}
const query = beforeCursor.slice(slashIndex + 1);
if (INVALID_SLASH_COMMAND_QUERY_CHARS.test(query)) {
continue;
}
return {
start: slashIndex,
end: clampedCursor,
query,
position: slashIndex === 0 ? "start" : "inline",
};
}
return null;
}
export function applySlashCommandReplacement(input: ApplySlashCommandReplacementInput): string {
const before = input.text.slice(0, input.command.start);
const after = input.text.slice(input.command.end);
return `${before}/${input.commandName}${after}`;
}
@@ -16,7 +16,6 @@ import {
parseHostWorkspaceOpenIntentFromPathname,
parseHostWorkspaceRouteFromPathname,
parseWorkspaceOpenIntent,
resolveKnownHostRoute,
} from "./host-routes";
describe("parseHostAgentRouteFromPathname", () => {
@@ -191,32 +190,3 @@ describe("host settings section slugs", () => {
expect(normalizeHostSectionSlug("daemon")).toBe("host");
});
});
describe("resolveKnownHostRoute", () => {
it("renders when the route host is still saved", () => {
expect(
resolveKnownHostRoute({
routeServerId: "srv-current",
hosts: [{ serverId: "srv-current" }, { serverId: "srv-next" }],
}),
).toEqual({ kind: "render" });
});
it("sends removed host routes to the next saved host home", () => {
expect(
resolveKnownHostRoute({
routeServerId: "srv-removed",
hosts: [{ serverId: "srv-next" }],
}),
).toEqual({ kind: "redirect", href: "/h/srv-next/open-project" });
});
it("sends host routes to welcome when no hosts are saved", () => {
expect(
resolveKnownHostRoute({
routeServerId: "srv-removed",
hosts: [],
}),
).toEqual({ kind: "redirect", href: "/welcome" });
});
});
-21
View File
@@ -355,27 +355,6 @@ export function buildHostOpenProjectRoute(serverId: string) {
return `${base}/open-project` as const;
}
export type KnownHostRouteResolution =
| { kind: "render" }
| { kind: "redirect"; href: ReturnType<typeof buildHostOpenProjectRoute> | "/welcome" };
export function resolveKnownHostRoute(input: {
routeServerId: string | null | undefined;
hosts: readonly { serverId: string }[];
}): KnownHostRouteResolution {
const routeServerId = trimNonEmpty(input.routeServerId);
if (routeServerId && input.hosts.some((host) => host.serverId === routeServerId)) {
return { kind: "render" };
}
const fallbackServerId = input.hosts[0]?.serverId;
if (fallbackServerId) {
return { kind: "redirect", href: buildHostOpenProjectRoute(fallbackServerId) };
}
return { kind: "redirect", href: "/welcome" };
}
export function buildHostNewWorkspaceRoute(
serverId: string,
sourceDirectory?: string,
@@ -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}",
},
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.93",
"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.93",
"@getpaseo/protocol": "0.1.93",
"@getpaseo/server": "0.1.93",
"@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",
+13 -1
View File
@@ -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;
}
+1 -29
View File
@@ -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",
},
});
});
});
+5 -7
View File
@@ -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,
+1 -1
View File
@@ -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(", ") : "-",
}));
-2
View File
@@ -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");
+4 -15
View File
@@ -45,11 +45,6 @@ interface ProviderListRow {
}
const EXPECTED_CLAUDE_MODELS = [
{
id: "claude-fable-5",
model: "Fable 5",
descriptionFragment: "Most powerful",
},
{
id: "claude-opus-4-8[1m]",
model: "Opus 4.8 1M",
@@ -205,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");
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.1.93",
"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.93",
"@getpaseo/relay": "0.1.93",
"@getpaseo/protocol": "0.1.91-beta.2",
"@getpaseo/relay": "0.1.91-beta.2",
"zod": "^3.23.8"
},
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.93",
"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",
@@ -15,7 +15,6 @@ import {
import {
checkForAppUpdate,
downloadAndInstallUpdate,
type AppUpdateCheckIntent,
type AppReleaseChannel,
} from "../features/auto-updater.js";
import { getCliInstallStatus, installCli } from "../integrations/cli-install/index.js";
@@ -82,12 +81,6 @@ function parseReleaseChannel(
return undefined;
}
function parseAppUpdateCheckIntent(
args: Record<string, unknown> | undefined,
): AppUpdateCheckIntent {
return args?.intent === "manual" ? "manual" : "automatic";
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
@@ -524,7 +517,6 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
return checkForAppUpdate({
currentVersion,
releaseChannel: await resolveRequestedReleaseChannel(args),
intent: parseAppUpdateCheckIntent(args),
});
},
install_app_update: async (args) => {
@@ -1,156 +0,0 @@
import { describe, expect, it } from "vitest";
import { bucketFromStagingUserId, shouldAdmitAppUpdate } from "./app-update-rollout";
describe("shouldAdmitAppUpdate", () => {
it("keeps automatic stable updates behind the rollout window", () => {
expect(
shouldAdmitAppUpdate({
channel: "stable",
intent: "automatic",
rolloutHours: 24,
releaseDate: "2026-04-28T00:00:00.000Z",
now: Date.parse("2026-04-28T12:00:00.000Z"),
bucket: 0.51,
}),
).toBe(false);
});
it("lets manual stable checks bypass rollout admission", () => {
expect(
shouldAdmitAppUpdate({
channel: "stable",
intent: "manual",
rolloutHours: 24,
releaseDate: "2026-04-28T00:00:00.000Z",
now: Date.parse("2026-04-28T12:00:00.000Z"),
bucket: 0.99,
}),
).toBe(true);
});
it("admits beta, missing rollout hours, zero-hour rollout, and missing release date", () => {
expect(
shouldAdmitAppUpdate({
channel: "beta",
intent: "automatic",
rolloutHours: 24,
releaseDate: "2026-04-28T00:00:00.000Z",
now: Date.parse("2026-04-28T01:00:00.000Z"),
bucket: 0.99,
}),
).toBe(true);
expect(
shouldAdmitAppUpdate({
channel: "stable",
intent: "automatic",
rolloutHours: undefined,
releaseDate: "2026-04-28T00:00:00.000Z",
now: Date.parse("2026-04-28T01:00:00.000Z"),
bucket: 0.99,
}),
).toBe(true);
expect(
shouldAdmitAppUpdate({
channel: "stable",
intent: "automatic",
rolloutHours: 0,
releaseDate: "2026-04-28T00:00:00.000Z",
now: Date.parse("2026-04-28T01:00:00.000Z"),
bucket: 0.99,
}),
).toBe(true);
expect(
shouldAdmitAppUpdate({
channel: "stable",
intent: "automatic",
rolloutHours: 24,
releaseDate: undefined,
now: Date.parse("2026-04-28T01:00:00.000Z"),
bucket: 0.99,
}),
).toBe(true);
});
it("blocks future automatic releases and admits the same release manually", () => {
const input = {
channel: "stable" as const,
rolloutHours: 24,
releaseDate: "2026-04-28T02:00:00.000Z",
now: Date.parse("2026-04-28T01:00:00.000Z"),
bucket: 0,
};
expect(shouldAdmitAppUpdate({ ...input, intent: "automatic" })).toBe(false);
expect(shouldAdmitAppUpdate({ ...input, intent: "manual" })).toBe(true);
});
it("blocks the bucket-zero client at exact release time, admits as soon as time advances", () => {
expect(
shouldAdmitAppUpdate({
channel: "stable",
intent: "automatic",
rolloutHours: 24,
releaseDate: "2026-04-28T00:00:00.000Z",
now: Date.parse("2026-04-28T00:00:00.000Z"),
bucket: 0,
}),
).toBe(false);
expect(
shouldAdmitAppUpdate({
channel: "stable",
intent: "automatic",
rolloutHours: 24,
releaseDate: "2026-04-28T00:00:00.000Z",
now: Date.parse("2026-04-28T00:00:00.001Z"),
bucket: 0,
}),
).toBe(true);
});
it("admits the highest-bucket automatic client at and past the rollout end", () => {
const maxBucket = (0x100000000 - 1) / 0x100000000;
expect(
shouldAdmitAppUpdate({
channel: "stable",
intent: "automatic",
rolloutHours: 24,
releaseDate: "2026-04-28T00:00:00.000Z",
now: Date.parse("2026-04-29T00:00:00.000Z"),
bucket: maxBucket,
}),
).toBe(true);
expect(
shouldAdmitAppUpdate({
channel: "stable",
intent: "automatic",
rolloutHours: 24,
releaseDate: "2026-04-28T00:00:00.000Z",
now: Date.parse("2027-04-28T00:00:00.000Z"),
bucket: maxBucket,
}),
).toBe(true);
});
it("admits when releaseDate is unparseable", () => {
expect(
shouldAdmitAppUpdate({
channel: "stable",
intent: "automatic",
rolloutHours: 24,
releaseDate: "not a date",
now: Date.parse("2026-04-28T12:00:00.000Z"),
bucket: 0.99,
}),
).toBe(true);
});
it("maps the maximum 32-bit slot to a bucket strictly less than 1", () => {
const allOnes = "ffffffff-ffff-ffff-ffff-ffffffffffff";
const allZeros = "00000000-0000-0000-0000-000000000000";
expect(bucketFromStagingUserId(allOnes)).toBeLessThan(1);
expect(bucketFromStagingUserId(allOnes)).toBeGreaterThan(0.999);
expect(bucketFromStagingUserId(allZeros)).toBe(0);
});
});
@@ -1,42 +0,0 @@
import { UUID } from "builder-util-runtime";
import { z } from "zod";
export type AppReleaseChannel = "stable" | "beta";
export type AppUpdateCheckIntent = "automatic" | "manual";
export const rolloutManifestSchema = z.object({
rolloutHours: z
.union([z.number(), z.string().transform(Number)])
.pipe(z.number().finite().nonnegative())
.optional()
.catch(undefined),
releaseDate: z.string().optional().catch(undefined),
});
export function shouldAdmitAppUpdate(args: {
channel: AppReleaseChannel;
intent: AppUpdateCheckIntent;
rolloutHours: number | undefined;
releaseDate: string | undefined;
now: number;
bucket: number;
}): boolean {
if (args.intent === "manual") return true;
if (args.channel !== "stable") return true;
if (args.rolloutHours == null) return true;
if (args.rolloutHours === 0) return true;
if (!args.releaseDate) return true;
const releaseTime = new Date(args.releaseDate).getTime();
if (Number.isNaN(releaseTime)) return true;
const ageHours = (args.now - releaseTime) / 3_600_000;
if (ageHours < 0) return false;
const pct = Math.min(100, (ageHours / args.rolloutHours) * 100);
return args.bucket * 100 < pct;
}
export function bucketFromStagingUserId(stagingUserId: string): number {
return UUID.parse(stagingUserId).readUInt32BE(12) / 0x100000000;
}
@@ -1,114 +0,0 @@
import { describe, expect, it } from "vitest";
import {
createAppUpdateService,
type AppUpdateRuntime,
type AppUpdateRuntimeConfiguration,
type RuntimeUpdateInfo,
} from "./app-update-service";
class FakeAppUpdateRuntime implements AppUpdateRuntime {
private checks: Array<{ isUpdateAvailable: boolean; updateInfo: RuntimeUpdateInfo } | null> = [];
private gate: ((info: RuntimeUpdateInfo) => boolean | Promise<boolean>) | null = null;
configure(input: AppUpdateRuntimeConfiguration): void {
this.gate = input.shouldAdmitUpdate;
}
nextCheck(result: { isUpdateAvailable: boolean; updateInfo: RuntimeUpdateInfo } | null): void {
this.checks.push(result);
}
async checkForUpdates(): Promise<{
isUpdateAvailable: boolean;
updateInfo: RuntimeUpdateInfo;
} | null> {
const result = this.checks.shift() ?? null;
if (!result || !this.gate) return result;
const admitted = await this.gate(result.updateInfo);
return { ...result, isUpdateAvailable: result.isUpdateAvailable && admitted };
}
async downloadUpdate(): Promise<void> {}
quitAndInstall(): void {}
}
function createService(input?: { now?: () => number; bucket?: () => Promise<number> }) {
const runtime = new FakeAppUpdateRuntime();
const service = createAppUpdateService({
runtime,
isPackaged: () => true,
now: input?.now ?? (() => Date.parse("2026-04-28T12:00:00.000Z")),
bucket: input?.bucket ?? (async () => 0.99),
});
return { runtime, service };
}
const rolledOutUpdate = {
version: "1.2.4",
releaseDate: "2026-04-28T00:00:00.000Z",
rolloutHours: 24,
};
describe("app update service", () => {
it("does not expose automatic stable updates before the user is admitted to rollout", async () => {
const { runtime, service } = createService();
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
const result = await service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "automatic",
});
expect(result).toEqual({
hasUpdate: false,
readyToInstall: false,
currentVersion: "1.2.3",
latestVersion: "1.2.3",
body: null,
date: null,
});
});
it("exposes manual stable updates even before the user is admitted to rollout", async () => {
const { runtime, service } = createService();
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
const result = await service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "manual",
});
expect(result).toEqual({
hasUpdate: true,
readyToInstall: false,
currentVersion: "1.2.3",
latestVersion: "1.2.4",
body: null,
date: "2026-04-28T00:00:00.000Z",
});
});
it("trusts the runtime availability decision before comparing versions", async () => {
const { runtime, service } = createService({ bucket: async () => 0 });
runtime.nextCheck({ isUpdateAvailable: false, updateInfo: rolledOutUpdate });
const result = await service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "manual",
});
expect(result).toEqual({
hasUpdate: false,
readyToInstall: false,
currentVersion: "1.2.3",
latestVersion: "1.2.3",
body: null,
date: null,
});
});
});
@@ -1,303 +0,0 @@
import {
rolloutManifestSchema,
shouldAdmitAppUpdate,
type AppReleaseChannel,
type AppUpdateCheckIntent,
} from "./app-update-rollout.js";
export interface AppUpdateCheckResult {
hasUpdate: boolean;
readyToInstall: boolean;
currentVersion: string;
latestVersion: string;
body: string | null;
date: string | null;
}
export interface AppUpdateInstallResult {
installed: boolean;
version: string | null;
message: string;
}
export interface RuntimeUpdateInfo {
version: string;
releaseNotes?: unknown;
releaseDate?: unknown;
rolloutHours?: unknown;
}
export interface RuntimeUpdateCheckResult {
isUpdateAvailable: boolean;
updateInfo: RuntimeUpdateInfo;
}
export interface AppUpdateRuntimeConfiguration {
releaseChannel: AppReleaseChannel;
shouldAdmitUpdate(info: RuntimeUpdateInfo): boolean | Promise<boolean>;
onUpdateAvailable(info: RuntimeUpdateInfo): void;
onUpdateDownloaded(info: RuntimeUpdateInfo): void;
onUpdateNotAvailable(): void;
onError(error: unknown): void;
}
export interface AppUpdateRuntime {
configure(input: AppUpdateRuntimeConfiguration): void;
checkForUpdates(): Promise<RuntimeUpdateCheckResult | null>;
downloadUpdate(): Promise<unknown>;
quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void;
}
export interface AppUpdateService {
checkForAppUpdate(input: {
currentVersion: string;
releaseChannel: AppReleaseChannel;
intent: AppUpdateCheckIntent;
}): Promise<AppUpdateCheckResult>;
downloadAndInstallUpdate(
input: {
currentVersion: string;
releaseChannel: AppReleaseChannel;
},
onBeforeQuit?: () => Promise<void>,
): Promise<AppUpdateInstallResult>;
}
export interface AppUpdateServiceDeps {
runtime: AppUpdateRuntime;
isPackaged(): boolean;
now(): number;
bucket(): Promise<number>;
reportCheckError?(error: unknown): void;
reportRuntimeError?(error: unknown): void;
reportInstallError?(message: string): void;
}
function buildCheckResult(input: {
currentVersion: string;
hasUpdate: boolean;
readyToInstall: boolean;
info?: RuntimeUpdateInfo | null;
}): AppUpdateCheckResult {
const { currentVersion, hasUpdate, readyToInstall, info } = input;
return {
hasUpdate,
readyToInstall,
currentVersion,
latestVersion: info?.version ?? currentVersion,
body: typeof info?.releaseNotes === "string" ? info.releaseNotes : null,
date: typeof info?.releaseDate === "string" ? info.releaseDate : null,
};
}
async function performQuitAndInstall(
runtime: AppUpdateRuntime,
onBeforeQuit?: () => Promise<void>,
): Promise<void> {
if (onBeforeQuit) await onBeforeQuit();
runtime.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
}
export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateService {
let cachedUpdateInfo: RuntimeUpdateInfo | null = null;
let downloadedUpdateVersion: string | null = null;
let downloading = false;
let configuredReleaseChannel: AppReleaseChannel | null = null;
function isReadyToInstallVersion(version: string): boolean {
return downloadedUpdateVersion === version;
}
function clearUpdateState(): void {
cachedUpdateInfo = null;
downloadedUpdateVersion = null;
downloading = false;
}
function configureRuntime(releaseChannel: AppReleaseChannel, intent: AppUpdateCheckIntent): void {
if (configuredReleaseChannel !== releaseChannel) {
clearUpdateState();
configuredReleaseChannel = releaseChannel;
}
deps.runtime.configure({
releaseChannel,
shouldAdmitUpdate: async (info) => {
const parsed = rolloutManifestSchema.parse(info);
return shouldAdmitAppUpdate({
channel: releaseChannel,
intent,
rolloutHours: parsed.rolloutHours,
releaseDate: parsed.releaseDate,
now: deps.now(),
bucket: await deps.bucket(),
});
},
onUpdateAvailable(info) {
cachedUpdateInfo = info;
downloadedUpdateVersion = null;
downloading = true;
},
onUpdateDownloaded(info) {
cachedUpdateInfo = info;
downloadedUpdateVersion = info.version;
downloading = false;
},
onUpdateNotAvailable() {
clearUpdateState();
},
onError(error) {
downloading = false;
deps.reportRuntimeError?.(error);
},
});
}
async function checkForAppUpdate({
currentVersion,
releaseChannel,
intent,
}: {
currentVersion: string;
releaseChannel: AppReleaseChannel;
intent: AppUpdateCheckIntent;
}): Promise<AppUpdateCheckResult> {
if (!deps.isPackaged()) {
return buildCheckResult({
currentVersion,
hasUpdate: false,
readyToInstall: false,
});
}
configureRuntime(releaseChannel, intent);
const cachedVersion = cachedUpdateInfo?.version ?? null;
if (cachedVersion && cachedVersion !== currentVersion) {
return buildCheckResult({
currentVersion,
hasUpdate: true,
readyToInstall: isReadyToInstallVersion(cachedVersion),
info: cachedUpdateInfo,
});
}
try {
const result = await deps.runtime.checkForUpdates();
if (!result || !result.updateInfo || !result.isUpdateAvailable) {
clearUpdateState();
return buildCheckResult({
currentVersion,
hasUpdate: false,
readyToInstall: false,
});
}
const info = result.updateInfo;
const latestVersion = info.version;
const hasUpdate = latestVersion !== currentVersion;
if (hasUpdate) {
cachedUpdateInfo = info;
downloading = !isReadyToInstallVersion(latestVersion);
return buildCheckResult({
currentVersion,
hasUpdate: true,
readyToInstall: isReadyToInstallVersion(latestVersion),
info,
});
}
clearUpdateState();
return buildCheckResult({
currentVersion,
hasUpdate: false,
readyToInstall: false,
});
} catch (error) {
deps.reportCheckError?.(error);
return buildCheckResult({
currentVersion,
hasUpdate: false,
readyToInstall: false,
});
}
}
async function downloadAndInstallUpdate(
{
currentVersion,
releaseChannel,
}: {
currentVersion: string;
releaseChannel: AppReleaseChannel;
},
onBeforeQuit?: () => Promise<void>,
): Promise<AppUpdateInstallResult> {
if (!deps.isPackaged()) {
return {
installed: false,
version: currentVersion,
message: "Auto-update is not available in development mode.",
};
}
if (!cachedUpdateInfo) {
return {
installed: false,
version: currentVersion,
message: "No update available. Check for updates first.",
};
}
configureRuntime(releaseChannel, "manual");
const readyVersion = cachedUpdateInfo.version;
if (isReadyToInstallVersion(readyVersion)) {
await performQuitAndInstall(deps.runtime, onBeforeQuit);
return {
installed: true,
version: readyVersion,
message: "Update downloaded. The app will restart shortly.",
};
}
if (downloading) {
return {
installed: false,
version: currentVersion,
message: "Update is still being prepared. Try again in a moment.",
};
}
downloading = true;
try {
await deps.runtime.downloadUpdate();
downloadedUpdateVersion = readyVersion;
downloading = false;
await performQuitAndInstall(deps.runtime, onBeforeQuit);
return {
installed: true,
version: readyVersion,
message: "Update downloaded. The app will restart shortly.",
};
} catch (error) {
downloading = false;
const message = error instanceof Error ? error.message : String(error);
deps.reportInstallError?.(message);
return {
installed: false,
version: currentVersion,
message: `Update failed: ${message}`,
};
}
}
return {
checkForAppUpdate,
downloadAndInstallUpdate,
};
}
+263 -92
View File
@@ -3,34 +3,48 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { app } from "electron";
import { UUID } from "builder-util-runtime";
import { autoUpdater } from "electron-updater";
import {
createAppUpdateService,
type AppUpdateCheckResult,
type AppUpdateInstallResult,
type AppUpdateRuntime,
type AppUpdateRuntimeConfiguration,
type RuntimeUpdateCheckResult,
type RuntimeUpdateInfo,
} from "./app-update-service.js";
import {
bucketFromStagingUserId,
rolloutManifestSchema,
shouldAdmitAppUpdate,
type AppReleaseChannel,
type AppUpdateCheckIntent,
} from "./app-update-rollout.js";
import { autoUpdater, type UpdateInfo } from "electron-updater";
import { z } from "zod";
export {
bucketFromStagingUserId,
rolloutManifestSchema,
shouldAdmitAppUpdate,
type AppReleaseChannel,
type AppUpdateCheckIntent,
type AppUpdateCheckResult,
type AppUpdateInstallResult,
};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface AppUpdateCheckResult {
hasUpdate: boolean;
readyToInstall: boolean;
currentVersion: string;
latestVersion: string;
body: string | null;
date: string | null;
}
export interface AppUpdateInstallResult {
installed: boolean;
version: string | null;
message: string;
}
export type AppReleaseChannel = "stable" | "beta";
export const rolloutManifestSchema = z.object({
rolloutHours: z
.union([z.number(), z.string().transform(Number)])
.pipe(z.number().finite().nonnegative())
.optional()
.catch(undefined),
releaseDate: z.string().optional().catch(undefined),
});
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let cachedUpdateInfo: UpdateInfo | null = null;
let downloadedUpdateVersion: string | null = null;
let downloading = false;
let autoUpdaterConfigured = false;
let configuredReleaseChannel: AppReleaseChannel | null = null;
let cachedStagingUserIdPromise: Promise<string> | null = null;
export function shouldAdmitToRollout(args: {
@@ -40,7 +54,23 @@ export function shouldAdmitToRollout(args: {
now: number;
bucket: number;
}): boolean {
return shouldAdmitAppUpdate({ ...args, intent: "automatic" });
if (args.channel !== "stable") return true;
if (args.rolloutHours == null) return true;
if (args.rolloutHours === 0) return true;
if (!args.releaseDate) return true;
const releaseTime = new Date(args.releaseDate).getTime();
if (Number.isNaN(releaseTime)) return true;
const ageHours = (args.now - releaseTime) / 3_600_000;
if (ageHours < 0) return false;
const pct = Math.min(100, (ageHours / args.rolloutHours) * 100);
return args.bucket * 100 < pct;
}
export function bucketFromStagingUserId(stagingUserId: string): number {
return UUID.parse(stagingUserId).readUInt32BE(12) / 0x100000000;
}
export async function resolveStagingUserId(filePath: string): Promise<string> {
@@ -76,74 +106,100 @@ export function getStagingUserId(): Promise<string> {
return cachedStagingUserIdPromise;
}
class ElectronAppUpdateRuntime implements AppUpdateRuntime {
private configured = false;
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
configure(input: AppUpdateRuntimeConfiguration): void {
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.autoRunAppAfterInstall = true;
autoUpdater.allowPrerelease = input.releaseChannel === "beta";
autoUpdater.channel = input.releaseChannel === "beta" ? "beta" : "latest";
autoUpdater.allowDowngrade = false;
autoUpdater.isUserWithinRollout = async (info) => {
try {
return await input.shouldAdmitUpdate(info as RuntimeUpdateInfo);
} catch {
return true;
}
};
function configureAutoUpdater(releaseChannel: AppReleaseChannel): void {
// Download updates in the background and only prompt once they are ready to install.
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
if (this.configured) return;
this.configured = true;
// Suppress built-in dialogs; the renderer handles UI.
autoUpdater.autoRunAppAfterInstall = true;
autoUpdater.allowPrerelease = releaseChannel === "beta";
autoUpdater.channel = releaseChannel === "beta" ? "beta" : "latest";
autoUpdater.allowDowngrade = false;
autoUpdater.isUserWithinRollout = async (info) => {
try {
const parsed = rolloutManifestSchema.parse(info);
const stagingUserId = await getStagingUserId();
autoUpdater.on("update-available", (info) => {
input.onUpdateAvailable(info as RuntimeUpdateInfo);
});
autoUpdater.on("update-downloaded", (info) => {
input.onUpdateDownloaded(info as RuntimeUpdateInfo);
});
autoUpdater.on("update-not-available", () => {
input.onUpdateNotAvailable();
});
autoUpdater.on("error", (error) => {
input.onError(error);
});
return shouldAdmitToRollout({
channel: releaseChannel,
rolloutHours: parsed.rolloutHours,
releaseDate: parsed.releaseDate,
now: Date.now(),
bucket: bucketFromStagingUserId(stagingUserId),
});
} catch {
return true;
}
};
if (configuredReleaseChannel !== releaseChannel) {
cachedUpdateInfo = null;
downloadedUpdateVersion = null;
downloading = false;
configuredReleaseChannel = releaseChannel;
}
async checkForUpdates(): Promise<RuntimeUpdateCheckResult | null> {
const result = await autoUpdater.checkForUpdates();
if (!result) return null;
return {
isUpdateAvailable: result.isUpdateAvailable,
updateInfo: result.updateInfo as RuntimeUpdateInfo,
};
if (autoUpdaterConfigured) {
return;
}
downloadUpdate(): Promise<unknown> {
return autoUpdater.downloadUpdate();
}
autoUpdaterConfigured = true;
quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void {
autoUpdater.quitAndInstall(isSilent, isForceRunAfter);
}
autoUpdater.on("update-available", (info) => {
cachedUpdateInfo = info;
downloadedUpdateVersion = null;
downloading = true;
});
autoUpdater.on("update-downloaded", (info) => {
cachedUpdateInfo = info;
downloadedUpdateVersion = info.version;
downloading = false;
});
autoUpdater.on("update-not-available", () => {
cachedUpdateInfo = null;
downloadedUpdateVersion = null;
downloading = false;
});
autoUpdater.on("error", (error) => {
downloading = false;
console.error("[auto-updater] Updater event failed:", error);
});
}
const appUpdateService = createAppUpdateService({
runtime: new ElectronAppUpdateRuntime(),
isPackaged: () => app.isPackaged,
now: () => Date.now(),
bucket: async () => bucketFromStagingUserId(await getStagingUserId()),
reportCheckError: (error) => {
console.error("[auto-updater] Failed to check for updates:", error);
},
reportRuntimeError: (error) => {
console.error("[auto-updater] Updater event failed:", error);
},
reportInstallError: (message) => {
console.error("[auto-updater] Failed to download/install update:", message);
},
});
function isReadyToInstallVersion(version: string): boolean {
return downloadedUpdateVersion === version;
}
function buildCheckResult(input: {
currentVersion: string;
hasUpdate: boolean;
readyToInstall: boolean;
info?: UpdateInfo | null;
}): AppUpdateCheckResult {
const { currentVersion, hasUpdate, readyToInstall, info } = input;
return {
hasUpdate,
readyToInstall,
currentVersion,
latestVersion: info?.version ?? currentVersion,
body: typeof info?.releaseNotes === "string" ? info.releaseNotes : null,
date: typeof info?.releaseDate === "string" ? info.releaseDate : null,
};
}
async function performQuitAndInstall(onBeforeQuit?: () => Promise<void>): Promise<void> {
if (onBeforeQuit) await onBeforeQuit();
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
}
// ---------------------------------------------------------------------------
// Public API
@@ -152,13 +208,73 @@ const appUpdateService = createAppUpdateService({
export async function checkForAppUpdate({
currentVersion,
releaseChannel,
intent,
}: {
currentVersion: string;
releaseChannel: AppReleaseChannel;
intent: AppUpdateCheckIntent;
}): Promise<AppUpdateCheckResult> {
return appUpdateService.checkForAppUpdate({ currentVersion, releaseChannel, intent });
if (!app.isPackaged) {
return buildCheckResult({
currentVersion,
hasUpdate: false,
readyToInstall: false,
});
}
configureAutoUpdater(releaseChannel);
const cachedVersion = cachedUpdateInfo?.version ?? null;
if (cachedVersion && cachedVersion !== currentVersion) {
return buildCheckResult({
currentVersion,
hasUpdate: true,
readyToInstall: isReadyToInstallVersion(cachedVersion),
info: cachedUpdateInfo,
});
}
try {
const result = await autoUpdater.checkForUpdates();
if (!result || !result.updateInfo) {
return buildCheckResult({
currentVersion,
hasUpdate: false,
readyToInstall: false,
});
}
const info = result.updateInfo;
const latestVersion = info.version;
const hasUpdate = latestVersion !== currentVersion;
if (hasUpdate) {
cachedUpdateInfo = info;
downloading = !isReadyToInstallVersion(latestVersion);
return buildCheckResult({
currentVersion,
hasUpdate: true,
readyToInstall: isReadyToInstallVersion(latestVersion),
info,
});
}
cachedUpdateInfo = null;
downloadedUpdateVersion = null;
downloading = false;
return buildCheckResult({
currentVersion,
hasUpdate: false,
readyToInstall: false,
});
} catch (error) {
console.error("[auto-updater] Failed to check for updates:", error);
return buildCheckResult({
currentVersion,
hasUpdate: false,
readyToInstall: false,
});
}
}
export async function downloadAndInstallUpdate(
@@ -171,8 +287,63 @@ export async function downloadAndInstallUpdate(
},
onBeforeQuit?: () => Promise<void>,
): Promise<AppUpdateInstallResult> {
return appUpdateService.downloadAndInstallUpdate(
{ currentVersion, releaseChannel },
onBeforeQuit,
);
if (!app.isPackaged) {
return {
installed: false,
version: currentVersion,
message: "Auto-update is not available in development mode.",
};
}
if (!cachedUpdateInfo) {
return {
installed: false,
version: currentVersion,
message: "No update available. Check for updates first.",
};
}
configureAutoUpdater(releaseChannel);
const readyVersion = cachedUpdateInfo.version;
if (isReadyToInstallVersion(readyVersion)) {
await performQuitAndInstall(onBeforeQuit);
return {
installed: true,
version: readyVersion,
message: "Update downloaded. The app will restart shortly.",
};
}
if (downloading) {
return {
installed: false,
version: currentVersion,
message: "Update is still being prepared. Try again in a moment.",
};
}
downloading = true;
try {
await autoUpdater.downloadUpdate();
downloadedUpdateVersion = readyVersion;
downloading = false;
await performQuitAndInstall(onBeforeQuit);
return {
installed: true,
version: readyVersion,
message: "Update downloaded. The app will restart shortly.",
};
} catch (error) {
downloading = false;
const message = error instanceof Error ? error.message : String(error);
console.error("[auto-updater] Failed to download/install update:", message);
return {
installed: false,
version: currentVersion,
message: `Update failed: ${message}`,
};
}
}
@@ -162,45 +162,7 @@ describe("desktop editor targets", () => {
expect(recorder.calls[0]?.args).toEqual(["-R", "/tmp/repo/src/index.ts"]);
});
it("opens the workspace directory in Explorer using Windows path separators", async () => {
const recorder = createSpawnRecorder();
await openEditorTarget(
{ editorId: "explorer", path: "C:/Users/me/project" },
{
platform: "win32",
env: { PATH: "C:/Windows" },
existsSync: createExistsSync(["C:/Users/me/project", "C:/Windows/explorer.exe"]),
spawn: recorder.spawn,
},
);
// explorer.exe reads each "/segment" of a forward-slash arg as a switch and
// falls back to the Documents folder. The path must use backslashes.
expect(recorder.calls[0]?.command).toBe("C:/Windows/explorer.exe");
expect(recorder.calls[0]?.args).toEqual(["C:\\Users\\me\\project"]);
expect(recorder.calls[0]?.options.shell).toBe(false);
});
it("opens UNC workspace directories in Explorer using Windows path separators", async () => {
const recorder = createSpawnRecorder();
await openEditorTarget(
{ editorId: "explorer", path: "//server/share/project" },
{
platform: "win32",
env: { PATH: "C:/Windows" },
existsSync: createExistsSync(["//server/share/project", "C:/Windows/explorer.exe"]),
spawn: recorder.spawn,
},
);
// UNC paths must become \\server\share\... — the leading // is preserved by
// the app's path normalization and explorer accepts the backslash form.
expect(recorder.calls[0]?.args).toEqual(["\\\\server\\share\\project"]);
});
it("reveals files in Explorer on Windows using Windows path separators", async () => {
it("reveals files in Explorer on Windows", async () => {
const recorder = createSpawnRecorder();
await openEditorTarget(
@@ -214,7 +176,7 @@ describe("desktop editor targets", () => {
);
expect(recorder.calls[0]?.command).toBe("C:/Windows/explorer.exe");
expect(recorder.calls[0]?.args).toEqual(["/select,", "C:\\repo\\src\\index.ts"]);
expect(recorder.calls[0]?.args).toEqual(["/select,", "C:/repo/src/index.ts"]);
expect(recorder.calls[0]?.options.shell).toBe(false);
});
@@ -234,80 +196,9 @@ describe("desktop editor targets", () => {
},
);
// Separators flip to backslashes; "&" stays literal and the path stays a
// separate arg token (Node only quotes the path, not the "/select," switch).
expect(recorder.calls[0]).toMatchObject({
command: "C:/Windows/explorer.exe",
args: ["/select,", "C:\\repo\\src\\file & calculator.ts"],
options: { shell: false },
});
});
it("does not convert separators for editor targets on Windows", async () => {
const recorder = createSpawnRecorder();
await openEditorTarget(
{ editorId: "vscode", path: "C:/repo/src/index.ts", cwd: "C:/repo" },
{
platform: "win32",
env: { PATH: "C:/Program Files/Microsoft VS Code/bin" },
existsSync: createExistsSync([
"C:/repo/src/index.ts",
"C:/repo",
"C:/Program Files/Microsoft VS Code/bin/code.exe",
]),
spawn: recorder.spawn,
},
);
// Editors accept forward slashes; the backslash conversion is Explorer-scoped.
expect(recorder.calls[0]?.args).toEqual(["C:/repo", "C:/repo/src/index.ts"]);
});
it("prefers Windows command shims over extensionless shell launchers", async () => {
const recorder = createSpawnRecorder();
const vscodeBin = "C:/Users/me/AppData/Local/Programs/Microsoft VS Code/bin";
await openEditorTarget(
{
editorId: "vscode",
path: "C:/repo",
},
{
platform: "win32",
env: { PATH: vscodeBin },
existsSync: createExistsSync(["C:/repo", `${vscodeBin}/code`, `${vscodeBin}/code.cmd`]),
spawn: recorder.spawn,
},
);
expect(recorder.calls[0]).toMatchObject({
command: `"${vscodeBin}/code.cmd"`,
args: ["C:/repo"],
options: { shell: true },
});
});
it("keeps the Windows extensionless fallback when no command shim exists", async () => {
const recorder = createSpawnRecorder();
const vscodeBin = "C:/Portable/VS Code/bin";
await openEditorTarget(
{
editorId: "vscode",
path: "C:/repo",
},
{
platform: "win32",
env: { PATH: vscodeBin },
existsSync: createExistsSync(["C:/repo", `${vscodeBin}/code`]),
spawn: recorder.spawn,
},
);
expect(recorder.calls[0]).toMatchObject({
command: `${vscodeBin}/code`,
args: ["C:/repo"],
args: ["/select,", "C:/repo/src/file & calculator.ts"],
options: { shell: false },
});
});
@@ -147,19 +147,16 @@ function resolveExecutable(
continue;
}
const candidate = `${directory}/${command}`;
if (input.existsSync(candidate)) {
return candidate;
}
if (input.platform === "win32") {
const hasExtension = Boolean(win32.extname(command));
const extensions = hasExtension ? [""] : [".exe", ".cmd", ".bat", ".com", ""];
for (const extension of extensions) {
for (const extension of [".exe", ".cmd"]) {
const windowsCandidate = `${candidate}${extension}`;
if (input.existsSync(windowsCandidate)) {
return windowsCandidate;
}
}
continue;
}
if (input.existsSync(candidate)) {
return candidate;
}
}
return null;
@@ -190,16 +187,6 @@ function dirnameForPlatform(value: string, platform: NodeJS.Platform): string {
return platform === "win32" ? win32.dirname(value) : posix.dirname(value);
}
// App paths are normalized to forward slashes everywhere (see file-open), but
// explorer.exe parses each "/segment" of an argument as a command-line switch.
// Given a POSIX-style path it finds no path token and silently falls back to
// the default shell folder (the user's Documents). Hand it native backslashes.
// Only the argument needs converting — CreateProcess resolves the command path
// fine with forward slashes.
function toWindowsPathSeparators(value: string): string {
return value.replace(/\//g, "\\");
}
function isWindowsCommandScript(executable: string, platform: NodeJS.Platform): boolean {
if (platform !== "win32") {
return false;
@@ -247,7 +234,7 @@ function buildLaunch(input: {
return { command: input.executable, args: ["-R", input.path] };
}
if (input.target.id === "explorer" && input.platform === "win32") {
return { command: input.executable, args: ["/select,", toWindowsPathSeparators(input.path)] };
return { command: input.executable, args: ["/select,", input.path] };
}
if (input.target.id === "file-manager") {
return { command: input.executable, args: [dirnameForPlatform(input.path, input.platform)] };
@@ -257,9 +244,6 @@ function buildLaunch(input: {
if (input.target.kind === "editor" && input.cwd && input.cwd !== input.path) {
return { command: input.executable, args: [input.cwd, input.path] };
}
if (input.target.id === "explorer" && input.platform === "win32") {
return { command: input.executable, args: [toWindowsPathSeparators(input.path)] };
}
return { command: input.executable, args: [input.path] };
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.93",
"version": "0.1.91-beta.2",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.93",
"version": "0.1.91-beta.2",
"files": [
"dist",
"!dist/**/*.map"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.1.93",
"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;
@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { SessionInboundMessageSchema, SessionOutboundMessageSchema } from "./messages.js";
import { SessionInboundMessageSchema } from "./messages.js";
describe("list_commands_request schema", () => {
test("accepts legacy agent-only payload", () => {
@@ -49,61 +49,4 @@ describe("list_commands_request schema", () => {
},
});
});
test("preserves command kind metadata in responses", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "list_commands_response",
payload: {
agentId: "agent-123",
requestId: "req-123",
error: null,
commands: [
{
name: "taste",
description: "Apply code taste",
argumentHint: "",
kind: "skill",
},
],
},
});
expect(parsed.type).toBe("list_commands_response");
if (parsed.type !== "list_commands_response") {
throw new Error("Expected list_commands_response message");
}
expect(parsed.payload.commands).toEqual([
{
name: "taste",
description: "Apply code taste",
argumentHint: "",
kind: "skill",
},
]);
});
test("falls back to command for unknown future command kinds", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "list_commands_response",
payload: {
agentId: "agent-123",
requestId: "req-123",
error: null,
commands: [
{
name: "future-command",
description: "Future command kind",
argumentHint: "",
kind: "future-kind",
},
],
},
});
expect(parsed.type).toBe("list_commands_response");
if (parsed.type !== "list_commands_response") {
throw new Error("Expected list_commands_response message");
}
expect(parsed.payload.commands[0]?.kind).toBe("command");
});
});
-1
View File
@@ -3557,7 +3557,6 @@ const AgentSlashCommandSchema = z.object({
name: z.string(),
description: z.string(),
argumentHint: z.string(),
kind: z.enum(["command", "skill"]).optional().catch("command"),
});
export const ListCommandsResponseSchema = z.object({
+1 -2
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.93",
"version": "0.1.91-beta.2",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.93",
"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.93",
"@getpaseo/highlight": "0.1.93",
"@getpaseo/protocol": "0.1.93",
"@getpaseo/relay": "0.1.93",
"@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",
+1 -13
View File
@@ -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);
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { AgentManager, type AgentManagerEvent, type ManagedAgent } from "./agent-manager.js";
import { AgentManager, type ManagedAgent } from "./agent-manager.js";
import { AgentStorage } from "./agent-storage.js";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { formatSystemNotificationPrompt } from "./agent-prompt.js";
@@ -25,9 +25,10 @@ import type {
AgentSessionConfig,
AgentStreamEvent,
AgentTimelineItem,
ImportProviderSessionInput,
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>;
@@ -48,7 +49,6 @@ function deferred<T>(): Deferred<T> {
const TEST_CAPABILITIES = {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsSessionListing: true,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
@@ -64,6 +64,30 @@ function createFeature(args: { id: string; label: string; value: boolean }): Age
};
}
function createPersistedDescriptor(args: {
cwd: string;
sessionId: string;
nativeHandle?: string;
}): PersistedAgentDescriptor {
return {
provider: "codex",
sessionId: args.sessionId,
cwd: args.cwd,
title: null,
lastActivityAt: new Date("2026-01-01T00:00:00Z"),
persistence: {
provider: "codex",
sessionId: args.sessionId,
nativeHandle: args.nativeHandle,
metadata: {
provider: "codex",
cwd: args.cwd,
},
},
timeline: [],
};
}
function expectArchivedAgentRecord(
record: StoredAgentRecord | null,
expectedLastStatus: "closed" | "idle",
@@ -499,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,
@@ -528,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,
@@ -925,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",
});
@@ -968,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({
@@ -987,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 = {
@@ -1024,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",
@@ -1096,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",
});
@@ -1448,48 +1524,36 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p
});
});
test("importProviderSession imports the selected session without listing and publishes ready state", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-import-session-"));
test("findPersistedAgent returns matching descriptors by session id or native handle", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-find-persisted-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const session = new TestAgentSession({ provider: "codex", cwd: workdir });
const events: AgentManagerEvent[] = [];
class ImportClient extends TestAgentClient {
listCalls = 0;
importInput: unknown = null;
const descriptors: PersistedAgentDescriptor[] = [
createPersistedDescriptor({
cwd: workdir,
sessionId: "session-direct",
nativeHandle: "native-direct",
}),
createPersistedDescriptor({
cwd: workdir,
sessionId: "session-other",
nativeHandle: "native-match",
}),
];
async listImportableSessions() {
this.listCalls += 1;
return [];
}
class PersistedAgentsClient extends TestAgentClient {
lastLimit: number | undefined;
lastCwd: string | undefined;
async importSession(input: ImportProviderSessionInput) {
this.importInput = input;
return {
session,
config: { provider: "codex" as const, cwd: workdir },
persistence: {
provider: "codex" as const,
sessionId: input.providerHandleId,
nativeHandle: input.providerHandleId,
metadata: { provider: "codex", cwd: workdir },
},
timeline: [
{
item: { type: "user_message" as const, text: "Trace provider imports" },
timestamp: "2026-01-02T00:00:00.000Z",
},
{
item: { type: "assistant_message" as const, text: "Done" },
timestamp: "2026-01-02T00:00:01.000Z",
},
],
};
override async listPersistedAgents(options?: { limit?: number; cwd?: string }) {
this.lastLimit = options?.limit;
this.lastCwd = options?.cwd;
return descriptors;
}
}
const client = new ImportClient();
const client = new PersistedAgentsClient();
const manager = new AgentManager({
clients: {
codex: client,
@@ -1497,32 +1561,15 @@ test("importProviderSession imports the selected session without listing and pub
registry: storage,
logger,
});
manager.subscribe((event) => events.push(event), { replayState: false });
const imported = await manager.importProviderSession({
provider: "codex",
providerHandleId: "thread-selected",
cwd: workdir,
});
expect(client.listCalls).toBe(0);
expect(client.importInput).toEqual({ providerHandleId: "thread-selected", cwd: workdir });
expect(imported.lifecycle).toBe("idle");
expect(imported.historyPrimed).toBe(true);
expect(manager.getTimeline(imported.id)).toEqual([
{ type: "user_message", text: "Trace provider imports" },
{ type: "assistant_message", text: "Done" },
]);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
type: "agent_state",
agent: {
id: imported.id,
lifecycle: "idle",
persistence: { nativeHandle: "thread-selected" },
},
});
expect((await storage.get(imported.id))?.title).toBe("Trace provider imports");
await expect(manager.findPersistedAgent("codex", "session-direct")).resolves.toBe(descriptors[0]);
await expect(manager.findPersistedAgent("codex", "native-match")).resolves.toBe(descriptors[1]);
await expect(manager.findPersistedAgent("codex", "missing")).resolves.toBeNull();
await expect(
manager.findPersistedAgent("codex", "session-direct", { cwd: "/tmp/project" }),
).resolves.toBe(descriptors[0]);
expect(client.lastLimit).toBe(200);
expect(client.lastCwd).toBe("/tmp/project");
});
test("reloadAgentSession passes daemon launch env through the provider launch context", async () => {
@@ -5717,16 +5764,17 @@ class RecordingPersistedAgentsClient implements AgentClient {
return [];
}
async listImportableSessions() {
async listPersistedAgents(): Promise<PersistedAgentDescriptor[]> {
this.calls += 1;
return [
{
providerHandleId: `${this.provider}-session`,
provider: this.provider,
sessionId: `${this.provider}-session`,
cwd: "/tmp/recent",
title: null,
lastActivityAt: new Date("2026-01-01T00:00:00Z"),
firstPromptPreview: null,
lastPromptPreview: null,
persistence: { provider: this.provider, sessionId: `${this.provider}-session` },
timeline: [],
},
];
}
@@ -5742,8 +5790,26 @@ 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 },
},
],
])(
"listImportableSessions skips %s providers in fan-out",
"listImportablePersistedAgents skips %s providers in fan-out",
async (_reason, includedProvider, skippedProvider, providerDefinitions) => {
const includedClient = new RecordingPersistedAgentsClient(includedProvider);
const skippedClient = new RecordingPersistedAgentsClient(skippedProvider);
@@ -5753,7 +5819,7 @@ test.each([
logger,
});
const result = await manager.listImportableSessions();
const result = await manager.listImportablePersistedAgents();
expect(includedClient.calls).toBe(1);
expect(skippedClient.calls).toBe(0);
@@ -5761,26 +5827,7 @@ test.each([
},
);
test("listImportableSessions 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.listImportableSessions();
expect(claudeClient.calls).toBe(1);
expect(ompClient.calls).toBe(1);
expect(result.map((d) => d.provider).sort()).toEqual(["claude", "omp"]);
});
test("listImportableSessions narrows to the providerFilter when supplied", async () => {
test("listImportablePersistedAgents narrows to the providerFilter when supplied", async () => {
const claudeClient = new RecordingPersistedAgentsClient("claude");
const codexClient = new RecordingPersistedAgentsClient("codex");
const manager = new AgentManager({
@@ -5792,7 +5839,7 @@ test("listImportableSessions narrows to the providerFilter when supplied", async
logger,
});
const result = await manager.listImportableSessions({
const result = await manager.listImportablePersistedAgents({
providerFilter: new Set(["claude"]),
});
@@ -5801,33 +5848,6 @@ test("listImportableSessions narrows to the providerFilter when supplied", async
expect(result.map((d) => d.provider)).toEqual(["claude"]);
});
test("listImportableSessions skips providers that lack supportsSessionListing even when row listing is defined", async () => {
const listableClient = new RecordingPersistedAgentsClient("claude");
const nonListableClient = new RecordingPersistedAgentsClient("acp");
// Override capabilities to remove session listing support
Object.defineProperty(nonListableClient, "capabilities", {
value: {
...TEST_CAPABILITIES,
supportsSessionListing: false,
},
});
const manager = new AgentManager({
clients: { claude: listableClient, acp: nonListableClient },
providerDefinitions: {
claude: { enabled: true, derivedFromProviderId: null },
acp: { enabled: true, derivedFromProviderId: null },
},
logger,
});
const result = await manager.listImportableSessions();
expect(listableClient.calls).toBe(1);
expect(nonListableClient.calls).toBe(0);
expect(result.map((d) => d.provider)).toEqual(["claude"]);
});
test("user_message events wrapping a paseo-system envelope are not added to the timeline", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-live-"));
const storagePath = join(workdir, "agents");
+59 -150
View File
@@ -33,9 +33,8 @@ import {
type AgentTimelineItem,
type AgentUsage,
type AgentRuntimeInfo,
type ImportedTimelineEntry,
type ImportableProviderSession,
type ListImportableSessionsOptions,
type ListPersistedAgentsOptions,
type PersistedAgentDescriptor,
} from "./agent-sdk-types.js";
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
@@ -55,10 +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 { resolveCreateAgentTitles } from "./create-agent-title.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;
@@ -148,7 +148,7 @@ interface HydrateTimelineOptions {
broadcast?: boolean;
}
export type ImportablePersistedAgentQueryOptions = ListImportableSessionsOptions & {
export type ImportablePersistedAgentQueryOptions = ListPersistedAgentsOptions & {
/**
* When set, only providers in this set are scanned, in addition to the
* built-in importable allowlist + enabled + non-derived rules.
@@ -156,10 +156,6 @@ export type ImportablePersistedAgentQueryOptions = ListImportableSessionsOptions
providerFilter?: Set<string>;
};
export interface ManagedImportableProviderSession extends ImportableProviderSession {
provider: AgentProvider;
}
export type AgentAttentionCallback = (params: {
agentId: string;
provider: AgentProvider;
@@ -194,7 +190,7 @@ export interface AgentManagerOptions {
onAgentAttention?: AgentAttentionCallback;
durableTimelineStore?: AgentTimelineStore;
terminalManager?: TerminalManager | null;
mcpBaseUrl?: string;
paseoToolingRuntime?: PaseoToolingRuntime;
appendSystemPrompt?: string;
agentStreamCoalesceWindowMs?: number;
rescueTimeouts?: AgentManagerRescueTimeouts;
@@ -421,53 +417,10 @@ function buildExplicitTimelineSeedForRegister(
};
}
function buildImportedTimelineRows(entries: readonly ImportedTimelineEntry[]): AgentTimelineRow[] {
const rows: AgentTimelineRow[] = [];
for (const entry of entries) {
if (entry.item.type === "user_message" && isSystemInjectedEnvelope(entry.item.text)) {
continue;
}
rows.push({
seq: rows.length + 1,
timestamp: entry.timestamp ?? new Date().toISOString(),
item: entry.item,
});
}
return rows;
}
function resolveImportedAgentTitle(
config: AgentSessionConfig,
timelineRows: readonly AgentTimelineRow[],
): string | null {
const initialPrompt = getFirstUserMessageTextFromRows(timelineRows);
if (!initialPrompt) {
return null;
}
const { explicitTitle, provisionalTitle } = resolveCreateAgentTitles({
configTitle: config.title,
initialPrompt,
});
return explicitTitle ?? provisionalTitle ?? null;
}
function getFirstUserMessageTextFromRows(rows: readonly AgentTimelineRow[]): string | null {
for (const row of rows) {
const item = row.item;
if (item.type !== "user_message") {
continue;
}
const text = item.text.trim();
if (text) {
return text;
}
}
return null;
}
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>();
@@ -480,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;
@@ -492,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 = {
@@ -526,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)) {
@@ -547,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 ?? "";
}
@@ -662,37 +612,34 @@ export class AgentManager {
.map((agent) => Object.assign({}, agent));
}
async listImportableSessions(
async listImportablePersistedAgents(
options?: ImportablePersistedAgentQueryOptions,
): Promise<ManagedImportableProviderSession[]> {
): Promise<PersistedAgentDescriptor[]> {
const providerEntries = Array.from(this.clients.entries()).filter(
([provider, client]) =>
client.capabilities.supportsSessionListing &&
!!client.listImportableSessions &&
!!client.listPersistedAgents &&
this.isProviderImportable(provider, options?.providerFilter),
);
const sessionLists = await Promise.all(
const descriptorLists = await Promise.all(
providerEntries.map(async ([provider, client]) => {
try {
return (
await client.listImportableSessions!({
limit: options?.limit,
cwd: options?.cwd,
})
).map((session) => Object.assign(session, { provider }));
return await client.listPersistedAgents!({
limit: options?.limit,
cwd: options?.cwd,
});
} catch (error) {
this.logger.warn(
{ err: error, provider },
"Failed to list importable sessions for provider",
"Failed to list persisted agents for provider",
);
return [];
}
}),
);
const sessions: ManagedImportableProviderSession[] = sessionLists.flat();
const descriptors: PersistedAgentDescriptor[] = descriptorLists.flat();
const limit = options?.limit ?? 20;
return sessions
return descriptors
.sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime())
.slice(0, limit);
}
@@ -701,15 +648,41 @@ 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;
}
return true;
}
async findPersistedAgent(
provider: AgentProvider,
sessionId: string,
options?: Pick<ListPersistedAgentsOptions, "cwd">,
): Promise<PersistedAgentDescriptor | null> {
const client = this.requireClient(provider);
if (!client.listPersistedAgents) {
return null;
}
const descriptors = await client.listPersistedAgents({ limit: 200, cwd: options?.cwd });
return (
descriptors.find((descriptor) => {
return (
descriptor.sessionId === sessionId || descriptor.persistence.nativeHandle === sessionId
);
}) ?? null
);
}
async listProviderAvailability(): Promise<ProviderAvailability[]> {
const checks = Array.from(this.clients.keys()).map(async (provider) => {
const client = this.clients.get(provider);
@@ -904,50 +877,6 @@ export class AgentManager {
return this.registerSession(session, storedConfig, resolvedAgentId, options);
}
async importProviderSession(input: {
provider: AgentProvider;
providerHandleId: string;
cwd: string;
labels?: Record<string, string>;
}): Promise<ManagedAgent> {
const resolvedAgentId = validateAgentId(this.idFactory(), "importProviderSession");
this.requireEnabledProvider(input.provider);
const client = await this.requireAvailableClient({ provider: input.provider });
if (!client.importSession) {
throw new Error(`Provider '${input.provider}' does not support importing sessions`);
}
const { storedConfig, launchConfig } = await this.prepareSessionConfig(
{
provider: input.provider,
cwd: input.cwd,
},
resolvedAgentId,
);
const launchContext = this.buildLaunchContext(resolvedAgentId);
const imported = await client.importSession(
{
providerHandleId: input.providerHandleId,
cwd: input.cwd,
},
{ config: launchConfig, storedConfig, launchContext },
);
const importedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(imported.config));
const timelineRows = buildImportedTimelineRows(imported.timeline);
const initialTitle = resolveImportedAgentTitle(importedConfig, timelineRows);
return this.registerSession(imported.session, importedConfig, resolvedAgentId, {
labels: input.labels,
timelineRows,
timelineNextSeq: timelineRows.length + 1,
persistence: imported.persistence,
historyPrimed: true,
initialTitle,
publishWhenReady: true,
});
}
// Hot-reload an active agent session with config overrides. By default the
// in-memory timeline is preserved (used for voice-mode toggles and similar
// config swaps). When `rehydrateFromDisk` is set, the timeline is wiped so a
@@ -2313,13 +2242,11 @@ export class AgentManager {
timeline?: AgentTimelineItem[];
timelineRows?: AgentTimelineRow[];
timelineNextSeq?: number;
persistence?: AgentPersistenceHandle;
historyPrimed?: boolean;
lastUsage?: AgentUsage;
lastError?: string;
attention?: AttentionState;
initialTitle?: string | null;
publishWhenReady?: boolean;
},
): Promise<ManagedAgent> {
const resolvedAgentId = validateAgentId(agentId, "registerSession");
@@ -2351,16 +2278,14 @@ export class AgentManager {
this.agents.set(resolvedAgentId, managed);
// Initialize previousStatus to track transitions
this.previousStatuses.set(resolvedAgentId, managed.lifecycle);
await this.refreshRuntimeInfo(managed, { emit: !options?.publishWhenReady });
await this.refreshRuntimeInfo(managed);
await this.persistSnapshot(managed, {
workspaceId: options?.workspaceId,
title: initialPersistedTitle,
});
if (!options?.publishWhenReady) {
this.emitState(managed, { persist: false });
}
this.emitState(managed, { persist: false });
await this.refreshSessionState(managed, { emit: !options?.publishWhenReady });
await this.refreshSessionState(managed);
managed.lifecycle = "idle";
await this.persistSnapshot(managed, { workspaceId: options?.workspaceId });
this.emitState(managed, { persist: false });
@@ -2376,7 +2301,6 @@ export class AgentManager {
timeline?: AgentTimelineItem[];
timelineRows?: AgentTimelineRow[];
timelineNextSeq?: number;
persistence?: AgentPersistenceHandle;
createdAt?: Date;
updatedAt?: Date;
}
@@ -2419,7 +2343,6 @@ export class AgentManager {
lastUsage?: AgentUsage;
lastError?: string;
attention?: AttentionState;
persistence?: AgentPersistenceHandle;
}
| undefined;
}): ActiveManagedAgent {
@@ -2445,10 +2368,7 @@ export class AgentManager {
foregroundTurnWaiters: new Set<ForegroundTurnWaiter>(),
finalizedForegroundTurnIds: new Set<string>(),
unsubscribeSession: null,
persistence: attachPersistenceCwd(
options?.persistence ?? session.describePersistence(),
config.cwd,
),
persistence: attachPersistenceCwd(session.describePersistence(), config.cwd),
historyPrimed: options?.historyPrimed ?? durableTimelineHasRows,
lastUserMessageAt: options?.lastUserMessageAt ?? null,
lastUsage: options?.lastUsage,
@@ -2646,10 +2566,7 @@ export class AgentManager {
return this.registry;
}
private async refreshSessionState(
agent: ActiveManagedAgent,
options?: { emit?: boolean },
): Promise<void> {
private async refreshSessionState(agent: ActiveManagedAgent): Promise<void> {
try {
const modes = await agent.session.getAvailableModes();
agent.availableModes = modes;
@@ -2671,13 +2588,10 @@ export class AgentManager {
}
this.syncFeaturesFromSession(agent);
await this.refreshRuntimeInfo(agent, options);
await this.refreshRuntimeInfo(agent);
}
private async refreshRuntimeInfo(
agent: ActiveManagedAgent,
options?: { emit?: boolean },
): Promise<void> {
private async refreshRuntimeInfo(agent: ActiveManagedAgent): Promise<void> {
try {
const newInfo = await agent.session.getRuntimeInfo();
const changed =
@@ -2693,7 +2607,7 @@ export class AgentManager {
);
}
// Emit state if runtimeInfo changed so clients get the updated model
if (changed && options?.emit !== false) {
if (changed) {
this.emitState(agent);
}
} catch {
@@ -3543,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 };
}
@@ -3572,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,
@@ -10,10 +10,10 @@ import {
import type { AgentSession } from "./agent-sdk-types.js";
import type {
AgentFeature,
ImportableProviderSession,
AgentPermissionRequest,
AgentPersistenceHandle,
AgentSessionConfig,
PersistedAgentDescriptor,
} from "./agent-sdk-types.js";
type ManagedAgentOverrides = Omit<Partial<ManagedAgent>, "config" | "pendingPermissions"> & {
@@ -400,18 +400,26 @@ describe("toAgentPayload", () => {
});
describe("toRecentProviderSessionDescriptorPayload", () => {
it("projects provider import rows to provider-opaque public recent sessions", () => {
const session: ImportableProviderSession & { provider: string } = {
it("projects persisted descriptors to provider-opaque public recent sessions", () => {
const descriptor: PersistedAgentDescriptor = {
provider: "codex-custom",
providerHandleId: "provider-native-handle",
sessionId: "legacy-session-id",
cwd: "/tmp/project",
title: "Import me",
firstPromptPreview: "First prompt with spacing",
lastPromptPreview: "Second prompt",
lastActivityAt: new Date("2026-04-30T12:34:56.000Z"),
persistence: {
provider: "codex-custom",
sessionId: "legacy-session-id",
nativeHandle: "provider-native-handle",
},
timeline: [
{ type: "assistant_message", text: "Ready" },
{ type: "user_message", text: " First prompt\n\nwith spacing " },
{ type: "user_message", text: "Second prompt" },
],
};
const payload = toRecentProviderSessionDescriptorPayload(session, {
const payload = toRecentProviderSessionDescriptorPayload(descriptor, {
providerLabel: "Custom Codex",
});
@@ -430,25 +438,28 @@ describe("toRecentProviderSessionDescriptorPayload", () => {
expect(payload).not.toHaveProperty("nativeHandle");
});
it("preserves null prompt previews", () => {
const session: ImportableProviderSession & { provider: string } = {
it("falls back to persistence session id when no provider native handle exists", () => {
const descriptor: PersistedAgentDescriptor = {
provider: "claude-custom",
providerHandleId: "provider-session-id",
sessionId: "descriptor-session-id",
cwd: "/tmp/project",
title: null,
lastActivityAt: new Date("2026-04-30T12:34:56.000Z"),
firstPromptPreview: null,
lastPromptPreview: null,
persistence: {
provider: "claude-custom",
sessionId: "persistence-session-id",
},
timeline: [],
};
expect(
toRecentProviderSessionDescriptorPayload(session, {
toRecentProviderSessionDescriptorPayload(descriptor, {
providerLabel: "Custom Claude",
}),
).toMatchObject({
providerId: "claude-custom",
providerLabel: "Custom Claude",
providerHandleId: "provider-session-id",
providerHandleId: "persistence-session-id",
firstPromptPreview: null,
lastPromptPreview: null,
});
@@ -14,8 +14,9 @@ import type {
AgentProvider,
AgentSessionConfig,
AgentRuntimeInfo,
AgentTimelineItem,
AgentUsage,
ImportableProviderSession,
PersistedAgentDescriptor,
} from "./agent-sdk-types.js";
import type { ManagedAgent } from "./agent-manager.js";
import type { JsonValue } from "../json-utils.js";
@@ -32,6 +33,8 @@ interface RecentProviderSessionProjectionOptions {
providerLabel: string;
}
const PROMPT_PREVIEW_MAX_LENGTH = 160;
function normalizeThinkingOptionId(value: string | null | undefined): string | null {
if (typeof value !== "string") return null;
const normalized = value.trim();
@@ -260,18 +263,20 @@ export function toAgentListItemPayload(agent: AgentSnapshotPayload): AgentListIt
}
export function toRecentProviderSessionDescriptorPayload(
session: ImportableProviderSession & { provider: string },
descriptor: PersistedAgentDescriptor,
options: RecentProviderSessionProjectionOptions,
): RecentProviderSessionDescriptorPayload {
const promptPreviews = collectPromptPreviews(descriptor.timeline);
return {
providerId: session.provider,
providerId: descriptor.provider,
providerLabel: options.providerLabel,
providerHandleId: session.providerHandleId,
cwd: session.cwd,
title: session.title,
firstPromptPreview: session.firstPromptPreview,
lastPromptPreview: session.lastPromptPreview,
lastActivityAt: session.lastActivityAt.toISOString(),
providerHandleId: descriptor.persistence.nativeHandle ?? descriptor.persistence.sessionId,
cwd: descriptor.cwd,
title: descriptor.title,
firstPromptPreview: promptPreviews[0] ?? null,
lastPromptPreview: promptPreviews.at(-1) ?? null,
lastActivityAt: descriptor.lastActivityAt.toISOString(),
};
}
@@ -292,6 +297,26 @@ export function resolveStoredAgentPayloadUpdatedAt(record: StoredAgentRecord): s
return timestamps[0].raw;
}
function collectPromptPreviews(timeline: readonly AgentTimelineItem[]): string[] {
return timeline.flatMap((item) => {
if (item.type !== "user_message") {
return [];
}
const preview = normalizePromptPreview(item.text);
return preview ? [preview] : [];
});
}
function normalizePromptPreview(text: string): string | null {
const normalized = text.trim().replace(/\s+/g, " ");
if (!normalized) {
return null;
}
return normalized.length > PROMPT_PREVIEW_MAX_LENGTH
? normalized.slice(0, PROMPT_PREVIEW_MAX_LENGTH)
: normalized;
}
function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentConfig | null {
const serializable: SerializableAgentConfig = {};
if (config.modeId) {

Some files were not shown because too many files have changed in this diff Show More