Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 401fc82a17 chore(release): cut 0.1.103 2026-07-01 21:37:24 +02:00
Mohamed Boudra 9059f8e0e9 fix(providers): seed Claude context from catalog 2026-07-01 21:24:46 +02:00
Mohamed Boudra 53c9c3c0be docs(changelog): note 0.1.103 2026-07-01 20:23:45 +02:00
Mohamed Boudra 01eafccb6a fix(providers): seed Sonnet 5 context window 2026-07-01 20:23:35 +02:00
Mohamed Boudra ab7543ae1d feat(providers): add Claude Sonnet 5 2026-07-01 19:47:27 +02:00
Mohamed Boudra e32c50f2d7 chore(release): cut 0.1.102 2026-06-30 12:00:31 +02:00
Mohamed Boudra e3eb633582 docs: update changelog for 0.1.102 2026-06-30 11:23:41 +02:00
Mohamed Boudra beac4544fd chore: update ACP provider catalog 2026-06-30 11:22:12 +02:00
Mohamed Boudra c2271ff0ca Place turn footers after trailing tools (#1827)
* fix(app): place turn footers after trailing tools

Completed turn footers now render on the last visible item before the next user message while still using the latest assistant message for footer content and timing.

* test(app): cover split turn footer placement
2026-06-30 10:51:53 +02:00
Mohamed Boudra f91806defe Use separate OpenAI endpoints for speech-to-text and text-to-speech (#1823)
* feat(voice): configure OpenAI STT and TTS endpoints separately

Replace providers.openai.voice (a single apiKey/baseUrl shared by
speech-to-text and text-to-speech) with independent providers.openai.stt
and providers.openai.tts, each carrying its own apiKey/baseUrl. STT and
TTS now resolve fully independently, so they can point at different
OpenAI-compatible endpoints. The env equivalents OPENAI_VOICE_API_KEY /
OPENAI_VOICE_BASE_URL split into OPENAI_STT_* and OPENAI_TTS_*.

No backcompat: the voice key is removed and no longer read. Each feature
still falls back to providers.openai.apiKey/baseUrl, then OPENAI_API_KEY/
OPENAI_BASE_URL. Composer dictation resolves from the STT endpoint.

* fix(voice): keep daemon bootable and respect global OpenAI key

Two issues from review of the STT/TTS endpoint split:

- A config from an older release that still sets providers.openai.voice
  crashed daemon startup, because the strict schema rejects the now-unknown
  key. Strip it before parsing (alongside the existing local.autoDownload
  strip) so the daemon boots; the value is discarded, not migrated.
- An empty endpoint env var (e.g. a copied .env.example leaving
  OPENAI_STT_API_KEY= blank) shadowed the OPENAI_API_KEY fallback, so
  speech was reported as missing credentials despite a configured global
  key. firstDefined now skips empty/whitespace strings.

* fix(voice): isolate STT and TTS option parsing per endpoint

An STT-only OpenAI setup could be broken by a stale or invalid TTS env
var (e.g. a leftover TTS_VOICE/TTS_MODEL), because the single resolution
schema validated both endpoints' option groups before the per-endpoint
gate. Split into endpoint-key, STT-option, and TTS-option schemas and
parse each option group only when that endpoint has credentials, so an
unused endpoint's bad env can no longer take down the configured one.

* fix(voice): tag voice-config shim and update direct-daemon test callers

- Mark the providers.openai.voice strip with a COMPAT(openaiVoiceConfig)
  comment + removal date so it shows up in the back-compat cleanup
  inventory, per repo convention.
- Update the tests that build the daemon directly with a resolved OpenAI
  config (bootstrap smoke + the real-API voice/daemon e2e suites) to the
  new { stt, tts } shape; the old top-level { apiKey } is no longer read,
  and these files are excluded from typecheck so the break was silent.

* fix(voice): update voice-roundtrip debug script to new OpenAI config shape

Last direct daemon caller still passing the removed top-level
openai: { apiKey }; the debug script lives outside tsconfig.scripts.json
so the stale shape wasn't caught by typecheck. Use { stt, tts }.
2026-06-30 10:00:27 +02:00
Mohamed Boudra e9431517a0 Show host in search results and filter sidebar by multiple hosts (#1825)
* feat(app): show host in command center and multi-select host filter

Command-center agent results now show which host the workspace lives on,
shown only when more than one host is connected (matching the sidebar).

The sidebar display-preferences filter becomes multi-select — pick any
number of hosts instead of one-or-all — and its host rows now carry a
status dot on the left like the other host pickers. Persisted filter
state migrates from the old single host to a host list.

* test(app): extract host-filter and command-center e2e DSL helpers

Move the mechanical menu/search interactions out of the new spec bodies
into focused helpers so the tests read as user intent.

* fix(app): drop the icon from the host filter "All hosts" item

* fix(app): make multi-host e2e seeding order-independent

The offline-host seed relied on Playwright running the test's init script
after the fixture's registry reset — an ordering Playwright does not
guarantee. Write the full registry and set the fixture's disable-once flag,
then reload, so the multi-host seed survives regardless of script order.

Also tag the pre-v2 hostFilter migration reader with COMPAT so the cleanup
sweep can find it.
2026-06-30 09:40:24 +02:00
Xisheng Parker Zhao 4e28b0941b fix(server): surface Kiro CLI slash commands and skills over ACP (#1792)
* fix(server): surface Kiro CLI slash commands and skills over ACP

Kiro CLI advertises its slash commands and skills through the
`_kiro.dev/commands/available` ACP extension notification rather than the
standard `available_commands_update` session update. ACPAgentSession only
trace-logged extension notifications and dropped the payload, so the
composer never showed any Kiro commands or skills.

- Map the extension payload onto the shared slash-command cache:
  `commands` (built-in, names arrive with a leading "/") and `prompts`
  (skills/prompts, tagged with a `skill:` serverName) are normalized to
  Paseo's AgentSlashCommand shape, stripping the leading slash and pulling
  the argument hint from `meta.hint`.
- Add KiroACPAgentClient (mirrors CursorACPAgentClient) so the provider
  waits for the first async command batch before listCommands() resolves,
  since Kiro emits the notification shortly after session/new.

* fix(server): settle on empty Kiro batch; inject Kiro commands via constructor option

Addresses review feedback on #1792.

Keep vendor-specific providers out of the generic acp-agent.ts, following
the same pattern as Cursor/Copilot: provider behavior is injected through
ACPAgentClient constructor options rather than special-cased in the base
class. Adds an optional extensionCommandsParser option on the generic ACP
client/session; the base extNotification() invokes it and routes any parsed
commands through a generic applyResolvedCommands() helper. Kiro supplies
parseKiroExtensionCommands (recognizes _kiro.dev/commands/available); the
base class no longer references any Kiro method string or payload shape.

Also fixes an empty-batch hang: applyResolvedCommands() always settles the
initial-commands gate, so a Kiro user with no slash commands no longer
blocks listCommands() for the full 10s timeout. Adds a regression test.
2026-06-30 09:36:48 +02:00
Mohamed Boudra 7c6152663e Show found desktop updates after manual checks (#1815)
* fix(desktop): report found app updates during manual checks

Manual checks could reuse cached update state while the renderer discarded pending update details. Keep found update versions visible while downloads prepare, and reserve install affordances for ready updates.

* test(desktop): cover manual update retry after errors

* fix(desktop): keep ready updates ready on recheck
2026-06-30 08:12:15 +02:00
paseo-ai[bot] 5fc53c576e fix: update lockfile signatures and Nix hash [skip ci] 2026-06-29 22:07:09 +00:00
75 changed files with 1572 additions and 496 deletions
+35 -31
View File
@@ -1,51 +1,55 @@
# Changelog
## 0.1.102-beta.2 - 2026-06-29
## 0.1.103 - 2026-07-01
### Added
- Fork a chat from any assistant answer ([#1788](https://github.com/getpaseo/paseo/pull/1788))
- Use Paseo from a browser served directly by your daemon ([#1635](https://github.com/getpaseo/paseo/pull/1635))
- Claude Sonnet 5 is available in the Claude model picker ([#1850](https://github.com/getpaseo/paseo/pull/1850))
## 0.1.102 - 2026-06-30
### Added
- Fork chats into a new tab or new worktree ([#1788](https://github.com/getpaseo/paseo/pull/1788))
- See workspaces from all connected hosts ([#1538](https://github.com/getpaseo/paseo/pull/1538), [#1775](https://github.com/getpaseo/paseo/pull/1775), [#1825](https://github.com/getpaseo/paseo/pull/1825))
- Daemon can now serve the web UI ([#1635](https://github.com/getpaseo/paseo/pull/1635), [#1739](https://github.com/getpaseo/paseo/pull/1739))
- Run Paseo from an official Docker image ([#1740](https://github.com/getpaseo/paseo/pull/1740) by [@Herbrant](https://github.com/Herbrant))
- Update a remote host from the app when a newer daemon is available ([#1513](https://github.com/getpaseo/paseo/pull/1513) by [@thedavidweng](https://github.com/thedavidweng))
- Dropped files can be attached from every composer ([#1750](https://github.com/getpaseo/paseo/pull/1750))
- MiniMax usage is available in provider quota views ([#1662](https://github.com/getpaseo/paseo/pull/1662) by [@ilteoood](https://github.com/ilteoood))
- C# code blocks now have syntax highlighting ([#1651](https://github.com/getpaseo/paseo/pull/1651) by [@dev693](https://github.com/dev693))
- Update a daemon remotely from the app ([#1513](https://github.com/getpaseo/paseo/pull/1513) by [@thedavidweng](https://github.com/thedavidweng))
- Configure separate OpenAI endpoints for speech-to-text and text-to-speech ([#1823](https://github.com/getpaseo/paseo/pull/1823))
- Drop files into any composer ([#1750](https://github.com/getpaseo/paseo/pull/1750), [#1801](https://github.com/getpaseo/paseo/pull/1801))
- Show MiniMax usage in quota views ([#1662](https://github.com/getpaseo/paseo/pull/1662) by [@ilteoood](https://github.com/ilteoood))
- Highlight C# code blocks ([#1651](https://github.com/getpaseo/paseo/pull/1651) by [@dev693](https://github.com/dev693))
### Improved
- The sidebar can show workspaces across all connected hosts ([#1538](https://github.com/getpaseo/paseo/pull/1538))
- Multi-host workspace rows show which host they belong to ([#1775](https://github.com/getpaseo/paseo/pull/1775))
- Host switcher rows show the connection currently in use
- Creating a new workspace is available from an app-wide screen ([#1746](https://github.com/getpaseo/paseo/pull/1746))
- New Workspace starts from the current project when opened inside a project ([#1806](https://github.com/getpaseo/paseo/pull/1806))
- Project search shows loading progress while results are fetched ([#1762](https://github.com/getpaseo/paseo/pull/1762))
- Desktop update checks show feedback while checking for updates ([#1808](https://github.com/getpaseo/paseo/pull/1808))
- Slow host requests time out less aggressively ([#1789](https://github.com/getpaseo/paseo/pull/1789))
- Pi waits longer for extension results before timing out ([#1732](https://github.com/getpaseo/paseo/pull/1732) by [@theslava](https://github.com/theslava))
- New Workspace opens from anywhere ([#1746](https://github.com/getpaseo/paseo/pull/1746), [#1806](https://github.com/getpaseo/paseo/pull/1806))
- Project search shows loading progress ([#1762](https://github.com/getpaseo/paseo/pull/1762))
- Desktop update checks show clearer status ([#1808](https://github.com/getpaseo/paseo/pull/1808), [#1815](https://github.com/getpaseo/paseo/pull/1815))
- Slow remote hosts time out less aggressively ([#1789](https://github.com/getpaseo/paseo/pull/1789))
- Pi waits longer for extension results ([#1732](https://github.com/getpaseo/paseo/pull/1732) by [@theslava](https://github.com/theslava))
- Open file tabs refresh when you revisit them ([#1699](https://github.com/getpaseo/paseo/pull/1699) by [@cleiter](https://github.com/cleiter))
- Web terminals stay smoother while scrolling ([#1622](https://github.com/getpaseo/paseo/pull/1622) by [@TommyLike](https://github.com/TommyLike))
- ACP provider catalog updated to the latest registry versions
- Web terminals scroll more smoothly ([#1622](https://github.com/getpaseo/paseo/pull/1622) by [@TommyLike](https://github.com/TommyLike))
### Fixed
- Freshly added projects can be edited without restarting ([#1761](https://github.com/getpaseo/paseo/pull/1761) by [@huiliaoning](https://github.com/huiliaoning))
- Opening projects and directory suggestions work better in large repos ([#1620](https://github.com/getpaseo/paseo/pull/1620) by [@jms830](https://github.com/jms830))
- Mobile opens the saved workspace reliably on launch ([#1777](https://github.com/getpaseo/paseo/pull/1777))
- Agent prompts no longer rename existing workspaces ([#1779](https://github.com/getpaseo/paseo/pull/1779))
- Chat stays at your scroll position when delayed history arrives ([#1776](https://github.com/getpaseo/paseo/pull/1776))
- Large repos open more reliably ([#1620](https://github.com/getpaseo/paseo/pull/1620) by [@jms830](https://github.com/jms830))
- Mobile restores the saved workspace on launch ([#1777](https://github.com/getpaseo/paseo/pull/1777))
- Agent prompts no longer rename workspaces ([#1779](https://github.com/getpaseo/paseo/pull/1779))
- Chat stays put when delayed history arrives ([#1776](https://github.com/getpaseo/paseo/pull/1776))
- Streamed chat images stay in order ([#1805](https://github.com/getpaseo/paseo/pull/1805))
- Chat actions wait until running tool calls finish
- Claude subagent narration no longer leaks into chat ([#1807](https://github.com/getpaseo/paseo/pull/1807))
- Agent lists keep working when project records go stale ([#1812](https://github.com/getpaseo/paseo/pull/1812))
- Windows image previews work with drive-letter paths ([#1811](https://github.com/getpaseo/paseo/pull/1811))
- Chat actions stay below tool output ([#1827](https://github.com/getpaseo/paseo/pull/1827))
- Claude subagent narration stays out of chat ([#1807](https://github.com/getpaseo/paseo/pull/1807))
- Kiro slash commands and skills appear in Paseo ([#1792](https://github.com/getpaseo/paseo/pull/1792) by [@park0er](https://github.com/park0er))
- Agent lists survive stale project records ([#1812](https://github.com/getpaseo/paseo/pull/1812))
- Windows image previews handle drive-letter paths ([#1811](https://github.com/getpaseo/paseo/pull/1811))
- OpenCode closes cleanly on Windows ([#1771](https://github.com/getpaseo/paseo/pull/1771) by [@agamotto](https://github.com/agamotto))
- Desktop file uploads keep their file extensions ([#1741](https://github.com/getpaseo/paseo/pull/1741))
- Closing Claude Code sessions no longer leaves child processes running ([#1540](https://github.com/getpaseo/paseo/pull/1540) by [@TommyLike](https://github.com/TommyLike))
- OpenCode no longer indexes your home directory by mistake ([#1704](https://github.com/getpaseo/paseo/pull/1704) by [@rex-chang](https://github.com/rex-chang))
- Desktop file uploads keep their extensions ([#1741](https://github.com/getpaseo/paseo/pull/1741))
- Claude Code cleanup kills child processes ([#1540](https://github.com/getpaseo/paseo/pull/1540) by [@TommyLike](https://github.com/TommyLike))
- OpenCode no longer indexes your home directory ([#1704](https://github.com/getpaseo/paseo/pull/1704) by [@rex-chang](https://github.com/rex-chang))
- Packaged macOS CLI daemon no longer shows extra Dock icons ([#1759](https://github.com/getpaseo/paseo/pull/1759) by [@yzim](https://github.com/yzim))
- `paseo daemon status` reports daemon health even when agent details are unavailable ([#1810](https://github.com/getpaseo/paseo/pull/1810))
- PR worktrees show pushed state correctly after checkout ([#1804](https://github.com/getpaseo/paseo/pull/1804))
- `paseo daemon status` works without loading agents ([#1810](https://github.com/getpaseo/paseo/pull/1810))
- PR worktrees show pushed state correctly ([#1804](https://github.com/getpaseo/paseo/pull/1804))
## 0.1.101 - 2026-06-26
+13 -4
View File
@@ -164,7 +164,12 @@ Single file, validated with `PersistedConfigSchema`.
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
},
providers: {
openai: { voice: { apiKey: string, baseUrl: string } },
openai: {
apiKey?: string,
baseUrl?: string,
stt?: { apiKey?: string, baseUrl?: string },
tts?: { apiKey?: string, baseUrl?: string }
},
local: { modelsDir: string }
},
agents: {
@@ -202,13 +207,17 @@ Set these to select OpenAI instead of local speech:
| `PASEO_DICTATION_STT_PROVIDER` | Composer dictation STT provider |
| `PASEO_VOICE_TTS_PROVIDER` | Voice mode TTS provider |
OpenAI voice can be configured under `providers.openai`:
OpenAI speech can be configured under `providers.openai`. STT and TTS resolve independently, so they can point at different endpoints:
```json
{
"providers": {
"openai": {
"voice": {
"stt": {
"apiKey": "sk-...",
"baseUrl": "https://stt.example.com/v1"
},
"tts": {
"apiKey": "sk-...",
"baseUrl": "https://api.openai.com/v1"
}
@@ -217,7 +226,7 @@ OpenAI voice can be configured under `providers.openai`:
}
```
`providers.openai.voice.apiKey` and `providers.openai.voice.baseUrl` apply only to Paseo OpenAI voice features.
`providers.openai.stt` is used for both composer dictation and voice mode speech-to-text; `providers.openai.tts` is used for voice mode text-to-speech. The equivalent env vars are `OPENAI_STT_API_KEY`/`OPENAI_STT_BASE_URL` and `OPENAI_TTS_API_KEY`/`OPENAI_TTS_BASE_URL`. Each feature falls back to `providers.openai.apiKey`/`providers.openai.baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when its own fields are unset. These settings apply only to Paseo OpenAI speech features, not to Codex or other OpenAI-backed tools.
Paseo uses these paths under the configured OpenAI base URL:
+1 -1
View File
@@ -1 +1 @@
sha256-4fS/faM5nUjfRnc0UznCisNAby2fiNg0ROklW0FyptY=
sha256-uGqJm/y14KJYBwutBkPwxhNbStzpeeVx2PcB081r4nk=
+21 -21
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.102-beta.2",
"version": "0.1.103",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.102-beta.2",
"version": "0.1.103",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -35132,7 +35132,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.102-beta.2",
"version": "0.1.103",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -36150,12 +36150,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.102-beta.2",
"version": "0.1.103",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.102-beta.2",
"@getpaseo/protocol": "0.1.102-beta.2",
"@getpaseo/server": "0.1.102-beta.2",
"@getpaseo/client": "0.1.103",
"@getpaseo/protocol": "0.1.103",
"@getpaseo/server": "0.1.103",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36401,10 +36401,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.1.102-beta.2",
"version": "0.1.103",
"dependencies": {
"@getpaseo/protocol": "0.1.102-beta.2",
"@getpaseo/relay": "0.1.102-beta.2",
"@getpaseo/protocol": "0.1.103",
"@getpaseo/relay": "0.1.103",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -36415,7 +36415,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.102-beta.2",
"version": "0.1.103",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -36658,7 +36658,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.102-beta.2",
"version": "0.1.103",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37554,7 +37554,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.102-beta.2",
"version": "0.1.103",
"dependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37786,7 +37786,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.1.102-beta.2",
"version": "0.1.103",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37798,7 +37798,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.102-beta.2",
"version": "0.1.103",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38016,15 +38016,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.102-beta.2",
"version": "0.1.103",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.102-beta.2",
"@getpaseo/highlight": "0.1.102-beta.2",
"@getpaseo/protocol": "0.1.102-beta.2",
"@getpaseo/relay": "0.1.102-beta.2",
"@getpaseo/client": "0.1.103",
"@getpaseo/highlight": "0.1.103",
"@getpaseo/protocol": "0.1.103",
"@getpaseo/relay": "0.1.103",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38561,7 +38561,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.102-beta.2",
"version": "0.1.103",
"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.102-beta.2",
"version": "0.1.103",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -0,0 +1,49 @@
import { randomUUID } from "node:crypto";
import { expect } from "@playwright/test";
import { test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { createIdleAgent } from "./helpers/archive-tab";
import { openCommandCenter } from "./helpers/command-center";
import { addOfflineHostAndReload } from "./helpers/hosts";
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
const PRIMARY_HOST_LABEL = "Primary Host";
const SECONDARY_HOST_ID = "host-command-center-secondary";
test.describe("Command center host labels", () => {
test.describe.configure({ timeout: 180_000 });
test("agent results show the host they live on when more than one host exists", async ({
page,
}) => {
const seeded = await seedWorkspace({ repoPrefix: "command-center-host-" });
const title = `cc-host-${randomUUID().slice(0, 8)}`;
try {
const agent = await createIdleAgent(seeded.client, {
cwd: seeded.repoPath,
workspaceId: seeded.workspaceId,
title,
});
// A second (offline) host makes the view multi-host, which is when the host label earns its space.
await gotoAppShell(page);
await addOfflineHostAndReload(page, {
serverId: SECONDARY_HOST_ID,
label: "Secondary Host",
primaryLabel: PRIMARY_HOST_LABEL,
});
const panel = await openCommandCenter(page);
// The shared daemon may carry agents from other specs, so target this agent by its id.
const row = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row).toContainText(title);
await expect(row).toContainText(PRIMARY_HOST_LABEL);
} finally {
await seeded.cleanup();
}
});
});
+18
View File
@@ -4,8 +4,11 @@ import { getServerId } from "./helpers/server-id";
import {
loadRealDaemonState,
injectDesktopBridge,
openDesktopAboutSettings,
openDesktopSettings,
expectUpdateBanner,
clickCheckForUpdates,
expectPendingUpdateCheckResult,
clickInstallUpdate,
expectInstallInProgress,
interceptDaemonManagementConfirmDialog,
@@ -45,6 +48,21 @@ test.describe("Desktop updates", () => {
await clickInstallUpdate(page);
await expectInstallInProgress(page);
});
test("manual check reports a found update while it downloads", async ({ page }) => {
await injectDesktopBridge(page, {
serverId: getServerId(),
updateAvailable: true,
latestVersion: "1.2.3",
updateReadyToInstall: false,
});
await gotoAppShell(page);
await openDesktopAboutSettings(page);
await clickCheckForUpdates(page);
await expectPendingUpdateCheckResult(page, "1.2.3");
});
});
test.describe("Desktop daemon management", () => {
@@ -0,0 +1,9 @@
import { expect, type Locator, type Page } from "@playwright/test";
// Opens the command center / global search palette from the sidebar and returns its panel.
export async function openCommandCenter(page: Page): Promise<Locator> {
await page.getByTestId("sidebar-command-center-search").click();
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
return panel;
}
+25 -2
View File
@@ -3,7 +3,8 @@ import { appendFile } from "node:fs/promises";
import { expect, type Page } from "@playwright/test";
import { openSettings } from "./app";
import { getE2EDaemonPort } from "./daemon-port";
import { openSettingsHost, openSettingsHostSection } from "./settings";
import { escapeRegex } from "./regex";
import { openSettingsHost, openSettingsHostSection, openSettingsSection } from "./settings";
interface DaemonApiStatus {
version: string;
@@ -52,6 +53,7 @@ export interface DesktopBridgeConfig {
serverId: string;
updateAvailable?: boolean;
latestVersion?: string;
updateReadyToInstall?: boolean;
slowInstall?: boolean;
/** Initial PID reported by desktop_daemon_status. Defaults to null. */
daemonPid?: number | null;
@@ -169,7 +171,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
return cfg.updateAvailable
? {
hasUpdate: true,
readyToInstall: true,
readyToInstall: cfg.updateReadyToInstall ?? true,
currentVersion: "1.0.0",
latestVersion: cfg.latestVersion ?? "1.2.3",
body: null,
@@ -276,12 +278,33 @@ export async function openDesktopSettings(page: Page, serverId: string): Promise
});
}
export async function openDesktopAboutSettings(page: Page): Promise<void> {
await openSettings(page);
await openSettingsSection(page, "about");
await expect(page.getByText("App updates", { exact: true })).toBeVisible();
}
export async function expectUpdateBanner(page: Page, version: string): Promise<void> {
const callout = page.getByTestId("update-callout");
await expect(callout).toBeVisible({ timeout: 15_000 });
await expect(callout).toContainText(`v${version.replace(/^v/i, "")}`);
}
export async function clickCheckForUpdates(page: Page): Promise<void> {
await page.getByRole("button", { name: "Check" }).click();
}
export async function expectPendingUpdateCheckResult(page: Page, version: string): Promise<void> {
const normalizedVersion = `v${version.replace(/^v/i, "")}`;
await expect(
page.getByText(
new RegExp(`Update found: ${escapeRegex(normalizedVersion)}\\. Downloading\\.\\.\\.`),
),
).toBeVisible();
await expect(page.getByText(`Ready to install: ${normalizedVersion}`)).toHaveCount(0);
await expect(page.getByRole("button", { name: "Update" })).toBeDisabled();
}
export async function clickInstallUpdate(page: Page): Promise<void> {
await page.getByRole("button", { name: "Install & restart" }).click();
}
+79
View File
@@ -0,0 +1,79 @@
import { expect, type Page } from "@playwright/test";
import { buildSeededHost } from "./daemon-registry";
const REGISTRY_KEY = "@paseo:daemon-registry";
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
// The multi-host UI (the command-center host label, the sidebar host filter) only renders once
// more than one host exists. The e2e harness runs a single real daemon, so we add an extra registry
// entry pointing at an unreachable endpoint: it stays offline, which is enough to make the UI treat
// the view as multi-host without standing up a second daemon.
//
// Must run AFTER the first navigation: the auto-seed fixture writes the registry + nonce on load,
// and reseeds on every navigation. We write the full registry here and set the fixture's
// disable-once flag, then reload — so the fixture skips its reset and the registry survives. This
// avoids depending on the (unspecified) ordering of multiple Playwright init scripts. Optionally
// relabels the seeded primary host so assertions can target a distinctive name.
export async function addOfflineHostAndReload(
page: Page,
input: { serverId: string; label: string; primaryLabel?: string },
): Promise<void> {
const offlineHost = buildSeededHost({
serverId: input.serverId,
label: input.label,
endpoint: "127.0.0.1:59999",
nowIso: new Date().toISOString(),
});
await page.evaluate(
({ host, keys, primaryLabel }) => {
const nonce = localStorage.getItem(keys.nonce);
if (!nonce) {
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
}
const raw = localStorage.getItem(keys.registry);
const registry: Array<{ serverId: string; label?: string }> = raw ? JSON.parse(raw) : [];
if (primaryLabel && registry[0]) {
registry[0].label = primaryLabel;
}
if (!registry.some((entry) => entry.serverId === host.serverId)) {
registry.push(host);
}
localStorage.setItem(keys.registry, JSON.stringify(registry));
localStorage.setItem(keys.disableSeedOnce, nonce);
},
{
host: offlineHost,
keys: {
registry: REGISTRY_KEY,
nonce: SEED_NONCE_KEY,
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
},
primaryLabel: input.primaryLabel,
},
);
await page.reload();
}
export async function openSidebarDisplayPreferences(page: Page): Promise<void> {
await page.getByTestId("sidebar-display-preferences-menu").click();
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible({
timeout: 10_000,
});
}
// A host's filter row carries a status dot on the left next to its label.
export async function expectHostFilterRow(page: Page, serverId: string): Promise<void> {
await expect(page.getByTestId(`sidebar-host-filter-${serverId}`)).toBeVisible();
await expect(page.getByTestId(`sidebar-host-filter-status-${serverId}`)).toBeVisible();
}
export async function toggleHostFilter(page: Page, serverId: string): Promise<void> {
await page.getByTestId(`sidebar-host-filter-${serverId}`).click();
}
export async function selectAllHostsFilter(page: Page): Promise<void> {
await page.getByTestId("sidebar-host-filter-all").click();
}
@@ -0,0 +1,56 @@
import { expect } from "@playwright/test";
import { test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
addOfflineHostAndReload,
expectHostFilterRow,
openSidebarDisplayPreferences,
selectAllHostsFilter,
toggleHostFilter,
} from "./helpers/hosts";
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
const SECONDARY_HOST_ID = "host-filter-secondary";
test.describe("Sidebar host filter (multi-select)", () => {
test.describe.configure({ timeout: 120_000 });
test("pins the sidebar to multiple selected hosts at once", async ({ page }) => {
const seeded = await seedWorkspace({ repoPrefix: "host-filter-" });
const serverId = getServerId();
const workspaceRow = page.getByTestId(
`sidebar-workspace-row-${serverId}:${seeded.workspaceId}`,
);
try {
// A second (offline) host is enough to surface the host filter without a second daemon.
await gotoAppShell(page);
await addOfflineHostAndReload(page, { serverId: SECONDARY_HOST_ID, label: "Secondary Host" });
await expect(workspaceRow).toBeVisible({ timeout: 30_000 });
await openSidebarDisplayPreferences(page);
await expectHostFilterRow(page, serverId);
await expectHostFilterRow(page, SECONDARY_HOST_ID);
// Pin the primary host — its workspace stays visible.
await toggleHostFilter(page, serverId);
await expect(workspaceRow).toBeVisible();
// Add the secondary host without clearing the primary. Under single-select this would replace
// the primary and hide the workspace; multi-select keeps both pinned, so it stays visible.
await toggleHostFilter(page, SECONDARY_HOST_ID);
await expect(workspaceRow).toBeVisible();
// Drop the primary host — only the (empty) secondary host remains pinned, so the workspace hides.
await toggleHostFilter(page, serverId);
await expect(workspaceRow).toHaveCount(0, { timeout: 10_000 });
// Back to all hosts — the workspace returns.
await selectAllHostsFilter(page);
await expect(workspaceRow).toBeVisible({ timeout: 10_000 });
} finally {
await seeded.cleanup();
}
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.102-beta.2",
"version": "0.1.103",
"private": true,
"main": "index.ts",
"scripts": {
+85 -6
View File
@@ -112,6 +112,26 @@ function footerOwners(layout: StreamLayout): string[] {
return owners;
}
function footerAssistantIds(layout: StreamLayout): string[] {
return [
...layout.history.flatMap((item) =>
item.completedFooter ? [item.completedFooter.itemId] : [],
),
...layout.liveHead.flatMap((item) =>
item.completedFooter ? [item.completedFooter.itemId] : [],
),
...(layout.auxiliaryTurnFooter ? [layout.auxiliaryTurnFooter.itemId] : []),
];
}
function inlineFooterPlacementByItemId(layout: StreamLayout): Record<string, string> {
return Object.fromEntries(
[...layout.history, ...layout.liveHead].flatMap((item) =>
item.completedFooter ? [[item.item.id, item.completedFooter.itemId]] : [],
),
);
}
function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
const item = [...layout.history, ...layout.liveHead].find(
(candidate) => candidate.item.id === id,
@@ -291,7 +311,7 @@ describe("layoutStream", () => {
});
it.each(["web", "android"] as const)(
"keeps inline footer on an assistant turn with trailing tool rows before the next user on %s",
"places inline footer after trailing visible tool rows before the next user on %s",
(platform) => {
const assistant = assistantMessage("a1", 2);
const tool = toolCall("tool-1", 3);
@@ -302,13 +322,36 @@ describe("layoutStream", () => {
});
expect(layout.auxiliaryTurnFooter).toBeNull();
expect(findLayoutItem(layout, assistant.id).completedFooter?.itemId).toBe(assistant.id);
expect(footerOwners(layout)).toEqual([assistant.id]);
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
expect(findLayoutItem(layout, tool.id).completedFooter?.itemId).toBe(assistant.id);
expect(footerOwners(layout)).toEqual([tool.id]);
expect(footerAssistantIds(layout)).toEqual([assistant.id]);
},
);
it.each(["web", "android"] as const)(
"uses the latest assistant for an inline footer when a turn has multiple assistant blocks on %s",
"places split live-head tool footer using the assistant from history on %s",
(platform) => {
const assistant = assistantMessage("a1", 2);
const tool = toolCall("tool-1", 3);
const layout = layoutFor({
platform,
tail: [userMessage("u1", 1), assistant],
head: [tool, userMessage("u2", 4)],
timingIds: [assistant.id],
});
expect(layout.auxiliaryTurnFooter).toBeNull();
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
expect(findLayoutItem(layout, tool.id).completedFooter?.itemId).toBe(assistant.id);
expect(inlineFooterPlacementByItemId(layout)).toEqual({
[tool.id]: assistant.id,
});
},
);
it.each(["web", "android"] as const)(
"uses the latest assistant for footer content while placing after the visible turn end on %s",
(platform) => {
const firstAssistant = assistantMessage("a1", 2);
const firstTool = toolCall("tool-1", 3);
@@ -329,10 +372,46 @@ describe("layoutStream", () => {
expect(layout.auxiliaryTurnFooter).toBeNull();
expect(findLayoutItem(layout, firstAssistant.id).completedFooter).toBeNull();
expect(findLayoutItem(layout, latestAssistant.id).completedFooter?.itemId).toBe(
expect(findLayoutItem(layout, latestAssistant.id).completedFooter).toBeNull();
expect(findLayoutItem(layout, latestTool.id).completedFooter?.itemId).toBe(
latestAssistant.id,
);
expect(footerOwners(layout)).toEqual([latestAssistant.id]);
expect(footerOwners(layout)).toEqual([latestTool.id]);
expect(footerAssistantIds(layout)).toEqual([latestAssistant.id]);
},
);
it.each(["web", "android"] as const)(
"keeps every completed turn footer while placing each one after that turn's last visible item on %s",
(platform) => {
const firstAssistant = assistantMessage("a1", 2);
const secondAssistant = assistantMessage("a2", 4);
const secondTool = toolCall("tool-2", 5);
const layout = layoutFor({
platform,
tail: [
userMessage("u1", 1),
firstAssistant,
userMessage("u2", 3),
secondAssistant,
secondTool,
userMessage("u3", 6),
],
timingIds: [firstAssistant.id, secondAssistant.id],
});
expect(layout.auxiliaryTurnFooter).toBeNull();
expect(findLayoutItem(layout, firstAssistant.id).completedFooter?.itemId).toBe(
firstAssistant.id,
);
expect(findLayoutItem(layout, secondAssistant.id).completedFooter).toBeNull();
expect(findLayoutItem(layout, secondTool.id).completedFooter?.itemId).toBe(
secondAssistant.id,
);
expect(inlineFooterPlacementByItemId(layout)).toEqual({
[firstAssistant.id]: firstAssistant.id,
[secondTool.id]: secondAssistant.id,
});
},
);
+72 -101
View File
@@ -44,7 +44,6 @@ export interface StreamLayoutInput {
interface LayoutSegmentInput {
strategy: StreamStrategy;
agentStatus: string;
items: StreamItem[];
timingByAssistantId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null;
@@ -52,6 +51,14 @@ interface LayoutSegmentInput {
boundaryIndex: number | null;
boundaryAboveItem: StreamItem | null;
boundaryBelowItem: StreamItem | null;
boundaryAboveItems: StreamItem[] | null;
boundaryAboveIndex: number | null;
}
interface AssistantFooterSource {
item: Extract<StreamItem, { kind: "assistant_message" }>;
items: StreamItem[];
index: number;
}
function createTurnFooterHost(input: {
@@ -68,25 +75,45 @@ function createTurnFooterHost(input: {
};
}
function findLatestAssistantIndexInTurn(input: {
function findLatestAssistantInTurn(input: {
strategy: StreamStrategy;
items: StreamItem[];
startIndex: number;
}): number | null {
for (
let index = input.startIndex;
index >= 0 && index < input.items.length;
index = input.strategy.getNeighborIndex(index, "above")
) {
const item = input.items[index];
if (!item || item.kind === "user_message") {
boundaryAboveItems?: StreamItem[] | null;
boundaryAboveIndex?: number | null;
}): AssistantFooterSource | null {
let items = input.items;
let index = input.startIndex;
let canCrossBoundary = true;
while (true) {
for (
;
index >= 0 && index < items.length;
index = input.strategy.getNeighborIndex(index, "above")
) {
const item = items[index];
if (!item || item.kind === "user_message") {
return null;
}
if (item.kind === "assistant_message") {
return { item, items, index };
}
}
if (
!canCrossBoundary ||
!input.boundaryAboveItems ||
input.boundaryAboveIndex === null ||
input.boundaryAboveIndex === undefined
) {
return null;
}
if (item.kind === "assistant_message") {
return index;
}
items = input.boundaryAboveItems;
index = input.boundaryAboveIndex;
canCrossBoundary = false;
}
return null;
}
function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | null {
@@ -100,104 +127,54 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost |
return null;
}
const assistantIndex = findLatestAssistantIndexInTurn({
const assistant = findLatestAssistantInTurn({
strategy: input.strategy,
items: footerItems,
startIndex: latestIndex,
});
if (assistantIndex === null) {
return null;
}
const item = footerItems[assistantIndex];
if (!item || item.kind !== "assistant_message") {
if (!assistant) {
return null;
}
return createTurnFooterHost({
item,
items: footerItems,
index: assistantIndex,
item: assistant.item,
items: assistant.items,
index: assistant.index,
timingByAssistantId: input.timingByAssistantId,
});
}
function findTurnEndIndexInSegment(input: {
strategy: StreamStrategy;
items: StreamItem[];
startIndex: number;
}): number {
let endIndex = input.startIndex;
for (
let index = input.strategy.getNeighborIndex(input.startIndex, "below");
index >= 0 && index < input.items.length;
index = input.strategy.getNeighborIndex(index, "below")
) {
const item = input.items[index];
if (!item || item.kind === "user_message") {
break;
}
endIndex = index;
}
return endIndex;
}
function shouldRenderCompletedFooter(input: {
function resolveCompletedFooter(input: {
strategy: StreamStrategy;
items: StreamItem[];
index: number;
item: StreamItem;
belowItem: StreamItem | null;
agentStatus: string;
timingByAssistantId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null;
boundaryIndex: number | null;
boundaryBelowItem: StreamItem | null;
}): boolean {
if (
input.item.kind !== "assistant_message" ||
input.auxiliaryTurnFooter?.itemId === input.item.id
) {
return false;
boundaryAboveItems: StreamItem[] | null;
boundaryAboveIndex: number | null;
}): TurnFooterHost | null {
if (input.item.kind === "user_message" || input.belowItem?.kind !== "user_message") {
return null;
}
if (
input.belowItem?.kind === "user_message" ||
(input.belowItem === null && input.agentStatus !== "running")
) {
return true;
}
if (!isToolSequenceItem(input.belowItem)) {
return false;
}
const sameSegmentBelowItem = input.strategy.getNeighborItem(input.items, input.index, "below");
if (sameSegmentBelowItem?.id !== input.belowItem.id) {
return false;
}
const turnEndIndex = findTurnEndIndexInSegment({
const assistant = findLatestAssistantInTurn({
strategy: input.strategy,
items: input.items,
startIndex: input.index,
boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex,
});
const belowTurnItem = getSegmentNeighbor({
strategy: input.strategy,
items: input.items,
index: turnEndIndex,
relation: "below",
boundaryIndex: input.boundaryIndex,
boundaryItem: input.boundaryBelowItem,
});
if (input.agentStatus === "running" && belowTurnItem?.kind !== "user_message") {
return false;
if (!assistant || input.auxiliaryTurnFooter?.itemId === assistant.item.id) {
return null;
}
const assistantIndex = findLatestAssistantIndexInTurn({
strategy: input.strategy,
items: input.items,
startIndex: turnEndIndex,
return createTurnFooterHost({
item: assistant.item,
items: assistant.items,
index: assistant.index,
timingByAssistantId: input.timingByAssistantId,
});
return assistantIndex === input.index;
}
function isToolSequenceItem(
@@ -270,24 +247,17 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
aboveItem,
belowItem,
});
const completedFooter = shouldRenderCompletedFooter({
const completedFooter = resolveCompletedFooter({
strategy: input.strategy,
items: input.items,
index,
item,
belowItem,
agentStatus: input.agentStatus,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
boundaryIndex: input.boundaryIndex,
boundaryBelowItem: input.boundaryBelowItem,
})
? createTurnFooterHost({
item,
items: input.items,
index,
timingByAssistantId: input.timingByAssistantId,
})
: null;
boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex,
});
return {
item,
@@ -328,7 +298,6 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
// and .kind are stable across text-only flushes (text growth doesn't change what kind of
// item borders history), so cached layout stays valid between flushes.
const historyCacheKey = [
input.agentStatus,
frameOrder,
historyBoundaryIndex ?? "null",
liveHeadBoundaryItem?.id ?? "null",
@@ -346,7 +315,6 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
} else {
history = layoutSegment({
strategy: input.strategy,
agentStatus: input.agentStatus,
items: input.history,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter,
@@ -354,6 +322,8 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
boundaryIndex: historyBoundaryIndex,
boundaryAboveItem: null,
boundaryBelowItem: liveHeadBoundaryItem,
boundaryAboveItems: null,
boundaryAboveIndex: null,
});
byKey.set(historyCacheKey, history);
}
@@ -363,7 +333,6 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
const liveHead = layoutSegment({
strategy: input.strategy,
agentStatus: input.agentStatus,
items: input.liveHead,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter,
@@ -371,6 +340,8 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
boundaryIndex: liveHeadBoundaryIndex,
boundaryAboveItem: historyBoundaryItem,
boundaryBelowItem: null,
boundaryAboveItems: input.history,
boundaryAboveIndex: historyBoundaryIndex,
});
return {
+12 -4
View File
@@ -13,6 +13,7 @@ import { Home, Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useCommandCenter } from "@/hooks/use-command-center";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useHosts } from "@/runtime/host-runtime";
import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { AgentStatusDot } from "@/components/agent-status-dot";
@@ -199,9 +200,10 @@ function CommandCenterAgentRow({
interface CommandCenterAgentRowContentProps {
agent: AggregatedAgent;
showHost: boolean;
}
function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentProps) {
function CommandCenterAgentRowContent({ agent, showHost }: CommandCenterAgentRowContentProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const titleStyle = useMemo(
@@ -213,7 +215,7 @@ function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentPro
[theme.colors.foregroundMuted],
);
return (
<View style={styles.rowContent}>
<View style={styles.rowContent} testID={`command-center-agent-${agent.serverId}:${agent.id}`}>
<View style={styles.rowMain}>
<View style={styles.iconSlot}>
<AgentStatusDot
@@ -226,7 +228,8 @@ function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentPro
<Text style={titleStyle} numberOfLines={1}>
{agent.title || t("shell.commandCenter.newAgent")}
</Text>
<Text style={subtitleStyle} numberOfLines={1}>
<Text style={subtitleStyle} numberOfLines={1} testID="command-center-agent-subtitle">
{showHost ? `${agent.serverLabel} · ` : ""}
{shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
</Text>
</View>
@@ -246,6 +249,7 @@ interface AgentItemsSectionProps {
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
sectionDividerStyle: React.ComponentProps<typeof View>["style"];
sectionLabelStyle: React.ComponentProps<typeof Text>["style"];
showHost: boolean;
}
function AgentItemsSection({
@@ -257,6 +261,7 @@ function AgentItemsSection({
onSelect,
sectionDividerStyle,
sectionLabelStyle,
showHost,
}: AgentItemsSectionProps) {
const { t } = useTranslation();
@@ -277,7 +282,7 @@ function AgentItemsSection({
onLayout={onRowLayout(rowIndex)}
onSelect={onSelect}
>
<CommandCenterAgentRowContent agent={agent} />
<CommandCenterAgentRowContent agent={agent} showHost={showHost} />
</CommandCenterAgentRow>
);
})}
@@ -302,6 +307,8 @@ export function CommandCenter() {
const isCompact = useIsCompactFormFactor();
const showBottomSheet = isCompact && isNative;
// Host names only earn their space once results can span more than one host.
const showHost = useHosts().length > 1;
const rowRefs = useRef<Map<number, View>>(new Map());
const rowLayouts = useRef<Map<number, { y: number; height: number }>>(new Map());
@@ -477,6 +484,7 @@ export function CommandCenter() {
onSelect={handleSelectItem}
sectionDividerStyle={sectionDividerStyle}
sectionLabelStyle={sectionLabelStyle}
showHost={showHost}
/>
) : null}
</>
@@ -256,7 +256,7 @@ function WorkspaceSelectionProbe({
function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
counts.frame += 1;
const { projects } = useSidebarWorkspacesList({ hostFilter: SERVER_ID });
const { projects } = useSidebarWorkspacesList({ hostFilters: [SERVER_ID] });
return (
<>
@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Settings2 } from "lucide-react-native";
@@ -10,11 +10,11 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { HostStatusDot } from "@/components/host-status-dot";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { useAppSettings, type WorkspaceTitleSource } from "@/hooks/use-settings";
import { useHosts, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
import { useHosts } from "@/runtime/host-runtime";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { formatConnectionStatus } from "@/utils/daemons";
const ThemedSettings2 = withUnistyles(Settings2);
const filterColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -36,9 +36,10 @@ interface DisplayPreferenceOption<Value extends string> {
export function SidebarDisplayPreferencesMenu() {
const groupMode = useSidebarViewStore((state) => state.groupMode);
const hostFilter = useSidebarViewStore((state) => state.hostFilter);
const hostFilters = useSidebarViewStore((state) => state.hostFilters);
const setGroupMode = useSidebarViewStore((state) => state.setGroupMode);
const setHostFilter = useSidebarViewStore((state) => state.setHostFilter);
const toggleHostFilter = useSidebarViewStore((state) => state.toggleHostFilter);
const clearHostFilters = useSidebarViewStore((state) => state.clearHostFilters);
const hosts = useHosts();
const {
settings: { workspaceTitleSource },
@@ -52,13 +53,6 @@ export function SidebarDisplayPreferencesMenu() {
[setGroupMode],
);
const handleSelectHost = useCallback(
(serverId: string | null) => {
setHostFilter(serverId);
},
[setHostFilter],
);
const handleWorkspaceTitleSourceSelect = useCallback(
(source: WorkspaceTitleSource) => {
void updateSettings({ workspaceTitleSource: source });
@@ -75,6 +69,7 @@ export function SidebarDisplayPreferencesMenu() {
);
const showHostFilter = hosts.length > 1;
const allHostsSelected = hostFilters.length === 0;
return (
<DropdownMenu>
@@ -105,20 +100,21 @@ export function SidebarDisplayPreferencesMenu() {
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Filter</Text>
</View>
<HostFilterItem
label="All hosts"
value={null}
hostFilter={hostFilter}
onSelect={handleSelectHost}
/>
<DropdownMenuItem
testID="sidebar-host-filter-all"
selected={allHostsSelected}
closeOnSelect={false}
onSelect={clearHostFilters}
>
All hosts
</DropdownMenuItem>
{hosts.map((host) => (
<HostFilterItem
key={host.serverId}
label={host.label?.trim() || host.serverId}
serverId={host.serverId}
value={host.serverId}
hostFilter={hostFilter}
onSelect={handleSelectHost}
selected={hostFilters.includes(host.serverId)}
onToggle={toggleHostFilter}
/>
))}
</>
@@ -167,25 +163,32 @@ function DisplayPreferenceMenuItem<Value extends string>({
function HostFilterItem({
label,
serverId,
value,
hostFilter,
onSelect,
selected,
onToggle,
}: {
label: string;
serverId?: string;
value: string | null;
hostFilter: string | null;
onSelect: (serverId: string | null) => void;
serverId: string;
selected: boolean;
onToggle: (serverId: string) => void;
}) {
const isSelected = hostFilter === value;
const handleSelect = useCallback(() => onSelect(value), [value, onSelect]);
const status = useHostRuntimeSnapshot(serverId ?? "");
const subtitle = serverId
? formatConnectionStatus(status?.connectionStatus ?? "idle")
: undefined;
const handleSelect = useCallback(() => onToggle(serverId), [serverId, onToggle]);
const leading = useMemo(
() => (
<View testID={`sidebar-host-filter-status-${serverId}`}>
<HostStatusDot serverId={serverId} />
</View>
),
[serverId],
);
return (
<DropdownMenuItem selected={isSelected} description={subtitle} onSelect={handleSelect}>
<DropdownMenuItem
testID={`sidebar-host-filter-${serverId}`}
selected={selected}
closeOnSelect={false}
leading={leading}
onSelect={handleSelect}
>
{label}
</DropdownMenuItem>
);
@@ -86,7 +86,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
});
useSidebarViewStore.setState({
groupMode: "project",
hostFilter: null,
hostFilters: [],
});
act(() => {
@@ -216,7 +216,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
);
useSessionStore.getState().setHasHydratedWorkspaces("host-a", true);
useSessionStore.getState().setHasHydratedWorkspaces("host-b", true);
useSidebarViewStore.getState().setHostFilter("host-b");
useSidebarViewStore.getState().toggleHostFilter("host-b");
});
await act(async () => {
@@ -159,10 +159,10 @@ const CATALOG_DATA = [
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.159.1",
version: "0.161.0",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.159.1", "exec", "--output-format", "acp-daemon"],
command: ["npx", "-y", "droid@0.161.0", "exec", "--output-format", "acp-daemon"],
env: {
DROID_DISABLE_AUTO_UPDATE: "true",
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
@@ -173,10 +173,10 @@ const CATALOG_DATA = [
id: "fast-agent",
title: "fast-agent",
description: "Code and build agents with comprehensive multi-provider support",
version: "0.8.0",
version: "0.8.1",
iconId: "fast-agent",
installLink: "https://fast-agent.ai/acp/",
command: ["uvx", "--from", "fast-agent-acp==0.8.0", "fast-agent-acp", "-x"],
command: ["uvx", "--from", "fast-agent-acp==0.8.1", "fast-agent-acp", "-x"],
},
{
id: "gemini",
@@ -284,10 +284,10 @@ const CATALOG_DATA = [
id: "nova",
title: "Nova",
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
version: "1.1.21",
version: "1.1.22",
iconId: "nova",
installLink: "https://www.compassap.ai/portfolio/nova.html",
command: ["npx", "-y", "@compass-ai/nova@1.1.21", "acp"],
command: ["npx", "-y", "@compass-ai/nova@1.1.22", "acp"],
},
{
id: "poolside",
@@ -114,15 +114,17 @@ describe("desktop app updater — check", () => {
});
});
it("reports 'pending' when the check resolves with an update that is not yet downloaded", async () => {
it("reports the found update while it is still preparing", async () => {
const { updater, port } = createUpdater();
port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: false }));
port.nextCheckResult(
buildFakeCheckResult({ hasUpdate: true, readyToInstall: false, latestVersion: "1.2.3" }),
);
await updater.checkForUpdates({ releaseChannel: "stable" });
expect(updater.getSnapshot()).toMatchObject({
status: "pending",
availableUpdate: null,
availableUpdate: { latestVersion: "1.2.3", readyToInstall: false },
});
});
@@ -408,17 +410,17 @@ describe("formatStatusText", () => {
).toBe("Update ready: v1.2.3");
});
it("keeps manual check feedback visible while an update is pending", () => {
it("shows the found version while an update is pending", () => {
expect(
formatStatusText({
status: "pending",
availableUpdate: null,
availableUpdate: buildFakeCheckResult({ latestVersion: "1.2.3" }),
installMessage: null,
lastCheckedAt: 42,
formatVersion,
formatLastCheckedAt,
}),
).toBe("We'll let you know when the update is ready. Last checked at time-42.");
).toBe("Update found: v1.2.3. Downloading... Last checked at time-42.");
});
it("keeps manual check feedback visible when an update is available", () => {
@@ -137,6 +137,18 @@ export function formatStatusText(input: {
}
if (status === "pending") {
if (availableUpdate?.latestVersion) {
return i18n.t(
lastCheckedAt != null
? "desktop.updates.status.pendingWithVersionAndLastChecked"
: "desktop.updates.status.pendingWithVersion",
{
version: formatVersion(availableUpdate.latestVersion),
time: lastCheckedAt != null ? formatLastCheckedAt(lastCheckedAt) : undefined,
},
);
}
if (lastCheckedAt != null) {
return i18n.t("desktop.updates.status.pendingWithLastChecked", {
time: formatLastCheckedAt(lastCheckedAt),
@@ -244,7 +256,7 @@ export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopApp
nextAvailable = result;
} else if (result.hasUpdate) {
nextStatus = "pending";
nextAvailable = null;
nextAvailable = result;
} else {
nextStatus = "up-to-date";
nextAvailable = null;
@@ -89,7 +89,7 @@ export interface SidebarWorkspacesListResult {
}
export function useSidebarWorkspacesList(options?: {
hostFilter?: string | null;
hostFilters?: readonly string[];
enabled?: boolean;
}): SidebarWorkspacesListResult {
const runtime = getHostRuntimeStore();
@@ -97,27 +97,31 @@ export function useSidebarWorkspacesList(options?: {
const hostRegistryLoaded = useHostRegistryLoaded();
const allServerIds = useMemo(() => allHosts.map((h) => h.serverId), [allHosts]);
const storeHostFilter = useSidebarViewStore((state) => state.hostFilter);
const hostFilter = options?.hostFilter ?? storeHostFilter;
const reconcileHostFilter = useSidebarViewStore((state) => state.reconcileHostFilter);
const hasHostFilterMatch = hostFilter ? allServerIds.includes(hostFilter) : false;
const effectiveHostFilter =
hostFilter && (!hostRegistryLoaded || hasHostFilterMatch) ? hostFilter : null;
const storeHostFilters = useSidebarViewStore((state) => state.hostFilters);
const hostFilters = options?.hostFilters ?? storeHostFilters;
const reconcileHostFilters = useSidebarViewStore((state) => state.reconcileHostFilters);
const isActive = options?.enabled !== false;
const serverIds = useMemo(() => {
if (effectiveHostFilter) {
return allServerIds.filter((id) => id === effectiveHostFilter);
if (hostFilters.length === 0) {
return allServerIds;
}
return allServerIds;
}, [allServerIds, effectiveHostFilter]);
const selected = new Set(hostFilters);
const matched = allServerIds.filter((id) => selected.has(id));
// Registry has settled but none of the pinned hosts still exist — fall back to every
// host rather than leaving the sidebar empty.
if (hostRegistryLoaded && matched.length === 0) {
return allServerIds;
}
return matched;
}, [allServerIds, hostFilters, hostRegistryLoaded]);
useEffect(() => {
if (!hostRegistryLoaded) {
return;
}
reconcileHostFilter(allServerIds);
}, [allServerIds, hostRegistryLoaded, reconcileHostFilter]);
reconcileHostFilters(allServerIds);
}, [allServerIds, hostRegistryLoaded, reconcileHostFilters]);
const persistedProjectOrder = useSidebarOrderStore((state) => state.projectOrder ?? EMPTY_ORDER);
+6
View File
@@ -524,6 +524,12 @@ describe("translation resources", () => {
expect(en.desktop.updates.status.pendingWithLastChecked).toBe(
"We'll let you know when the update is ready. Last checked at {{time}}.",
);
expect(en.desktop.updates.status.pendingWithVersion).toBe(
"Update found: {{version}}. Downloading...",
);
expect(en.desktop.updates.status.pendingWithVersionAndLastChecked).toBe(
"Update found: {{version}}. Downloading... Last checked at {{time}}.",
);
expect(en.desktop.updates.status.availableWithVersion).toBe("Update ready: {{version}}");
expect(en.desktop.updates.status.availableWithVersionAndLastChecked).toBe(
"Update ready: {{version}}. Last checked at {{time}}.",
+3
View File
@@ -939,6 +939,9 @@ export const ar: TranslationResources = {
upToDateWithLastChecked: "Up to date. Last checked at {{time}}.",
pending: "سنخبرك عندما يصبح التحديث جاهزًا.",
pendingWithLastChecked: "سنخبرك عندما يصبح التحديث جاهزًا. آخر فحص في {{time}}.",
pendingWithVersion: "تم العثور على تحديث: {{version}}. جارٍ التنزيل...",
pendingWithVersionAndLastChecked:
"تم العثور على تحديث: {{version}}. جارٍ التنزيل... آخر فحص في {{time}}.",
availableWithVersion: "التحديث جاهز:{{version}}",
availableWithVersionAndLastChecked: "التحديث جاهز:{{version}}. آخر فحص في {{time}}.",
available: "تحديث التطبيق جاهز للتثبيت.",
+3
View File
@@ -947,6 +947,9 @@ export const en = {
pending: "We'll let you know when the update is ready.",
pendingWithLastChecked:
"We'll let you know when the update is ready. Last checked at {{time}}.",
pendingWithVersion: "Update found: {{version}}. Downloading...",
pendingWithVersionAndLastChecked:
"Update found: {{version}}. Downloading... Last checked at {{time}}.",
availableWithVersion: "Update ready: {{version}}",
availableWithVersionAndLastChecked: "Update ready: {{version}}. Last checked at {{time}}.",
available: "An app update is ready to install.",
+3
View File
@@ -967,6 +967,9 @@ export const es: TranslationResources = {
pending: "Le avisaremos cuando la actualización esté lista.",
pendingWithLastChecked:
"Le avisaremos cuando la actualización esté lista. Última comprobación a las {{time}}.",
pendingWithVersion: "Actualización encontrada: {{version}}. Descargando...",
pendingWithVersionAndLastChecked:
"Actualización encontrada: {{version}}. Descargando... Última comprobación a las {{time}}.",
availableWithVersion: "Actualización lista:{{version}}",
availableWithVersionAndLastChecked:
"Actualización lista:{{version}}. Última comprobación a las {{time}}.",
+3
View File
@@ -966,6 +966,9 @@ export const fr: TranslationResources = {
pending: "Nous vous informerons lorsque la mise à jour sera prête.",
pendingWithLastChecked:
"Nous vous informerons lorsque la mise à jour sera prête. Dernière vérification à {{time}}.",
pendingWithVersion: "Mise à jour trouvée : {{version}}. Téléchargement...",
pendingWithVersionAndLastChecked:
"Mise à jour trouvée : {{version}}. Téléchargement... Dernière vérification à {{time}}.",
availableWithVersion: "Mise à jour prête:{{version}}",
availableWithVersionAndLastChecked:
"Mise à jour prête:{{version}}. Dernière vérification à {{time}}.",
+3
View File
@@ -951,6 +951,9 @@ export const ja: TranslationResources = {
upToDateWithLastChecked: "最新の状態です。最終確認: {{time}}。",
pending: "更新の準備ができたらお知らせします。",
pendingWithLastChecked: "更新の準備ができたらお知らせします。最終確認: {{time}}。",
pendingWithVersion: "更新が見つかりました: {{version}}。ダウンロード中...",
pendingWithVersionAndLastChecked:
"更新が見つかりました: {{version}}。ダウンロード中... 最終確認: {{time}}。",
availableWithVersion: "更新の準備ができました: {{version}}",
availableWithVersionAndLastChecked:
"更新の準備ができました: {{version}}。最終確認: {{time}}。",
+3
View File
@@ -958,6 +958,9 @@ export const ptBR: TranslationResources = {
pending: "Avisaremos quando a atualização estiver pronta.",
pendingWithLastChecked:
"Avisaremos quando a atualização estiver pronta. Última verificação às {{time}}.",
pendingWithVersion: "Atualização encontrada: {{version}}. Baixando...",
pendingWithVersionAndLastChecked:
"Atualização encontrada: {{version}}. Baixando... Última verificação às {{time}}.",
availableWithVersion: "Atualização pronta: {{version}}",
availableWithVersionAndLastChecked:
"Atualização pronta: {{version}}. Última verificação às {{time}}.",
+3
View File
@@ -959,6 +959,9 @@ export const ru: TranslationResources = {
pending: "Мы сообщим вам, когда обновление будет готово.",
pendingWithLastChecked:
"Мы сообщим вам, когда обновление будет готово. Последняя проверка в {{time}}.",
pendingWithVersion: "Найдено обновление: {{version}}. Загрузка...",
pendingWithVersionAndLastChecked:
"Найдено обновление: {{version}}. Загрузка... Последняя проверка в {{time}}.",
availableWithVersion: "Обновление готово:{{version}}",
availableWithVersionAndLastChecked:
"Обновление готово:{{version}}. Последняя проверка в {{time}}.",
+3
View File
@@ -928,6 +928,9 @@ export const zhCN: TranslationResources = {
upToDateWithLastChecked: "已是最新版本。上次检查时间:{{time}}。",
pending: "更新准备好后会通知你。",
pendingWithLastChecked: "更新准备好后会通知你。上次检查时间:{{time}}。",
pendingWithVersion: "发现更新:{{version}}。正在下载...",
pendingWithVersionAndLastChecked:
"发现更新:{{version}}。正在下载... 上次检查时间:{{time}}。",
availableWithVersion: "更新已就绪:{{version}}",
availableWithVersionAndLastChecked: "更新已就绪:{{version}}。上次检查时间:{{time}}。",
available: "有 app 更新可安装。",
+7 -4
View File
@@ -701,6 +701,9 @@ function DesktopAppUpdateRow() {
});
}, [installUpdate, isDesktopApp, t]);
const isUpdateReady = availableUpdate?.readyToInstall === true;
const readyUpdateVersion = isUpdateReady ? availableUpdate?.latestVersion : null;
if (!isDesktopApp) {
return null;
}
@@ -725,10 +728,10 @@ function DesktopAppUpdateRow() {
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.about.updates.label")}</Text>
<Text style={settingsStyles.rowHint}>{statusText}</Text>
{availableUpdate?.latestVersion ? (
{readyUpdateVersion ? (
<Text style={settingsStyles.rowHint}>
{t("settings.about.updates.readyToInstall", {
version: formatVersionWithPrefix(availableUpdate.latestVersion),
version: formatVersionWithPrefix(readyUpdateVersion),
})}
</Text>
) : null}
@@ -747,9 +750,9 @@ function DesktopAppUpdateRow() {
variant="default"
size="sm"
onPress={handleInstallUpdate}
disabled={isChecking || isInstalling || !availableUpdate}
disabled={isChecking || isInstalling || !isUpdateReady}
>
{getUpdateButtonLabel(t, isInstalling, availableUpdate?.latestVersion)}
{getUpdateButtonLabel(t, isInstalling, readyUpdateVersion)}
</Button>
</View>
</View>
@@ -39,24 +39,44 @@ describe("sidebar view store", () => {
beforeEach(() => {
useSidebarViewStore.setState({
groupMode: "project",
hostFilter: null,
hostFilters: [],
});
});
it("keeps a host filter that still points at an available host", () => {
useSidebarViewStore.getState().setHostFilter("host-a");
it("toggles multiple hosts into and out of the filter", () => {
const store = useSidebarViewStore.getState();
store.toggleHostFilter("host-a");
store.toggleHostFilter("host-b");
useSidebarViewStore.getState().reconcileHostFilter(["host-a", "host-b"]);
expect(useSidebarViewStore.getState().hostFilters).toEqual(["host-a", "host-b"]);
expect(useSidebarViewStore.getState().hostFilter).toBe("host-a");
store.toggleHostFilter("host-a");
expect(useSidebarViewStore.getState().hostFilters).toEqual(["host-b"]);
store.clearHostFilters();
expect(useSidebarViewStore.getState().hostFilters).toEqual([]);
});
it("clears a host filter after that host is removed", () => {
useSidebarViewStore.getState().setHostFilter("removed-host");
it("keeps host filters that still point at available hosts", () => {
const store = useSidebarViewStore.getState();
store.toggleHostFilter("host-a");
store.toggleHostFilter("host-b");
useSidebarViewStore.getState().reconcileHostFilter(["host-a"]);
store.reconcileHostFilters(["host-a", "host-b", "host-c"]);
expect(useSidebarViewStore.getState().hostFilter).toBeNull();
expect(useSidebarViewStore.getState().hostFilters).toEqual(["host-a", "host-b"]);
});
it("drops a host filter after that host is removed", () => {
const store = useSidebarViewStore.getState();
store.toggleHostFilter("host-a");
store.toggleHostFilter("removed-host");
store.reconcileHostFilters(["host-a"]);
expect(useSidebarViewStore.getState().hostFilters).toEqual(["host-a"]);
});
it("migrates legacy per-host group modes to the new global mode", () => {
@@ -69,11 +89,11 @@ describe("sidebar view store", () => {
}),
).toEqual({
groupMode: "status",
hostFilter: null,
hostFilters: [],
});
});
it("keeps current persisted sidebar view state during version migration", () => {
it("migrates a pre-v2 single host filter to the multi-host list", () => {
expect(
migrateSidebarViewState({
groupMode: "status",
@@ -81,7 +101,19 @@ describe("sidebar view store", () => {
}),
).toEqual({
groupMode: "status",
hostFilter: "host-a",
hostFilters: ["host-a"],
});
});
it("keeps current persisted sidebar view state during version migration", () => {
expect(
migrateSidebarViewState({
groupMode: "status",
hostFilters: ["host-a", "host-b"],
}),
).toEqual({
groupMode: "status",
hostFilters: ["host-a", "host-b"],
});
});
@@ -108,8 +140,8 @@ describe("sidebar view store", () => {
it("uses the new storage key without reading the legacy key when current state exists", async () => {
const storage = createMemoryStorage({
"sidebar-view": JSON.stringify({
state: { groupMode: "project", hostFilter: "host-a" },
version: 1,
state: { groupMode: "project", hostFilters: ["host-a"] },
version: 2,
}),
"sidebar-group-mode": JSON.stringify({
state: { groupModeByServerId: { "host-b": "status" } },
@@ -121,8 +153,8 @@ describe("sidebar view store", () => {
expect(value).toBe(
JSON.stringify({
state: { groupMode: "project", hostFilter: "host-a" },
version: 1,
state: { groupMode: "project", hostFilters: ["host-a"] },
version: 2,
}),
);
expect(storage.reads).toEqual(["sidebar-view"]);
+40 -14
View File
@@ -6,19 +6,21 @@ export type SidebarGroupMode = "project" | "status";
const SIDEBAR_VIEW_STORAGE_KEY = "sidebar-view";
const LEGACY_SIDEBAR_GROUP_MODE_STORAGE_KEY = "sidebar-group-mode";
const SIDEBAR_VIEW_STORE_VERSION = 1;
const SIDEBAR_VIEW_STORE_VERSION = 2;
interface SidebarViewStoreState {
groupMode: SidebarGroupMode;
hostFilter: string | null;
// Empty means "all hosts". A non-empty list pins the sidebar to those hosts.
hostFilters: string[];
setGroupMode: (mode: SidebarGroupMode) => void;
setHostFilter: (serverId: string | null) => void;
reconcileHostFilter: (serverIds: readonly string[]) => void;
toggleHostFilter: (serverId: string) => void;
clearHostFilters: () => void;
reconcileHostFilters: (serverIds: readonly string[]) => void;
}
interface SidebarViewPersistedState {
groupMode: SidebarGroupMode;
hostFilter: string | null;
hostFilters: string[];
}
function isSidebarGroupMode(value: unknown): value is SidebarGroupMode {
@@ -40,19 +42,32 @@ function readLegacyGroupMode(persistedState: Record<string, unknown>): SidebarGr
return modes.includes("status") ? "status" : "project";
}
// Reads the host filter from any persisted shape: the current `hostFilters` array, or the
// pre-v2 single `hostFilter` string (null/absent meant "all hosts").
function readHostFilters(persistedState: Record<string, unknown>): string[] {
const hostFilters = persistedState.hostFilters;
if (Array.isArray(hostFilters)) {
return hostFilters.filter((value): value is string => typeof value === "string");
}
// COMPAT(sidebarHostFilters): added in v0.1.102, remove after 2026-12-30 once pre-v2 persisted
// sidebar state (a single `hostFilter` string) has aged out.
const legacyHostFilter = persistedState.hostFilter;
return typeof legacyHostFilter === "string" ? [legacyHostFilter] : [];
}
export function migrateSidebarViewState(persistedState: unknown): SidebarViewPersistedState {
if (!isRecord(persistedState)) {
return { groupMode: "project", hostFilter: null };
return { groupMode: "project", hostFilters: [] };
}
const legacyGroupMode = readLegacyGroupMode(persistedState);
if (legacyGroupMode) {
return { groupMode: legacyGroupMode, hostFilter: null };
return { groupMode: legacyGroupMode, hostFilters: [] };
}
return {
groupMode: isSidebarGroupMode(persistedState.groupMode) ? persistedState.groupMode : "project",
hostFilter: typeof persistedState.hostFilter === "string" ? persistedState.hostFilter : null,
hostFilters: readHostFilters(persistedState),
};
}
@@ -76,15 +91,26 @@ export const useSidebarViewStore = create<SidebarViewStoreState>()(
persist(
(set) => ({
groupMode: "project",
hostFilter: null,
hostFilters: [],
setGroupMode: (mode) => set({ groupMode: mode }),
setHostFilter: (serverId) => set({ hostFilter: serverId }),
reconcileHostFilter: (serverIds) =>
toggleHostFilter: (serverId) =>
set((state) => ({
hostFilters: state.hostFilters.includes(serverId)
? state.hostFilters.filter((id) => id !== serverId)
: [...state.hostFilters, serverId],
})),
clearHostFilters: () => set({ hostFilters: [] }),
reconcileHostFilters: (serverIds) =>
set((state) => {
if (!state.hostFilter || serverIds.includes(state.hostFilter)) {
if (state.hostFilters.length === 0) {
return state;
}
return { hostFilter: null };
const allowed = new Set(serverIds);
const next = state.hostFilters.filter((id) => allowed.has(id));
if (next.length === state.hostFilters.length) {
return state;
}
return { hostFilters: next };
}),
}),
{
@@ -93,7 +119,7 @@ export const useSidebarViewStore = create<SidebarViewStoreState>()(
storage: createJSONStorage(createSidebarViewStorage),
partialize: (state) => ({
groupMode: state.groupMode,
hostFilter: state.hostFilter,
hostFilters: state.hostFilters,
}),
migrate: migrateSidebarViewState,
},
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.102-beta.2",
"version": "0.1.103",
"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.102-beta.2",
"@getpaseo/protocol": "0.1.102-beta.2",
"@getpaseo/server": "0.1.102-beta.2",
"@getpaseo/client": "0.1.103",
"@getpaseo/protocol": "0.1.103",
"@getpaseo/server": "0.1.103",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+6 -1
View File
@@ -60,6 +60,11 @@ const EXPECTED_CLAUDE_MODELS = [
model: "Opus 4.8",
descriptionFragment: "Latest release",
},
{
id: "claude-sonnet-5",
model: "Sonnet 5",
descriptionFragment: "Efficient for routine tasks",
},
{
id: "claude-opus-4-7[1m]",
model: "Opus 4.7 1M",
@@ -392,7 +397,7 @@ try {
"--quiet should print the current Claude catalog IDs",
);
assert(
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-4-6"),
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-5"),
"captured --json output should include the current Claude everyday model id",
);
console.log("✓ provider models --quiet outputs model IDs only\n");
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.1.102-beta.2",
"version": "0.1.103",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -35,8 +35,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.1.102-beta.2",
"@getpaseo/relay": "0.1.102-beta.2",
"@getpaseo/protocol": "0.1.103",
"@getpaseo/relay": "0.1.103",
"zod": "^4.4.3"
},
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.102-beta.2",
"version": "0.1.103",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
@@ -58,6 +58,10 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime {
this.configuration?.onUpdateAvailable(info);
}
finishUpdateDownload(info: RuntimeUpdateInfo): void {
this.configuration?.onUpdateDownloaded(info);
}
async checkForUpdates(): Promise<{
isUpdateAvailable: boolean;
updateInfo: RuntimeUpdateInfo;
@@ -144,6 +148,37 @@ describe("app update service", () => {
});
});
it("performs a fresh manual check when an update is already cached", async () => {
const { runtime, service } = createService({ bucket: async () => 0 });
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
await service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "automatic",
});
runtime.nextCheck({
isUpdateAvailable: true,
updateInfo: { ...rolledOutUpdate, version: "1.2.5" },
});
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.5",
body: null,
date: "2026-04-28T00:00:00.000Z",
errorMessage: null,
});
});
it("trusts the runtime availability decision before comparing versions", async () => {
const { runtime, service } = createService({ bucket: async () => 0 });
runtime.nextCheck({ isUpdateAvailable: false, updateInfo: rolledOutUpdate });
@@ -347,6 +382,70 @@ describe("app update service", () => {
});
});
it("performs a fresh manual check after an update preparation error", async () => {
const { runtime, service } = createService();
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
await service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "manual",
});
runtime.failRuntime(new Error("sha512 checksum mismatch"));
runtime.nextCheck({
isUpdateAvailable: true,
updateInfo: { ...rolledOutUpdate, version: "1.2.5" },
});
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.5",
body: null,
date: "2026-04-28T00:00:00.000Z",
errorMessage: null,
});
});
it("keeps a downloaded update ready when a manual check re-announces it", async () => {
const { runtime, service } = createService();
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
await service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "manual",
});
runtime.finishUpdateDownload(rolledOutUpdate);
const recheck = runtime.deferNextCheck();
const pending = service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "manual",
});
runtime.prepareUpdate(rolledOutUpdate);
recheck.resolve({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
const result = await pending;
expect(result).toEqual({
hasUpdate: true,
readyToInstall: true,
currentVersion: "1.2.3",
latestVersion: "1.2.4",
body: null,
date: "2026-04-28T00:00:00.000Z",
errorMessage: null,
});
});
it("returns runtime update errors to multiple automatic checks before a manual retry clears them", async () => {
const { runtime, service } = createService();
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
@@ -163,9 +163,10 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
});
},
onUpdateAvailable(info) {
const alreadyReady = downloadedUpdateVersion === info.version;
cachedUpdateInfo = info;
downloadedUpdateVersion = null;
downloading = true;
downloadedUpdateVersion = alreadyReady ? info.version : null;
downloading = !alreadyReady;
runtimeErrorMessage = null;
},
onUpdateDownloaded(info) {
@@ -212,7 +213,12 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
}
const cachedVersion = cachedUpdateInfo?.version ?? null;
if (!runtimeErrorResult && cachedVersion && cachedVersion !== currentVersion) {
if (
!runtimeErrorResult &&
intent === "automatic" &&
cachedVersion &&
cachedVersion !== currentVersion
) {
return buildCheckResult({
currentVersion,
hasUpdate: true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.102-beta.2",
"version": "0.1.103",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.102-beta.2",
"version": "0.1.103",
"files": [
"dist",
"!dist/**/*.map"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.1.102-beta.2",
"version": "0.1.103",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+1
View File
@@ -77,6 +77,7 @@ export interface AgentModelDefinition {
label: string;
description?: string;
isDefault?: boolean;
contextWindowMaxTokens?: number;
metadata?: AgentMetadata;
thinkingOptions?: AgentSelectOption[];
defaultThinkingOptionId?: string;
@@ -48,6 +48,7 @@ describe("provider snapshot message schemas", () => {
id: "MiniMax-M2.7",
label: "MiniMax-M2.7",
isDefault: true,
contextWindowMaxTokens: 200_000,
thinkingOptions: [
{ id: "off", label: "Off" },
{ id: "max", label: "Max", isDefault: true },
@@ -62,6 +63,7 @@ describe("provider snapshot message schemas", () => {
id: "MiniMax-M2.7",
label: "MiniMax-M2.7",
isDefault: true,
contextWindowMaxTokens: 200_000,
thinkingOptions: [
{ id: "off", label: "Off" },
{ id: "max", label: "Max", isDefault: true },
+1
View File
@@ -242,6 +242,7 @@ const AgentModelDefinitionSchema: z.ZodType<AgentModelDefinition> = z
label: z.string(),
description: z.string().optional(),
isDefault: z.boolean().optional(),
contextWindowMaxTokens: z.number().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
thinkingOptions: z.array(AgentSelectOptionSchema).optional(),
defaultThinkingOptionId: z.string().optional(),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.102-beta.2",
"version": "0.1.103",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+8
View File
@@ -1,6 +1,14 @@
# OpenAI API Key (for GPT-4, Whisper STT, and TTS)
OPENAI_API_KEY=
# Optional: point speech-to-text and text-to-speech at different OpenAI-compatible
# endpoints. Each falls back to OPENAI_API_KEY / OPENAI_BASE_URL when unset.
# STT covers composer dictation + voice mode STT; TTS covers voice mode TTS.
OPENAI_STT_API_KEY=
OPENAI_STT_BASE_URL=
OPENAI_TTS_API_KEY=
OPENAI_TTS_BASE_URL=
# TTS Configuration (optional - defaults shown)
TTS_VOICE=alloy
# Available voices: alloy, echo, fable, onyx, nova, shimmer
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.102-beta.2",
"version": "0.1.103",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -65,10 +65,10 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.102-beta.2",
"@getpaseo/highlight": "0.1.102-beta.2",
"@getpaseo/protocol": "0.1.102-beta.2",
"@getpaseo/relay": "0.1.102-beta.2",
"@getpaseo/client": "0.1.103",
"@getpaseo/highlight": "0.1.103",
"@getpaseo/protocol": "0.1.103",
"@getpaseo/relay": "0.1.103",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -26,7 +26,7 @@ async function main(): Promise<void> {
const daemon = await createTestPaseoDaemon({
logger,
agentClients: {},
openai: { apiKey },
openai: { stt: { apiKey }, tts: { apiKey } },
speech: {
providers: {
dictationStt: { provider: "openai", explicit: true },
@@ -78,6 +78,7 @@ export interface AgentModelDefinition {
label: string;
description?: string;
isDefault?: boolean;
contextWindowMaxTokens?: number;
metadata?: AgentMetadata;
thinkingOptions?: AgentSelectOption[];
defaultThinkingOptionId?: string;
@@ -33,6 +33,7 @@ import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js
import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js";
import { CursorACPAgentClient } from "./providers/cursor-acp-agent.js";
import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
import { KiroACPAgentClient } from "./providers/kiro-acp-agent.js";
import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
import { PiRpcAgentClient } from "./providers/pi/agent.js";
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
@@ -644,24 +645,23 @@ function addDerivedProviders(
enabled: override.enabled !== false,
derivedFromProviderId: null,
providerParams: override.params,
createBaseClient: (logger) =>
providerId === "cursor"
? new CursorACPAgentClient({
logger,
command,
env: override.env,
providerId,
label: override.label ?? providerId,
providerParams: override.params,
})
: new GenericACPAgentClient({
logger,
command,
env: override.env,
providerId,
label: override.label ?? providerId,
providerParams: override.params,
}),
createBaseClient: (logger) => {
const acpOptions = {
logger,
command,
env: override.env,
providerId,
label: override.label ?? providerId,
providerParams: override.params,
};
if (providerId === "cursor") {
return new CursorACPAgentClient(acpOptions);
}
if (providerId === "kiro") {
return new KiroACPAgentClient(acpOptions);
}
return new GenericACPAgentClient(acpOptions);
},
});
continue;
}
@@ -41,6 +41,7 @@ import {
writeCopilotProviderMode,
} from "./copilot-acp-agent.js";
import { GenericACPAgentClient } from "./generic-acp-agent.js";
import { parseKiroExtensionCommands } from "./kiro-acp-agent.js";
import { transformPiModels } from "./pi/agent.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import type { AgentCapabilityFlags, AgentPersistenceHandle } from "../agent-sdk-types.js";
@@ -165,6 +166,35 @@ function createSessionWithConfig(
);
}
function createKiroSession(
options: { waitForInitialCommands?: boolean; initialCommandsWaitTimeoutMs?: number } = {},
logger: ReturnType<typeof createTestLogger> = createTestLogger(),
): ACPAgentSession {
return new ACPAgentSession(
{
provider: "kiro",
cwd: "/tmp/paseo-acp-test",
},
{
provider: "kiro",
logger,
defaultCommand: ["kiro-cli", "acp"],
defaultModes: [],
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
extensionCommandsParser: parseKiroExtensionCommands,
waitForInitialCommands: options.waitForInitialCommands ?? false,
initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs,
},
);
}
function createTerminalChildStub(): ChildProcess {
const child = new EventEmitter() as ChildProcess;
child.stdout = new EventEmitter() as ChildProcess["stdout"];
@@ -1902,6 +1932,86 @@ describe("ACPAgentSession", () => {
);
});
test("maps the Kiro _kiro.dev/commands/available notification into slash commands and skills", async () => {
const session = createKiroSession({
waitForInitialCommands: true,
initialCommandsWaitTimeoutMs: 1500,
});
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
const listCommandsPromise = session.listCommands();
await session.extNotification("_kiro.dev/commands/available", {
sessionId: "session-1",
commands: [
{
name: "/agent",
description: "Select or list available agents",
meta: { inputType: "selection", hint: "swap <name>" },
},
],
prompts: [
{
name: "agent-sync-doctor",
description: "Hand off Claude or Codex state across Macs",
arguments: [],
serverName: "skill:config",
},
],
// Tools are not slash commands and must be ignored.
tools: [{ name: "code", description: "Code intelligence", source: "built-in" }],
});
expect(await listCommandsPromise).toEqual([
{
name: "agent",
description: "Select or list available agents",
argumentHint: "swap <name>",
kind: "command",
},
{
name: "agent-sync-doctor",
description: "Hand off Claude or Codex state across Macs",
argumentHint: "",
kind: "skill",
},
]);
});
test("ignores Kiro _kiro.dev/commands/available for a different session", async () => {
const session = createKiroSession();
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
await session.extNotification("_kiro.dev/commands/available", {
sessionId: "other-session",
commands: [{ name: "/agent", description: "Select or list available agents" }],
prompts: [],
});
expect(await session.listCommands()).toEqual([]);
});
test("settles listCommands() immediately on an empty Kiro commands batch", async () => {
// A long timeout means a resolution can only come from settleCommandsReady()
// firing — not from the wait timer — so this test would hang if the empty
// batch failed to unblock listCommands() (the P1 regression).
const session = createKiroSession({
waitForInitialCommands: true,
initialCommandsWaitTimeoutMs: 60_000,
});
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
const listCommandsPromise = session.listCommands();
await session.extNotification("_kiro.dev/commands/available", {
sessionId: "session-1",
commands: [],
prompts: [],
});
expect(await listCommandsPromise).toEqual([]);
});
test("emits assistant and reasoning chunks as deltas while user chunks stay accumulated", async () => {
const session = createSession();
const events: Array<{ type: string; item?: { type: string; text?: string } }> = [];
@@ -334,6 +334,17 @@ export function createLoggedNdJsonStream(
return { readable, writable };
}
// Lets a provider that publishes its slash commands through a vendor-specific
// ACP extension notification (rather than the standard
// `available_commands_update` session update) translate that payload into Paseo
// slash commands, without the generic ACP session/client carrying any vendor
// knowledge. Return the parsed commands (possibly empty) for a notification this
// provider owns, or null to ignore notifications it does not handle.
export type ACPExtensionCommandsParser = (
method: string,
params: Record<string, unknown>,
) => AgentSlashCommand[] | null;
interface ACPAgentClientOptions {
provider: string;
logger: Logger;
@@ -356,6 +367,7 @@ interface ACPAgentClientOptions {
thinkingOptionId: string,
) => Promise<void>;
capabilities?: AgentCapabilityFlags;
extensionCommandsParser?: ACPExtensionCommandsParser;
waitForInitialCommands?: boolean;
initialCommandsWaitTimeoutMs?: number;
terminateProcess?: ProcessTerminator;
@@ -383,6 +395,7 @@ interface ACPAgentSessionOptions {
thinkingOptionId: string,
) => Promise<void>;
capabilities: AgentCapabilityFlags;
extensionCommandsParser?: ACPExtensionCommandsParser;
handle?: AgentPersistenceHandle;
agentId?: string;
launchEnv?: Record<string, string>;
@@ -692,6 +705,7 @@ export class ACPAgentClient implements AgentClient {
) => Promise<void>;
private readonly waitForInitialCommands: boolean;
private readonly initialCommandsWaitTimeoutMs: number;
private readonly extensionCommandsParser?: ACPExtensionCommandsParser;
protected readonly terminateProcess: ProcessTerminator;
constructor(options: ACPAgentClientOptions) {
@@ -716,6 +730,7 @@ export class ACPAgentClient implements AgentClient {
this.thinkingOptionWriter = options.thinkingOptionWriter;
this.waitForInitialCommands = options.waitForInitialCommands ?? false;
this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500;
this.extensionCommandsParser = options.extensionCommandsParser;
}
async createSession(
@@ -743,6 +758,7 @@ export class ACPAgentClient implements AgentClient {
capabilities: this.capabilities,
agentId: launchContext?.agentId,
launchEnv: launchContext?.env,
extensionCommandsParser: this.extensionCommandsParser,
waitForInitialCommands: this.waitForInitialCommands,
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
},
@@ -791,6 +807,7 @@ export class ACPAgentClient implements AgentClient {
handle,
agentId: launchContext?.agentId,
launchEnv: launchContext?.env,
extensionCommandsParser: this.extensionCommandsParser,
waitForInitialCommands: this.waitForInitialCommands,
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
});
@@ -1273,6 +1290,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private commandsReadySettled = false;
private waitForInitialCommands: boolean;
private initialCommandsWaitTimeoutMs: number;
private readonly extensionCommandsParser?: ACPExtensionCommandsParser;
private currentTurnUsage: AgentUsage | undefined;
private activeForegroundTurnId: string | null = null;
private closed = false;
@@ -1309,6 +1327,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.currentTitle = config.title ?? null;
this.waitForInitialCommands = options.waitForInitialCommands ?? false;
this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500;
this.extensionCommandsParser = options.extensionCommandsParser;
}
get id(): string | null {
@@ -2082,6 +2101,39 @@ export class ACPAgentSession implements AgentSession, ACPClient {
},
"provider.acp.extension_notification",
);
const parsedCommands = this.extensionCommandsParser?.(method, params);
if (parsedCommands) {
this.applyResolvedCommands(parsedCommands, {
sessionId: typeof params.sessionId === "string" ? params.sessionId : undefined,
});
}
}
// Cache an asynchronously-delivered slash-command batch and unblock any
// listCommands() call that is waiting on the initial batch. Used when a
// provider supplies an extensionCommandsParser whose result arrives after
// session/new (e.g. via a vendor extension notification). The ready gate is
// always settled — even for an empty batch — so a provider that legitimately
// reports no commands does not leave listCommands() blocked for the full
// initial-commands timeout. An optional sessionId scopes the batch to this
// session; notifications addressed to a different session are ignored.
private applyResolvedCommands(
commands: AgentSlashCommand[],
options?: { sessionId?: string },
): void {
if (
options?.sessionId !== undefined &&
this.sessionId !== null &&
options.sessionId !== this.sessionId
) {
return;
}
if (commands.length > 0) {
this.cachedCommands = commands;
}
this.settleCommandsReady();
}
async readTextFile(params: ReadTextFileRequest): Promise<{ content: string }> {
@@ -416,6 +416,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
"claude-fable-5",
"claude-opus-4-8[1m]",
"claude-opus-4-8",
"claude-sonnet-5",
"claude-opus-4-7[1m]",
"claude-opus-4-7",
"claude-opus-4-6[1m]",
@@ -457,6 +458,8 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
expect(getThinkingIds("claude-fable-5")).toContain("ultracode");
expect(getThinkingIds("claude-opus-4-8[1m]")).toContain("ultracode");
expect(getThinkingIds("claude-opus-4-8")).toContain("ultracode");
expect(getThinkingIds("claude-sonnet-5")).toContain("xhigh");
expect(getThinkingIds("claude-sonnet-5")).not.toContain("ultracode");
expect(getThinkingIds("claude-opus-4-7")).not.toContain("ultracode");
expect(getThinkingIds("claude-sonnet-4-6")).not.toContain("ultracode");
} finally {
@@ -1889,6 +1892,45 @@ describe("ClaudeAgentSession context window usage", () => {
}
});
test("selected native-1M Claude models seed active context window usage with 1M max tokens", async () => {
const session = await createSessionForTurns(
[
[
createInitMessage(),
createMessageStartEvent(),
createSuccessResult({ modelUsage: undefined }),
],
],
{ model: "claude-sonnet-5" },
);
try {
const events = await collectStreamEvents(session);
expect(events).toContainEqual(
expect.objectContaining({
type: "usage_updated",
provider: "claude",
usage: {
contextWindowMaxTokens: 1_000_000,
contextWindowUsedTokens: 150,
},
}),
);
expect(events).toContainEqual(
expect.objectContaining({
type: "turn_completed",
provider: "claude",
usage: expect.objectContaining({
contextWindowMaxTokens: 1_000_000,
}),
}),
);
} finally {
await session.close();
}
});
test("message_delta stream events update per-request usage", async () => {
const session = await createSessionForTurns([
[
@@ -29,7 +29,11 @@ import {
mapTaskNotificationSystemRecordToToolCall,
mapTaskNotificationUserContentToToolCall,
} from "./task-notification-tool-call.js";
import { getClaudeModelsWithSettings, normalizeClaudeRuntimeModelId } from "./models.js";
import {
findClaudeModel,
getClaudeModelsWithSettings,
normalizeClaudeRuntimeModelId,
} from "./models.js";
import { parsePartialJsonObject } from "./partial-json.js";
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
import { buildClaudeFeatures, claudeModelSupportsFastMode } from "./feature-definitions.js";
@@ -1626,23 +1630,6 @@ function extractContextWindowSize(modelUsage: unknown): number | undefined {
return maxContextWindow;
}
function resolveInitialContextWindowSize(modelId: string | null | undefined): number | undefined {
const normalized = typeof modelId === "string" ? modelId.trim().toLowerCase() : "";
if (!normalized) {
return undefined;
}
if (normalized.includes("[1m]") || normalized.includes("context-1m")) {
return 1_000_000;
}
if (normalized.includes("claude-fable-5")) {
return 1_000_000;
}
if (/(?:^|[~/_-])(?:claude[-_ ]*)?(opus|sonnet|haiku)(?:$|[-_ ./])/.test(normalized)) {
return 200_000;
}
return undefined;
}
function readStreamRequestInputTokens(event: Record<string, unknown>): number | undefined {
const messageUsage = toObjectRecord(toObjectRecord(event.message)?.usage);
if (!messageUsage) {
@@ -1941,7 +1928,7 @@ class ClaudeAgentSession implements AgentSession {
this.queryFactory = options.queryFactory;
this.resolveBinary = options.resolveBinary;
this.contextUsage = new ClaudeContextUsageState(
resolveInitialContextWindowSize(this.config.model),
findClaudeModel(this.config.model)?.contextWindowMaxTokens,
);
const handle = options.handle;
@@ -2187,7 +2174,7 @@ class ClaudeAgentSession implements AgentSession {
await this.applyFastModeFeature(false, activeQuery);
}
this.contextUsage.setInitialContextWindowMaxTokens(
resolveInitialContextWindowSize(this.config.model),
findClaudeModel(this.config.model)?.contextWindowMaxTokens,
);
this.lastOptionsModel = normalizedModelId ?? this.lastOptionsModel;
this.lastRuntimeModel = null;
@@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { ClaudeAgentClient } from "./agent.js";
import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js";
import { findClaudeModel, getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js";
const createdClaudeConfigDirs: string[] = [];
@@ -38,6 +38,7 @@ describe("getClaudeModels", () => {
"claude-fable-5",
"claude-opus-4-8[1m]",
"claude-opus-4-8",
"claude-sonnet-5",
"claude-opus-4-7[1m]",
"claude-opus-4-7",
"claude-opus-4-6[1m]",
@@ -55,6 +56,22 @@ describe("getClaudeModels", () => {
expect(defaults[0].id).toBe("claude-opus-4-8");
});
it("keeps fixed context windows on the model entries", () => {
const modelsById = new Map(getClaudeModels().map((model) => [model.id, model]));
expect(modelsById.get("claude-fable-5")?.contextWindowMaxTokens).toBe(1_000_000);
expect(modelsById.get("claude-opus-4-8[1m]")?.contextWindowMaxTokens).toBe(1_000_000);
expect(modelsById.get("claude-opus-4-8")?.contextWindowMaxTokens).toBe(200_000);
expect(modelsById.get("claude-sonnet-5")?.contextWindowMaxTokens).toBe(1_000_000);
expect(modelsById.get("claude-sonnet-4-6")?.contextWindowMaxTokens).toBe(200_000);
expect(modelsById.get("claude-haiku-4-5")?.contextWindowMaxTokens).toBe(200_000);
});
it("finds models through the runtime normalizer", () => {
expect(findClaudeModel("claude-sonnet-5-20260101")?.id).toBe("claude-sonnet-5");
expect(findClaudeModel("claude-sonnet-5[1m]")?.contextWindowMaxTokens).toBe(1_000_000);
});
it("returns fresh copies each call", () => {
const a = getClaudeModels();
const b = getClaudeModels();
@@ -203,12 +220,14 @@ describe("normalizeClaudeRuntimeModelId", () => {
expect(normalizeClaudeRuntimeModelId("claude-fable-5")).toBe("claude-fable-5");
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6")).toBe("claude-opus-4-6");
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6[1m]")).toBe("claude-opus-4-6[1m]");
expect(normalizeClaudeRuntimeModelId("claude-sonnet-5")).toBe("claude-sonnet-5");
expect(normalizeClaudeRuntimeModelId("claude-sonnet-4-6")).toBe("claude-sonnet-4-6");
expect(normalizeClaudeRuntimeModelId("claude-haiku-4-5")).toBe("claude-haiku-4-5");
});
it("normalizes dated model IDs to base model", () => {
expect(normalizeClaudeRuntimeModelId("claude-fable-5-20260301")).toBe("claude-fable-5");
expect(normalizeClaudeRuntimeModelId("claude-sonnet-5-20260101")).toBe("claude-sonnet-5");
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6-20260101")).toBe("claude-opus-4-6");
expect(normalizeClaudeRuntimeModelId("claude-sonnet-4-6-20260101")).toBe("claude-sonnet-4-6");
expect(normalizeClaudeRuntimeModelId("claude-haiku-4-5-20251001")).toBe("claude-haiku-4-5");
@@ -218,6 +237,10 @@ describe("normalizeClaudeRuntimeModelId", () => {
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6[1m]")).toBe("claude-opus-4-6[1m]");
});
it("strips [1m] suffix for natively-1M models", () => {
expect(normalizeClaudeRuntimeModelId("claude-sonnet-5[1m]")).toBe("claude-sonnet-5");
});
it("returns null for empty/null/undefined", () => {
expect(normalizeClaudeRuntimeModelId(null)).toBeNull();
expect(normalizeClaudeRuntimeModelId(undefined)).toBeNull();
@@ -12,7 +12,7 @@ const CLAUDE_THINKING_OPTIONS = [
{ id: "max", label: "Max" },
] as const;
const CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS = [
const CLAUDE_EXTENDED_THINKING_OPTIONS = [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
@@ -21,7 +21,7 @@ const CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS = [
] as const;
const CLAUDE_ULTRACODE_THINKING_OPTIONS = [
...CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS,
...CLAUDE_EXTENDED_THINKING_OPTIONS,
{ id: "ultracode", label: "Ultracode" },
] as const;
@@ -31,6 +31,7 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
id: "claude-fable-5",
label: "Fable 5",
description: "Fable 5 · Most powerful model",
contextWindowMaxTokens: 1_000_000,
thinkingOptions: [...CLAUDE_ULTRACODE_THINKING_OPTIONS],
},
{
@@ -38,6 +39,7 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
id: "claude-opus-4-8[1m]",
label: "Opus 4.8 1M",
description: "Opus 4.8 with 1M context window",
contextWindowMaxTokens: 1_000_000,
thinkingOptions: [...CLAUDE_ULTRACODE_THINKING_OPTIONS],
},
{
@@ -46,27 +48,39 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
label: "Opus 4.8",
description: "Opus 4.8 · Latest release",
isDefault: true,
contextWindowMaxTokens: 200_000,
thinkingOptions: [...CLAUDE_ULTRACODE_THINKING_OPTIONS],
},
{
provider: "claude",
id: "claude-sonnet-5",
label: "Sonnet 5",
description: "Sonnet 5 · Efficient for routine tasks",
contextWindowMaxTokens: 1_000_000,
thinkingOptions: [...CLAUDE_EXTENDED_THINKING_OPTIONS],
},
{
provider: "claude",
id: "claude-opus-4-7[1m]",
label: "Opus 4.7 1M",
description: "Opus 4.7 with 1M context window",
thinkingOptions: [...CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS],
contextWindowMaxTokens: 1_000_000,
thinkingOptions: [...CLAUDE_EXTENDED_THINKING_OPTIONS],
},
{
provider: "claude",
id: "claude-opus-4-7",
label: "Opus 4.7",
description: "Opus 4.7 · Previous release",
thinkingOptions: [...CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS],
contextWindowMaxTokens: 200_000,
thinkingOptions: [...CLAUDE_EXTENDED_THINKING_OPTIONS],
},
{
provider: "claude",
id: "claude-opus-4-6[1m]",
label: "Opus 4.6 1M",
description: "Opus 4.6 with 1M context window",
contextWindowMaxTokens: 1_000_000,
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
},
{
@@ -74,6 +88,7 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
id: "claude-opus-4-6",
label: "Opus 4.6",
description: "Opus 4.6 · Most capable for complex work",
contextWindowMaxTokens: 200_000,
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
},
{
@@ -81,6 +96,7 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
id: "claude-sonnet-4-6[1m]",
label: "Sonnet 4.6 1M",
description: "Sonnet 4.6 with 1M context window",
contextWindowMaxTokens: 1_000_000,
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
},
{
@@ -88,6 +104,7 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
id: "claude-sonnet-4-6",
label: "Sonnet 4.6",
description: "Sonnet 4.6 · Best for everyday tasks",
contextWindowMaxTokens: 200_000,
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
},
{
@@ -95,6 +112,7 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
id: "claude-haiku-4-5",
label: "Haiku 4.5",
description: "Haiku 4.5 · Fastest for quick answers",
contextWindowMaxTokens: 200_000,
},
];
@@ -110,6 +128,14 @@ export function getClaudeModels(): AgentModelDefinition[] {
return CLAUDE_MODELS.map((model) => ({ ...model }));
}
export function findClaudeModel(modelId: string | null | undefined): AgentModelDefinition | null {
const normalizedModelId = normalizeClaudeRuntimeModelId(modelId);
if (!normalizedModelId) {
return null;
}
return CLAUDE_MODELS.find((model) => model.id === normalizedModelId) ?? null;
}
export async function getClaudeModelsWithSettings(
logger: Logger,
configDir?: string,
@@ -218,13 +244,17 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined):
return trimmed;
}
// Fable uses a single-segment version (claude-fable-5), not the {major}-{minor}
// scheme of opus/sonnet/haiku, so match it separately. This maps dated runtime
// strings (e.g. claude-fable-5-20260301) back to the catalog ID. No [1m] variant:
// Fable 5 is natively 1M, so there is no 200K-default model to opt into 1M.
const fableMatch = trimmed.match(/(?:claude-)?fable[-_ ]+(\d+)/i);
if (fableMatch) {
return `claude-fable-${fableMatch[1]}`;
// Some new Claude model families use single-segment versions, not the
// {major}-{minor} scheme of older opus/sonnet/haiku models. Map dated or
// suffixed runtime strings back to the matching catalog ID.
const singleSegmentMatch = trimmed.match(/(?:claude-)?(fable|sonnet)[-_ ]+(\d+)/i);
if (singleSegmentMatch) {
const family = singleSegmentMatch[1].toLowerCase();
const major = singleSegmentMatch[2];
const modelId = `claude-${family}-${major}`;
if (CLAUDE_MODELS.some((model) => model.id === modelId)) {
return modelId;
}
}
// Match: claude-{family}-{major}-{minor}[1m]? possibly followed by a date suffix
@@ -3,7 +3,11 @@ import { z } from "zod";
import type { AgentCapabilityFlags } from "../agent-sdk-types.js";
import { checkProviderLaunchAvailable, resolveProviderLaunch } from "../provider-launch-config.js";
import { ACPAgentClient, DEFAULT_ACP_CAPABILITIES } from "./acp-agent.js";
import {
ACPAgentClient,
DEFAULT_ACP_CAPABILITIES,
type ACPExtensionCommandsParser,
} from "./acp-agent.js";
import {
buildBinaryDiagnosticRows,
formatProviderDiagnostic,
@@ -29,6 +33,7 @@ interface GenericACPAgentClientOptions {
waitForInitialCommands?: boolean;
initialCommandsWaitTimeoutMs?: number;
diagnosticPhaseTimeoutMs?: number;
extensionCommandsParser?: ACPExtensionCommandsParser;
}
export class GenericACPAgentClient extends ACPAgentClient {
@@ -48,6 +53,7 @@ export class GenericACPAgentClient extends ACPAgentClient {
capabilities: buildGenericACPCapabilities(options),
waitForInitialCommands: options.waitForInitialCommands,
initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs,
extensionCommandsParser: options.extensionCommandsParser,
});
this.command = options.command;
@@ -0,0 +1,101 @@
import type { Logger } from "pino";
import type { ACPExtensionCommandsParser } from "./acp-agent.js";
import { GenericACPAgentClient } from "./generic-acp-agent.js";
import type { AgentSlashCommand, AgentSlashCommandKind } from "../agent-sdk-types.js";
interface KiroACPAgentClientOptions {
logger: Logger;
command: [string, ...string[]];
env?: Record<string, string>;
providerId?: string;
label?: string;
providerParams?: unknown;
}
// Kiro CLI publishes its slash commands and skills asynchronously through the
// `_kiro.dev/commands/available` extension notification shortly after
// `session/new` resolves. Wait for that first batch so listCommands() doesn't
// resolve to an empty list before Kiro has reported its commands.
const KIRO_INITIAL_COMMANDS_WAIT_TIMEOUT_MS = 10_000;
// ACP extension method (per the `_`-prefixed vendor namespace convention) that
// Kiro CLI uses to publish its slash commands and skills after session/new.
const KIRO_COMMANDS_AVAILABLE_METHOD = "_kiro.dev/commands/available";
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
// Maps a `_kiro.dev/commands/available` payload onto Paseo slash commands.
// Kiro reports built-in slash commands under `commands` (names arrive with a
// leading "/", e.g. "/agent") and skills/prompts under `prompts` (names without
// a slash, tagged with a `skill:` serverName). Paseo stores command names
// without the leading slash — the composer prepends it on insertion.
function mapKiroAvailableCommands(params: Record<string, unknown>): AgentSlashCommand[] {
const result: AgentSlashCommand[] = [];
const seen = new Set<string>();
const pushEntry = (entry: unknown): void => {
if (!isRecord(entry)) {
return;
}
const rawName = typeof entry.name === "string" ? entry.name.trim() : "";
const name = rawName.replace(/^\/+/, "");
if (!name || seen.has(name)) {
return;
}
seen.add(name);
const description = typeof entry.description === "string" ? entry.description : "";
const meta = isRecord(entry.meta) ? entry.meta : null;
const argumentHint = meta && typeof meta.hint === "string" ? meta.hint : "";
const serverName = typeof entry.serverName === "string" ? entry.serverName : "";
const kind: AgentSlashCommandKind = serverName.startsWith("skill:") ? "skill" : "command";
result.push({ name, description, argumentHint, kind });
};
if (Array.isArray(params.commands)) {
for (const entry of params.commands) {
pushEntry(entry);
}
}
if (Array.isArray(params.prompts)) {
for (const entry of params.prompts) {
pushEntry(entry);
}
}
return result;
}
// Provider-specific parser injected into the generic ACP session via the
// `extensionCommandsParser` option (mirrors how Cursor/Copilot inject their
// behavior through constructor options). Kiro advertises its slash commands and
// skills through the `_kiro.dev/commands/available` extension notification
// instead of the standard `available_commands_update` session update; this
// recognizes that one method and returns the parsed commands (possibly empty),
// or null for any other notification so the base session ignores it.
export const parseKiroExtensionCommands: ACPExtensionCommandsParser = (method, params) => {
if (method !== KIRO_COMMANDS_AVAILABLE_METHOD) {
return null;
}
return mapKiroAvailableCommands(params);
};
export class KiroACPAgentClient extends GenericACPAgentClient {
constructor(options: KiroACPAgentClientOptions) {
super({
logger: options.logger,
command: options.command,
env: options.env,
providerId: options.providerId,
label: options.label,
providerParams: options.providerParams,
waitForInitialCommands: true,
initialCommandsWaitTimeoutMs: KIRO_INITIAL_COMMANDS_WAIT_TIMEOUT_MS,
extensionCommandsParser: parseKiroExtensionCommands,
});
}
}
@@ -21,7 +21,7 @@ describe("paseo daemon bootstrap", () => {
test("starts and serves health endpoint", async () => {
const daemonHandle = await createTestPaseoDaemon({
openai: { apiKey: "test-openai-api-key" },
openai: { stt: { apiKey: "test-openai-api-key" }, tts: { apiKey: "test-openai-api-key" } },
speech: {
providers: {
dictationStt: { provider: "openai", explicit: true },
@@ -547,7 +547,9 @@ beforeAll(async () => {
ctx = await createDaemonTestContext({
dictationFinalTimeoutMs: 5000,
...(openaiApiKey ? { openai: { apiKey: openaiApiKey } } : {}),
...(openaiApiKey
? { openai: { stt: { apiKey: openaiApiKey }, tts: { apiKey: openaiApiKey } } }
: {}),
...(speechConfig ? { speech: speechConfig } : {}),
});
}, 60000);
@@ -116,20 +116,26 @@ describe("PersistedConfigSchema worktrees config", () => {
});
describe("PersistedConfigSchema provider credentials", () => {
test("accepts OpenAI voice credentials", () => {
test("accepts separate OpenAI STT and TTS credentials", () => {
const parsed = PersistedConfigSchema.parse({
providers: {
openai: {
voice: {
apiKey: " voice-secret ",
baseUrl: " https://voice.example.com/v1 ",
stt: {
apiKey: " stt-secret ",
baseUrl: " https://stt.example.com/v1 ",
},
tts: {
apiKey: " tts-secret ",
baseUrl: " https://tts.example.com/v1 ",
},
},
},
});
expect(parsed.providers?.openai?.voice?.apiKey).toBe("voice-secret");
expect(parsed.providers?.openai?.voice?.baseUrl).toBe("https://voice.example.com/v1");
expect(parsed.providers?.openai?.stt?.apiKey).toBe("stt-secret");
expect(parsed.providers?.openai?.stt?.baseUrl).toBe("https://stt.example.com/v1");
expect(parsed.providers?.openai?.tts?.apiKey).toBe("tts-secret");
expect(parsed.providers?.openai?.tts?.baseUrl).toBe("https://tts.example.com/v1");
});
});
@@ -650,6 +656,38 @@ describe("loadPersistedConfig", () => {
rmSync(home, { recursive: true, force: true });
}
});
test("loads a config that still uses the removed providers.openai.voice block", () => {
const home = createTempHome();
const configPath = path.join(home, "config.json");
try {
writeFileSync(
configPath,
`${JSON.stringify(
{
version: 1,
providers: {
openai: {
apiKey: "global-key",
voice: { apiKey: "voice-key", baseUrl: "https://voice.example.com/v1" },
},
},
},
null,
2,
)}\n`,
);
const config = loadPersistedConfig(home);
expect(config.providers?.openai?.apiKey).toBe("global-key");
expect((config.providers?.openai as Record<string, unknown>)?.voice).toBeUndefined();
expect(config.providers?.openai?.stt).toBeUndefined();
expect(config.providers?.openai?.tts).toBeUndefined();
} finally {
rmSync(home, { recursive: true, force: true });
}
});
});
describe.skipIf(process.platform === "win32")("persisted config file permissions", () => {
+24 -12
View File
@@ -45,7 +45,7 @@ const LogConfigSchema = z
})
.strict();
const OpenAiVoiceProviderSchema = z
const OpenAiSpeechEndpointSchema = z
.object({
apiKey: z.string().trim().min(1).optional(),
baseUrl: z.string().trim().min(1).optional(),
@@ -55,8 +55,9 @@ const OpenAiVoiceProviderSchema = z
const OpenAiProviderSchema = z
.object({
apiKey: z.string().min(1).optional(),
voice: OpenAiVoiceProviderSchema.optional(),
baseUrl: z.string().trim().min(1).optional(),
stt: OpenAiSpeechEndpointSchema.optional(),
tts: OpenAiSpeechEndpointSchema.optional(),
})
.strict();
@@ -346,7 +347,11 @@ function getLogger(logger: LoggerLike | undefined): LoggerLike | undefined {
return logger?.child({ module: "config" });
}
function stripDeprecatedLocalSpeechConfigFields(parsed: unknown): unknown {
// Removed config fields are stripped before parsing so the strict schema does not
// reject a config written by an older release. The stripped values are discarded,
// not migrated — there is no back-compat for the removed `providers.openai.voice`
// block (use `providers.openai.stt` / `providers.openai.tts`).
function stripRemovedConfigFields(parsed: unknown): unknown {
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return parsed;
}
@@ -358,18 +363,25 @@ function stripDeprecatedLocalSpeechConfigFields(parsed: unknown): unknown {
}
const providersRecord = { ...(providers as Record<string, unknown>) };
const local = providersRecord.local;
if (!local || typeof local !== "object" || Array.isArray(local)) {
root.providers = providersRecord;
return root;
}
const localRecord = { ...(local as Record<string, unknown>) };
if ("autoDownload" in localRecord) {
if (local && typeof local === "object" && !Array.isArray(local)) {
const localRecord = { ...(local as Record<string, unknown>) };
delete localRecord.autoDownload;
providersRecord.local = localRecord;
}
const openai = providersRecord.openai;
if (openai && typeof openai === "object" && !Array.isArray(openai)) {
const openaiRecord = { ...(openai as Record<string, unknown>) };
// COMPAT(openaiVoiceConfig): added 2026-06-30, remove after 2026-12-30.
// Drop a `providers.openai.voice` block left by an older release so the strict
// schema doesn't reject it. The value is discarded, not migrated — there is no
// back-compat; configure `providers.openai.stt` / `providers.openai.tts` instead.
delete openaiRecord.voice;
providersRecord.openai = openaiRecord;
}
providersRecord.local = localRecord;
root.providers = providersRecord;
return root;
}
@@ -412,7 +424,7 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per
});
}
const migrated = stripDeprecatedLocalSpeechConfigFields(parsed);
const migrated = stripRemovedConfigFields(parsed);
const result = PersistedConfigSchema.safeParse(migrated);
if (!result.success) {
const issues = result.error.issues
@@ -2,6 +2,14 @@ import { describe, expect, test } from "vitest";
import { PersistedConfigSchema } from "../../../persisted-config.js";
import { resolveOpenAiSpeechConfig } from "./config.js";
import type { RequestedSpeechProviders } from "../../speech-types.js";
const ALL_OPENAI: RequestedSpeechProviders = {
dictationStt: { provider: "openai", explicit: true },
voiceTurnDetection: { provider: "local", explicit: false },
voiceStt: { provider: "openai", explicit: true },
voiceTts: { provider: "openai", explicit: true },
};
describe("resolveOpenAiSpeechConfig", () => {
test("treats empty OPENAI_API_KEY as unset", () => {
@@ -14,6 +22,7 @@ describe("resolveOpenAiSpeechConfig", () => {
env,
persisted,
providers: {
...ALL_OPENAI,
dictationStt: { provider: "local", explicit: false },
voiceStt: { provider: "local", explicit: false },
voiceTts: { provider: "local", explicit: false },
@@ -23,138 +32,175 @@ describe("resolveOpenAiSpeechConfig", () => {
expect(resolved).toBeUndefined();
});
test("uses trimmed OPENAI_API_KEY when configured", () => {
test("applies trimmed OPENAI_API_KEY to both STT and TTS", () => {
const persisted = PersistedConfigSchema.parse({});
const env = {
OPENAI_API_KEY: " sk-test ",
} as NodeJS.ProcessEnv;
const resolved = resolveOpenAiSpeechConfig({
env,
persisted,
providers: {
dictationStt: { provider: "openai", explicit: true },
voiceStt: { provider: "openai", explicit: true },
voiceTts: { provider: "openai", explicit: true },
},
});
const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI });
expect(resolved?.apiKey).toBe("sk-test");
expect(resolved?.stt?.apiKey).toBe("sk-test");
expect(resolved?.tts?.apiKey).toBe("sk-test");
});
test("uses nested voice config before env and non-voice fallbacks", () => {
test("resolves distinct endpoints for STT and TTS", () => {
const persisted = PersistedConfigSchema.parse({
providers: {
openai: {
apiKey: "fallback-config-key",
voice: {
apiKey: "voice-config-key",
baseUrl: " https://voice.example.com/v1 ",
stt: {
apiKey: "stt-key",
baseUrl: " https://stt.example.com/v1 ",
},
tts: {
apiKey: "tts-key",
baseUrl: " https://tts.example.com/v1 ",
},
baseUrl: "https://legacy-config.example.com/v1",
},
},
});
const env = {
OPENAI_API_KEY: "env-key",
OPENAI_VOICE_API_KEY: "voice-env-key",
OPENAI_VOICE_BASE_URL: "https://voice-env.example.com/v1",
OPENAI_BASE_URL: "https://env.example.com/v1",
} as NodeJS.ProcessEnv;
const resolved = resolveOpenAiSpeechConfig({
env,
env: {} as NodeJS.ProcessEnv,
persisted,
providers: {
dictationStt: { provider: "openai", explicit: true },
voiceStt: { provider: "openai", explicit: true },
voiceTts: { provider: "openai", explicit: true },
},
providers: ALL_OPENAI,
});
expect(resolved?.apiKey).toBe("voice-config-key");
expect(resolved?.baseUrl).toBe("https://voice.example.com/v1");
expect(resolved?.stt?.apiKey).toBe("voice-config-key");
expect(resolved?.stt?.baseUrl).toBe("https://voice.example.com/v1");
expect(resolved?.tts?.apiKey).toBe("voice-config-key");
expect(resolved?.tts?.baseUrl).toBe("https://voice.example.com/v1");
expect(resolved?.stt?.apiKey).toBe("stt-key");
expect(resolved?.stt?.baseUrl).toBe("https://stt.example.com/v1");
expect(resolved?.tts?.apiKey).toBe("tts-key");
expect(resolved?.tts?.baseUrl).toBe("https://tts.example.com/v1");
});
test("uses voice env config when nested voice config is unset", () => {
const persisted = PersistedConfigSchema.parse({});
const env = {
OPENAI_API_KEY: "sk-test",
OPENAI_VOICE_API_KEY: "voice-env-key",
OPENAI_VOICE_BASE_URL: " https://voice-env.example.com/v1 ",
OPENAI_BASE_URL: "https://env.example.com/v1",
} as NodeJS.ProcessEnv;
const resolved = resolveOpenAiSpeechConfig({
env,
persisted,
providers: {
dictationStt: { provider: "openai", explicit: true },
voiceStt: { provider: "openai", explicit: true },
voiceTts: { provider: "openai", explicit: true },
},
});
expect(resolved?.apiKey).toBe("voice-env-key");
expect(resolved?.stt?.apiKey).toBe("voice-env-key");
expect(resolved?.tts?.apiKey).toBe("voice-env-key");
expect(resolved?.baseUrl).toBe("https://voice-env.example.com/v1");
expect(resolved?.stt?.baseUrl).toBe("https://voice-env.example.com/v1");
expect(resolved?.tts?.baseUrl).toBe("https://voice-env.example.com/v1");
});
test("falls back to non-voice OpenAI config", () => {
test("prefers nested STT/TTS config over env and global fallbacks", () => {
const persisted = PersistedConfigSchema.parse({
providers: {
openai: {
apiKey: "fallback-config-key",
baseUrl: " https://legacy-config.example.com/v1 ",
baseUrl: "https://global-config.example.com/v1",
stt: { apiKey: "stt-config-key", baseUrl: " https://stt.example.com/v1 " },
tts: { apiKey: "tts-config-key", baseUrl: " https://tts.example.com/v1 " },
},
},
});
const env = {
OPENAI_API_KEY: "sk-test",
OPENAI_BASE_URL: " https://env.example.com/v1 ",
OPENAI_API_KEY: "env-key",
OPENAI_STT_API_KEY: "stt-env-key",
OPENAI_STT_BASE_URL: "https://stt-env.example.com/v1",
OPENAI_TTS_API_KEY: "tts-env-key",
OPENAI_TTS_BASE_URL: "https://tts-env.example.com/v1",
OPENAI_BASE_URL: "https://env.example.com/v1",
} as NodeJS.ProcessEnv;
const resolved = resolveOpenAiSpeechConfig({
env,
persisted,
providers: {
dictationStt: { provider: "openai", explicit: true },
voiceStt: { provider: "openai", explicit: true },
voiceTts: { provider: "openai", explicit: true },
},
});
const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI });
expect(resolved?.apiKey).toBe("fallback-config-key");
expect(resolved?.baseUrl).toBe("https://legacy-config.example.com/v1");
expect(resolved?.stt?.apiKey).toBe("stt-config-key");
expect(resolved?.stt?.baseUrl).toBe("https://stt.example.com/v1");
expect(resolved?.tts?.apiKey).toBe("tts-config-key");
expect(resolved?.tts?.baseUrl).toBe("https://tts.example.com/v1");
});
test("falls back to global OpenAI env config when voice-specific inputs are unset", () => {
test("uses STT/TTS env config when nested config is unset", () => {
const persisted = PersistedConfigSchema.parse({});
const env = {
OPENAI_API_KEY: "sk-test",
OPENAI_STT_API_KEY: "stt-env-key",
OPENAI_STT_BASE_URL: " https://stt-env.example.com/v1 ",
OPENAI_TTS_API_KEY: "tts-env-key",
OPENAI_TTS_BASE_URL: " https://tts-env.example.com/v1 ",
OPENAI_BASE_URL: "https://env.example.com/v1",
} as NodeJS.ProcessEnv;
const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI });
expect(resolved?.stt?.apiKey).toBe("stt-env-key");
expect(resolved?.stt?.baseUrl).toBe("https://stt-env.example.com/v1");
expect(resolved?.tts?.apiKey).toBe("tts-env-key");
expect(resolved?.tts?.baseUrl).toBe("https://tts-env.example.com/v1");
});
test("falls back to global OpenAI config for both STT and TTS", () => {
const persisted = PersistedConfigSchema.parse({
providers: {
openai: {
apiKey: "fallback-config-key",
baseUrl: " https://global-config.example.com/v1 ",
},
},
});
const env = {} as NodeJS.ProcessEnv;
const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI });
expect(resolved?.stt?.apiKey).toBe("fallback-config-key");
expect(resolved?.stt?.baseUrl).toBe("https://global-config.example.com/v1");
expect(resolved?.tts?.apiKey).toBe("fallback-config-key");
expect(resolved?.tts?.baseUrl).toBe("https://global-config.example.com/v1");
});
test("falls back to global OpenAI env config when feature inputs are unset", () => {
const persisted = PersistedConfigSchema.parse({});
const env = {
OPENAI_API_KEY: "env-key",
OPENAI_BASE_URL: " https://env.example.com/v1 ",
} as NodeJS.ProcessEnv;
const resolved = resolveOpenAiSpeechConfig({
env,
persisted,
const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI });
expect(resolved?.stt?.apiKey).toBe("env-key");
expect(resolved?.stt?.baseUrl).toBe("https://env.example.com/v1");
expect(resolved?.tts?.apiKey).toBe("env-key");
expect(resolved?.tts?.baseUrl).toBe("https://env.example.com/v1");
});
test("ignores empty endpoint env vars and falls back to OPENAI_API_KEY", () => {
const persisted = PersistedConfigSchema.parse({});
const env = {
OPENAI_API_KEY: "global-key",
OPENAI_STT_API_KEY: "",
OPENAI_STT_BASE_URL: " ",
OPENAI_TTS_API_KEY: "",
OPENAI_TTS_BASE_URL: "",
} as NodeJS.ProcessEnv;
const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI });
expect(resolved?.stt?.apiKey).toBe("global-key");
expect(resolved?.tts?.apiKey).toBe("global-key");
});
test("omits TTS when only an STT key is configured", () => {
const persisted = PersistedConfigSchema.parse({
providers: {
dictationStt: { provider: "openai", explicit: true },
voiceStt: { provider: "openai", explicit: true },
voiceTts: { provider: "openai", explicit: true },
openai: {
stt: { apiKey: "stt-only-key" },
},
},
});
expect(resolved?.apiKey).toBe("env-key");
expect(resolved?.baseUrl).toBe("https://env.example.com/v1");
const resolved = resolveOpenAiSpeechConfig({
env: {} as NodeJS.ProcessEnv,
persisted,
providers: ALL_OPENAI,
});
expect(resolved?.stt?.apiKey).toBe("stt-only-key");
expect(resolved?.tts).toBeUndefined();
});
test("resolves STT even when an unused TTS env var is invalid", () => {
const persisted = PersistedConfigSchema.parse({
providers: {
openai: {
stt: { apiKey: "stt-only-key" },
},
},
});
const env = { TTS_VOICE: "not-a-real-voice", TTS_MODEL: "bogus-model" } as NodeJS.ProcessEnv;
const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI });
expect(resolved?.stt?.apiKey).toBe("stt-only-key");
expect(resolved?.tts).toBeUndefined();
});
});
@@ -8,8 +8,6 @@ import type { TTSConfig } from "./tts.js";
export const DEFAULT_OPENAI_TTS_MODEL = "tts-1";
export interface OpenAiSpeechProviderConfig {
apiKey?: string;
baseUrl?: string;
stt?: Partial<STTConfig> & { apiKey?: string };
tts?: Partial<TTSConfig> & { apiKey?: string };
}
@@ -30,11 +28,23 @@ const OptionalTrimmedStringSchema = z
.optional()
.transform((value) => (value && value.length > 0 ? value : undefined));
const OpenAiSpeechResolutionSchema = z.object({
apiKey: OptionalTrimmedStringSchema,
baseUrl: OptionalTrimmedStringSchema,
// Endpoint credentials only — plain trimmed strings, so this never throws on a
// malformed value. The STT/TTS option groups parse separately and only for the
// endpoint that is actually configured, so a stale env var for an unused endpoint
// (e.g. a leftover TTS_VOICE in an STT-only setup) can't break the other one.
const OpenAiEndpointKeysSchema = z.object({
sttApiKey: OptionalTrimmedStringSchema,
sttBaseUrl: OptionalTrimmedStringSchema,
ttsApiKey: OptionalTrimmedStringSchema,
ttsBaseUrl: OptionalTrimmedStringSchema,
});
const OpenAiSttOptionsSchema = z.object({
sttConfidenceThreshold: OptionalFiniteNumberSchema,
sttModel: OptionalTrimmedStringSchema,
});
const OpenAiTtsOptionsSchema = z.object({
ttsVoice: z.string().trim().toLowerCase().pipe(OpenAiTtsVoiceSchema).default("alloy"),
ttsModel: z
.string()
@@ -57,9 +67,15 @@ function pickIfOpenAi<T>(
function firstDefined<T>(values: Array<T | null | undefined>): T | undefined {
for (const value of values) {
if (value !== undefined && value !== null) {
return value;
if (value === undefined || value === null) {
continue;
}
// Empty/whitespace env vars (e.g. a copied .env.example with OPENAI_STT_API_KEY=)
// must not shadow a later fallback such as OPENAI_API_KEY.
if (typeof value === "string" && value.trim().length === 0) {
continue;
}
return value;
}
return undefined;
}
@@ -108,18 +124,32 @@ function buildOpenAiResolutionInput(params: {
persisted: PersistedConfig;
providers: RequestedSpeechProviders;
}): Record<string, unknown> {
const { env } = params;
const openai = params.persisted.providers?.openai;
return {
apiKey: firstDefined<string>([
params.persisted.providers?.openai?.voice?.apiKey,
params.env.OPENAI_VOICE_API_KEY,
params.persisted.providers?.openai?.apiKey,
params.env.OPENAI_API_KEY,
sttApiKey: firstDefined<string>([
openai?.stt?.apiKey,
env.OPENAI_STT_API_KEY,
openai?.apiKey,
env.OPENAI_API_KEY,
]),
baseUrl: firstDefined<string>([
params.persisted.providers?.openai?.voice?.baseUrl,
params.env.OPENAI_VOICE_BASE_URL,
params.persisted.providers?.openai?.baseUrl,
params.env.OPENAI_BASE_URL,
sttBaseUrl: firstDefined<string>([
openai?.stt?.baseUrl,
env.OPENAI_STT_BASE_URL,
openai?.baseUrl,
env.OPENAI_BASE_URL,
]),
ttsApiKey: firstDefined<string>([
openai?.tts?.apiKey,
env.OPENAI_TTS_API_KEY,
openai?.apiKey,
env.OPENAI_API_KEY,
]),
ttsBaseUrl: firstDefined<string>([
openai?.tts?.baseUrl,
env.OPENAI_TTS_BASE_URL,
openai?.baseUrl,
env.OPENAI_BASE_URL,
]),
...buildOpenAiSttInput(params),
...buildOpenAiTtsInput(params),
@@ -131,29 +161,46 @@ export function resolveOpenAiSpeechConfig(params: {
persisted: PersistedConfig;
providers: RequestedSpeechProviders;
}): OpenAiSpeechProviderConfig | undefined {
const parsed = OpenAiSpeechResolutionSchema.parse(buildOpenAiResolutionInput(params));
const input = buildOpenAiResolutionInput(params);
const keys = OpenAiEndpointKeysSchema.parse(input);
if (!parsed.apiKey) {
if (!keys.sttApiKey && !keys.ttsApiKey) {
return undefined;
}
return {
apiKey: parsed.apiKey,
...(parsed.baseUrl ? { baseUrl: parsed.baseUrl } : {}),
stt: {
apiKey: parsed.apiKey,
...(parsed.baseUrl ? { baseUrl: parsed.baseUrl } : {}),
...(parsed.sttConfidenceThreshold !== undefined
? { confidenceThreshold: parsed.sttConfidenceThreshold }
: {}),
...(parsed.sttModel ? { model: parsed.sttModel } : {}),
},
tts: {
apiKey: parsed.apiKey,
...(parsed.baseUrl ? { baseUrl: parsed.baseUrl } : {}),
voice: parsed.ttsVoice,
model: parsed.ttsModel,
responseFormat: "pcm",
},
...(keys.sttApiKey ? { stt: buildSttConfig(keys.sttApiKey, keys.sttBaseUrl, input) } : {}),
...(keys.ttsApiKey ? { tts: buildTtsConfig(keys.ttsApiKey, keys.ttsBaseUrl, input) } : {}),
};
}
function buildSttConfig(
apiKey: string,
baseUrl: string | undefined,
input: Record<string, unknown>,
): OpenAiSpeechProviderConfig["stt"] {
const options = OpenAiSttOptionsSchema.parse(input);
return {
apiKey,
...(baseUrl ? { baseUrl } : {}),
...(options.sttConfidenceThreshold !== undefined
? { confidenceThreshold: options.sttConfidenceThreshold }
: {}),
...(options.sttModel ? { model: options.sttModel } : {}),
};
}
function buildTtsConfig(
apiKey: string,
baseUrl: string | undefined,
input: Record<string, unknown>,
): OpenAiSpeechProviderConfig["tts"] {
const options = OpenAiTtsOptionsSchema.parse(input);
return {
apiKey,
...(baseUrl ? { baseUrl } : {}),
voice: options.ttsVoice,
model: options.ttsModel,
responseFormat: "pcm",
};
}
@@ -15,7 +15,6 @@ describe("initializeOpenAiSpeechServices", () => {
voiceTts: { provider: "openai", explicit: true },
},
openaiConfig: {
apiKey: "sk-test",
stt: { apiKey: "sk-test" },
tts: { apiKey: "sk-test" },
},
@@ -29,11 +29,11 @@ export interface SpeechServices {
function resolveOpenAiCredentials(
openaiConfig: OpenAiSpeechProviderConfig | undefined,
): OpenAiCredentialState {
const openaiApiKey = openaiConfig?.apiKey;
const sttApiKey = openaiConfig?.stt?.apiKey;
return {
openaiSttApiKey: openaiConfig?.stt?.apiKey ?? openaiApiKey,
openaiTtsApiKey: openaiConfig?.tts?.apiKey ?? openaiApiKey,
openaiDictationApiKey: openaiApiKey,
openaiSttApiKey: sttApiKey,
openaiTtsApiKey: openaiConfig?.tts?.apiKey,
openaiDictationApiKey: sttApiKey,
};
}
@@ -129,7 +129,8 @@ describe("resolveSpeechConfig", () => {
dictation: "es",
voice: "pt",
});
expect(result.openai?.apiKey).toBe("persisted-key");
expect(result.openai?.stt?.apiKey).toBe("persisted-key");
expect(result.openai?.tts?.apiKey).toBe("persisted-key");
expect(result.openai?.stt?.model).toBe("gpt-4o-transcribe");
});
@@ -79,7 +79,7 @@ function waitForSignal<T>(
beforeAll(async () => {
ctx = await createDaemonTestContext({
agentClients: {},
openai: { apiKey: openaiApiKey! },
openai: { stt: { apiKey: openaiApiKey! }, tts: { apiKey: openaiApiKey! } },
speech: {
providers: {
dictationStt: { provider: "openai", explicit: true },
@@ -161,7 +161,7 @@ let ctx: DaemonTestContext;
beforeAll(async () => {
ctx = await createDaemonTestContext({
agentClients: {},
openai: { apiKey: openaiApiKey! },
openai: { stt: { apiKey: openaiApiKey! }, tts: { apiKey: openaiApiKey! } },
speech: {
providers: {
dictationStt: { provider: "openai", explicit: true },
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.102-beta.2",
"version": "0.1.103",
"private": true,
"type": "module",
"scripts": {
@@ -174,6 +174,38 @@
"apiKey": {
"type": "string",
"minLength": 1
},
"baseUrl": {
"type": "string",
"minLength": 1
},
"stt": {
"type": "object",
"properties": {
"apiKey": {
"type": "string",
"minLength": 1
},
"baseUrl": {
"type": "string",
"minLength": 1
}
},
"additionalProperties": false
},
"tts": {
"type": "object",
"properties": {
"apiKey": {
"type": "string",
"minLength": 1
},
"baseUrl": {
"type": "string",
"minLength": 1
}
},
"additionalProperties": false
}
},
"additionalProperties": false
+2
View File
@@ -204,6 +204,8 @@ In the mobile app, enter the password in the direct connection setup screen.
- `PASEO_LOG_FILE_ROTATE_COUNT`, override `log.file.rotate.maxFiles`
- `PASEO_LOG`, `PASEO_LOG_FORMAT`, legacy log overrides (still supported)
- `OPENAI_API_KEY`, override OpenAI provider key
- `OPENAI_STT_API_KEY`, `OPENAI_STT_BASE_URL`, OpenAI speech-to-text endpoint (dictation + voice mode STT)
- `OPENAI_TTS_API_KEY`, `OPENAI_TTS_BASE_URL`, OpenAI text-to-speech endpoint (voice mode TTS)
- `PASEO_VOICE_LLM_PROVIDER`, override voice LLM provider (`claude`, `codex`, `opencode`)
- `PASEO_DICTATION_STT_PROVIDER`, `PASEO_VOICE_STT_PROVIDER`, `PASEO_VOICE_TTS_PROVIDER`, override voice provider selection (`local` or `openai`)
- `PASEO_LOCAL_MODELS_DIR`, control local model directory
+8 -2
View File
@@ -90,7 +90,11 @@ You can switch dictation, voice STT, and voice TTS to OpenAI by setting provider
},
"providers": {
"openai": {
"voice": {
"stt": {
"apiKey": "...",
"baseUrl": "https://api.openai.com/v1"
},
"tts": {
"apiKey": "...",
"baseUrl": "https://api.openai.com/v1"
}
@@ -99,7 +103,7 @@ You can switch dictation, voice STT, and voice TTS to OpenAI by setting provider
}
```
`providers.openai.voice.apiKey` and `providers.openai.voice.baseUrl` configure only Paseo OpenAI voice traffic, without changing Codex or other OpenAI-backed tools.
`providers.openai.stt` covers dictation and voice mode speech-to-text, and `providers.openai.tts` covers voice mode text-to-speech. Because they resolve independently, you can point STT and TTS at different endpoints. Each falls back to `providers.openai.apiKey`/`baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when unset. These settings configure only Paseo OpenAI speech traffic, without changing Codex or other OpenAI-backed tools.
Paseo uses these paths under the configured OpenAI base URL:
@@ -111,6 +115,8 @@ Paseo uses these paths under the configured OpenAI base URL:
- `PASEO_VOICE_LLM_PROVIDER`, voice agent provider override
- `PASEO_DICTATION_STT_PROVIDER`, `PASEO_VOICE_STT_PROVIDER`, `PASEO_VOICE_TTS_PROVIDER`, speech provider selection (`local` or `openai`)
- `OPENAI_STT_API_KEY`, `OPENAI_STT_BASE_URL`, OpenAI speech-to-text endpoint (dictation + voice mode STT)
- `OPENAI_TTS_API_KEY`, `OPENAI_TTS_BASE_URL`, OpenAI text-to-speech endpoint (voice mode TTS)
- `PASEO_LOCAL_MODELS_DIR`, local model storage directory
- `PASEO_DICTATION_LOCAL_STT_MODEL`, local dictation STT model ID
- `PASEO_VOICE_LOCAL_STT_MODEL`, `PASEO_VOICE_LOCAL_TTS_MODEL`, local voice STT/TTS model IDs